feat(quickshell): enhance dock with window previews and customization
This commit is contained in:
parent
be5b257190
commit
c6b9b4f47f
55 changed files with 5145 additions and 98 deletions
38
docs/quickshell-dock-full-width-edge-20261111.md
Normal file
38
docs/quickshell-dock-full-width-edge-20261111.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# QuickShell Dock — full-width bottom edge activation (2026-11-11)
|
||||
|
||||
## Report and root cause
|
||||
|
||||
The centered QuickShell Dock used a layer-shell `PanelWindow` whose width
|
||||
matched its visible row of icons. Its hidden activation sensor was limited to
|
||||
`min(360, dock.width)`. Outside that narrow central region, moving the
|
||||
pointer against the bottom edge of an output could not enter the surface.
|
||||
|
||||
## Change
|
||||
|
||||
`dock/Dock.qml` now anchors its input surface to the bottom, left and right
|
||||
edges of **each output**. Only the **4px bottom sensor** is interactive while
|
||||
hidden; the panel retains `exclusionMode: Ignore`, `exclusiveZone: 0`,
|
||||
`WlrKeyboardFocus.None` and the existing input `Region` mask.
|
||||
|
||||
The visible `body` is centered within this full-width surface and preserves
|
||||
its former calculated width, icon geometry, hover scale, 180ms activation dwell,
|
||||
210ms slide, 450ms hide grace and scroll behavior. Context popup coordinates
|
||||
include the visual body's horizontal offset. This does not affect the top bar,
|
||||
Sway bindings, system shortcuts, favorites persistence or Session Restore V2.
|
||||
|
||||
## Validation
|
||||
|
||||
`qmllint quickshell/.config/quickshell/labfy-sway/dock/Dock.qml` passed;
|
||||
`node tests/dock/test_models.js` and the six unit tests in `tests/dock/`
|
||||
passed. Full `tests/dock/headless.py` ran with a private SwayFX and a
|
||||
separate artifacts directory in `~/.cache/labfy-dock-edge-test`:
|
||||
|
||||
- `PASS` for activation from the left and right screen corners;
|
||||
- sensor width equals output width while visual body remains centered;
|
||||
- all existing favorites, auto-hide, context, scrolling, focus, drag,
|
||||
multi-window, framebuffer, animation and resolution scenarios pass;
|
||||
- no visual geometry or SVG raster reload during the frame sweep.
|
||||
|
||||
No user desktop windows or active Sway compositor were touched by the
|
||||
headless tests. The production QuickShell service must reload the amended
|
||||
QML to receive the new input region; Sway itself does not need a reload.
|
||||
79
docs/quickshell-dock-layout-and-context-v1.md
Normal file
79
docs/quickshell-dock-layout-and-context-v1.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# QuickShell Dock — organisation et menu contextuel (11 novembre 2026)
|
||||
|
||||
## Objectif
|
||||
|
||||
Conserver le dock QuickShell SwayFX existant (zoom fixe, centrage, bande
|
||||
d'activation de 4 px sur toute la largeur, couche Top sans focus clavier),
|
||||
mais permettre de réorganiser les icônes épinglées et de gérer des séparateurs.
|
||||
|
||||
## Organisation du dock
|
||||
|
||||
- Glisser une icône épinglée pour la replacer par rapport aux autres favoris.
|
||||
- Clic droit sur une icône épinglée : « Déplacer à gauche » et
|
||||
« Déplacer à droite » lorsqu'un déplacement est possible.
|
||||
- Clic droit sur une icône épinglée : « Ajouter séparateur avant/après ».
|
||||
- Cliquer ou faire clic droit sur un séparateur : déplacer à gauche/droite
|
||||
ou le supprimer. Le glisser-déposer d'un séparateur est également pris en charge.
|
||||
- Les fenêtres ouvertes non épinglées demeurent après les favoris ; le
|
||||
séparateur automatique à cette frontière est conservé.
|
||||
|
||||
La liste partagée des favoris conserve son contrat et son fichier **v1** :
|
||||
`~/.local/state/labfy-sway/applications-favorites.json`. Les séparateurs
|
||||
sont enregistrés séparément dans
|
||||
`~/.local/state/labfy-sway/dock-separators.json`. Le format est strict,
|
||||
limité à 16 séparateurs, avec écriture atomique, verrou de répertoire et
|
||||
rejet sans écrasement d'un fichier invalide. Aucun identifiant de séparateur
|
||||
n'est accepté comme application exécutable.
|
||||
|
||||
## Fermeture et transparence du menu
|
||||
|
||||
Le menu contextuel n'a plus de ligne « Fermer le menu ».
|
||||
|
||||
- Un clic dans la marge autour du menu le ferme immédiatement, sans lancer
|
||||
l'application située derrière cette marge.
|
||||
- Un éloignement du pointeur au-delà de cette zone déclenche une fermeture
|
||||
avec **480 ms de tolérance** ; survoler de nouveau le menu annule l'échéance.
|
||||
- La surface Wayland d'entrée est limitée à une petite zone autour du menu et
|
||||
au dock existant. Le reste du bureau demeure interactif ; en dehors de la
|
||||
zone de capture, la fermeture provient de l'éloignement du pointeur, pas
|
||||
d'une interception globale des clics.
|
||||
- Le fond de menu a **85 % d'opacité** (soit 15 % de transparence), avec les
|
||||
textes entièrement opaques. Le mode de contraste élevé conserve un fond
|
||||
presque opaque (94 %). Les survols de ses actions restent translucides.
|
||||
|
||||
L'auto-hide, les menus, l'animation et le focus des fenêtres ne sont pas
|
||||
transformés en commandes Sway. Aucun redémarrage de Sway n'est nécessaire.
|
||||
|
||||
## Vérifications non destructives
|
||||
|
||||
- `tests/dock/test_separators.py` valide stockage, suppression, déplacements,
|
||||
corruption, liens symboliques, opérations concurrentes et invariants v1.
|
||||
- `tests/dock/test_models.js` valide les séparateurs et déplacements.
|
||||
- `tests/dock/headless.py` utilise un **SwayFX privé** ; les actions réelles
|
||||
du menu sont exercées, y compris clic à côté, délai d'éloignement, survol
|
||||
prolongé, couleur et gestion réelle d'un séparateur.
|
||||
- Les tests applications/dock, le shell complet, la compatibilité Session V2
|
||||
et le lint QML sont exécutés avant l'activation sur ArchASP.
|
||||
|
||||
Les traces de test sont conservées dans
|
||||
`~/.cache/labfy-dock-context-dismiss-v1`. Aucune modification volontaire
|
||||
de la liste réelle de favoris ni de l'état Session Restore V2.
|
||||
|
||||
## Correction de la zone morte centrale (11 novembre 2026)
|
||||
|
||||
La fermeture automatique introduisait une régression dans la zone de survol :
|
||||
les régions `dismissZone` et `context` étaient **soustraites** du masque
|
||||
Wayland lorsqu'elles étaient invisibles. Leur géométrie continuait pourtant
|
||||
d'exister, ce qui creusait des trous dans la bande sensible de 4 px, notamment
|
||||
au centre de l'écran.
|
||||
|
||||
Les régions secondaires du masque utilisent désormais uniquement l'opération
|
||||
`Combine`, avec largeur et hauteur égales à zéro tant que leurs composants
|
||||
ne doivent pas recevoir la souris. Le capteur pleine largeur n'est donc
|
||||
jamais soustrait, ni au démarrage ni après fermeture d'un menu. La zone
|
||||
cliquable du menu n'est ajoutée qu'à son ouverture.
|
||||
|
||||
Le test SwayFX privé `tests/dock/headless.py` vérifie les trois régions
|
||||
vides lorsque le dock est masqué, leur activation lorsque le menu apparaît,
|
||||
leur désactivation après fermeture et un nouveau survol au centre, en plus
|
||||
des deux coins. Il n'agit pas sur le pointeur physique de l'utilisateur.
|
||||
81
docs/quickshell-dock-window-previews-v1.md
Normal file
81
docs/quickshell-dock-window-previews-v1.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# QuickShell Dock — points de fenêtres et vignettes Wayland (11 novembre 2026)
|
||||
|
||||
## Comportement
|
||||
|
||||
Chaque application de la rangée du dock affiche autant de points sous son icône
|
||||
qu'elle possède de fenêtres **réellement détectées par Sway**. Les icônes des
|
||||
séparateurs n'affichent pas de points. Un survol intentionnel (> 330 ms) sur
|
||||
une application ouverte affiche des cartes de fenêtres, avec leur **aperçu
|
||||
visuel ponctuel**, leur titre et leur workspace. Cliquer une carte transmet
|
||||
l'identifiant exact au mécanisme existant `FavoritesService.focusWindow()`.
|
||||
Le survol seul ne déplace aucune fenêtre, ne change aucun workspace et ne
|
||||
modifie jamais le focus clavier.
|
||||
|
||||
Au-delà de quatre cartes, le panneau défile horizontalement. L'interface
|
||||
ne prend jamais le focus clavier. Après éloignement du pointeur (620 ms), le
|
||||
panneau disparaît. Les menus contextuels et le déplacement des favoris ont
|
||||
priorité sur les vignettes.
|
||||
|
||||
## Capture native, sans surveillance permanente
|
||||
|
||||
SwayFX sur ArchASP publie les protocoles `ext-foreign-toplevel-list-v1`,
|
||||
`ext-image-copy-capture-v1` et
|
||||
`ext-foreign-toplevel-image-capture-source-manager-v1`. Les fenêtres de son
|
||||
arbre IPC possèdent un `foreign_toplevel_identifier` permettant une
|
||||
correspondance stricte entre le clic sur une vignette et la fenêtre Sway.
|
||||
|
||||
QuickShell 0.3.2 ne fournit pas un `ScreencopyView` utilisable pour cette
|
||||
capture individuelle sur Sway (son chemin toplevel dépend de Hyprland).
|
||||
Le collecteur ponctuel en Go est fondé sur le projet MIT
|
||||
`mil-ad/wlthumbs`, commit amont
|
||||
`516603c3144078b4d974108b80f7e9622ee4f3c9`, avec le code et la
|
||||
licence conservés sous `tools/wlthumbs/`. Le correctif local ajoute
|
||||
`-ids` : la capture est refusée sans une liste explicite de 1 à 8
|
||||
identifiants hexadécimaux (32 caractères), et ne capture **que** ces
|
||||
fenêtres. La capture de sorties complètes est interdite dans ce mode.
|
||||
Le build reproductible utilisateur, sans sudo :
|
||||
`tools/build-labfy-wlthumbs.sh`, installe le binaire dans
|
||||
`~/.local/libexec/labfy-wlthumbs`.
|
||||
|
||||
Le wrapper `bin/dock-thumbnails.py` valide une seconde fois tous les
|
||||
identifiants Sway et étrangers, limite le nombre de captures à 8 et la
|
||||
taille des PNG, impose un dossier de session `0700` dans
|
||||
`$XDG_RUNTIME_DIR/labfy-dock-window-thumbnails` et restitue seulement
|
||||
les URI des images validées. Le nettoyage est déclenché après fermeture
|
||||
du panneau, ainsi qu'à chaque nouvelle capture. Aucune capture
|
||||
permanente, périodique, en arrière-plan ou à la déconnexion n'est créée.
|
||||
En cas d'échec d'un aperçu, la carte affiche l'icône de l'application,
|
||||
le titre et le workspace ; la sélection reste fonctionnelle.
|
||||
|
||||
## Compatibilité et sécurité
|
||||
|
||||
- Les favoris partagés, les séparateurs, le modèle d'applications et le menu
|
||||
QuickShell existant conservent leur format.
|
||||
- La récupération des fenêtres réutilise le snapshot IPC Sway existant,
|
||||
sans nouvelle boucle de polling.
|
||||
- La capture est limitée à la demande de l'utilisateur, et les noms des
|
||||
fenêtres ne sont pas utilisés pour décider de leur identité.
|
||||
- La couche du dock conserve `WlrKeyboardFocus.None`,
|
||||
`exclusionMode.Ignore`, `exclusiveZone: 0` et la bande d'activation
|
||||
de 4 px sur toute la largeur.
|
||||
- Les PNG locaux constituent des données sensibles ; ils résident seulement
|
||||
dans le runtime privé et sont supprimés à la fermeture du panneau.
|
||||
- Les vignettes ne modifient pas Session Restore V2, l'autostart ni les
|
||||
raccourcis Sway.
|
||||
|
||||
## Validation
|
||||
|
||||
- Tests unitaires `tests/dock/test_thumbnails.py` : entrées bornées,
|
||||
captures sélectives, permissions, nettoyage et refus des chemins suspects.
|
||||
- `tests/dock/headless.py` : vrai SwayFX et vrai QuickShell isolés,
|
||||
trois fenêtres Kitty pour trois points et trois cartes sélectionnables,
|
||||
non-vol de focus, captures réelles de la session de test, nettoyage, et
|
||||
anciens scénarios de dock.
|
||||
- Test non destructif sur ArchASP : sélection de trois fenêtres Firefox,
|
||||
production de trois PNG temporaires puis nettoyage, sans changement
|
||||
de focus ; ne conserve pas leur contenu.
|
||||
- `qmllint`, suites applications/dock, chargement du shell complet et
|
||||
compatibilité Session Restore V2.
|
||||
|
||||
Les artefacts de test générés sont dans le cache privé de développement,
|
||||
pas dans le dépôt. Aucun média de la session de travail n'est publié.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
129
quickshell/.config/quickshell/labfy-sway/bin/dock-thumbnails.py
Normal file
129
quickshell/.config/quickshell/labfy-sway/bin/dock-thumbnails.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
#!/usr/bin/python3
|
||||
"""Capture only requested Sway toplevels, on demand for the dock hover panel.
|
||||
|
||||
Privacy: 0700 XDG_RUNTIME_DIR cache; no full-desktop capture, no permanent
|
||||
storage, bounded captures, and no Wayland focus/activation commands.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
WINDOW_ID = re.compile(r"^[1-9][0-9]{0,18}$")
|
||||
FOREIGN_ID = re.compile(r"^[a-f0-9]{32}$")
|
||||
ROOT_NAME = "labfy-dock-window-thumbnails"
|
||||
MAX_IMAGES = 8
|
||||
MAX_PNG_BYTES = 2_000_000
|
||||
MAX_ARGUMENT = 2048
|
||||
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def cache_root():
|
||||
runtime = Path(os.environ["XDG_RUNTIME_DIR"])
|
||||
if not runtime.is_dir() or runtime.is_symlink() or runtime.stat().st_uid != os.getuid():
|
||||
raise ValueError("XDG_RUNTIME_DIR invalide")
|
||||
root = runtime / ROOT_NAME
|
||||
if root.is_symlink():
|
||||
raise ValueError("Cache de vignettes invalide")
|
||||
root.mkdir(mode=0o700, exist_ok=True)
|
||||
info = root.stat()
|
||||
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid()
|
||||
or info.st_mode & 0o077):
|
||||
raise ValueError("Permissions du cache de vignettes invalides")
|
||||
return root
|
||||
|
||||
|
||||
def purge(root, *, all_dirs=False):
|
||||
for entry in root.iterdir():
|
||||
if not entry.name.startswith("shot-") or entry.is_symlink() or not entry.is_dir():
|
||||
continue
|
||||
try:
|
||||
info = entry.stat()
|
||||
if info.st_uid == os.getuid() and (all_dirs or time.time() - info.st_mtime > 120):
|
||||
shutil.rmtree(entry)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def validate(raw):
|
||||
if not isinstance(raw, str) or len(raw) > MAX_ARGUMENT:
|
||||
raise ValueError("Demande de vignettes invalide")
|
||||
rows = json.loads(raw)
|
||||
if not isinstance(rows, list) or not 1 <= len(rows) <= MAX_IMAGES:
|
||||
raise ValueError("Nombre de vignettes invalide")
|
||||
result = []
|
||||
ids, foreign = set(), set()
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or set(row) != {"id", "foreignId"}:
|
||||
raise ValueError("Donnée de fenêtre invalide")
|
||||
wid, fid = row["id"], row["foreignId"]
|
||||
if (not isinstance(wid, str) or WINDOW_ID.fullmatch(wid) is None
|
||||
or not isinstance(fid, str) or FOREIGN_ID.fullmatch(fid) is None
|
||||
or wid in ids or fid in foreign):
|
||||
raise ValueError("Identifiant de fenêtre invalide")
|
||||
ids.add(wid)
|
||||
foreign.add(fid)
|
||||
result.append((wid, fid))
|
||||
return result
|
||||
|
||||
|
||||
def capture(raw, *, tool=None):
|
||||
selected = validate(raw)
|
||||
root = cache_root()
|
||||
purge(root)
|
||||
folder = Path(tempfile.mkdtemp(prefix="shot-", dir=root))
|
||||
output = {}
|
||||
tool = tool or str(Path.home() / ".local/libexec/labfy-wlthumbs")
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[tool, "-ids", ",".join(fid for _, fid in selected),
|
||||
"-size", "224", "-dir", str(folder)],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE, timeout=6, check=False)
|
||||
if completed.returncode != 0:
|
||||
return {"images": {}, "error": "Capture indisponible"}
|
||||
for wid, fid in selected:
|
||||
path = folder / (fid + ".png")
|
||||
try:
|
||||
info = path.lstat()
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
|
||||
continue
|
||||
if not 8 < info.st_size < MAX_PNG_BYTES:
|
||||
continue
|
||||
with path.open("rb") as stream:
|
||||
if stream.read(8) != PNG_MAGIC:
|
||||
continue
|
||||
output[wid] = path.as_uri()
|
||||
except OSError:
|
||||
continue
|
||||
return {"images": output, "error": ""}
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return {"images": {}, "error": "Capture indisponible"}
|
||||
finally:
|
||||
if not output:
|
||||
shutil.rmtree(folder, ignore_errors=True)
|
||||
|
||||
|
||||
def main(argv):
|
||||
try:
|
||||
if len(argv) == 1 and argv[0] == "clear":
|
||||
purge(cache_root(), all_dirs=True)
|
||||
return 0
|
||||
if len(argv) != 2 or argv[0] != "capture":
|
||||
raise ValueError("Commande invalide")
|
||||
result = capture(argv[1])
|
||||
print(json.dumps(result, ensure_ascii=False), flush=True)
|
||||
return 0
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
||||
print(json.dumps({"images": {}, "error": "Capture indisponible"}), flush=True)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
|
|
@ -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(); }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
42
quickshell/.config/quickshell/labfy-sway/dock/DockModel.js
Normal file
42
quickshell/.config/quickshell/labfy-sway/dock/DockModel.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
.pragma library
|
||||
|
||||
// The favorites file remains a pure ordered list of desktop IDs. User separators
|
||||
// belong to their own document and live at *gaps* between pinned applications.
|
||||
function entries(applications, separators) {
|
||||
const pinned = applications.filter(entry => entry.pinned);
|
||||
const running = applications.filter(entry => !entry.pinned);
|
||||
const result = [];
|
||||
const at = new Map();
|
||||
for (const separator of (separators || [])) {
|
||||
if (!separator || !/^sep-[0-9a-f]{12}$/.test(separator.id)
|
||||
|| !Number.isInteger(separator.position) || separator.position < 0)
|
||||
continue;
|
||||
const index = Math.min(pinned.length, separator.position);
|
||||
if (!at.has(index)) at.set(index, []);
|
||||
at.get(index).push({id: separator.id, kind: "separator",
|
||||
position: separator.position,
|
||||
name: "Séparateur", windows: [], pinned: false});
|
||||
}
|
||||
for (let i = 0; i <= pinned.length; ++i) {
|
||||
for (const sep of (at.get(i) || [])) result.push(sep);
|
||||
if (i < pinned.length) result.push(pinned[i]);
|
||||
}
|
||||
if (pinned.length && running.length)
|
||||
result.push({id: "dock-running-boundary", kind: "automatic-separator",
|
||||
name: "Applications ouvertes", windows: [], pinned: false});
|
||||
return result.concat(running);
|
||||
}
|
||||
|
||||
function hasSeparator(separators, position) {
|
||||
return (separators || []).some(sep => sep.position === position);
|
||||
}
|
||||
|
||||
// A pinned icon can move relative to other pinned icons, never into an
|
||||
// unpinned process or an arbitrary desktop ID.
|
||||
function moveTarget(ids, id, direction) {
|
||||
const index = ids.indexOf(id);
|
||||
if (direction === -1 && index > 0) return {id: id, before: ids[index - 1]};
|
||||
if (direction === 1 && index >= 0 && index < ids.length - 1)
|
||||
return {id: id, before: ids[index + 2] || ""};
|
||||
return null;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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", []):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=%h/.local/share/trainlog/rollouts/brand-android-v1-20261111/desktop/libexec/trainlog -w
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
"""Private Sway only; production Dock/Menu, real Python bridge and input paths."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
|
@ -13,7 +14,8 @@ from headless import Lab
|
|||
from ipc_proxy import IpcProxy
|
||||
|
||||
SOURCE = REPO / 'quickshell/.config/quickshell/labfy-sway'
|
||||
ARTIFACTS = REPO / 'docs/validation/quickshell-dock-tela-polish-v2'
|
||||
ARTIFACTS = Path(os.environ.get('LABFY_DOCK_TEST_ARTIFACTS',
|
||||
str(REPO / 'docs/validation/quickshell-dock-tela-polish-v2')))
|
||||
NINE = ['firefox.desktop', 'kitty.desktop', 'yazi.desktop', 'nvim.desktop',
|
||||
'org.darktable.darktable.desktop', 'orca-slicer.desktop', 'org.freecad.FreeCAD.desktop',
|
||||
'org.kde.digikam.desktop', 'AppFlowy.desktop']
|
||||
|
|
@ -34,6 +36,10 @@ class DockLab(Lab):
|
|||
with DockLab() as lab:
|
||||
proxy=IpcProxy(lab.directory/'dock-ipc.sock',lab.socket)
|
||||
lab.env['SWAYSOCK']=str(lab.directory/'dock-ipc.sock')
|
||||
# Capture helper in private HOME, bound only to the private Wayland socket.
|
||||
capture_tool=Path(lab.env['HOME'])/'.local/libexec/labfy-wlthumbs'
|
||||
capture_tool.parent.mkdir(parents=True,exist_ok=True)
|
||||
shutil.copy2(Path.home()/'.local/libexec/labfy-wlthumbs',capture_tool)
|
||||
path = Path(lab.env['XDG_STATE_HOME']) / 'labfy-sway/applications-favorites.json'
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps(dict(version=1, ids=NINE)))
|
||||
|
|
@ -80,6 +86,8 @@ ShellRoot {
|
|||
function state(): string {
|
||||
return JSON.stringify({ready:A.FavoritesService.ready, ids:A.FavoritesService.ids,
|
||||
error:A.FavoritesService.error, phase:dock.phase, revealed:dock.revealed,
|
||||
separators:A.FavoritesService.separators,
|
||||
dividerIds:Array.from(dock.contentItemRow.children).filter(t=>t.modelData && t.modelData.kind).map(t=>t.modelData.id),
|
||||
entries:dock.entries, theme:A.FavoritesService.iconTheme,
|
||||
menu:menu.active ? menu.item.favoriteIds : null,
|
||||
searchFocused:menu.active && menu.item.searchFocused,
|
||||
|
|
@ -87,6 +95,26 @@ ShellRoot {
|
|||
gridFocused:menu.active && menu.item.gridFocused,
|
||||
screenWidth:dock.screen.width,
|
||||
bodyY:dock.surfaceBody.y, width:dock.width, pointerX:dock.pointerX,
|
||||
bodyX:dock.surfaceBody.x, bodyWidth:dock.surfaceBody.width,
|
||||
sensorWidth:dock.edgeArea.width,
|
||||
bodyMaskWidth:dock.bodyMaskRegion.width,
|
||||
dismissMaskWidth:dock.dismissMaskRegion.width,
|
||||
contextMaskWidth:dock.contextMaskRegion.width,
|
||||
previewMaskWidth:dock.previewMaskRegion.width,
|
||||
previewVisible:dock.previewVisible,
|
||||
pendingPreviewId:dock.pendingPreviewId,
|
||||
previewArmRunning:dock.previewArmRunning,
|
||||
previewGeneration:dock.previewGeneration,
|
||||
previewWindowIds:dock.previewEntry ? dock.previewEntry.windows.map(w=>w.id) : [],
|
||||
previewCardCount:Array.from(dock.previewCardsRow.children).filter(c=>c.windowData).length,
|
||||
previewImageCount:Object.keys(dock.previewImages).length,
|
||||
hoveredTiles:Array.from(dock.contentItemRow.children)
|
||||
.filter(t=>t.modelData && t.children[3] && t.children[3].containsMouse)
|
||||
.map(t=>t.modelData.id),
|
||||
dockPressed:dock.pressed,
|
||||
dotCounts:Array.from(dock.contentItemRow.children)
|
||||
.filter(t=>t.modelData && !t.modelData.kind)
|
||||
.map(t=>({id:t.modelData.id,dots:t.dotCount})),
|
||||
|
||||
tileWidth:dock.contentItemRow.children[0] ? dock.contentItemRow.children[0].width : 0,
|
||||
tiles:Array.from(dock.contentItemRow.children).filter(t => t.modelData).map(t => ({
|
||||
|
|
@ -94,7 +122,9 @@ ShellRoot {
|
|||
visualLeft:t.children[1].mapToItem(dock.contentItemRow.parent.parent,0,0).x,
|
||||
visualRight:t.children[1].mapToItem(dock.contentItemRow.parent.parent,46,0).x,
|
||||
iconWidth:t.children[1].width, imageStatus:t.children[1].children[0].status})),
|
||||
context:!!dock.contextEntry});
|
||||
context:!!dock.contextEntry,
|
||||
contextAlpha:dock.contextCard.color.a,
|
||||
dismissVisible:dock.contextDismissArea.visible});
|
||||
}
|
||||
function startFrames(): void {
|
||||
sweepFrames.intervals=[]; sweepFrames.origin=dock.width;
|
||||
|
|
@ -107,7 +137,20 @@ ShellRoot {
|
|||
geometryChanged:sweepFrames.geometryChanged,imageChanged:sweepFrames.imageChanged});
|
||||
}
|
||||
function edge(): void { input.mouseMove(dock.edgeArea, dock.edgeArea.width/2, 2); }
|
||||
function edgeLeft(): void { input.mouseMove(dock.edgeArea, 2, 2); }
|
||||
function edgeRight(): void { input.mouseMove(dock.edgeArea, dock.edgeArea.width-3, 2); }
|
||||
function body(): void { input.mouseMove(dock.surfaceBody, 35, 70); }
|
||||
function hoverPinned(id:string): void {
|
||||
const tile=Array.from(dock.contentItemRow.children).find(t=>t.modelData && t.modelData.id===id);
|
||||
if(!tile) throw new Error('No tile '+id);
|
||||
input.mouseMove(tile.children[3],tile.width/2,65);
|
||||
}
|
||||
function hoverPreview(): void { input.mouseMove(dock.previewPopupItem,25,30); }
|
||||
function clickPreview(index:int): void {
|
||||
const cards=Array.from(dock.previewCardsRow.children).filter(c=>c.windowData);
|
||||
if(index<0 || index>=cards.length) throw new Error('No preview card');
|
||||
input.mouseClick(cards[index],cards[index].width/2,cards[index].height/2);
|
||||
}
|
||||
function sweep(x:real): void { input.mouseMove(dock.surfaceBody, x, 70); }
|
||||
function scrollEnd(): void {
|
||||
const v=dock.contentItemRow.parent.parent; v.contentX=Math.max(0,v.contentWidth-v.width);
|
||||
|
|
@ -116,6 +159,18 @@ ShellRoot {
|
|||
function leave(): void { input.mouseMove(dock.contentItem, 1, 1); }
|
||||
function menuToggle(id:string): void { if (!menu.active) menu.active=true; menu.item.toggleFavorite(id); }
|
||||
function dockToggle(id:string): void { A.FavoritesService.toggle(id); }
|
||||
function addSeparatorAt(position:int): void { A.FavoritesService.addSeparator(position); }
|
||||
function moveSeparatorTo(id:string,position:int): void { A.FavoritesService.moveSeparator(id,position); }
|
||||
function removeSeparator(id:string): void { A.FavoritesService.removeSeparator(id); }
|
||||
function openSeparatorMenu(): void {
|
||||
const tile=Array.from(dock.contentItemRow.children).find(t=>t.modelData && t.modelData.kind==="separator");
|
||||
if (!tile) throw new Error("missing separator tile");
|
||||
input.mouseClick(tile,tile.width/2,65,Qt.RightButton);
|
||||
}
|
||||
function removeSeparatorFromMenu(): void {
|
||||
const action=input.findChild(dock.contextCard,"dock-remove-separator");
|
||||
input.mouseClick(action,action.width/2,action.height/2);
|
||||
}
|
||||
function move(id:string, before:string): void { A.FavoritesService.move(id,before); }
|
||||
function closeMenu(): void { menu.active=false; }
|
||||
function tab(): void { input.keyClick(Qt.Key_Tab); }
|
||||
|
|
@ -126,6 +181,11 @@ ShellRoot {
|
|||
function rightClick(): void {
|
||||
input.mouseClick(dock.contentItemRow.children[0],27,65,Qt.RightButton);
|
||||
}
|
||||
function hoverContext(): void { input.mouseMove(dock.contextCard,15,15); }
|
||||
function clickAside(): void {
|
||||
const area=dock.contextDismissArea;
|
||||
input.mouseClick(area,5,Math.min(95,area.height-8));
|
||||
}
|
||||
function dragFirstAfterSecond(): void {
|
||||
const first=dock.contentItemRow.children[0];
|
||||
input.mousePress(first,27,65,Qt.LeftButton);
|
||||
|
|
@ -133,7 +193,7 @@ ShellRoot {
|
|||
input.mouseRelease(first,125,65,Qt.LeftButton);
|
||||
}
|
||||
function activateFirst(): void { input.mouseClick(dock.contentItemRow.children[0],27,65); }
|
||||
function context(): void { dock.openContext(dock.entries[0],dock.width/2); }
|
||||
function context(): void { dock.openContext(dock.entries[0],dock.surfaceBody.width/2); }
|
||||
function closeContext(): void { dock.closeContext(); }
|
||||
function suppress(value:bool): void { dock.suppressed=value; }
|
||||
function focus(id:string): void { A.FavoritesService.focusWindow(id); }
|
||||
|
|
@ -159,10 +219,69 @@ ShellRoot {
|
|||
time.sleep(1.2)
|
||||
initial=wait(lambda s:s['ready'])
|
||||
assert initial['ids']==NINE and initial['phase']=='hidden', initial
|
||||
# Regression: hidden popup areas must NOT subtract holes from the
|
||||
# four-pixel sensor (especially at the centered dock position).
|
||||
assert initial['sensorWidth']==initial['screenWidth'], initial
|
||||
assert initial['previewMaskWidth']==0 and not initial['previewVisible'],initial
|
||||
assert all(initial[key]==0 for key in
|
||||
('bodyMaskWidth', 'dismissMaskWidth', 'contextMaskWidth')), initial
|
||||
ARTIFACTS.mkdir(parents=True,exist_ok=True)
|
||||
def capture(name):
|
||||
subprocess.run(['grim',str(ARTIFACTS / (name+'.png'))],env=lab.env,check=True)
|
||||
capture('closed')
|
||||
# Separator mutations must be durable and visible to the same live model.
|
||||
call('edge'); wait(lambda s:s['revealed'])
|
||||
call('addSeparatorAt',2)
|
||||
state=wait(lambda s:len(s['separators'])==1 and len(s['dividerIds'])==1)
|
||||
separator_id=state['separators'][0]['id']
|
||||
assert state['separators'][0]['position']==2,state
|
||||
assert state['dividerIds']==[separator_id],state
|
||||
separator_file=Path(lab.env['XDG_STATE_HOME'])/'labfy-sway/dock-separators.json'
|
||||
assert json.loads(separator_file.read_text())['separators'][0]['id']==separator_id
|
||||
call('moveSeparatorTo',separator_id,3)
|
||||
wait(lambda s:s['separators'][0]['position']==3)
|
||||
call('openSeparatorMenu')
|
||||
wait(lambda s:s['context'] and s['phase']=='held')
|
||||
call('removeSeparatorFromMenu')
|
||||
wait(lambda s:not s['separators'] and not s['dividerIds'] and not s['context'])
|
||||
assert json.loads(separator_file.read_text())['separators']==[]
|
||||
call('leave'); wait(lambda s:s['phase']=='hidden')
|
||||
# Translucent menu; hover keeps it open, clicking beside closes at once;
|
||||
# moving away allows a short grace without a visible close button.
|
||||
call('edge'); wait(lambda s:s['revealed'])
|
||||
call('rightClick')
|
||||
opened=wait(lambda s:s['context'])
|
||||
assert opened['dismissVisible'],opened
|
||||
assert opened['dismissMaskWidth'] > 0 and opened['contextMaskWidth'] > 0, opened
|
||||
assert abs(opened['contextAlpha'] - .85) < .01,opened
|
||||
call('hoverContext'); time.sleep(.72)
|
||||
assert call('state')['context'],call('state')
|
||||
call('clickAside')
|
||||
after_close=wait(lambda s:not s['context'])
|
||||
assert after_close['dismissMaskWidth']==0 and after_close['contextMaskWidth']==0, after_close
|
||||
call('edge'); wait(lambda s:s['revealed'])
|
||||
call('rightClick'); wait(lambda s:s['context'])
|
||||
call('leave'); wait(lambda s:not s['context'],2)
|
||||
hidden_after_popup=wait(lambda s:s['phase']=='hidden')
|
||||
assert all(hidden_after_popup[key]==0 for key in
|
||||
('bodyMaskWidth', 'dismissMaskWidth', 'contextMaskWidth')), hidden_after_popup
|
||||
# A fresh center-edge hover must work after dismissing a popup.
|
||||
call('edge')
|
||||
center=wait(lambda s:s['revealed'])
|
||||
assert center['sensorWidth']==center['screenWidth'], center
|
||||
call('leave'); wait(lambda s:s['phase']=='hidden')
|
||||
# Every horizontal position of the four-pixel strip must arm the same
|
||||
# centered dock, including both physical screen corners.
|
||||
assert initial['width']==initial['screenWidth'], initial
|
||||
assert initial['sensorWidth']==initial['screenWidth'], initial
|
||||
assert initial['bodyWidth']<initial['width'], initial
|
||||
assert abs(initial['bodyX']*2 + initial['bodyWidth']-initial['width'])<1.1, initial
|
||||
for corner in ('edgeLeft', 'edgeRight'):
|
||||
call(corner)
|
||||
revealed=wait(lambda s:s['revealed'])
|
||||
assert revealed['sensorWidth']==revealed['screenWidth'], revealed
|
||||
assert abs(revealed['bodyX']*2+revealed['bodyWidth']-revealed['width'])<1.1, revealed
|
||||
call('leave'); wait(lambda s:s['phase']=='hidden')
|
||||
call('edge'); wait(lambda s:s['revealed']); time.sleep(.35)
|
||||
capture('open')
|
||||
resting=call('state')
|
||||
|
|
@ -264,18 +383,49 @@ ShellRoot {
|
|||
assert str(focused())==firewin
|
||||
for action in ('edge','body','leave'):
|
||||
call(action); time.sleep(.25); assert str(focused())==firewin
|
||||
# Three real test windows must show three dots and three selectable cards.
|
||||
extra1=lab.kitty('kitty')
|
||||
extra2=lab.kitty('kitty')
|
||||
grouped=wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==3 for e in s['entries']))
|
||||
dots=next(t for t in grouped['dotCounts'] if t['id']=='kitty.desktop')
|
||||
assert dots['dots']==3,grouped
|
||||
focus_before=focused()
|
||||
call('edge'); wait(lambda s:s['revealed'])
|
||||
call('hoverPinned','kitty.desktop')
|
||||
peek=wait(lambda s:s['previewVisible'] and s['previewCardCount']==3,12)
|
||||
assert peek['previewMaskWidth']>0 and len(peek['previewWindowIds'])==3,peek
|
||||
assert focused()==focus_before,'Hover changed focus'
|
||||
call('hoverPreview'); time.sleep(.8)
|
||||
active_preview=call('state')
|
||||
assert active_preview['previewVisible'],'Preview must stay open on hover'
|
||||
captured=wait(lambda s:s['previewImageCount']>=1,5)
|
||||
assert captured['previewImageCount'] <= 3,captured
|
||||
print('PRIVATE_CAPTURED_PREVIEWS=',captured['previewImageCount'])
|
||||
target=int(peek['previewWindowIds'][0])
|
||||
call('clickPreview',0)
|
||||
wait(lambda s:not s['previewVisible'])
|
||||
for _ in range(20):
|
||||
if focused()==target:break
|
||||
time.sleep(.1)
|
||||
assert focused()==target,(focused(),target)
|
||||
call('leave'); wait(lambda s:s['phase']=='hidden')
|
||||
cache=Path(lab.env['XDG_RUNTIME_DIR'])/'labfy-dock-window-thumbnails'
|
||||
for _ in range(30):
|
||||
if not cache.exists() or not list(cache.iterdir()):break
|
||||
time.sleep(.15)
|
||||
assert not cache.exists() or not list(cache.iterdir()),cache
|
||||
# Layer-shell surfaces cannot enter the Session V2 toplevel inventory.
|
||||
assert not any('labfy-sway-dock' in str(n.get('app_id','')) for n in lab.windows())
|
||||
lab.command('output HEADLESS-1 mode 800x600')
|
||||
resized=wait(lambda s:s['screenWidth']==800)
|
||||
assert resized['width']<=776,resized
|
||||
assert resized['width']==800 and resized['bodyWidth']<=776,resized
|
||||
# Magnified end icons keep their visual margin at both scroll limits.
|
||||
lab.command('output HEADLESS-1 mode 400x600')
|
||||
wait(lambda s:s['screenWidth']==400)
|
||||
call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
|
||||
call('scrollEnd'); call('sweep',325); time.sleep(.15)
|
||||
end=call('state')
|
||||
assert end['tiles'][-1]['scale']>1 and end['tiles'][-1]['visualRight']<=end['width']-24,end
|
||||
assert end['tiles'][-1]['scale']>1 and end['tiles'][-1]['visualRight']<=end['bodyWidth']-24,end
|
||||
call('scrollStart'); call('sweep',51); time.sleep(.15)
|
||||
start=call('state')
|
||||
assert start['tiles'][0]['scale']>1 and start['tiles'][0]['visualLeft']>=0,start
|
||||
|
|
@ -290,7 +440,8 @@ ShellRoot {
|
|||
assert wait(lambda s:s['ready'])['ids']==[]
|
||||
(ARTIFACTS/'result.json').write_text(json.dumps(dict(status='PASS',initial=initial,hoverSamples=samples,animationTiming=timing,frameSweep=frames,
|
||||
reordered=reordered['ids'],checks=['shared favorites','menu and dock mutations','durable reorder',
|
||||
'empty restart','autohide','context hold','kitty multi-window','floating scratchpad explicit focus',
|
||||
'empty restart','autohide','full-width left/right edge activation','centered visual dock',
|
||||
'context hold','kitty multi-window','floating scratchpad explicit focus',
|
||||
'window close','no hover focus','real pointer drag','fixed layout transform hover', 'stable SVG readiness', 'lateral sweep 73 samples','IPC reconnect','Firefox private profile','desktop install/remove monitoring','external writer','keyboard menu navigation','resolution change','SwayFX dock blur disabled']),indent=2)+'\n')
|
||||
proxy.close()
|
||||
print('Dock headless: PASS',lab.directory)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
82
tests/dock/test_separators.py
Normal file
82
tests/dock/test_separators.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Separators persist independently of the v1 favorites file."""
|
||||
import concurrent.futures
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
APP = Path(__file__).resolve().parents[2] / "quickshell/.config/quickshell/labfy-sway/applications"
|
||||
sys.path.insert(0, str(APP))
|
||||
import backend
|
||||
import dock_separators as sep
|
||||
|
||||
|
||||
class SeparatorPersistence(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.favorites = Path(self.temp.name) / "labfy-sway/applications-favorites.json"
|
||||
backend.write_favorites(self.favorites, "seed",
|
||||
["a.desktop", "b.desktop", "c.desktop"])
|
||||
self.original = self.favorites.read_bytes()
|
||||
self.path = sep.path_for(self.favorites)
|
||||
|
||||
def test_add_move_delete_durable(self):
|
||||
self.assertEqual(sep.read(self.path), [])
|
||||
first = sep.write(self.path, "add", [1])
|
||||
self.assertEqual(len(first), 1)
|
||||
self.assertEqual(first[0]["position"], 1)
|
||||
identifier = first[0]["id"]
|
||||
self.assertRegex(identifier, r"^sep-[0-9a-f]{12}$")
|
||||
self.assertEqual(sep.read(self.path), first)
|
||||
self.assertEqual(sep.write(self.path, "move", [identifier, 2])[0]["position"], 2)
|
||||
second = sep.write(self.path, "add", [0])
|
||||
self.assertEqual([x["position"] for x in second], [0, 2])
|
||||
self.assertEqual(sep.write(self.path, "remove", [identifier])[0]["position"], 0)
|
||||
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(self.favorites.read_bytes(), self.original)
|
||||
|
||||
def test_invalid_actions_never_replace_previous_state(self):
|
||||
entry = sep.write(self.path, "add", [1])[0]
|
||||
before = self.path.read_bytes()
|
||||
invalid = [("add", [1]), ("add", [-1]), ("add", [True]),
|
||||
("add", ["1"]), ("add", [65]), ("move", [entry["id"], 65]),
|
||||
("move", ["sep-000000000000", 3]), ("move", [entry["id"], False]),
|
||||
("remove", ["../escape"]), ("remove", ["sep-000000000000"]),
|
||||
("invalid", [])]
|
||||
for action, value in invalid:
|
||||
with self.subTest(action=action, value=value), self.assertRaises(ValueError):
|
||||
sep.write(self.path, action, value)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
self.assertEqual(self.favorites.read_bytes(), self.original)
|
||||
|
||||
def test_corrupt_and_symlink_state_refuse_overwrite(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text('{"version":1,"separators":[{"id":"invalid","position":2}]}')
|
||||
before = self.path.read_bytes()
|
||||
with self.assertRaises(ValueError):
|
||||
sep.write(self.path, "add", [1])
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
self.path.unlink()
|
||||
self.path.symlink_to(self.favorites)
|
||||
with self.assertRaises(ValueError):
|
||||
sep.write(self.path, "add", [1])
|
||||
self.assertEqual(self.favorites.read_bytes(), self.original)
|
||||
|
||||
def test_concurrent_changes_stay_atomic_and_bounded(self):
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:
|
||||
items = list(pool.map(lambda i: sep.write(self.path, "add", [i]),
|
||||
range(10)))
|
||||
self.assertEqual(len(items), 10)
|
||||
self.assertEqual(len(sep.read(self.path)), 10)
|
||||
for index in range(10, 16):
|
||||
sep.write(self.path, "add", [index])
|
||||
with self.assertRaisesRegex(ValueError, "Trop de séparateurs"):
|
||||
sep.write(self.path, "add", [16])
|
||||
self.assertEqual(len(sep.read(self.path)), 16)
|
||||
self.assertEqual(self.favorites.read_bytes(), self.original)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
76
tests/dock/test_thumbnails.py
Normal file
76
tests/dock/test_thumbnails.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Private, one-shot thumbnail contract; never grabs a desktop during tests."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
SOURCE = (Path(__file__).resolve().parents[2] /
|
||||
"quickshell/.config/quickshell/labfy-sway/bin/dock-thumbnails.py")
|
||||
spec = importlib.util.spec_from_file_location("labfy_dock_thumbnails", SOURCE)
|
||||
thumbs = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(thumbs)
|
||||
|
||||
|
||||
class ThumbnailContract(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
env = mock.patch.dict(os.environ, XDG_RUNTIME_DIR=self.temp.name)
|
||||
env.start()
|
||||
self.addCleanup(env.stop)
|
||||
self.a = {"id": "51", "foreignId": "a" * 32}
|
||||
self.b = {"id": "52", "foreignId": "b" * 32}
|
||||
|
||||
def test_strict_window_ids_and_limit(self):
|
||||
valid = thumbs.validate(json.dumps([self.a, self.b]))
|
||||
self.assertEqual(valid, [("51", "a" * 32), ("52", "b" * 32)])
|
||||
for values in ([], [self.a] * 2, [self.a] * 9,
|
||||
[{"id": "../../x", "foreignId": "a" * 32}],
|
||||
[{"id": "51", "foreignId": "../target"}],
|
||||
[{"id": "51", "foreignId": "A" * 32}],
|
||||
[{"id": 51, "foreignId": "a" * 32}],
|
||||
[{"id": "51", "foreignId": "a" * 32, "title": "private"}]):
|
||||
with self.subTest(rows=values), self.assertRaises(ValueError):
|
||||
thumbs.validate(json.dumps(values))
|
||||
|
||||
def test_approved_ids_only_private_files_and_cleanup(self):
|
||||
calls = []
|
||||
def runner(argv, **kwargs):
|
||||
calls.append((argv, kwargs))
|
||||
folder = Path(argv[argv.index("-dir") + 1])
|
||||
self.assertTrue(folder.is_dir())
|
||||
self.assertEqual(folder.parent, thumbs.cache_root())
|
||||
self.assertEqual(folder.parent.stat().st_mode & 0o777, 0o700)
|
||||
self.assertEqual(argv[argv.index("-ids")+1], "a" * 32 + "," + "b" * 32)
|
||||
self.assertEqual(argv[argv.index("-size")+1], "224")
|
||||
for ident in ("a" * 32, "b" * 32):
|
||||
(folder / (ident + ".png")).write_bytes(thumbs.PNG_MAGIC + b"specimen")
|
||||
return mock.Mock(returncode=0)
|
||||
with mock.patch.object(thumbs.subprocess, "run", side_effect=runner):
|
||||
result = thumbs.capture(json.dumps([self.a, self.b]), tool="/mock/trusted-capture")
|
||||
self.assertEqual(set(result["images"]), {"51", "52"})
|
||||
self.assertEqual(result["error"], "")
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertTrue(all(uri.startswith(thumbs.cache_root().as_uri())
|
||||
for uri in result["images"].values()))
|
||||
thumbs.purge(thumbs.cache_root(), all_dirs=True)
|
||||
self.assertEqual(list(thumbs.cache_root().iterdir()), [])
|
||||
|
||||
def test_failure_does_not_leak_files_or_capture_other_windows(self):
|
||||
with mock.patch.object(thumbs.subprocess, "run", return_value=mock.Mock(returncode=3)):
|
||||
self.assertEqual(thumbs.capture(json.dumps([self.a]), tool="/mock/fail")["images"], {})
|
||||
self.assertEqual(list(thumbs.cache_root().iterdir()), [])
|
||||
|
||||
def test_invalid_and_symlink_cache_refused(self):
|
||||
root = thumbs.cache_root()
|
||||
root.rmdir()
|
||||
root.symlink_to(Path(self.temp.name))
|
||||
with self.assertRaises(ValueError):
|
||||
thumbs.cache_root()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
87
tests/session_v2/test_startup_x11_stability.py
Normal file
87
tests/session_v2/test_startup_x11_stability.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""TEST_ONLY: late ChatGPT X11 window remapping during prewarm.
|
||||
|
||||
An old con_id may be replaced before the transaction. Never admit the
|
||||
transient leaf, and never relax identities after Recovery starts.
|
||||
"""
|
||||
import copy
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from common import *
|
||||
from session_v2.executor import Executor
|
||||
from session_v2.errors import Failure
|
||||
|
||||
|
||||
class StartupX11StabilityTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def state(ident, app, ws, pid):
|
||||
return {
|
||||
"con_id": ident, "app_id": app, "pid": pid,
|
||||
"starttime": "123", "workspace": ws, "output": "HEADLESS-1",
|
||||
"path": [0], "floating": False, "geometry": None,
|
||||
"scratchpad": "none",
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
self.sway = Mock(session="a" * 64)
|
||||
self.engine = Executor.__new__(Executor)
|
||||
self.engine.sway = self.sway
|
||||
self.base = {5: self.state(5, "limusic-app", "10", 500)}
|
||||
self.spec = Spec("chatgpt", "chatgpt.desktop",
|
||||
("x11-class:Chatgpt",), "single-window",
|
||||
("/usr/bin/chatgpt",))
|
||||
self.allowed = {"limusic-app", "x11-class:Chatgpt"}
|
||||
|
||||
def test_late_remap_is_reobserved_before_pinning(self):
|
||||
started = time.monotonic()
|
||||
def sample():
|
||||
elapsed = time.monotonic() - started
|
||||
con_id = 17 if elapsed < .07 else 32
|
||||
return {**copy.deepcopy(self.base),
|
||||
con_id: self.state(con_id, "x11-class:Chatgpt", "1", 753170)}
|
||||
self.engine._observe_prepared = sample
|
||||
result = self.engine._settle_fresh_chatgpt(
|
||||
self.base, self.spec, self.sway.session, self.allowed,
|
||||
.7, minimum_age=.22, stable_for=.09)
|
||||
self.assertIn(32, result)
|
||||
self.assertNotIn(17, result)
|
||||
self.sway.command.assert_not_called()
|
||||
|
||||
def test_preexisting_client_change_is_never_accepted(self):
|
||||
def sample():
|
||||
d = copy.deepcopy(self.base)
|
||||
d[5]["pid"] = 501
|
||||
d[17] = self.state(17, "x11-class:Chatgpt", "1", 753170)
|
||||
return d
|
||||
self.engine._observe_prepared = sample
|
||||
with self.assertRaisesRegex(Failure, "TARGET_OBSERVATION_CHANGED"):
|
||||
self.engine._settle_fresh_chatgpt(
|
||||
self.base, self.spec, self.sway.session, self.allowed,
|
||||
.4, minimum_age=.1, stable_for=.05)
|
||||
|
||||
def test_unknown_new_application_is_not_admitted(self):
|
||||
self.engine._observe_prepared = lambda: {
|
||||
**copy.deepcopy(self.base),
|
||||
17: self.state(17, "x11-class:Chatgpt", "1", 753170),
|
||||
99: self.state(99, "intruder", "2", 999),
|
||||
}
|
||||
with self.assertRaisesRegex(Failure, "APPLICATION_MEMBERSHIP_CHANGED"):
|
||||
self.engine._settle_fresh_chatgpt(
|
||||
self.base, self.spec, self.sway.session, self.allowed,
|
||||
.3, minimum_age=.1, stable_for=.05)
|
||||
|
||||
def test_incomplete_settle_deferred_before_transaction(self):
|
||||
self.engine._observe_prepared = lambda: {
|
||||
**copy.deepcopy(self.base),
|
||||
17: self.state(17, "x11-class:Chatgpt", "1", 753170),
|
||||
}
|
||||
with self.assertRaisesRegex(Failure, "APPLICATION_WINDOWS_INCOMPLETE"):
|
||||
self.engine._settle_fresh_chatgpt(
|
||||
self.base, self.spec, self.sway.session, self.allowed,
|
||||
.08, minimum_age=.3, stable_for=.05)
|
||||
self.sway.command.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
95
tests/session_v2/test_unary_layout.py
Normal file
95
tests/session_v2/test_unary_layout.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Regression: unary tabbed/stacked Sway containers are restorable V2 nodes.
|
||||
|
||||
No commands are sent to the user's active compositor.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from common import *
|
||||
from test_prewarm import Sway
|
||||
from session_v2.snapshot import capture
|
||||
from session_v2.ipc import workspace, canonical
|
||||
from session_v2.schema import validate
|
||||
from session_v2.layout import compile_plan
|
||||
from session_v2.checkpoint import Store
|
||||
from session_v2.errors import Failure
|
||||
|
||||
|
||||
class UnaryLayoutTests(unittest.TestCase):
|
||||
def sample(self, layout):
|
||||
sway = Sway()
|
||||
ws = workspace(sway.value, "1")
|
||||
workspace(sway.value, "10")["layout"] = "splith"
|
||||
ws["layout"] = "splith"
|
||||
first = sway.add("kitty", 7, focus=True)
|
||||
second = sway.add("proton-pass", 8, focus=False)
|
||||
ws["nodes"] = [
|
||||
first,
|
||||
{"id": 70, "type": "con", "layout": layout, "nodes": [second]},
|
||||
]
|
||||
return sway
|
||||
|
||||
def test_unary_tabbed_and_stacked_capture_schema_and_persistence(self):
|
||||
for layout in ("tabbed", "stacked"):
|
||||
with self.subTest(layout=layout):
|
||||
sway = self.sample(layout)
|
||||
before = sway.tree()
|
||||
data = capture(sway, Catalog(), None)
|
||||
root = next(w["root"] for w in data["workspaces"] if w["workspace"] == "1")
|
||||
self.assertEqual(root["layout"], "splith")
|
||||
group = root["children"][1]
|
||||
self.assertEqual(group["layout"], layout)
|
||||
self.assertEqual(len(group["children"]), 1)
|
||||
slot = group["children"][0]["slot_id"]
|
||||
self.assertEqual(next(s["tree_path"] for s in data["window_slots"]
|
||||
if s["slot_id"] == slot), [1, 0])
|
||||
self.assertEqual(validate(data), data)
|
||||
self.assertEqual(sway.tree(), before)
|
||||
plan = compile_plan(data, "a" * 32)
|
||||
self.assertTrue(any(op.kind == "layout" and op.layout == layout and
|
||||
op.parent == (1,) for op in plan))
|
||||
mapping = {7: root["children"][0]["slot_id"], 8: slot}
|
||||
self.assertEqual(canonical(before, "1", mapping),
|
||||
{"splith": [root["children"][0]["slot_id"],
|
||||
{layout: [slot]}]})
|
||||
with tempfile.TemporaryDirectory() as path:
|
||||
store = Store(Path(path))
|
||||
store.save("unary", data)
|
||||
self.assertEqual(store.load("unary"), data)
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
continue
|
||||
schema = json.loads((BACKEND / "session_v2/schema-v2.json").read_text())
|
||||
jsonschema.Draft202012Validator.check_schema(schema)
|
||||
jsonschema.Draft202012Validator(schema).validate(data)
|
||||
|
||||
def test_unary_split_and_none_remain_normalized(self):
|
||||
for layout in ("splith", "splitv", "none"):
|
||||
with self.subTest(layout=layout):
|
||||
sway = self.sample(layout)
|
||||
data = capture(sway, Catalog(), None)
|
||||
root = next(w["root"] for w in data["workspaces"] if w["workspace"] == "1")
|
||||
self.assertIn("slot_id", root["children"][1])
|
||||
self.assertEqual(len(data["window_slots"]), 3)
|
||||
|
||||
def test_invalid_unary_split_stays_rejected(self):
|
||||
data = capture(self.sample("tabbed"), Catalog(), None)
|
||||
root = next(w["root"] for w in data["workspaces"] if w["workspace"] == "1")
|
||||
root["children"][1]["layout"] = "splitv"
|
||||
with self.assertRaisesRegex(Failure, "TREE_ARITY"):
|
||||
validate(data)
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
return
|
||||
from jsonschema import ValidationError
|
||||
contract = json.loads((BACKEND / "session_v2/schema-v2.json").read_text())
|
||||
with self.assertRaises(ValidationError):
|
||||
jsonschema.validate(data, contract)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -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"):
|
||||
|
|
|
|||
55
tests/session_v2/unary_layout_runtime.py
Normal file
55
tests/session_v2/unary_layout_runtime.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env python3
|
||||
"""TEST_ONLY: reconstruct nested unary tabbed/stacked under isolated SwayFX.
|
||||
|
||||
No IPC goes to the user's Sway socket; all processes are lab-owned children.
|
||||
"""
|
||||
from common import *
|
||||
from lab import Lab, Surfaces, build_helper
|
||||
from session_v2.ipc import Sway
|
||||
from session_v2.schema import validate
|
||||
from session_v2.snapshot import capture
|
||||
from session_v2.executor import Executor
|
||||
from session_v2.storage import private_dir
|
||||
|
||||
|
||||
def target(layout):
|
||||
data = fixture({"splith": ["A", "B"]})
|
||||
data["version"] = 2
|
||||
data["focus_workspace"] = "TEST_ONLY_TARGET"
|
||||
for slot in data["window_slots"]:
|
||||
slot["state"]["scratchpad"] = "none"
|
||||
data["workspaces"][0]["root"]["children"][1] = {
|
||||
"layout": layout, "children": [{"slot_id": "B"}]}
|
||||
next(s for s in data["window_slots"] if s["slot_id"] == "B")["tree_path"] = [1, 0]
|
||||
return validate(data)
|
||||
|
||||
|
||||
def main():
|
||||
with Lab() as lab:
|
||||
helper = build(lab.directory)
|
||||
surface_binary = build_helper(lab.directory)
|
||||
runtime = private_dir(lab.directory / "runtime-v2")
|
||||
sway = Sway(lab.socket)
|
||||
for layout in ("tabbed", "stacked"):
|
||||
data = target(layout)
|
||||
apps = Surfaces(lab, surface_binary)
|
||||
lab.command('workspace "TEST_ONLY_SOURCE"')
|
||||
try:
|
||||
apps.create("TEST_ONLY_app-A")
|
||||
apps.create("TEST_ONLY_app-B")
|
||||
result = Executor(sway, catalog(data), None, runtime,
|
||||
helper, lab.env).apply(data, True)
|
||||
saved = capture(sway, catalog(data), None)
|
||||
root = next(w["root"] for w in saved["workspaces"]
|
||||
if w["workspace"] == "TEST_ONLY_TARGET")
|
||||
assert root["layout"] == "splith", root
|
||||
child = root["children"][1]
|
||||
assert child["layout"] == layout and len(child["children"]) == 1, root
|
||||
assert result["status"] == "success", result
|
||||
print(f"HEADLESS_{layout.upper()}_RECONSTRUCTION=PASS", flush=True)
|
||||
finally:
|
||||
apps.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -42,6 +42,26 @@ class CollectorTests(unittest.TestCase):
|
|||
self.assertEqual(rows[0]["origin"], "Arch")
|
||||
self.assertEqual(self.collect(["yay", "-Qua"], 0, "pkg-git 1 -> 2\n")[0]["origin"], "AUR")
|
||||
|
||||
def test_aur_relative_age_suffix_keeps_versions_and_count(self):
|
||||
output = ("proton-pass-bin 1.43.0-1 -> 1.43.1-1 [12h36m]\n"
|
||||
"example-bin 2:2.0-1 -> 2:2.1-1 [2d3h]\n"
|
||||
"plain-git 1 -> 2\n")
|
||||
rows = self.collect(["yay", "-Qua"], 0, output)
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(rows[0]["name"], "proton-pass-bin")
|
||||
self.assertEqual(rows[0]["available"], "1.43.1-1")
|
||||
self.assertEqual(rows[1]["available"], "2:2.1-1")
|
||||
self.assertEqual([row["origin"] for row in rows], ["AUR"] * 3)
|
||||
|
||||
def test_age_suffix_is_bounded_and_aur_only(self):
|
||||
for output in ("pkg 1 -> 2 [something went wrong]\n",
|
||||
"pkg 1 -> 2 [12h36m] extra\n",
|
||||
"pkg 1 -> 2 [123456789h]\n" * 100):
|
||||
with self.subTest(output=output[:40]), self.assertRaises(RuntimeError):
|
||||
self.collect(["yay", "-Qua"], 0, output)
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.collect(["checkupdates"], 0, "pkg 1 -> 2 [12h36m]\n")
|
||||
|
||||
def test_errors_not_zero(self):
|
||||
for command, code, stdout, stderr in ((["checkupdates"], 1, "", ""), (["checkupdates"], 2, "", "failure"),
|
||||
(["yay"], 1, "", "network"), (["yay"], 0, "bad output", "")):
|
||||
|
|
|
|||
20
tools/build-labfy-wlthumbs.sh
Executable file
20
tools/build-labfy-wlthumbs.sh
Executable file
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build the audited, MIT-licensed, ID-filtered Wayland window snapshot helper.
|
||||
# User scope only. No sudo, no package installation and no session restart.
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
src="$root/wlthumbs"
|
||||
dest="$HOME/.local/libexec"
|
||||
mkdir -p -- "$dest"
|
||||
tmp="$(mktemp -- "$dest/.labfy-wlthumbs.XXXXXXXX")"
|
||||
trap 'rm -f -- "$tmp"' EXIT
|
||||
|
||||
(
|
||||
cd -- "$src"
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$tmp" .
|
||||
)
|
||||
chmod 755 "$tmp"
|
||||
mv -f -- "$tmp" "$dest/labfy-wlthumbs"
|
||||
printf 'Binary installed: %s/labfy-wlthumbs\n' "$dest"
|
||||
sha256sum "$dest/labfy-wlthumbs"
|
||||
21
tools/wlthumbs/LICENSE
Normal file
21
tools/wlthumbs/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Milad Alizadeh
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
29
tools/wlthumbs/README.LABFY.md
Normal file
29
tools/wlthumbs/README.LABFY.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# wlthumbs — copie spécifique au dock Labfy
|
||||
|
||||
Source amont MIT : https://github.com/mil-ad/wlthumbs
|
||||
Commit de référence : `516603c3144078b4d974108b80f7e9622ee4f3c9`
|
||||
La licence MIT d'origine est conservée dans `LICENSE`.
|
||||
|
||||
**Différence intentionnelle :** un argument `-ids` obligatoire (1 à 8
|
||||
identifiants `foreign_toplevel_identifier` hexadécimaux de 32 caractères),
|
||||
avec liste blanche appliquée **avant** toute création de source de capture.
|
||||
La capture des sorties complètes par `-outputs` est refusée dans notre version.
|
||||
C'est une précaution essentielle : le dock ne doit jamais capturer toutes les
|
||||
fenêtres quand l'utilisateur survole une seule application.
|
||||
|
||||
Ce programme ne surveille ni le bureau ni le pointeur. Il est lancé uniquement
|
||||
à la demande par `bin/dock-thumbnails.py`, effectue une seule capture par fenêtre
|
||||
autorisée, puis se termine.
|
||||
|
||||
Pour construire et installer dans le répertoire utilisateur :
|
||||
|
||||
```sh
|
||||
~/.dotfiles/tools/build-labfy-wlthumbs.sh
|
||||
```
|
||||
|
||||
Dépendances de compilation : Go (module déclaré dans `go.mod`, versions
|
||||
figées dans `go.sum`). Pas de `sudo`, aucune modification de SwayFX, ni
|
||||
de connexion réseau à l'exécution du programme compilé.
|
||||
|
||||
Voir `docs/quickshell-dock-window-previews-v1.md` pour les restrictions de
|
||||
confidentialité, le cache temporaire et les tests.
|
||||
85
tools/wlthumbs/README.md
Normal file
85
tools/wlthumbs/README.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# wlthumbs
|
||||
|
||||
A tiny Wayland client that captures thumbnails of every open window (and,
|
||||
optionally, every monitor) over a **single** Wayland connection, using the
|
||||
wlroots capture protocols:
|
||||
|
||||
- `ext-foreign-toplevel-list-v1` — enumerate windows
|
||||
- `ext-image-capture-source-v1` — a capture source from a toplevel handle or output
|
||||
- `ext-image-copy-capture-v1` — copy a frame into a `wl_shm` buffer
|
||||
|
||||
It scales each frame in-process and writes a small PNG, printing one
|
||||
tab-separated row per source:
|
||||
|
||||
```
|
||||
TYPE <TAB> ID <TAB> THUMBPATH <TAB> APP <TAB> TITLE
|
||||
```
|
||||
|
||||
- `TYPE` — `window` or `output`
|
||||
- `ID` — the window's `foreign_toplevel_identifier` (matches sway's
|
||||
`foreign_toplevel_identifier`) or the output name (e.g. `DP-1`)
|
||||
- `THUMBPATH` — path to the PNG, or empty if that source failed to capture
|
||||
- `APP` — `app_id` (window) or `make model` (output)
|
||||
- `TITLE` — window title, or output name
|
||||
|
||||
It does one thing: dump window/monitor thumbnails for a shell front-end (e.g. a
|
||||
rofi grid) to consume. It does **not** draw any UI.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
wlthumbs [-size N] [-dir DIR] [-windows] [-outputs]
|
||||
```
|
||||
|
||||
- `-size N` max thumbnail dimension in px (default 256)
|
||||
- `-dir DIR` output directory (default: a fresh temp dir under `$XDG_RUNTIME_DIR`)
|
||||
- `-windows` capture windows (default true)
|
||||
- `-outputs` also capture monitors (default false)
|
||||
|
||||
Each thumbnail is named `<ID>.png` (the ID column), so a caller that already
|
||||
knows the id can find the file directly — `"$dir/$id.png"` — without parsing
|
||||
stdout; a missing file means that source failed to capture.
|
||||
|
||||
The caller owns `-dir`'s lifetime — wlthumbs never deletes thumbnails (a front-end
|
||||
typically reads them after wlthumbs exits). With no `-dir`, PNGs land in a temp dir
|
||||
under `$XDG_RUNTIME_DIR` that you should clean up yourself.
|
||||
|
||||
Set `WLTHUMBS_TIME=1` to print phase timings to stderr.
|
||||
|
||||
Example — thumbnail every window, then feed a rofi grid:
|
||||
|
||||
```sh
|
||||
wlthumbs -size 220 | while IFS=$'\t' read -r type id thumb app title; do
|
||||
printf '%s\x00icon\x1f%s\n' "$title" "$thumb"
|
||||
done | rofi -dmenu -show-icons
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
```
|
||||
go build -o wlthumbs .
|
||||
```
|
||||
|
||||
Requires a wlroots-based compositor (sway, etc.) advertising the three `ext-*`
|
||||
protocols above. Pure Go — no cgo.
|
||||
|
||||
## Notes
|
||||
|
||||
- Captures run sequentially on the one connection (wlroots does not parallelize
|
||||
them); each frame is scaled/encoded on a worker goroutine while the next window
|
||||
is captured, so encode cost hides under capture time.
|
||||
- The protocol bindings in `proto/` are generated from the XML in `protocol/`
|
||||
and committed. See `generate.sh` to regenerate (and the manual patch it
|
||||
documents).
|
||||
- Two go-wayland quirks are worked around in the code: `Registry.Bind` writing a
|
||||
padded string length (rejected by newer libwayland — see `bind` in
|
||||
`wayland.go`), and no support for server-allocated objects (see
|
||||
`proto/register.go`).
|
||||
|
||||
## Source layout
|
||||
|
||||
- `main.go` — CLI flags, orchestration, phase timing (`WLTHUMBS_TIME`)
|
||||
- `wayland.go` — the Wayland client: connect/bind globals, enumerate toplevels
|
||||
and outputs, and capture one frame per source
|
||||
- `image.go` — shm pixel-format handling, output-transform, scaling, PNG output
|
||||
- `proto/` — generated protocol bindings (+ `register.go`); see `generate.sh`
|
||||
39
tools/wlthumbs/generate.sh
Executable file
39
tools/wlthumbs/generate.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env bash
|
||||
# Regenerate the Wayland protocol bindings in proto/ from the XML in protocol/.
|
||||
#
|
||||
# The bindings are committed so normal builds need no network or codegen; run
|
||||
# this only when bumping a protocol. After running, re-apply the manual patch
|
||||
# noted at the bottom.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
SCANNER="github.com/rajveermalviya/go-wayland/cmd/go-wayland-scanner@latest"
|
||||
|
||||
gen() { # <xml> <out> <pkg-suffix-stripped-name>
|
||||
go run "$SCANNER" -pkg proto -suffix v1 -o "proto/$2" -i "protocol/$1"
|
||||
}
|
||||
|
||||
gen ext-foreign-toplevel-list-v1.xml ext_foreign_toplevel_list.go
|
||||
gen ext-image-capture-source-v1.xml ext_image_capture_source.go
|
||||
gen ext-image-copy-capture-v1.xml ext_image_copy_capture.go
|
||||
|
||||
cat <<'NOTE'
|
||||
|
||||
Bindings regenerated.
|
||||
|
||||
MANUAL PATCH REQUIRED (see proto/register.go for the why):
|
||||
In proto/ext_foreign_toplevel_list.go, the generated Dispatch for the
|
||||
`toplevel` event does:
|
||||
|
||||
e.Toplevel = i.Context().GetProxy(client.Uint32(data[l : l+4])).(*ExtForeignToplevelHandle)
|
||||
|
||||
That panics because the toplevel arg is a *server*-allocated new_id that was
|
||||
never registered client-side. Replace it with:
|
||||
|
||||
toplevelID := client.Uint32(data[l : l+4])
|
||||
handle := &ExtForeignToplevelHandle{}
|
||||
registerServerObject(i.Context(), toplevelID, handle)
|
||||
e.Toplevel = handle
|
||||
|
||||
Then: gofmt -w proto/*.go && go build ./...
|
||||
NOTE
|
||||
9
tools/wlthumbs/go.mod
Normal file
9
tools/wlthumbs/go.mod
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
module github.com/mil-ad/wlthumbs
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/rajveermalviya/go-wayland/wayland v0.0.0-20230130181619-0ad78d1310b2
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1
|
||||
)
|
||||
6
tools/wlthumbs/go.sum
Normal file
6
tools/wlthumbs/go.sum
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
github.com/rajveermalviya/go-wayland/wayland v0.0.0-20230130181619-0ad78d1310b2 h1:tRhbehjSCwQSZL7A2AoZlKrDYhZzaPIAcpnhfaUc0Tw=
|
||||
github.com/rajveermalviya/go-wayland/wayland v0.0.0-20230130181619-0ad78d1310b2/go.mod h1:PXhW/GoWcMBeiZ39ZdgoMs/xduJEEUE+kxUBB2Kwd+M=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1 h1:kwrAHlwJ0DUBZwQ238v+Uod/3eZ8B2K5rYsUHBQvzmI=
|
||||
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
128
tools/wlthumbs/image.go
Normal file
128
tools/wlthumbs/image.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
// shmFormat is one 32-bit packed format we know how to read. wlroots advertises
|
||||
// formats as DRM fourccs (plus the two historical wl_shm enum values 0/1 for
|
||||
// ARGB/XRGB8888). swapRB is true when the memory byte order is B,G,R,x (so we
|
||||
// must swap R and B to get RGBA); false when it is already R,G,B,x.
|
||||
type shmFormat struct {
|
||||
value uint32
|
||||
swapRB bool
|
||||
}
|
||||
|
||||
// knownFormats is the preference order for capture buffers. We force opaque
|
||||
// output, so alpha vs. no-alpha doesn't matter; any entry present is accepted.
|
||||
var knownFormats = []shmFormat{
|
||||
{1, true}, // wl_shm XRGB8888 -> B,G,R,x
|
||||
{0, true}, // wl_shm ARGB8888 -> B,G,R,A
|
||||
{0x34325258, true}, // DRM XR24 XRGB8888 -> B,G,R,x
|
||||
{0x34325241, true}, // DRM AR24 ARGB8888 -> B,G,R,A
|
||||
{0x34324258, false}, // DRM XB24 XBGR8888 -> R,G,B,x
|
||||
{0x34324241, false}, // DRM AB24 ABGR8888 -> R,G,B,A
|
||||
}
|
||||
|
||||
// chooseFormat picks the first format the compositor offered that we recognize.
|
||||
func chooseFormat(offered []uint32) (value uint32, swapRB, ok bool) {
|
||||
for _, kf := range knownFormats {
|
||||
for _, v := range offered {
|
||||
if v == kf.value {
|
||||
return kf.value, kf.swapRB, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
// writeThumb converts raw pixels to RGBA, applies the output transform, scales
|
||||
// to fit within `size`, and writes a PNG.
|
||||
func writeThumb(raw []byte, w, h int, transform uint32, swapRB bool, size int, path string) error {
|
||||
img := toRGBA(raw, w, h, swapRB)
|
||||
img = applyTransform(img, transform)
|
||||
|
||||
b := img.Bounds()
|
||||
sw, sh := b.Dx(), b.Dy()
|
||||
dw, dh := sw, sh
|
||||
if sw > size || sh > size {
|
||||
if sw >= sh {
|
||||
dw, dh = size, size*sh/sw
|
||||
} else {
|
||||
dw, dh = size*sw/sh, size
|
||||
}
|
||||
}
|
||||
dw, dh = max(dw, 1), max(dh, 1)
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
|
||||
draw.BiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
enc := png.Encoder{CompressionLevel: png.BestSpeed}
|
||||
return enc.Encode(f, dst)
|
||||
}
|
||||
|
||||
// toRGBA converts a packed 32-bit buffer into an opaque image.RGBA. If swapRB is
|
||||
// set the source byte order is B,G,R,x; otherwise it is already R,G,B,x.
|
||||
func toRGBA(raw []byte, w, h int, swapRB bool) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
dst := img.Pix
|
||||
for i := 0; i < w*h; i++ {
|
||||
j := i * 4
|
||||
if swapRB {
|
||||
dst[j+0], dst[j+1], dst[j+2] = raw[j+2], raw[j+1], raw[j+0]
|
||||
} else {
|
||||
dst[j+0], dst[j+1], dst[j+2] = raw[j+0], raw[j+1], raw[j+2]
|
||||
}
|
||||
dst[j+3] = 255 // force opaque
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// applyTransform re-orients pixels per the wl_output transform enum (0-7). It is
|
||||
// a no-op for the common "normal" transform.
|
||||
func applyTransform(src *image.RGBA, t uint32) *image.RGBA {
|
||||
if t == 0 {
|
||||
return src
|
||||
}
|
||||
b := src.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
dw, dh := w, h
|
||||
if t == 1 || t == 3 || t == 5 || t == 7 { // 90/270 rotations swap dimensions
|
||||
dw, dh = h, w
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
|
||||
for sy := 0; sy < h; sy++ {
|
||||
for sx := 0; sx < w; sx++ {
|
||||
var dx, dy int
|
||||
switch t {
|
||||
case 1: // 90
|
||||
dx, dy = sy, w-1-sx
|
||||
case 2: // 180
|
||||
dx, dy = w-1-sx, h-1-sy
|
||||
case 3: // 270
|
||||
dx, dy = h-1-sy, sx
|
||||
case 4: // flipped
|
||||
dx, dy = w-1-sx, sy
|
||||
case 5: // flipped-90
|
||||
dx, dy = sy, sx
|
||||
case 6: // flipped-180
|
||||
dx, dy = sx, h-1-sy
|
||||
case 7: // flipped-270
|
||||
dx, dy = h-1-sy, w-1-sx
|
||||
}
|
||||
si := src.PixOffset(sx, sy)
|
||||
di := dst.PixOffset(dx, dy)
|
||||
copy(dst.Pix[di:di+4], src.Pix[si:si+4])
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
171
tools/wlthumbs/main.go
Normal file
171
tools/wlthumbs/main.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// wlthumbs captures thumbnails of every open window (and optionally every
|
||||
// monitor) over a single Wayland connection using the wlroots capture protocols
|
||||
// (ext-foreign-toplevel-list-v1, ext-image-capture-source-v1, and
|
||||
// ext-image-copy-capture-v1). It scales each frame in-process and writes a small
|
||||
// PNG, printing one TSV row per source:
|
||||
//
|
||||
// TYPE \t ID \t THUMBPATH \t APP \t TITLE
|
||||
//
|
||||
// where TYPE is "window" or "output". A failed capture still prints its row with
|
||||
// an empty THUMBPATH so callers can show a label without an icon. The line-
|
||||
// oriented output is meant to be joined/filtered by a shell caller (e.g. a rofi
|
||||
// grid front-end).
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("wlthumbs: ")
|
||||
|
||||
size := flag.Int("size", 256, "max thumbnail dimension in px")
|
||||
dir := flag.String("dir", "", "output directory for thumbnails (default: a temp dir under $XDG_RUNTIME_DIR)")
|
||||
windows := flag.Bool("windows", true, "capture windows (toplevels)")
|
||||
outputs := flag.Bool("outputs", false, "capture monitors (outputs)")
|
||||
ids := flag.String("ids", "", "comma-separated foreign toplevel IDs to capture; required for dock")
|
||||
flag.Parse()
|
||||
allowed := make(map[string]bool)
|
||||
for _, id := range strings.Split(*ids, ",") {
|
||||
if len(id) != 32 || strings.Trim(id, "0123456789abcdef") != "" {
|
||||
log.Fatal("invalid foreign toplevel identifier")
|
||||
}
|
||||
allowed[id] = true
|
||||
}
|
||||
if len(allowed) == 0 || len(allowed) > 8 || *outputs {
|
||||
log.Fatal("dock capture requires 1-8 window IDs and no output capture")
|
||||
}
|
||||
|
||||
outDir, err := ensureDir(*dir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
t := newTimer()
|
||||
a := &app{}
|
||||
if err := a.connect(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer a.display.Context().Close()
|
||||
t.mark("connect")
|
||||
|
||||
a.enumerate()
|
||||
t.mark("enumerate")
|
||||
|
||||
sources := a.buildSources(*windows, *outputs, allowed)
|
||||
t.mark("sources")
|
||||
|
||||
a.capture(sources, outDir, *size)
|
||||
t.mark("capture+encode")
|
||||
|
||||
for _, s := range sources {
|
||||
fmt.Printf("%s\t%s\t%s\t%s\t%s\n", s.typ, s.id, s.thumb, s.app, s.title)
|
||||
}
|
||||
}
|
||||
|
||||
// buildSources creates a capture source for each requested window/monitor, in a
|
||||
// stable order (windows then outputs).
|
||||
func (a *app) buildSources(windows, outputs bool, allowed map[string]bool) []*source {
|
||||
var sources []*source
|
||||
if windows {
|
||||
for _, t := range a.toplevels {
|
||||
if t.closed || !allowed[t.id] {
|
||||
continue
|
||||
}
|
||||
src, err := a.toplevelSrcMgr.CreateSource(t.handle)
|
||||
if err != nil {
|
||||
log.Printf("create window source %q: %v", t.id, err)
|
||||
continue
|
||||
}
|
||||
sources = append(sources, &source{typ: "window", id: t.id, app: t.appID, title: t.title, cap: src})
|
||||
}
|
||||
}
|
||||
if outputs {
|
||||
for _, o := range a.outputs {
|
||||
src, err := a.outputSrcMgr.CreateSource(o.wl)
|
||||
if err != nil {
|
||||
log.Printf("create output source %q: %v", o.name, err)
|
||||
continue
|
||||
}
|
||||
model := strings.TrimSpace(o.make_ + " " + o.model)
|
||||
sources = append(sources, &source{typ: "output", id: o.name, app: model, title: o.name, cap: src})
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
// capture grabs each source one at a time on the single connection (wlroots does
|
||||
// not parallelize captures anyway) and hands the frame to a worker pool to scale
|
||||
// + encode while the next window is captured — so encode cost hides under the
|
||||
// compositor's capture time, and only one capture session is ever live at once.
|
||||
func (a *app) capture(sources []*source, outDir string, size int) {
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, max(2, runtime.NumCPU()))
|
||||
for _, s := range sources {
|
||||
if !a.captureOne(s) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(outDir, safeName(s.id)+".png")
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if err := writeThumb(s.raw, int(s.w), int(s.h), s.transform, s.swapRB, size, path); err != nil {
|
||||
log.Printf("thumb %s: %v", path, err)
|
||||
return
|
||||
}
|
||||
s.thumb = path
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// safeName turns a source id into a filename. Foreign-toplevel ids and output
|
||||
// names are filename-safe on sway; we only guard against path separators and
|
||||
// control characters so a caller can build "$dir/$id.png" from the id alone.
|
||||
func safeName(id string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '/' || r < 0x20 {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, id)
|
||||
}
|
||||
|
||||
func ensureDir(dir string) (string, error) {
|
||||
if dir != "" {
|
||||
return dir, os.MkdirAll(dir, 0o700)
|
||||
}
|
||||
base := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if base == "" {
|
||||
base = os.TempDir()
|
||||
}
|
||||
return os.MkdirTemp(base, "wlthumbs-")
|
||||
}
|
||||
|
||||
// timer prints phase durations to stderr when WLTHUMBS_TIME is set.
|
||||
type timer struct {
|
||||
on bool
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func newTimer() *timer { return &timer{on: os.Getenv("WLTHUMBS_TIME") != "", last: time.Now()} }
|
||||
|
||||
func (t *timer) mark(label string) {
|
||||
if !t.on {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
fmt.Fprintf(os.Stderr, "%-12s %6.1fms\n", label, float64(now.Sub(t.last).Microseconds())/1000)
|
||||
t.last = now
|
||||
}
|
||||
399
tools/wlthumbs/proto/ext_foreign_toplevel_list.go
Normal file
399
tools/wlthumbs/proto/ext_foreign_toplevel_list.go
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
// Generated by go-wayland-scanner
|
||||
// https://github.com/rajveermalviya/go-wayland/cmd/go-wayland-scanner
|
||||
// XML file : protocol/ext-foreign-toplevel-list-v1.xml
|
||||
//
|
||||
// ext_foreign_toplevel_list_v1 Protocol Copyright:
|
||||
//
|
||||
// Copyright © 2018 Ilia Bozhinov
|
||||
// Copyright © 2020 Isaac Freund
|
||||
// Copyright © 2022 wb9688
|
||||
// Copyright © 2023 i509VCB
|
||||
//
|
||||
// Permission to use, copy, modify, distribute, and sell this
|
||||
// software and its documentation for any purpose is hereby granted
|
||||
// without fee, provided that the above copyright notice appear in
|
||||
// all copies and that both that copyright notice and this permission
|
||||
// notice appear in supporting documentation, and that the name of
|
||||
// the copyright holders not be used in advertising or publicity
|
||||
// pertaining to distribution of the software without specific,
|
||||
// written prior permission. The copyright holders make no
|
||||
// representations about the suitability of this software for any
|
||||
// purpose. It is provided "as is" without express or implied
|
||||
// warranty.
|
||||
//
|
||||
// THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
|
||||
// SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
// FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
// SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
|
||||
// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
||||
// ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||
// THIS SOFTWARE.
|
||||
|
||||
package proto
|
||||
|
||||
import "github.com/rajveermalviya/go-wayland/wayland/client"
|
||||
|
||||
// ExtForeignToplevelList : list toplevels
|
||||
//
|
||||
// A toplevel is defined as a surface with a role similar to xdg_toplevel.
|
||||
// XWayland surfaces may be treated like toplevels in this protocol.
|
||||
//
|
||||
// After a client binds the ext_foreign_toplevel_list_v1, each mapped
|
||||
// toplevel window will be sent using the ext_foreign_toplevel_list_v1.toplevel
|
||||
// event.
|
||||
//
|
||||
// Clients which only care about the current state can perform a roundtrip after
|
||||
// binding this global.
|
||||
//
|
||||
// For each instance of ext_foreign_toplevel_list_v1, the compositor must
|
||||
// create a new ext_foreign_toplevel_handle_v1 object for each mapped toplevel.
|
||||
//
|
||||
// If a compositor implementation sends the ext_foreign_toplevel_list_v1.finished
|
||||
// event after the global is bound, the compositor must not send any
|
||||
// ext_foreign_toplevel_list_v1.toplevel events.
|
||||
type ExtForeignToplevelList struct {
|
||||
client.BaseProxy
|
||||
toplevelHandler ExtForeignToplevelListToplevelHandlerFunc
|
||||
finishedHandler ExtForeignToplevelListFinishedHandlerFunc
|
||||
}
|
||||
|
||||
// NewExtForeignToplevelList : list toplevels
|
||||
//
|
||||
// A toplevel is defined as a surface with a role similar to xdg_toplevel.
|
||||
// XWayland surfaces may be treated like toplevels in this protocol.
|
||||
//
|
||||
// After a client binds the ext_foreign_toplevel_list_v1, each mapped
|
||||
// toplevel window will be sent using the ext_foreign_toplevel_list_v1.toplevel
|
||||
// event.
|
||||
//
|
||||
// Clients which only care about the current state can perform a roundtrip after
|
||||
// binding this global.
|
||||
//
|
||||
// For each instance of ext_foreign_toplevel_list_v1, the compositor must
|
||||
// create a new ext_foreign_toplevel_handle_v1 object for each mapped toplevel.
|
||||
//
|
||||
// If a compositor implementation sends the ext_foreign_toplevel_list_v1.finished
|
||||
// event after the global is bound, the compositor must not send any
|
||||
// ext_foreign_toplevel_list_v1.toplevel events.
|
||||
func NewExtForeignToplevelList(ctx *client.Context) *ExtForeignToplevelList {
|
||||
extForeignToplevelListV1 := &ExtForeignToplevelList{}
|
||||
ctx.Register(extForeignToplevelListV1)
|
||||
return extForeignToplevelListV1
|
||||
}
|
||||
|
||||
// Stop : stop sending events
|
||||
//
|
||||
// This request indicates that the client no longer wishes to receive
|
||||
// events for new toplevels.
|
||||
//
|
||||
// The Wayland protocol is asynchronous, meaning the compositor may send
|
||||
// further toplevel events until the stop request is processed.
|
||||
// The client should wait for a ext_foreign_toplevel_list_v1.finished
|
||||
// event before destroying this object.
|
||||
func (i *ExtForeignToplevelList) Stop() error {
|
||||
const opcode = 0
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Destroy : destroy the ext_foreign_toplevel_list_v1 object
|
||||
//
|
||||
// This request should be called either when the client will no longer
|
||||
// use the ext_foreign_toplevel_list_v1 or after the finished event
|
||||
// has been received to allow destruction of the object.
|
||||
//
|
||||
// If a client wishes to destroy this object it should send a
|
||||
// ext_foreign_toplevel_list_v1.stop request and wait for a ext_foreign_toplevel_list_v1.finished
|
||||
// event, then destroy the handles and then this object.
|
||||
func (i *ExtForeignToplevelList) Destroy() error {
|
||||
defer i.Context().Unregister(i)
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExtForeignToplevelListToplevelEvent : a toplevel has been created
|
||||
//
|
||||
// This event is emitted whenever a new toplevel window is created. It is
|
||||
// emitted for all toplevels, regardless of the app that has created them.
|
||||
//
|
||||
// All initial properties of the toplevel (identifier, title, app_id) will be sent
|
||||
// immediately after this event using the corresponding events for
|
||||
// ext_foreign_toplevel_handle_v1. The compositor will use the
|
||||
// ext_foreign_toplevel_handle_v1.done event to indicate when all data has
|
||||
// been sent.
|
||||
type ExtForeignToplevelListToplevelEvent struct {
|
||||
Toplevel *ExtForeignToplevelHandle
|
||||
}
|
||||
type ExtForeignToplevelListToplevelHandlerFunc func(ExtForeignToplevelListToplevelEvent)
|
||||
|
||||
// SetToplevelHandler : sets handler for ExtForeignToplevelListToplevelEvent
|
||||
func (i *ExtForeignToplevelList) SetToplevelHandler(f ExtForeignToplevelListToplevelHandlerFunc) {
|
||||
i.toplevelHandler = f
|
||||
}
|
||||
|
||||
// ExtForeignToplevelListFinishedEvent : the compositor has finished with the toplevel manager
|
||||
//
|
||||
// This event indicates that the compositor is done sending events
|
||||
// to this object. The client should destroy the object.
|
||||
// See ext_foreign_toplevel_list_v1.destroy for more information.
|
||||
//
|
||||
// The compositor must not send any more toplevel events after this event.
|
||||
type ExtForeignToplevelListFinishedEvent struct{}
|
||||
type ExtForeignToplevelListFinishedHandlerFunc func(ExtForeignToplevelListFinishedEvent)
|
||||
|
||||
// SetFinishedHandler : sets handler for ExtForeignToplevelListFinishedEvent
|
||||
func (i *ExtForeignToplevelList) SetFinishedHandler(f ExtForeignToplevelListFinishedHandlerFunc) {
|
||||
i.finishedHandler = f
|
||||
}
|
||||
|
||||
func (i *ExtForeignToplevelList) Dispatch(opcode uint32, fd int, data []byte) {
|
||||
switch opcode {
|
||||
case 0:
|
||||
if i.toplevelHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtForeignToplevelListToplevelEvent
|
||||
l := 0
|
||||
// PATCHED (see register.go): the toplevel arg is a server-allocated
|
||||
// new_id; the stock scanner emits GetProxy(...).(*T) which panics because
|
||||
// the object was never registered. Create and register it under the
|
||||
// server id instead.
|
||||
toplevelID := client.Uint32(data[l : l+4])
|
||||
handle := &ExtForeignToplevelHandle{}
|
||||
registerServerObject(i.Context(), toplevelID, handle)
|
||||
e.Toplevel = handle
|
||||
l += 4
|
||||
|
||||
i.toplevelHandler(e)
|
||||
case 1:
|
||||
if i.finishedHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtForeignToplevelListFinishedEvent
|
||||
|
||||
i.finishedHandler(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ExtForeignToplevelHandle : a mapped toplevel
|
||||
//
|
||||
// A ext_foreign_toplevel_handle_v1 object represents a mapped toplevel
|
||||
// window. A single app may have multiple mapped toplevels.
|
||||
type ExtForeignToplevelHandle struct {
|
||||
client.BaseProxy
|
||||
closedHandler ExtForeignToplevelHandleClosedHandlerFunc
|
||||
doneHandler ExtForeignToplevelHandleDoneHandlerFunc
|
||||
titleHandler ExtForeignToplevelHandleTitleHandlerFunc
|
||||
appIdHandler ExtForeignToplevelHandleAppIdHandlerFunc
|
||||
identifierHandler ExtForeignToplevelHandleIdentifierHandlerFunc
|
||||
}
|
||||
|
||||
// NewExtForeignToplevelHandle : a mapped toplevel
|
||||
//
|
||||
// A ext_foreign_toplevel_handle_v1 object represents a mapped toplevel
|
||||
// window. A single app may have multiple mapped toplevels.
|
||||
func NewExtForeignToplevelHandle(ctx *client.Context) *ExtForeignToplevelHandle {
|
||||
extForeignToplevelHandleV1 := &ExtForeignToplevelHandle{}
|
||||
ctx.Register(extForeignToplevelHandleV1)
|
||||
return extForeignToplevelHandleV1
|
||||
}
|
||||
|
||||
// Destroy : destroy the ext_foreign_toplevel_handle_v1 object
|
||||
//
|
||||
// This request should be used when the client will no longer use the handle
|
||||
// or after the closed event has been received to allow destruction of the
|
||||
// object.
|
||||
//
|
||||
// When a handle is destroyed, a new handle may not be created by the server
|
||||
// until the toplevel is unmapped and then remapped. Destroying a toplevel handle
|
||||
// is not recommended unless the client is cleaning up child objects
|
||||
// before destroying the ext_foreign_toplevel_list_v1 object, the toplevel
|
||||
// was closed or the toplevel handle will not be used in the future.
|
||||
//
|
||||
// Other protocols which extend the ext_foreign_toplevel_handle_v1
|
||||
// interface should require destructors for extension interfaces be
|
||||
// called before allowing the toplevel handle to be destroyed.
|
||||
func (i *ExtForeignToplevelHandle) Destroy() error {
|
||||
defer i.Context().Unregister(i)
|
||||
const opcode = 0
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExtForeignToplevelHandleClosedEvent : the toplevel has been closed
|
||||
//
|
||||
// The server will emit no further events on the ext_foreign_toplevel_handle_v1
|
||||
// after this event. Any requests received aside from the destroy request must
|
||||
// be ignored. Upon receiving this event, the client should destroy the handle.
|
||||
//
|
||||
// Other protocols which extend the ext_foreign_toplevel_handle_v1
|
||||
// interface must also ignore requests other than destructors.
|
||||
type ExtForeignToplevelHandleClosedEvent struct{}
|
||||
type ExtForeignToplevelHandleClosedHandlerFunc func(ExtForeignToplevelHandleClosedEvent)
|
||||
|
||||
// SetClosedHandler : sets handler for ExtForeignToplevelHandleClosedEvent
|
||||
func (i *ExtForeignToplevelHandle) SetClosedHandler(f ExtForeignToplevelHandleClosedHandlerFunc) {
|
||||
i.closedHandler = f
|
||||
}
|
||||
|
||||
// ExtForeignToplevelHandleDoneEvent : all information about the toplevel has been sent
|
||||
//
|
||||
// This event is sent after all changes in the toplevel state have
|
||||
// been sent.
|
||||
//
|
||||
// This allows changes to the ext_foreign_toplevel_handle_v1 properties
|
||||
// to be atomically applied. Other protocols which extend the
|
||||
// ext_foreign_toplevel_handle_v1 interface may use this event to also
|
||||
// atomically apply any pending state.
|
||||
//
|
||||
// This event must not be sent after the ext_foreign_toplevel_handle_v1.closed
|
||||
// event.
|
||||
type ExtForeignToplevelHandleDoneEvent struct{}
|
||||
type ExtForeignToplevelHandleDoneHandlerFunc func(ExtForeignToplevelHandleDoneEvent)
|
||||
|
||||
// SetDoneHandler : sets handler for ExtForeignToplevelHandleDoneEvent
|
||||
func (i *ExtForeignToplevelHandle) SetDoneHandler(f ExtForeignToplevelHandleDoneHandlerFunc) {
|
||||
i.doneHandler = f
|
||||
}
|
||||
|
||||
// ExtForeignToplevelHandleTitleEvent : title change
|
||||
//
|
||||
// The title of the toplevel has changed.
|
||||
//
|
||||
// The configured state must not be applied immediately. See
|
||||
// ext_foreign_toplevel_handle_v1.done for details.
|
||||
type ExtForeignToplevelHandleTitleEvent struct {
|
||||
Title string
|
||||
}
|
||||
type ExtForeignToplevelHandleTitleHandlerFunc func(ExtForeignToplevelHandleTitleEvent)
|
||||
|
||||
// SetTitleHandler : sets handler for ExtForeignToplevelHandleTitleEvent
|
||||
func (i *ExtForeignToplevelHandle) SetTitleHandler(f ExtForeignToplevelHandleTitleHandlerFunc) {
|
||||
i.titleHandler = f
|
||||
}
|
||||
|
||||
// ExtForeignToplevelHandleAppIdEvent : app_id change
|
||||
//
|
||||
// The app id of the toplevel has changed.
|
||||
//
|
||||
// The configured state must not be applied immediately. See
|
||||
// ext_foreign_toplevel_handle_v1.done for details.
|
||||
type ExtForeignToplevelHandleAppIdEvent struct {
|
||||
AppId string
|
||||
}
|
||||
type ExtForeignToplevelHandleAppIdHandlerFunc func(ExtForeignToplevelHandleAppIdEvent)
|
||||
|
||||
// SetAppIdHandler : sets handler for ExtForeignToplevelHandleAppIdEvent
|
||||
func (i *ExtForeignToplevelHandle) SetAppIdHandler(f ExtForeignToplevelHandleAppIdHandlerFunc) {
|
||||
i.appIdHandler = f
|
||||
}
|
||||
|
||||
// ExtForeignToplevelHandleIdentifierEvent : a stable identifier for a toplevel
|
||||
//
|
||||
// This identifier is used to check if two or more toplevel handles belong
|
||||
// to the same toplevel.
|
||||
//
|
||||
// The identifier is useful for command line tools or privileged clients
|
||||
// which may need to reference an exact toplevel across processes or
|
||||
// instances of the ext_foreign_toplevel_list_v1 global.
|
||||
//
|
||||
// The compositor must only send this event when the handle is created.
|
||||
//
|
||||
// The identifier must be unique per toplevel and it's handles. Two different
|
||||
// toplevels must not have the same identifier. The identifier is only valid
|
||||
// as long as the toplevel is mapped. If the toplevel is unmapped the identifier
|
||||
// must not be reused. An identifier must not be reused by the compositor to
|
||||
// ensure there are no races when sharing identifiers between processes.
|
||||
//
|
||||
// An identifier is a string that contains up to 32 printable ASCII bytes.
|
||||
// An identifier must not be an empty string. It is recommended that a
|
||||
// compositor includes an opaque generation value in identifiers. How the
|
||||
// generation value is used when generating the identifier is implementation
|
||||
// dependent.
|
||||
type ExtForeignToplevelHandleIdentifierEvent struct {
|
||||
Identifier string
|
||||
}
|
||||
type ExtForeignToplevelHandleIdentifierHandlerFunc func(ExtForeignToplevelHandleIdentifierEvent)
|
||||
|
||||
// SetIdentifierHandler : sets handler for ExtForeignToplevelHandleIdentifierEvent
|
||||
func (i *ExtForeignToplevelHandle) SetIdentifierHandler(f ExtForeignToplevelHandleIdentifierHandlerFunc) {
|
||||
i.identifierHandler = f
|
||||
}
|
||||
|
||||
func (i *ExtForeignToplevelHandle) Dispatch(opcode uint32, fd int, data []byte) {
|
||||
switch opcode {
|
||||
case 0:
|
||||
if i.closedHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtForeignToplevelHandleClosedEvent
|
||||
|
||||
i.closedHandler(e)
|
||||
case 1:
|
||||
if i.doneHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtForeignToplevelHandleDoneEvent
|
||||
|
||||
i.doneHandler(e)
|
||||
case 2:
|
||||
if i.titleHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtForeignToplevelHandleTitleEvent
|
||||
l := 0
|
||||
titleLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
|
||||
l += 4
|
||||
e.Title = client.String(data[l : l+titleLen])
|
||||
l += titleLen
|
||||
|
||||
i.titleHandler(e)
|
||||
case 3:
|
||||
if i.appIdHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtForeignToplevelHandleAppIdEvent
|
||||
l := 0
|
||||
appIdLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
|
||||
l += 4
|
||||
e.AppId = client.String(data[l : l+appIdLen])
|
||||
l += appIdLen
|
||||
|
||||
i.appIdHandler(e)
|
||||
case 4:
|
||||
if i.identifierHandler == nil {
|
||||
return
|
||||
}
|
||||
var e ExtForeignToplevelHandleIdentifierEvent
|
||||
l := 0
|
||||
identifierLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
|
||||
l += 4
|
||||
e.Identifier = client.String(data[l : l+identifierLen])
|
||||
l += identifierLen
|
||||
|
||||
i.identifierHandler(e)
|
||||
}
|
||||
}
|
||||
195
tools/wlthumbs/proto/ext_image_capture_source.go
Normal file
195
tools/wlthumbs/proto/ext_image_capture_source.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// Generated by go-wayland-scanner
|
||||
// https://github.com/rajveermalviya/go-wayland/cmd/go-wayland-scanner
|
||||
// XML file : protocol/ext-image-capture-source-v1.xml
|
||||
//
|
||||
// ext_image_capture_source_v1 Protocol Copyright:
|
||||
//
|
||||
// Copyright © 2022 Andri Yngvason
|
||||
// Copyright © 2024 Simon Ser
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
// copy of this software and associated documentation files (the "Software"),
|
||||
// to deal in the Software without restriction, including without limitation
|
||||
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
// and/or sell copies of the Software, and to permit persons to whom the
|
||||
// Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice (including the next
|
||||
// paragraph) shall be included in all copies or substantial portions of the
|
||||
// Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
package proto
|
||||
|
||||
import "github.com/rajveermalviya/go-wayland/wayland/client"
|
||||
|
||||
// ExtImageCaptureSource : opaque image capture source object
|
||||
//
|
||||
// The image capture source object is an opaque descriptor for a capturable
|
||||
// resource. This resource may be any sort of entity from which an image
|
||||
// may be derived.
|
||||
//
|
||||
// Note, because ext_image_capture_source_v1 objects are created from multiple
|
||||
// independent factory interfaces, the ext_image_capture_source_v1 interface is
|
||||
// frozen at version 1.
|
||||
type ExtImageCaptureSource struct {
|
||||
client.BaseProxy
|
||||
}
|
||||
|
||||
// NewExtImageCaptureSource : opaque image capture source object
|
||||
//
|
||||
// The image capture source object is an opaque descriptor for a capturable
|
||||
// resource. This resource may be any sort of entity from which an image
|
||||
// may be derived.
|
||||
//
|
||||
// Note, because ext_image_capture_source_v1 objects are created from multiple
|
||||
// independent factory interfaces, the ext_image_capture_source_v1 interface is
|
||||
// frozen at version 1.
|
||||
func NewExtImageCaptureSource(ctx *client.Context) *ExtImageCaptureSource {
|
||||
extImageCaptureSourceV1 := &ExtImageCaptureSource{}
|
||||
ctx.Register(extImageCaptureSourceV1)
|
||||
return extImageCaptureSourceV1
|
||||
}
|
||||
|
||||
// Destroy : delete this object
|
||||
//
|
||||
// Destroys the image capture source. This request may be sent at any time
|
||||
// by the client.
|
||||
func (i *ExtImageCaptureSource) Destroy() error {
|
||||
defer i.Context().Unregister(i)
|
||||
const opcode = 0
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExtOutputImageCaptureSourceManager : image capture source manager for outputs
|
||||
//
|
||||
// A manager for creating image capture source objects for wl_output objects.
|
||||
type ExtOutputImageCaptureSourceManager struct {
|
||||
client.BaseProxy
|
||||
}
|
||||
|
||||
// NewExtOutputImageCaptureSourceManager : image capture source manager for outputs
|
||||
//
|
||||
// A manager for creating image capture source objects for wl_output objects.
|
||||
func NewExtOutputImageCaptureSourceManager(ctx *client.Context) *ExtOutputImageCaptureSourceManager {
|
||||
extOutputImageCaptureSourceManagerV1 := &ExtOutputImageCaptureSourceManager{}
|
||||
ctx.Register(extOutputImageCaptureSourceManagerV1)
|
||||
return extOutputImageCaptureSourceManagerV1
|
||||
}
|
||||
|
||||
// CreateSource : create source object for output
|
||||
//
|
||||
// Creates a source object for an output. Images captured from this source
|
||||
// will show the same content as the output. Some elements may be omitted,
|
||||
// such as cursors and overlays that have been marked as transparent to
|
||||
// capturing.
|
||||
func (i *ExtOutputImageCaptureSourceManager) CreateSource(output *client.Output) (*ExtImageCaptureSource, error) {
|
||||
source := NewExtImageCaptureSource(i.Context())
|
||||
const opcode = 0
|
||||
const _reqBufLen = 8 + 4 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], source.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], output.ID())
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return source, err
|
||||
}
|
||||
|
||||
// Destroy : delete this object
|
||||
//
|
||||
// Destroys the manager. This request may be sent at any time by the client
|
||||
// and objects created by the manager will remain valid after its
|
||||
// destruction.
|
||||
func (i *ExtOutputImageCaptureSourceManager) Destroy() error {
|
||||
defer i.Context().Unregister(i)
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExtForeignToplevelImageCaptureSourceManager : image capture source manager for foreign toplevels
|
||||
//
|
||||
// A manager for creating image capture source objects for
|
||||
// ext_foreign_toplevel_handle_v1 objects.
|
||||
type ExtForeignToplevelImageCaptureSourceManager struct {
|
||||
client.BaseProxy
|
||||
}
|
||||
|
||||
// NewExtForeignToplevelImageCaptureSourceManager : image capture source manager for foreign toplevels
|
||||
//
|
||||
// A manager for creating image capture source objects for
|
||||
// ext_foreign_toplevel_handle_v1 objects.
|
||||
func NewExtForeignToplevelImageCaptureSourceManager(ctx *client.Context) *ExtForeignToplevelImageCaptureSourceManager {
|
||||
extForeignToplevelImageCaptureSourceManagerV1 := &ExtForeignToplevelImageCaptureSourceManager{}
|
||||
ctx.Register(extForeignToplevelImageCaptureSourceManagerV1)
|
||||
return extForeignToplevelImageCaptureSourceManagerV1
|
||||
}
|
||||
|
||||
// CreateSource : create source object for foreign toplevel
|
||||
//
|
||||
// Creates a source object for a foreign toplevel handle. Images captured
|
||||
// from this source will show the same content as the toplevel.
|
||||
func (i *ExtForeignToplevelImageCaptureSourceManager) CreateSource(toplevelHandle *ExtForeignToplevelHandle) (*ExtImageCaptureSource, error) {
|
||||
source := NewExtImageCaptureSource(i.Context())
|
||||
const opcode = 0
|
||||
const _reqBufLen = 8 + 4 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], source.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], toplevelHandle.ID())
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return source, err
|
||||
}
|
||||
|
||||
// Destroy : delete this object
|
||||
//
|
||||
// Destroys the manager. This request may be sent at any time by the client
|
||||
// and objects created by the manager will remain valid after its
|
||||
// destruction.
|
||||
func (i *ExtForeignToplevelImageCaptureSourceManager) Destroy() error {
|
||||
defer i.Context().Unregister(i)
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
1083
tools/wlthumbs/proto/ext_image_copy_capture.go
Normal file
1083
tools/wlthumbs/proto/ext_image_copy_capture.go
Normal file
File diff suppressed because it is too large
Load diff
25
tools/wlthumbs/proto/register.go
Normal file
25
tools/wlthumbs/proto/register.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package proto
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"unsafe"
|
||||
|
||||
"github.com/rajveermalviya/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
// registerServerObject registers a proxy under a server-allocated object id.
|
||||
//
|
||||
// go-wayland only knows how to create client-allocated objects (its Context has
|
||||
// no public way to register an object under an id chosen by the compositor). But
|
||||
// the ext_foreign_toplevel_list_v1.toplevel event delivers a new_id the *server*
|
||||
// allocates, and the object must be registered under that id for its subsequent
|
||||
// events (identifier/title/app_id/closed/done) to be dispatched to it. We reach
|
||||
// into the Context's private objects map to do so. This is safe because the
|
||||
// go-wayland dependency is version-pinned, so its internal layout is fixed.
|
||||
func registerServerObject(ctx *client.Context, id uint32, p client.Proxy) {
|
||||
p.SetContext(ctx)
|
||||
p.SetID(id)
|
||||
f := reflect.ValueOf(ctx).Elem().FieldByName("objects")
|
||||
f = reflect.NewAt(f.Type(), unsafe.Pointer(f.UnsafeAddr())).Elem()
|
||||
f.SetMapIndex(reflect.ValueOf(id), reflect.ValueOf(p))
|
||||
}
|
||||
219
tools/wlthumbs/protocol/ext-foreign-toplevel-list-v1.xml
Normal file
219
tools/wlthumbs/protocol/ext-foreign-toplevel-list-v1.xml
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="ext_foreign_toplevel_list_v1">
|
||||
<copyright>
|
||||
Copyright © 2018 Ilia Bozhinov
|
||||
Copyright © 2020 Isaac Freund
|
||||
Copyright © 2022 wb9688
|
||||
Copyright © 2023 i509VCB
|
||||
|
||||
Permission to use, copy, modify, distribute, and sell this
|
||||
software and its documentation for any purpose is hereby granted
|
||||
without fee, provided that the above copyright notice appear in
|
||||
all copies and that both that copyright notice and this permission
|
||||
notice appear in supporting documentation, and that the name of
|
||||
the copyright holders not be used in advertising or publicity
|
||||
pertaining to distribution of the software without specific,
|
||||
written prior permission. The copyright holders make no
|
||||
representations about the suitability of this software for any
|
||||
purpose. It is provided "as is" without express or implied
|
||||
warranty.
|
||||
|
||||
THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
|
||||
SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
|
||||
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
||||
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||
THIS SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<description summary="list toplevels">
|
||||
The purpose of this protocol is to provide protocol object handles for
|
||||
toplevels, possibly originating from another client.
|
||||
|
||||
This protocol is intentionally minimalistic and expects additional
|
||||
functionality (e.g. creating a screencopy source from a toplevel handle,
|
||||
getting information about the state of the toplevel) to be implemented
|
||||
in extension protocols.
|
||||
|
||||
The compositor may choose to restrict this protocol to a special client
|
||||
launched by the compositor itself or expose it to all clients,
|
||||
this is compositor policy.
|
||||
|
||||
The key words "must", "must not", "required", "shall", "shall not",
|
||||
"should", "should not", "recommended", "may", and "optional" in this
|
||||
document are to be interpreted as described in IETF RFC 2119.
|
||||
|
||||
Warning! The protocol described in this file is currently in the testing
|
||||
phase. Backward compatible changes may be added together with the
|
||||
corresponding interface version bump. Backward incompatible changes can
|
||||
only be done by creating a new major version of the extension.
|
||||
</description>
|
||||
|
||||
<interface name="ext_foreign_toplevel_list_v1" version="1">
|
||||
<description summary="list toplevels">
|
||||
A toplevel is defined as a surface with a role similar to xdg_toplevel.
|
||||
XWayland surfaces may be treated like toplevels in this protocol.
|
||||
|
||||
After a client binds the ext_foreign_toplevel_list_v1, each mapped
|
||||
toplevel window will be sent using the ext_foreign_toplevel_list_v1.toplevel
|
||||
event.
|
||||
|
||||
Clients which only care about the current state can perform a roundtrip after
|
||||
binding this global.
|
||||
|
||||
For each instance of ext_foreign_toplevel_list_v1, the compositor must
|
||||
create a new ext_foreign_toplevel_handle_v1 object for each mapped toplevel.
|
||||
|
||||
If a compositor implementation sends the ext_foreign_toplevel_list_v1.finished
|
||||
event after the global is bound, the compositor must not send any
|
||||
ext_foreign_toplevel_list_v1.toplevel events.
|
||||
</description>
|
||||
|
||||
<event name="toplevel">
|
||||
<description summary="a toplevel has been created">
|
||||
This event is emitted whenever a new toplevel window is created. It is
|
||||
emitted for all toplevels, regardless of the app that has created them.
|
||||
|
||||
All initial properties of the toplevel (identifier, title, app_id) will be sent
|
||||
immediately after this event using the corresponding events for
|
||||
ext_foreign_toplevel_handle_v1. The compositor will use the
|
||||
ext_foreign_toplevel_handle_v1.done event to indicate when all data has
|
||||
been sent.
|
||||
</description>
|
||||
<arg name="toplevel" type="new_id" interface="ext_foreign_toplevel_handle_v1"/>
|
||||
</event>
|
||||
|
||||
<event name="finished">
|
||||
<description summary="the compositor has finished with the toplevel manager">
|
||||
This event indicates that the compositor is done sending events
|
||||
to this object. The client should destroy the object.
|
||||
See ext_foreign_toplevel_list_v1.destroy for more information.
|
||||
|
||||
The compositor must not send any more toplevel events after this event.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<request name="stop">
|
||||
<description summary="stop sending events">
|
||||
This request indicates that the client no longer wishes to receive
|
||||
events for new toplevels.
|
||||
|
||||
The Wayland protocol is asynchronous, meaning the compositor may send
|
||||
further toplevel events until the stop request is processed.
|
||||
The client should wait for a ext_foreign_toplevel_list_v1.finished
|
||||
event before destroying this object.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the ext_foreign_toplevel_list_v1 object">
|
||||
This request should be called either when the client will no longer
|
||||
use the ext_foreign_toplevel_list_v1 or after the finished event
|
||||
has been received to allow destruction of the object.
|
||||
|
||||
If a client wishes to destroy this object it should send a
|
||||
ext_foreign_toplevel_list_v1.stop request and wait for a ext_foreign_toplevel_list_v1.finished
|
||||
event, then destroy the handles and then this object.
|
||||
</description>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="ext_foreign_toplevel_handle_v1" version="1">
|
||||
<description summary="a mapped toplevel">
|
||||
A ext_foreign_toplevel_handle_v1 object represents a mapped toplevel
|
||||
window. A single app may have multiple mapped toplevels.
|
||||
</description>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the ext_foreign_toplevel_handle_v1 object">
|
||||
This request should be used when the client will no longer use the handle
|
||||
or after the closed event has been received to allow destruction of the
|
||||
object.
|
||||
|
||||
When a handle is destroyed, a new handle may not be created by the server
|
||||
until the toplevel is unmapped and then remapped. Destroying a toplevel handle
|
||||
is not recommended unless the client is cleaning up child objects
|
||||
before destroying the ext_foreign_toplevel_list_v1 object, the toplevel
|
||||
was closed or the toplevel handle will not be used in the future.
|
||||
|
||||
Other protocols which extend the ext_foreign_toplevel_handle_v1
|
||||
interface should require destructors for extension interfaces be
|
||||
called before allowing the toplevel handle to be destroyed.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<event name="closed">
|
||||
<description summary="the toplevel has been closed">
|
||||
The server will emit no further events on the ext_foreign_toplevel_handle_v1
|
||||
after this event. Any requests received aside from the destroy request must
|
||||
be ignored. Upon receiving this event, the client should destroy the handle.
|
||||
|
||||
Other protocols which extend the ext_foreign_toplevel_handle_v1
|
||||
interface must also ignore requests other than destructors.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<event name="done">
|
||||
<description summary="all information about the toplevel has been sent">
|
||||
This event is sent after all changes in the toplevel state have
|
||||
been sent.
|
||||
|
||||
This allows changes to the ext_foreign_toplevel_handle_v1 properties
|
||||
to be atomically applied. Other protocols which extend the
|
||||
ext_foreign_toplevel_handle_v1 interface may use this event to also
|
||||
atomically apply any pending state.
|
||||
|
||||
This event must not be sent after the ext_foreign_toplevel_handle_v1.closed
|
||||
event.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<event name="title">
|
||||
<description summary="title change">
|
||||
The title of the toplevel has changed.
|
||||
|
||||
The configured state must not be applied immediately. See
|
||||
ext_foreign_toplevel_handle_v1.done for details.
|
||||
</description>
|
||||
<arg name="title" type="string"/>
|
||||
</event>
|
||||
|
||||
<event name="app_id">
|
||||
<description summary="app_id change">
|
||||
The app id of the toplevel has changed.
|
||||
|
||||
The configured state must not be applied immediately. See
|
||||
ext_foreign_toplevel_handle_v1.done for details.
|
||||
</description>
|
||||
<arg name="app_id" type="string"/>
|
||||
</event>
|
||||
|
||||
<event name="identifier">
|
||||
<description summary="a stable identifier for a toplevel">
|
||||
This identifier is used to check if two or more toplevel handles belong
|
||||
to the same toplevel.
|
||||
|
||||
The identifier is useful for command line tools or privileged clients
|
||||
which may need to reference an exact toplevel across processes or
|
||||
instances of the ext_foreign_toplevel_list_v1 global.
|
||||
|
||||
The compositor must only send this event when the handle is created.
|
||||
|
||||
The identifier must be unique per toplevel and it's handles. Two different
|
||||
toplevels must not have the same identifier. The identifier is only valid
|
||||
as long as the toplevel is mapped. If the toplevel is unmapped the identifier
|
||||
must not be reused. An identifier must not be reused by the compositor to
|
||||
ensure there are no races when sharing identifiers between processes.
|
||||
|
||||
An identifier is a string that contains up to 32 printable ASCII bytes.
|
||||
An identifier must not be an empty string. It is recommended that a
|
||||
compositor includes an opaque generation value in identifiers. How the
|
||||
generation value is used when generating the identifier is implementation
|
||||
dependent.
|
||||
</description>
|
||||
<arg name="identifier" type="string"/>
|
||||
</event>
|
||||
</interface>
|
||||
</protocol>
|
||||
109
tools/wlthumbs/protocol/ext-image-capture-source-v1.xml
Normal file
109
tools/wlthumbs/protocol/ext-image-capture-source-v1.xml
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="ext_image_capture_source_v1">
|
||||
<copyright>
|
||||
Copyright © 2022 Andri Yngvason
|
||||
Copyright © 2024 Simon Ser
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next
|
||||
paragraph) shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<description summary="opaque image capture source objects">
|
||||
This protocol serves as an intermediary between capturing protocols and
|
||||
potential image capture sources such as outputs and toplevels.
|
||||
|
||||
This protocol may be extended to support more image capture sources in the
|
||||
future, thereby adding those image capture sources to other protocols that
|
||||
use the image capture source object without having to modify those
|
||||
protocols.
|
||||
|
||||
Warning! The protocol described in this file is currently in the testing
|
||||
phase. Backward compatible changes may be added together with the
|
||||
corresponding interface version bump. Backward incompatible changes can
|
||||
only be done by creating a new major version of the extension.
|
||||
</description>
|
||||
|
||||
<interface name="ext_image_capture_source_v1" version="1">
|
||||
<description summary="opaque image capture source object">
|
||||
The image capture source object is an opaque descriptor for a capturable
|
||||
resource. This resource may be any sort of entity from which an image
|
||||
may be derived.
|
||||
|
||||
Note, because ext_image_capture_source_v1 objects are created from multiple
|
||||
independent factory interfaces, the ext_image_capture_source_v1 interface is
|
||||
frozen at version 1.
|
||||
</description>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="delete this object">
|
||||
Destroys the image capture source. This request may be sent at any time
|
||||
by the client.
|
||||
</description>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="ext_output_image_capture_source_manager_v1" version="1">
|
||||
<description summary="image capture source manager for outputs">
|
||||
A manager for creating image capture source objects for wl_output objects.
|
||||
</description>
|
||||
|
||||
<request name="create_source">
|
||||
<description summary="create source object for output">
|
||||
Creates a source object for an output. Images captured from this source
|
||||
will show the same content as the output. Some elements may be omitted,
|
||||
such as cursors and overlays that have been marked as transparent to
|
||||
capturing.
|
||||
</description>
|
||||
<arg name="source" type="new_id" interface="ext_image_capture_source_v1"/>
|
||||
<arg name="output" type="object" interface="wl_output"/>
|
||||
</request>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="delete this object">
|
||||
Destroys the manager. This request may be sent at any time by the client
|
||||
and objects created by the manager will remain valid after its
|
||||
destruction.
|
||||
</description>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="ext_foreign_toplevel_image_capture_source_manager_v1" version="1">
|
||||
<description summary="image capture source manager for foreign toplevels">
|
||||
A manager for creating image capture source objects for
|
||||
ext_foreign_toplevel_handle_v1 objects.
|
||||
</description>
|
||||
|
||||
<request name="create_source">
|
||||
<description summary="create source object for foreign toplevel">
|
||||
Creates a source object for a foreign toplevel handle. Images captured
|
||||
from this source will show the same content as the toplevel.
|
||||
</description>
|
||||
<arg name="source" type="new_id" interface="ext_image_capture_source_v1"/>
|
||||
<arg name="toplevel_handle" type="object" interface="ext_foreign_toplevel_handle_v1"/>
|
||||
</request>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="delete this object">
|
||||
Destroys the manager. This request may be sent at any time by the client
|
||||
and objects created by the manager will remain valid after its
|
||||
destruction.
|
||||
</description>
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
456
tools/wlthumbs/protocol/ext-image-copy-capture-v1.xml
Normal file
456
tools/wlthumbs/protocol/ext-image-copy-capture-v1.xml
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="ext_image_copy_capture_v1">
|
||||
<copyright>
|
||||
Copyright © 2021-2023 Andri Yngvason
|
||||
Copyright © 2024 Simon Ser
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next
|
||||
paragraph) shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<description summary="image capturing into client buffers">
|
||||
This protocol allows clients to ask the compositor to capture image sources
|
||||
such as outputs and toplevels into user submitted buffers.
|
||||
|
||||
Warning! The protocol described in this file is currently in the testing
|
||||
phase. Backward compatible changes may be added together with the
|
||||
corresponding interface version bump. Backward incompatible changes can
|
||||
only be done by creating a new major version of the extension.
|
||||
</description>
|
||||
|
||||
<interface name="ext_image_copy_capture_manager_v1" version="1">
|
||||
<description summary="manager to inform clients and begin capturing">
|
||||
This object is a manager which offers requests to start capturing from a
|
||||
source.
|
||||
</description>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="invalid_option" value="1" summary="invalid option flag"/>
|
||||
</enum>
|
||||
|
||||
<enum name="options" bitfield="true">
|
||||
<entry name="paint_cursors" value="1" summary="paint cursors onto captured frames"/>
|
||||
</enum>
|
||||
|
||||
<request name="create_session">
|
||||
<description summary="capture an image capture source">
|
||||
Create a capturing session for an image capture source.
|
||||
|
||||
If the paint_cursors option is set, cursors shall be composited onto
|
||||
the captured frame. The cursor must not be composited onto the frame
|
||||
if this flag is not set.
|
||||
|
||||
If the options bitfield is invalid, the invalid_option protocol error
|
||||
is sent.
|
||||
</description>
|
||||
<arg name="session" type="new_id" interface="ext_image_copy_capture_session_v1"/>
|
||||
<arg name="source" type="object" interface="ext_image_capture_source_v1"/>
|
||||
<arg name="options" type="uint" enum="options"/>
|
||||
</request>
|
||||
|
||||
<request name="create_pointer_cursor_session">
|
||||
<description summary="capture the pointer cursor of an image capture source">
|
||||
Create a cursor capturing session for the pointer of an image capture
|
||||
source.
|
||||
</description>
|
||||
<arg name="session" type="new_id" interface="ext_image_copy_capture_cursor_session_v1"/>
|
||||
<arg name="source" type="object" interface="ext_image_capture_source_v1"/>
|
||||
<arg name="pointer" type="object" interface="wl_pointer"/>
|
||||
</request>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy the manager">
|
||||
Destroy the manager object.
|
||||
|
||||
Other objects created via this interface are unaffected.
|
||||
</description>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="ext_image_copy_capture_session_v1" version="1">
|
||||
<description summary="image copy capture session">
|
||||
This object represents an active image copy capture session.
|
||||
|
||||
After a capture session is created, buffer constraint events will be
|
||||
emitted from the compositor to tell the client which buffer types and
|
||||
formats are supported for reading from the session. The compositor may
|
||||
re-send buffer constraint events whenever they change.
|
||||
|
||||
To advertise buffer constraints, the compositor must send in no
|
||||
particular order: zero or more shm_format and dmabuf_format events, zero
|
||||
or one dmabuf_device event, and exactly one buffer_size event. Then the
|
||||
compositor must send a done event.
|
||||
|
||||
When the client has received all the buffer constraints, it can create a
|
||||
buffer accordingly, attach it to the capture session using the
|
||||
attach_buffer request, set the buffer damage using the damage_buffer
|
||||
request and then send the capture request.
|
||||
</description>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="duplicate_frame" value="1"
|
||||
summary="create_frame sent before destroying previous frame"/>
|
||||
</enum>
|
||||
|
||||
<event name="buffer_size">
|
||||
<description summary="image capture source dimensions">
|
||||
Provides the dimensions of the source image in buffer pixel coordinates.
|
||||
|
||||
The client must attach buffers that match this size.
|
||||
</description>
|
||||
<arg name="width" type="uint" summary="buffer width"/>
|
||||
<arg name="height" type="uint" summary="buffer height"/>
|
||||
</event>
|
||||
|
||||
<event name="shm_format">
|
||||
<description summary="shm buffer format">
|
||||
Provides the format that must be used for shared-memory buffers.
|
||||
|
||||
This event may be emitted multiple times, in which case the client may
|
||||
choose any given format.
|
||||
</description>
|
||||
<arg name="format" type="uint" enum="wl_shm.format" summary="shm format"/>
|
||||
</event>
|
||||
|
||||
<event name="dmabuf_device">
|
||||
<description summary="dma-buf device">
|
||||
This event advertises the device buffers must be allocated on for
|
||||
dma-buf buffers.
|
||||
|
||||
In general the device is a DRM node. The DRM node type (primary vs.
|
||||
render) is unspecified. Clients must not rely on the compositor sending
|
||||
a particular node type. Clients cannot check two devices for equality
|
||||
by comparing the dev_t value.
|
||||
</description>
|
||||
<arg name="device" type="array" summary="device dev_t value"/>
|
||||
</event>
|
||||
|
||||
<event name="dmabuf_format">
|
||||
<description summary="dma-buf format">
|
||||
Provides the format that must be used for dma-buf buffers.
|
||||
|
||||
The client may choose any of the modifiers advertised in the array of
|
||||
64-bit unsigned integers.
|
||||
|
||||
This event may be emitted multiple times, in which case the client may
|
||||
choose any given format.
|
||||
</description>
|
||||
<arg name="format" type="uint" summary="drm format code"/>
|
||||
<arg name="modifiers" type="array" summary="drm format modifiers"/>
|
||||
</event>
|
||||
|
||||
<event name="done">
|
||||
<description summary="all constraints have been sent">
|
||||
This event is sent once when all buffer constraint events have been
|
||||
sent.
|
||||
|
||||
The compositor must always end a batch of buffer constraint events with
|
||||
this event, regardless of whether it sends the initial constraints or
|
||||
an update.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<event name="stopped">
|
||||
<description summary="session is no longer available">
|
||||
This event indicates that the capture session has stopped and is no
|
||||
longer available. This can happen in a number of cases, e.g. when the
|
||||
underlying source is destroyed, if the user decides to end the image
|
||||
capture, or if an unrecoverable runtime error has occurred.
|
||||
|
||||
The client should destroy the session after receiving this event.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<request name="create_frame">
|
||||
<description summary="create a frame">
|
||||
Create a capture frame for this session.
|
||||
|
||||
At most one frame object can exist for a given session at any time. If
|
||||
a client sends a create_frame request before a previous frame object
|
||||
has been destroyed, the duplicate_frame protocol error is raised.
|
||||
</description>
|
||||
<arg name="frame" type="new_id" interface="ext_image_copy_capture_frame_v1"/>
|
||||
</request>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="delete this object">
|
||||
Destroys the session. This request can be sent at any time by the
|
||||
client.
|
||||
|
||||
This request doesn't affect ext_image_copy_capture_frame_v1 objects created by
|
||||
this object.
|
||||
</description>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="ext_image_copy_capture_frame_v1" version="1">
|
||||
<description summary="image capture frame">
|
||||
This object represents an image capture frame.
|
||||
|
||||
The client should attach a buffer, damage the buffer, and then send a
|
||||
capture request.
|
||||
|
||||
If the capture is successful, the compositor must send the frame metadata
|
||||
(transform, damage, presentation_time in any order) followed by the ready
|
||||
event.
|
||||
|
||||
If the capture fails, the compositor must send the failed event.
|
||||
</description>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="no_buffer" value="1" summary="capture sent without attach_buffer"/>
|
||||
<entry name="invalid_buffer_damage" value="2" summary="invalid buffer damage"/>
|
||||
<entry name="already_captured" value="3" summary="capture request has been sent"/>
|
||||
</enum>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="destroy this object">
|
||||
Destroys the frame. This request can be sent at any time by the
|
||||
client.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<request name="attach_buffer">
|
||||
<description summary="attach buffer to session">
|
||||
Attach a buffer to the session.
|
||||
|
||||
The wl_buffer.release request is unused.
|
||||
|
||||
The new buffer replaces any previously attached buffer.
|
||||
|
||||
This request must not be sent after capture, or else the
|
||||
already_captured protocol error is raised.
|
||||
</description>
|
||||
<arg name="buffer" type="object" interface="wl_buffer"/>
|
||||
</request>
|
||||
|
||||
<request name="damage_buffer">
|
||||
<description summary="damage buffer">
|
||||
Apply damage to the buffer which is to be captured next. This request
|
||||
may be sent multiple times to describe a region.
|
||||
|
||||
The client indicates the accumulated damage since this wl_buffer was
|
||||
last captured. During capture, the compositor will update the buffer
|
||||
with at least the union of the region passed by the client and the
|
||||
region advertised by ext_image_copy_capture_frame_v1.damage.
|
||||
|
||||
When a wl_buffer is captured for the first time, or when the client
|
||||
doesn't track damage, the client must damage the whole buffer.
|
||||
|
||||
This is for optimisation purposes. The compositor may use this
|
||||
information to reduce copying.
|
||||
|
||||
These coordinates originate from the upper left corner of the buffer.
|
||||
|
||||
If x or y are strictly negative, or if width or height are negative or
|
||||
zero, the invalid_buffer_damage protocol error is raised.
|
||||
|
||||
This request must not be sent after capture, or else the
|
||||
already_captured protocol error is raised.
|
||||
</description>
|
||||
<arg name="x" type="int" summary="region x coordinate"/>
|
||||
<arg name="y" type="int" summary="region y coordinate"/>
|
||||
<arg name="width" type="int" summary="region width"/>
|
||||
<arg name="height" type="int" summary="region height"/>
|
||||
</request>
|
||||
|
||||
<request name="capture">
|
||||
<description summary="capture a frame">
|
||||
Capture a frame.
|
||||
|
||||
Unless this is the first successful captured frame performed in this
|
||||
session, the compositor may wait an indefinite amount of time for the
|
||||
source content to change before performing the copy.
|
||||
|
||||
This request may only be sent once, or else the already_captured
|
||||
protocol error is raised. A buffer must be attached before this request
|
||||
is sent, or else the no_buffer protocol error is raised.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<event name="transform">
|
||||
<description summary="buffer transform">
|
||||
This event is sent before the ready event and holds the transform that
|
||||
the compositor has applied to the buffer contents.
|
||||
</description>
|
||||
<arg name="transform" type="uint" enum="wl_output.transform"/>
|
||||
</event>
|
||||
|
||||
<event name="damage">
|
||||
<description summary="buffer damaged region">
|
||||
This event is sent before the ready event. It may be generated multiple
|
||||
times to describe a region.
|
||||
|
||||
The first captured frame in a session will always carry full damage.
|
||||
Subsequent frames' damaged regions describe which parts of the buffer
|
||||
have changed since the last ready event.
|
||||
|
||||
These coordinates originate in the upper left corner of the buffer.
|
||||
</description>
|
||||
<arg name="x" type="int" summary="damage x coordinate"/>
|
||||
<arg name="y" type="int" summary="damage y coordinate"/>
|
||||
<arg name="width" type="int" summary="damage width"/>
|
||||
<arg name="height" type="int" summary="damage height"/>
|
||||
</event>
|
||||
|
||||
<event name="presentation_time">
|
||||
<description summary="presentation time of the frame">
|
||||
This event indicates the time at which the frame is presented to the
|
||||
output in system monotonic time. This event is sent before the ready
|
||||
event.
|
||||
|
||||
The timestamp is expressed as tv_sec_hi, tv_sec_lo, tv_nsec triples,
|
||||
each component being an unsigned 32-bit value. Whole seconds are in
|
||||
tv_sec which is a 64-bit value combined from tv_sec_hi and tv_sec_lo,
|
||||
and the additional fractional part in tv_nsec as nanoseconds. Hence,
|
||||
for valid timestamps tv_nsec must be in [0, 999999999].
|
||||
</description>
|
||||
<arg name="tv_sec_hi" type="uint"
|
||||
summary="high 32 bits of the seconds part of the timestamp"/>
|
||||
<arg name="tv_sec_lo" type="uint"
|
||||
summary="low 32 bits of the seconds part of the timestamp"/>
|
||||
<arg name="tv_nsec" type="uint"
|
||||
summary="nanoseconds part of the timestamp"/>
|
||||
</event>
|
||||
|
||||
<event name="ready">
|
||||
<description summary="frame is available for reading">
|
||||
Called as soon as the frame is copied, indicating it is available
|
||||
for reading.
|
||||
|
||||
The buffer may be re-used by the client after this event.
|
||||
|
||||
After receiving this event, the client must destroy the object.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<enum name="failure_reason">
|
||||
<entry name="unknown" value="0">
|
||||
<description summary="unknown runtime error">
|
||||
An unspecified runtime error has occurred. The client may retry.
|
||||
</description>
|
||||
</entry>
|
||||
<entry name="buffer_constraints" value="1">
|
||||
<description summary="buffer constraints mismatch">
|
||||
The buffer submitted by the client doesn't match the latest session
|
||||
constraints. The client should re-allocate its buffers and retry.
|
||||
</description>
|
||||
</entry>
|
||||
<entry name="stopped" value="2">
|
||||
<description summary="session is no longer available">
|
||||
The session has stopped. See ext_image_copy_capture_session_v1.stopped.
|
||||
</description>
|
||||
</entry>
|
||||
</enum>
|
||||
|
||||
<event name="failed">
|
||||
<description summary="capture failed">
|
||||
This event indicates that the attempted frame copy has failed.
|
||||
|
||||
After receiving this event, the client must destroy the object.
|
||||
</description>
|
||||
<arg name="reason" type="uint" enum="failure_reason"/>
|
||||
</event>
|
||||
</interface>
|
||||
|
||||
<interface name="ext_image_copy_capture_cursor_session_v1" version="1">
|
||||
<description summary="cursor capture session">
|
||||
This object represents a cursor capture session. It extends the base
|
||||
capture session with cursor-specific metadata.
|
||||
</description>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="duplicate_session" value="1" summary="get_capture_session sent twice"/>
|
||||
</enum>
|
||||
|
||||
<request name="destroy" type="destructor">
|
||||
<description summary="delete this object">
|
||||
Destroys the session. This request can be sent at any time by the
|
||||
client.
|
||||
|
||||
This request doesn't affect ext_image_copy_capture_frame_v1 objects created by
|
||||
this object.
|
||||
</description>
|
||||
</request>
|
||||
|
||||
<request name="get_capture_session">
|
||||
<description summary="get image copy capturer session">
|
||||
Gets the image copy capture session for this cursor session.
|
||||
|
||||
The session will produce frames of the cursor image. The compositor may
|
||||
pause the session when the cursor leaves the captured area.
|
||||
|
||||
This request must not be sent more than once, or else the
|
||||
duplicate_session protocol error is raised.
|
||||
</description>
|
||||
<arg name="session" type="new_id" interface="ext_image_copy_capture_session_v1"/>
|
||||
</request>
|
||||
|
||||
<event name="enter">
|
||||
<description summary="cursor entered captured area">
|
||||
Sent when a cursor enters the captured area. It shall be generated
|
||||
before the "position" and "hotspot" events when and only when a cursor
|
||||
enters the area.
|
||||
|
||||
The cursor enters the captured area when the cursor image intersects
|
||||
with the captured area. Note, this is different from e.g.
|
||||
wl_pointer.enter.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<event name="leave">
|
||||
<description summary="cursor left captured area">
|
||||
Sent when a cursor leaves the captured area. No "position" or "hotspot"
|
||||
event is generated for the cursor until the cursor enters the captured
|
||||
area again.
|
||||
</description>
|
||||
</event>
|
||||
|
||||
<event name="position">
|
||||
<description summary="position changed">
|
||||
Cursors outside the image capture source do not get captured and no
|
||||
event will be generated for them.
|
||||
|
||||
The given position is the position of the cursor's hotspot and it is
|
||||
relative to the main buffer's top left corner in transformed buffer
|
||||
pixel coordinates. The coordinates may be negative or greater than the
|
||||
main buffer size.
|
||||
</description>
|
||||
<arg name="x" type="int" summary="position x coordinates"/>
|
||||
<arg name="y" type="int" summary="position y coordinates"/>
|
||||
</event>
|
||||
|
||||
<event name="hotspot">
|
||||
<description summary="hotspot changed">
|
||||
The hotspot describes the offset between the cursor image and the
|
||||
position of the input device.
|
||||
|
||||
The given coordinates are the hotspot's offset from the origin in
|
||||
buffer coordinates.
|
||||
|
||||
Clients should not apply the hotspot immediately: the hotspot becomes
|
||||
effective when the next ext_image_copy_capture_frame_v1.ready event is received.
|
||||
|
||||
Compositors may delay this event until the client captures a new frame.
|
||||
</description>
|
||||
<arg name="x" type="int" summary="hotspot x coordinates"/>
|
||||
<arg name="y" type="int" summary="hotspot y coordinates"/>
|
||||
</event>
|
||||
</interface>
|
||||
</protocol>
|
||||
333
tools/wlthumbs/wayland.go
Normal file
333
tools/wlthumbs/wayland.go
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/mil-ad/wlthumbs/proto"
|
||||
"github.com/rajveermalviya/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type toplevel struct {
|
||||
handle *proto.ExtForeignToplevelHandle
|
||||
id, title, appID string
|
||||
closed, done bool
|
||||
}
|
||||
|
||||
type output struct {
|
||||
wl *client.Output
|
||||
name string
|
||||
make_, model string
|
||||
done bool
|
||||
}
|
||||
|
||||
// source is a normalized capture target (window or monitor). captureOne fills
|
||||
// the trailing fields with the captured frame.
|
||||
type source struct {
|
||||
typ string // "window" | "output"
|
||||
id string
|
||||
app string
|
||||
title string
|
||||
cap *proto.ExtImageCaptureSource
|
||||
|
||||
raw []byte // captured pixels (nil until/unless capture succeeds)
|
||||
w, h uint32
|
||||
transform uint32
|
||||
swapRB bool
|
||||
thumb string // path to the written PNG (empty if capture/encode failed)
|
||||
}
|
||||
|
||||
// app holds the Wayland connection and the globals/objects we bind on it.
|
||||
type app struct {
|
||||
display *client.Display
|
||||
registry *client.Registry
|
||||
shm *client.Shm
|
||||
copyMgr *proto.ExtImageCopyCaptureManager
|
||||
toplevelSrcMgr *proto.ExtForeignToplevelImageCaptureSourceManager
|
||||
outputSrcMgr *proto.ExtOutputImageCaptureSourceManager
|
||||
toplevelList *proto.ExtForeignToplevelList
|
||||
|
||||
toplevels []*toplevel
|
||||
outputs []*output
|
||||
}
|
||||
|
||||
// connect opens the Wayland connection, binds the globals we need, and verifies
|
||||
// the compositor supports the required capture protocols.
|
||||
func (a *app) connect() error {
|
||||
display, err := client.Connect("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to wayland: %w", err)
|
||||
}
|
||||
a.display = display
|
||||
display.SetErrorHandler(func(e client.DisplayErrorEvent) {
|
||||
log.Printf("display error: object=%d code=%d %q", e.ObjectId, e.Code, e.Message)
|
||||
})
|
||||
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get registry: %w", err)
|
||||
}
|
||||
a.registry = registry
|
||||
registry.SetGlobalHandler(a.handleGlobal)
|
||||
|
||||
a.roundtrip() // register globals
|
||||
a.roundtrip() // deliver their initial events
|
||||
|
||||
var missing []string
|
||||
if a.shm == nil {
|
||||
missing = append(missing, "wl_shm")
|
||||
}
|
||||
if a.copyMgr == nil {
|
||||
missing = append(missing, "ext_image_copy_capture_manager_v1")
|
||||
}
|
||||
if a.toplevelSrcMgr == nil {
|
||||
missing = append(missing, "ext_foreign_toplevel_image_capture_source_manager_v1")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("compositor is missing required protocols: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) ctx() *client.Context { return a.display.Context() }
|
||||
|
||||
// bind is a spec-correct wl_registry.bind. go-wayland's Registry.Bind writes the
|
||||
// string-length prefix as the *padded* length instead of len+1; older libwayland
|
||||
// tolerated it, but newer libwayland rejects it with "invalid arguments". The
|
||||
// proxy is already registered by its New* constructor, so we only marshal and
|
||||
// send the request here.
|
||||
func (a *app) bind(e client.RegistryGlobalEvent, p client.Proxy) {
|
||||
iface := e.Interface
|
||||
strLen := len(iface) + 1 // includes the null terminator
|
||||
pad := strLen
|
||||
if pad%4 != 0 {
|
||||
pad += 4 - pad%4
|
||||
}
|
||||
const opcode = 0
|
||||
bufLen := 8 + 4 + (4 + pad) + 4 + 4
|
||||
buf := make([]byte, bufLen)
|
||||
client.PutUint32(buf[0:4], a.registry.ID())
|
||||
client.PutUint32(buf[4:8], uint32(bufLen<<16|opcode))
|
||||
client.PutUint32(buf[8:12], e.Name)
|
||||
client.PutString(buf[12:12+4+pad], iface, strLen)
|
||||
o := 12 + 4 + pad
|
||||
client.PutUint32(buf[o:o+4], e.Version)
|
||||
client.PutUint32(buf[o+4:o+8], p.ID())
|
||||
if err := a.ctx().WriteMsg(buf, nil); err != nil {
|
||||
log.Fatalf("bind %s: %v", iface, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) handleGlobal(e client.RegistryGlobalEvent) {
|
||||
switch e.Interface {
|
||||
case "wl_shm":
|
||||
a.shm = client.NewShm(a.ctx())
|
||||
a.bind(e, a.shm)
|
||||
case "ext_image_copy_capture_manager_v1":
|
||||
a.copyMgr = proto.NewExtImageCopyCaptureManager(a.ctx())
|
||||
a.bind(e, a.copyMgr)
|
||||
case "ext_foreign_toplevel_image_capture_source_manager_v1":
|
||||
a.toplevelSrcMgr = proto.NewExtForeignToplevelImageCaptureSourceManager(a.ctx())
|
||||
a.bind(e, a.toplevelSrcMgr)
|
||||
case "ext_output_image_capture_source_manager_v1":
|
||||
a.outputSrcMgr = proto.NewExtOutputImageCaptureSourceManager(a.ctx())
|
||||
a.bind(e, a.outputSrcMgr)
|
||||
case "ext_foreign_toplevel_list_v1":
|
||||
a.toplevelList = proto.NewExtForeignToplevelList(a.ctx())
|
||||
a.bind(e, a.toplevelList)
|
||||
a.toplevelList.SetToplevelHandler(a.handleToplevel)
|
||||
case "wl_output":
|
||||
o := &output{wl: client.NewOutput(a.ctx())}
|
||||
a.bind(e, o.wl)
|
||||
a.outputs = append(a.outputs, o)
|
||||
o.wl.SetGeometryHandler(func(e client.OutputGeometryEvent) { o.make_, o.model = e.Make, e.Model })
|
||||
o.wl.SetNameHandler(func(e client.OutputNameEvent) { o.name = e.Name })
|
||||
o.wl.SetDoneHandler(func(client.OutputDoneEvent) { o.done = true })
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) handleToplevel(e proto.ExtForeignToplevelListToplevelEvent) {
|
||||
t := &toplevel{handle: e.Toplevel}
|
||||
a.toplevels = append(a.toplevels, t)
|
||||
t.handle.SetIdentifierHandler(func(e proto.ExtForeignToplevelHandleIdentifierEvent) { t.id = e.Identifier })
|
||||
t.handle.SetTitleHandler(func(e proto.ExtForeignToplevelHandleTitleEvent) { t.title = e.Title })
|
||||
t.handle.SetAppIdHandler(func(e proto.ExtForeignToplevelHandleAppIdEvent) { t.appID = e.AppId })
|
||||
t.handle.SetClosedHandler(func(proto.ExtForeignToplevelHandleClosedEvent) { t.closed = true })
|
||||
t.handle.SetDoneHandler(func(proto.ExtForeignToplevelHandleDoneEvent) { t.done = true })
|
||||
}
|
||||
|
||||
// enumerate collects toplevels and outputs, round-tripping until each has
|
||||
// delivered its full initial state (a "done" event) and no new ones appear.
|
||||
func (a *app) enumerate() {
|
||||
const maxRounds = 20 // safety bound; converges in 1-2 in practice
|
||||
for range maxRounds {
|
||||
before := len(a.toplevels)
|
||||
a.roundtrip()
|
||||
stable := len(a.toplevels) == before
|
||||
allDone := true
|
||||
for _, t := range a.toplevels {
|
||||
if !t.done && !t.closed {
|
||||
allDone = false
|
||||
}
|
||||
}
|
||||
for _, o := range a.outputs {
|
||||
if !o.done {
|
||||
allDone = false
|
||||
}
|
||||
}
|
||||
if stable && allDone {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// roundtrip flushes the connection and dispatches events until the compositor
|
||||
// answers a sync callback, i.e. until everything queued so far is processed.
|
||||
func (a *app) roundtrip() {
|
||||
cb, err := a.display.Sync()
|
||||
if err != nil {
|
||||
log.Fatalf("sync: %v", err)
|
||||
}
|
||||
defer cb.Destroy()
|
||||
done := false
|
||||
cb.SetDoneHandler(func(client.CallbackDoneEvent) { done = true })
|
||||
for !done {
|
||||
if err := a.dispatch(); err != nil {
|
||||
log.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch processes one event, tolerating events addressed to objects we have
|
||||
// already destroyed. The compositor may have queued events for a session/frame
|
||||
// before it saw our destroy request; go-wayland reports those as "unable find
|
||||
// sender" but has still consumed the message, so it is safe to continue. A real
|
||||
// connection failure (EOF, broken pipe) is returned so callers can abort.
|
||||
func (a *app) dispatch() error {
|
||||
err := a.ctx().Dispatch()
|
||||
if err != nil && strings.Contains(err.Error(), "unable find sender") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// captureOne grabs a single frame for one source into an shm buffer and stores
|
||||
// the raw pixels (and dimensions/transform/format) on the source. It returns
|
||||
// false if the compositor could not satisfy the capture. All Wayland and mmap
|
||||
// resources for this source are released before returning.
|
||||
func (a *app) captureOne(s *source) bool {
|
||||
session, err := a.copyMgr.CreateSession(s.cap, 0)
|
||||
if err != nil {
|
||||
log.Printf("create session %q: %v", s.id, err)
|
||||
return false
|
||||
}
|
||||
defer session.Destroy()
|
||||
defer s.cap.Destroy()
|
||||
|
||||
var w, h uint32
|
||||
var formats []uint32
|
||||
sessionDone := false
|
||||
session.SetBufferSizeHandler(func(e proto.ExtImageCopyCaptureSessionBufferSizeEvent) { w, h = e.Width, e.Height })
|
||||
session.SetShmFormatHandler(func(e proto.ExtImageCopyCaptureSessionShmFormatEvent) { formats = append(formats, e.Format) })
|
||||
session.SetDoneHandler(func(proto.ExtImageCopyCaptureSessionDoneEvent) { sessionDone = true })
|
||||
session.SetStoppedHandler(func(proto.ExtImageCopyCaptureSessionStoppedEvent) { sessionDone = true })
|
||||
|
||||
a.roundtrip()
|
||||
if !sessionDone || w == 0 || h == 0 {
|
||||
log.Printf("no buffer constraints for %q", s.id)
|
||||
return false
|
||||
}
|
||||
|
||||
format, swapRB, ok := chooseFormat(formats)
|
||||
if !ok {
|
||||
log.Printf("no supported shm format for %q (got %v)", s.id, formats)
|
||||
return false
|
||||
}
|
||||
|
||||
stride := int(w) * 4
|
||||
size := stride * int(h)
|
||||
fd, err := shmFile(size)
|
||||
if err != nil {
|
||||
log.Printf("shm file for %q: %v", s.id, err)
|
||||
return false
|
||||
}
|
||||
defer unix.Close(fd)
|
||||
|
||||
data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
if err != nil {
|
||||
log.Printf("mmap for %q: %v", s.id, err)
|
||||
return false
|
||||
}
|
||||
defer unix.Munmap(data)
|
||||
|
||||
pool, err := a.shm.CreatePool(fd, int32(size))
|
||||
if err != nil {
|
||||
log.Printf("create pool for %q: %v", s.id, err)
|
||||
return false
|
||||
}
|
||||
defer pool.Destroy()
|
||||
|
||||
buf, err := pool.CreateBuffer(0, int32(w), int32(h), int32(stride), uint32(format))
|
||||
if err != nil {
|
||||
log.Printf("create buffer for %q: %v", s.id, err)
|
||||
return false
|
||||
}
|
||||
defer buf.Destroy()
|
||||
|
||||
frame, err := session.CreateFrame()
|
||||
if err != nil {
|
||||
log.Printf("create frame for %q: %v", s.id, err)
|
||||
return false
|
||||
}
|
||||
defer frame.Destroy()
|
||||
|
||||
var tf uint32
|
||||
ready, failed := false, false
|
||||
frame.SetTransformHandler(func(e proto.ExtImageCopyCaptureFrameTransformEvent) { tf = e.Transform })
|
||||
frame.SetReadyHandler(func(proto.ExtImageCopyCaptureFrameReadyEvent) { ready = true })
|
||||
frame.SetFailedHandler(func(e proto.ExtImageCopyCaptureFrameFailedEvent) {
|
||||
failed = true
|
||||
log.Printf("capture failed for %q (reason=%d)", s.id, e.Reason)
|
||||
})
|
||||
|
||||
if err := frame.AttachBuffer(buf); err != nil {
|
||||
log.Printf("attach buffer for %q: %v", s.id, err)
|
||||
return false
|
||||
}
|
||||
frame.DamageBuffer(0, 0, int32(w), int32(h))
|
||||
if err := frame.Capture(); err != nil {
|
||||
log.Printf("capture for %q: %v", s.id, err)
|
||||
return false
|
||||
}
|
||||
|
||||
for !ready && !failed {
|
||||
if err := a.dispatch(); err != nil {
|
||||
log.Printf("dispatch during capture %q: %v", s.id, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
return false
|
||||
}
|
||||
|
||||
// Copy the pixels out of the mapping before the deferred Munmap runs.
|
||||
s.raw = make([]byte, size)
|
||||
copy(s.raw, data)
|
||||
s.w, s.h, s.transform, s.swapRB = w, h, tf, swapRB
|
||||
return true
|
||||
}
|
||||
|
||||
// shmFile returns an fd backed by an anonymous in-memory file of the given size.
|
||||
func shmFile(size int) (int, error) {
|
||||
fd, err := unix.MemfdCreate("wlthumbs", unix.MFD_CLOEXEC)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("memfd_create: %w", err)
|
||||
}
|
||||
if err := unix.Ftruncate(fd, int64(size)); err != nil {
|
||||
unix.Close(fd)
|
||||
return -1, fmt.Errorf("ftruncate: %w", err)
|
||||
}
|
||||
return fd, nil
|
||||
}
|
||||
BIN
wallpapers/.wallpapers/BookOfMormon.jpg
Normal file
BIN
wallpapers/.wallpapers/BookOfMormon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Loading…
Reference in a new issue