From 55477c3a1dcabbda1c589025ed9036959119bcc8 Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Mon, 3 Aug 2026 21:21:48 +0200 Subject: [PATCH 01/15] =?UTF-8?q?docs:=20ajoute=20les=20sp=C3=A9cification?= =?UTF-8?q?s=20de=20TutoClic=20(v2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spécifications complètes du produit, issues d'une session de conception puis d'une revue d'architecture. Trois paris techniques de la version initiale ont été invalidés par vérification et corrigés : - une extension GNOME Shell ne voit pas les clics des fenêtres clientes ; - AT-SPI ne livre pas d'événements souris sous Wayland ; - le portail GlobalShortcuts n'est pas implémenté sur GNOME. Le moteur de capture repose donc sur cursor_mode = metadata du portail ScreenCast, qui livre la position du pointeur par frame sans aucun privilège, plus une détection de changement de frame comme déclencheur. Sources vérifiées en annexe A, questions ouvertes en annexe B. Co-Authored-By: Claude Opus 5 --- SPEC.md | 1839 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1839 insertions(+) create mode 100644 SPEC.md diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..1d276f1 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,1839 @@ +# Cahier des charges — TutoClic + +**Version 2.0 (finale) — 3 août 2026** +Remplace : `Cahier_des_charges_TutoSnap_v1.md` +Dépôt : `github.com/TutoTech/TutoClic` +Licence du document : CC BY-SA 4.0 + +--- + +## 0. Notes de révision : ce qui change par rapport à la v1, et pourquoi + +Cette section existe pour une raison : la v1 contenait trois paris techniques qui ne +tiennent pas sur la plateforme cible. Ils ont été vérifiés, pas supposés. Un développeur +qui reprend la v1 sans lire cette section perdra deux à trois semaines sur des API qui +ne délivrent rien. + +### 0.1 Corrections bloquantes + +| Point v1 | Réalité vérifiée | Décision v2 | +|---|---|---| +| §3.2, §4.3 : extension GNOME Shell notifiant les clics gauche/droit/central | Les événements branchés sur `global.stage` ne se déclenchent que pour l'UI de GNOME Shell, jamais pour les fenêtres clientes. La piste AT-SPI est fermée aussi : les événements souris ne sont pas livrés sous Wayland faute de *device controller* exposé par Mutter, ce qui est précisément pourquoi le « mouse review » d'Orca est cassé sous Wayland. | **L'extension GNOME Shell sort du périmètre.** Remplacée par le moteur curseur + différence de frames (§4). | +| §4.3, §3.3 : « raccourci global si disponible » | Le portail `org.freedesktop.portal.GlobalShortcuts` n'est **pas implémenté sur GNOME**. KDE et Hyprland l'ont, GNOME non. | Raccourci global via un `custom-keybinding` GSettings, écrit uniquement sur action explicite de l'utilisateur et retirable en un clic (§4.3.2). | +| §3.1 : Tauri 2 + Svelte + ProseMirror | WebKitGTK + pilote NVIDIA propriétaire donne une fenêtre blanche au lancement ; le contournement documenté par Tauri lui-même est `WEBKIT_DISABLE_DMABUF_RENDERER=1`, au prix du chemin de rendu rapide. La machine de référence du projet a une GTX 1060. | **GTK4 + libadwaita + Rust.** ProseMirror tombe, remplacé par CommonMark (§6). | +| §9.3 : Typst « sous réserve de validation de licence » | Typst est sous Apache-2.0, que la FSF liste comme compatible GPLv3. Le risque réel n'est pas la licence, c'est la volatilité de l'API d'intégration. | Typst retenu, version épinglée, `World` minimal maison (§9.3). | +| §11.1 : WCAG 2.2 AA pour l'interface | WCAG s'applique au contenu web. Une app GTK4 se mesure au GNOME HIG et à la conformité AT-SPI, testée avec Orca. | WCAG 2.2 AA reste la cible **de l'export HTML**, qui est du vrai contenu web. L'interface visée le HIG + Orca (§11). | + +### 0.2 Pistes évaluées et écartées, avec la raison + +- **Portail `InputCapture`** (implémenté dans `xdg-desktop-portal-gnome`) : son seul + déclencheur défini est le franchissement d'une barrière de pointeur, et il **saisit** + l'entrée au lieu de l'observer. Le bureau cesse de la recevoir. Inutilisable pour de + l'observation passive pendant que l'utilisateur travaille. +- **Lecture directe de libinput par un helper privilégié** (motif `showmethekey`) : + fonctionne, mais crée par construction un canal capable de lire toutes les entrées, + impossible à distribuer en Flatpak, et à l'opposé de la promesse produit. Documenté + en §17 comme extension V2 explicitement optionnelle, jamais activée par défaut. +- **Appartenance au groupe `input`** : équivaut à donner à toute application lancée par + l'utilisateur la capacité de lire le clavier. Refusé. + +### 0.3 L'ouverture qui rend le produit possible + +Le portail ScreenCast accepte `cursor_mode = metadata`. Dans ce mode le curseur n'est pas +composité dans le flux : il arrive en métadonnée PipeWire `SPA_META_Cursor`, obligatoire +dans ce mode, contenant **la position exacte du pointeur pour chaque frame**. Aucun +privilège supplémentaire, une seule autorisation utilisateur. + +Il ne manque donc que l'instant de l'appui bouton. Or pour un tutoriel, cet instant n'est +pas ce qu'on veut : on veut la frame où l'interface **a répondu**. La détection de +changement de frame, calculable gratuitement sur un flux déjà reçu, est un meilleur signal +que le clic lui-même, et elle résout au passage tout le problème de délai post-clic +que la v1 traitait en §4.4 par des temporisations empiriques. + +**Position du curseur + différence de frames = capture automatique réelle, sans extension, +sans helper privilégié, sans keylogger.** C'est la contrainte Wayland retournée en avantage +de conception, et c'est le cœur technique de TutoClic. + +### 0.4 Corrections issues de la revue d'architecture + +Un premier jet de cette v2 a été relu en revue d'ingénierie. Treize trouvailles, dont +quatre étaient des contradictions internes plutôt que des oublis, ce qui est le mode +d'échec normal d'un document écrit d'un seul jet. Les corrections sont intégrées ; ce +tableau existe pour qu'elles soient traçables. + +| Trouvaille | Problème | Section corrigée | +|---|---|---| +| Fenêtre flottante « toujours au-dessus » | Impossible sur GNOME Wayland : `set_keep_above` retiré en GTK4, aucun protocole standard, layer-shell non pris en charge par GNOME. Le premier jet en faisait le chemin principal. | §3.3 réécrit, raccourci clavier primaire | +| Mode confidentiel vs index de recherche | Ne pas écrire l'image ne suffit pas : le texte OCR partait dans l'index FTS5, donc sur le disque. | §7.3 | +| Coordonnées après recapture | Le premier jet affirmait que les annotations « restent valides ». Faux dès que le rapport d'image change. | §5.2, §5.3 | +| Frontière CLI / D-Bus / verrou | `tutoclic capture` doit parler à une instance vivante, `tutoclic export` doit tourner en CI sans bureau. La même commande était les deux. | §3.4 ajouté, §9.5 réécrit | +| Markdown dans un champ JSON | §6 promettait un format « diffable » que l'encodage JSON annulait. | §6, §8.1, §8.2, §5.1 | +| Flux mort en pleine session | Aucune politique sur les étapes déjà capturées. | §4.3.3 | +| Conversion de coordonnées décrite deux fois | Quatre repères, un seul module autorisé à convertir. | §4.5, §5.2 | +| Dégradation explicite non outillée | Un principe sans type d'erreur est un vœu. | §2.5 | +| Capacités sondées sans consommateur | Cinq des neuf servent des fonctions de Phase 2. | §3.2 | +| « Reconstructible » sans commande | Propriété non vérifiable sans point d'entrée. | §8.1, §9.5 | +| Six seuils sans corpus | Calibrer sans jeu de référence n'est pas mesurable. | §15.5, Phase 0 | +| Recherche plein texte sans mécanisme | FTS5 est intégré à SQLite. | §3.1 | +| `derived/` sans clé de cache | Un export après édition réaplatissait 200 images. | §8.1 | + +La Phase 1 a par ailleurs été découpée en 1a et 1b (§16). Rien n'a été retiré du +périmètre : un point de contrôle a été ajouté au milieu. + +### 0.5 Ce que la v1 avait juste, conservé sans modification + +Le local-first sans télémétrie, le modèle d'annotation non destructif à coordonnées +normalisées, le manifeste JSON versionné à écritures atomiques et assets adressés par +empreinte, le durcissement de l'import d'archive, le principe de dégradation +fonctionnelle explicite (§2.4, devenu load-bearing), la détection de capacités plutôt +qu'une liste de versions, la copie assainie, la détection de données sensibles présentée +comme suggestion seulement, et le refus du keylogging. + +--- + +## 1. Présentation + +### 1.1 Nom et identifiants + +**TutoClic.** Un seul nom pour tout. Ce tableau est normatif : chaque identifiant y est +figé, parce qu'un renommage ultérieur impose une migration du format de fichier chez les +utilisateurs. + +| Élément | Valeur | +|---|---| +| Nom produit | TutoClic | +| Application ID (Flatpak, `.desktop`, GSettings) | `org.tutotech.TutoClic` | +| Nom de bus D-Bus | `org.tutotech.TutoClic` | +| Chemin d'objet D-Bus | `/org/tutotech/TutoClic` | +| Binaire | `tutoclic` | +| Répertoire de projet | `.tutoclic-project/` | +| Archive portable | `.tutoclic` | +| Type MIME | `application/vnd.tutotech.tutoclic+zip` | +| Paquet Debian | `tutoclic` | +| ID Flathub | `org.tutotech.TutoClic` | +| `format` du manifeste | `org.tutotech.tutoclic.project` | +| Dépôt | `github.com/TutoTech/TutoClic` | + +Le nom « Snap » a été écarté : c'est le nom du format de paquet de Canonical sur la +plateforme cible exacte, ce qui garantissait une confusion permanente en support et en +recherche web. + +### 1.2 Objet + +TutoClic est une application de bureau libre qui transforme une manipulation réelle en +document pas-à-pas illustré. L'utilisateur démarre une session, effectue sa procédure, +et TutoClic produit une suite d'étapes numérotées, annotées et exportables. + +Cas d'usage visés : + +- tutoriels et modes opératoires ; +- procédures internes et documentation de support ; +- guides illustrés et supports pédagogiques ; +- captures de reproduction de bug destinées à un ticket. + +### 1.3 Plateformes cibles + +**Référence, testée et garantie :** + +- Ubuntu Desktop 24.04 LTS, GNOME 46, session Wayland ; +- Ubuntu Desktop 26.04 LTS, GNOME 48 ou ultérieur, session Wayland. + +**Supportées sans garantie équivalente :** + +- autres distributions à bureau GNOME récent ; +- autres bureaux implémentant correctement `xdg-desktop-portal` (KDE Plasma, Sway via + `xdg-desktop-portal-wlr`) ; +- session GNOME sur X11, uniquement en mode dégradé. + +Wayland est la plateforme de référence. X11 ne dicte aucune décision d'architecture, et +aucune fonctionnalité n'est conçue d'abord pour X11 puis portée. + +#### Ce que « mode dégradé sur X11 » signifie exactement + +La v1 mentionnait X11 sans dire ce qui change. Sur une session GNOME X11 : + +- la capture passe par le même chemin portail plus PipeWire, `xdg-desktop-portal-gnome` + servant les deux sessions ; le mode automatique fonctionne donc identiquement ; +- `SPA_META_Cursor` doit être vérifié séparément sur X11 (voir annexe B) ; si absent, + le mode automatique fonctionne sans repère de clic ; +- les coordonnées d'écran sont en pixels physiques, sans échelle fractionnaire par + moniteur : la conversion de §4.5 est plus simple, jamais plus complexe ; +- aucune fonctionnalité X11 spécifique n'est ajoutée. En particulier, TutoClic n'utilise + jamais `XRecord`, `XTest` ni la capture directe du root window, même quand ils sont + disponibles et plus simples. Une base de code qui a deux moteurs de capture en a un + qui n'est pas testé. + +#### Emplacements de données + +| Contenu | Chemin | +|---|---| +| Préférences | `$XDG_CONFIG_HOME/tutoclic/` et GSettings sous `/org/tutotech/TutoClic/` | +| Projets, dossier par défaut | `$XDG_DOCUMENTS_DIR/TutoClic/` | +| Caches applicatifs, modèles OCR téléchargés par l'utilisateur | `$XDG_CACHE_HOME/tutoclic/` | +| Journaux | `$XDG_STATE_HOME/tutoclic/logs/`, rotation à 5 Mo, 3 générations | +| Jetons de session et clés API | Secret Service uniquement, jamais sur disque en clair | + +### 1.4 Licence et conformité + +- Application : **GPL-3.0-or-later**. +- Documentation : CC BY-SA 4.0. +- Chaque dépendance doit être libre et compatible GPL-3.0-or-later. +- Un SBOM au format CycloneDX est généré à chaque release et publié avec l'artefact. +- La CI échoue si une dépendance introduit une licence non listée dans + `deny.toml` (via `cargo-deny`). + +Licences des dépendances majeures, vérifiées : GTK4 et libadwaita LGPL-2.1+, `ashpd` MIT, +`pipewire-rs` MIT, Typst Apache-2.0 (compatible GPLv3 selon la FSF), Tesseract Apache-2.0, +`pulldown-cmark` MIT. + +--- + +## 2. Principes fondamentaux + +Ces principes sont des contraintes de conception, pas des arguments de communication. +Une fonctionnalité qui en viole un est refusée, même si elle est utile. + +### 2.1 Local-first + +Toutes les fonctions principales fonctionnent sans compte, sans serveur et sans +connexion Internet : capture, édition, annotation, sauvegarde, OCR, recherche, export, +et l'intégralité des fonctions IA quand un moteur local est présent. + +### 2.2 Aucun privilège élevé + +**TutoClic ne demande jamais de privilège root, ne s'installe jamais setuid, et +n'exige jamais l'appartenance de l'utilisateur au groupe `input` ou `video`.** + +Ce point est ce qui distingue TutoClic de la plupart des outils de capture sous Linux, +et c'est ce qui rend le Flatpak possible. Toute fonctionnalité qui exigerait un +privilège est soit abandonnée, soit isolée dans un composant séparé, opt-in, hors du +paquet principal (voir §17). + +### 2.3 Vie privée par défaut + +Par défaut, et sans réglage à faire : + +- aucune télémétrie, aucun ping de version, aucun rapport d'erreur distant ; +- aucune capture envoyée à un service externe ; +- aucune frappe clavier lue, jamais, par aucun chemin de code ; +- les titres de fenêtres et noms de fichiers ne sont pas enregistrés (option, désactivée + par défaut) ; +- aucune donnée dans les journaux au-delà de ce que §12.2 autorise. + +### 2.4 Consentement par les portails + +TutoClic passe exclusivement par les interfaces prévues : `xdg-desktop-portal`, +PipeWire, GSettings avec confirmation explicite. Aucune tentative de contournement du +modèle de sécurité Wayland, y compris quand un contournement est techniquement possible. + +### 2.5 Dégradation fonctionnelle explicite + +Toute capacité absente est affichée, nommée, et accompagnée d'une action. Aucun échec +silencieux, aucun bouton grisé sans explication. + +Exemples de messages exigés : + +> « Le mode automatique est indisponible : le portail de partage d'écran a refusé la +> session. Utilise le bouton Capturer ou le raccourci clavier. [Réessayer] » + +> « Les métadonnées de curseur ne sont pas fournies par ce bureau. Le mode automatique +> capturera les changements d'écran mais ne pourra pas placer le repère de clic. +> [En savoir plus] » + +Un écran **Diagnostic** (§12.4) liste chaque capacité détectée avec son état et la +raison, pour que l'utilisateur puisse comprendre et rapporter un problème sans lire +un journal. + +#### Ce principe est outillé, pas seulement affiché + +Un principe qui dépend de la discipline du développeur au moment d'écrire chaque message +n'est pas un principe, c'est un vœu. Contrainte de code correspondante : + +- chaque frontière de module expose **une énumération d'erreurs** (`thiserror`), jamais + un type d'erreur opaque en chaîne de caractères ; +- chaque variante porte son message utilisateur et une **action proposée** typée + (`Retry`, `OpenSettings`, `OpenHelp(url)`, `InstallPackage(nom)`, `None`) ; +- la couche interface lit l'action et construit le bouton. Elle ne devine jamais l'action + en inspectant le texte du message. + +Sans ça, le `[Réessayer]` des exemples ci-dessus ne peut pas être décidé par le code, et +§2.5 devient une intention. + +--- + +## 3. Architecture technique + +### 3.1 Pile retenue + +#### Application + +| Couche | Choix | Version minimale | +|---|---|---| +| Langage | Rust stable, édition 2021 | 1.80 | +| Interface | GTK 4 via `gtk4-rs` | GTK 4.14 | +| Widgets et style | libadwaita via `libadwaita-rs` | 1.5 | +| Description d'UI | Blueprint (`.blp`) compilé par `blueprint-compiler`, ou `.ui` XML | — | +| Boucle principale | GLib main loop, `async` via `glib::spawn_future_local` | — | +| Build | Meson pour l'intégration GNOME, Cargo pour Rust | Meson 1.0 | + +Le patron d'architecture est **composants avec état explicite** : `relm4` est autorisé +mais non imposé. La décision finale est prise à la fin de la Phase 0 sur la base du PoC, +et documentée dans un ADR. + +Justification du choix GTK4 plutôt que Tauri : + +1. Aucun WebKitGTK, donc aucune exposition au bug de rendu DMA-BUF sur pilote NVIDIA + propriétaire que Tauri documente lui-même. +2. Accessibilité native : GTK expose directement AT-SPI, testable avec Orca sans + couche intermédiaire. +3. Les frames de capture restent dans le processus qui les traite, sans traversée de + frontière IPC vers une webview. +4. Aspect natif GNOME, thème clair/sombre et adaptation automatiques via libadwaita. +5. La chaîne portail + PipeWire + GTK4 + Rust est prouvée par + [Kooha](https://github.com/SeaDve/Kooha), enregistreur d'écran GNOME sous GPL-3.0, + dont le code est lisible et réutilisable en tant que référence. + +#### Intégration Linux + +| Fonction | Bibliothèque | Notes | +|---|---|---| +| Portails XDG | `ashpd` | ScreenCast, Screenshot, FileChooser, Settings | +| Flux vidéo | `pipewire-rs` | consommation directe, sans GStreamer | +| D-Bus | `zbus` | service propre + interrogation de `org.gnome.Shell.Introspect` | +| Secrets | `oo7` ou `libsecret` via GI | jetons de session et clés API | +| Images | `image` + `fast_image_resize` | miniatures, redimensionnement, hachage perceptuel | +| Rendu d'annotations | GTK Snapshot / Cairo | pas de moteur externe | + +#### Consommation du flux : la vraie question de faisabilité + +TutoClic n'encode pas de vidéo. Il consomme des frames brutes et n'en retient qu'une à la +fois. La tentation est donc d'écarter GStreamer et de consommer `pipewire-rs` directement. + +Le point dur est le type de tampon négocié. PipeWire peut livrer les frames en +`SPA_DATA_MemFd` ou `MemPtr`, lisibles directement par le CPU, ou en `SPA_DATA_DmaBuf`, +qui vit sur le GPU et demande un import EGL ou GL pour être lu. Kooha passe par +`pipewiresrc` de GStreamer précisément parce que GStreamer gère cette négociation. + +Décision, à confirmer en Phase 0 : + +1. **Chemin préféré** : négocier explicitement des tampons CPU (`MemFd` ou `MemPtr`) avec + `pipewire-rs`. Mutter sait produire ce format. Aucune dépendance GStreamer, aucun + import EGL, code de lecture trivial. +2. **Repli si seul le DMA-BUF est offert** : `pipewiresrc` plus `videoconvert` plus + `appsink` de GStreamer, en acceptant la chaîne de plugins. C'est le chemin prouvé. +3. **Non retenu** : écrire un import EGL maison. Trop de code sensible au pilote pour le + gain, et c'est exactement le genre de chemin qui casse sur NVIDIA propriétaire. + +Ce point est un critère de sortie de Phase 0 (§15.2) et non une hypothèse. GStreamer +redevient de toute façon nécessaire si l'export vidéo entre au périmètre (hors V1, §18). + +#### Édition et persistance + +| Besoin | Choix | +|---|---| +| Contenu textuel d'étape | CommonMark, **un fichier `.md` par étape** (§6, §8.1) | +| Parsing Markdown | `pulldown-cmark` | +| Édition | `GtkSourceView` 5 avec barre d'outils de formatage | +| Annotations | modèle vectoriel sérialisé en JSON (§7.2) | +| Manifeste | JSON versionné, schéma JSON publié (§8.2) | +| Index de recherche | SQLite via `rusqlite`, **table virtuelle FTS5**, entièrement reconstructible | +| Assets | fichiers adressés par SHA-256 | + +La recherche plein texte utilise **FTS5**, la table virtuelle de recherche intégrée à +SQLite. Ce point est normatif parce que l'alternative par défaut, un balayage `LIKE` sur +les colonnes de texte OCR, se dégrade linéairement et sera écrite par quiconque ne trouve +pas la consigne ici. + +### 3.2 Détection de capacités + +Aucun test de numéro de version. TutoClic sonde les capacités au démarrage et à chaque +ouverture de session, met le résultat en cache pour la session, et l'expose dans +l'écran Diagnostic. + +| Capacité | Test | Si absente | +|---|---|---| +| `portal.screencast` | présence de l'interface, `version` ≥ 4 | mode automatique et session persistante indisponibles ; repli sur le portail Screenshot | +| `portal.screencast.persist` | `PersistMode::ExplicitlyRevoked` accepté et `restore_token` retourné | reconsentement à chaque session, message explicite | +| `portal.screencast.cursor_metadata` | `cursor_mode` accepte la valeur `metadata` | mode automatique sans repère de clic | +| `portal.screenshot` | présence de l'interface | capture ponctuelle indisponible | +| `shell.introspect` | `org.gnome.Shell.Introspect.GetWindows` répond | métadonnées d'application et de fenêtre absentes, ce qui est sans conséquence : elles sont désactivées par défaut | +| `atspi.query` | bus a11y joignable, `Atspi` interrogeable au point | pas de contexte accessible pour l'aide à la rédaction | +| `gsettings.custom_keybinding` | schéma `org.gnome.settings-daemon.plugins.media-keys` accessible | raccourci global non proposé | +| `ocr.tesseract` | binaire ou bibliothèque présente, langues installées | OCR indisponible | +| `ai.local` | endpoint local répond | fonctions IA masquées, pas grisées | + +**Phasage du sondage.** Cette table est la cible finale, pas le périmètre de la première +tranche. Sonder une capacité sans consommateur produit du code mort qu'il faut quand même +tester. La Phase 1a ne sonde que `gsettings.custom_keybinding` ; la Phase 1b ajoute les +quatre capacités de portail ; la Phase 2 ajoute `atspi.query`, `shell.introspect`, +`ocr.tesseract` et `ai.local`. L'écran Diagnostic grandit avec les fonctionnalités. + +**À valider en Phase 0 :** la disponibilité de `org.gnome.Shell.Introspect` pour une +application non sandboxée puis sous Flatpak n'a pas été vérifiée pour ce document. Elle +est traitée comme une capacité optionnelle et son absence ne dégrade aucune +fonctionnalité principale. + +### 3.3 Contrôles de session : le raccourci clavier est le chemin principal + +GNOME n'affiche pas les icônes de zone de notification héritées, et **aucune application +ne peut se maintenir au-dessus des autres sur GNOME Wayland**. `gtk_window_set_keep_above` +a été retiré en GTK4, il n'existe aucun protocole Wayland standard pour ça, et +`gtk4-layer-shell`, la seule alternative, ne fonctionne pas sur GNOME Wayland (annexe A). + +Une fenêtre flottante ne peut donc pas être le chemin principal : elle passe derrière +précisément quand tu documentes une application en plein écran, soit le cas normal. Ordre +de priorité corrigé : + +1. **Raccourci clavier global** (§4.3.2). Chemin principal. Proposé au démarrage de la + première session, pas enterré dans les préférences. Fonctionne quelle que soit la + fenêtre au premier plan, ce qu'aucun autre chemin ne garantit. +2. **Notification persistante** avec boutons d'action Pause et Stop, via `GNotification` + avec `set_priority(HIGH)` et des actions enregistrées sur l'application. Reste + accessible depuis le centre de notifications pendant toute la session. +3. **Fenêtre de contrôle flottante** : petite fenêtre `AdwWindow` déplaçable, contenant + Capturer, Pause, Stop, le compteur d'étapes et la vignette de la dernière capture. + Présente, utile sur un grand écran, mais **sans promesse de rester au-dessus**. Une + aide contextuelle explique le clic droit sur la barre de titre puis « Toujours au + premier plan », qui est une action de l'utilisateur et fonctionne, elle. +4. **Fenêtre principale**, quand elle est visible. +5. **D-Bus et CLI** (§3.4, §9.5), pour un script ou une touche de macro clavier. + +En mode automatique, tu n'as besoin que de Pause et Stop pendant la session, donc les deux +premiers chemins suffisent. La fenêtre flottante s'exclut elle-même de toute capture +(§4.7), comme toutes les fenêtres de TutoClic. + +### 3.4 Interface D-Bus + +Le nom de bus et le chemin d'objet sont figés en §1.1. L'interface elle-même est une +**API publique** : la CLI de session en est un client (§9.5), donc elle se versionne et se +documente comme le format de fichier, pas comme un détail interne. + +Interface `org.tutotech.TutoClic.Session` sur `/org/tutotech/TutoClic` : + +| Membre | Type | Rôle | +|---|---|---| +| `InterfaceVersion` | propriété, `u` | version de l'interface, incrémentée sur tout changement incompatible | +| `IsSessionActive` | propriété, `b` | une session de capture est en cours | +| `StepCount` | propriété, `u` | nombre d'étapes capturées dans la session en cours | +| `Capture()` | méthode | déclenche une capture immédiate ; échoue si aucune session n'est active | +| `CaptureImmediate()` | méthode | capture sans attendre la stabilisation (§4.3.3, menus fugaces) | +| `Pause()` / `Resume()` | méthodes | suspend et reprend le flux | +| `Stop()` | méthode | termine la session et libère le flux | +| `Panic()` | méthode | arrêt d'urgence (§4.7) : jette la frame en cours avant écriture | +| `StepAdded(u id)` | signal | une étape a été validée sur disque | +| `SessionEnded(s reason)` | signal | fin de session, avec la cause | + +L'application est activable par D-Bus via un fichier `.service`, pour que +`tutoclic capture` fonctionne même si l'interface graphique n'est pas déjà lancée. Elle +démarre alors sans session active et la méthode échoue avec un message explicite plutôt +que de lancer une capture non consentie. + +--- + +## 4. Moteur de capture + +C'est la partie la plus risquée du produit et la seule qui doit être validée avant +tout développement d'interface. + +### 4.1 Configuration d'une session + +Avant de démarrer, l'utilisateur choisit : + +- **Source** : écran entier, moniteur précis, ou fenêtre, selon ce que le portail + propose. La sélection réelle est faite par le dialogue du portail, pas par TutoClic. +- **Mode** : manuel, automatique, ou minuterie. +- **Curseur** : `metadata` (recommandé, repère de clic reconstitué avec un style + cohérent), `embedded` (curseur système visible dans l'image), ou `hidden`. +- **Régions et applications exclues** (§4.7). +- **Sensibilité de détection** en mode automatique (§4.4), trois presets plus un mode + expert. +- **Conserver l'image avant action** : oui/non. + +### 4.2 Session ScreenCast persistante + +Une session de capture ouvre **un seul** flux PipeWire et le garde jusqu'à l'arrêt. + +1. `ScreenCast.CreateSession`. +2. `SelectSources` avec `cursor_mode = metadata`, `multiple = false`, + `persist_mode = ExplicitlyRevoked`. +3. `Start` : l'utilisateur voit le dialogue du portail et choisit sa source. C'est le + seul moment où il est sollicité. +4. Le `restore_token` retourné est enregistré dans le Secret Service, jamais dans le + manifeste ni dans un fichier de configuration en clair. +5. Le flux PipeWire est consommé jusqu'à `Session.Close`, appelé sur arrêt normal, + sur fermeture de l'application, et depuis un gestionnaire de panique. + +Le portail Screenshot reste utilisé pour une capture ponctuelle hors session, où ouvrir +un flux serait disproportionné. + +**Robustesse du jeton, vérifiée :** pendant que la session est verrouillée, Mutter refuse +de créer ou de restaurer un screencast, et chaque tentative pendant le verrouillage tend +à consommer le jeton enregistré. Conséquences normatives : + +- le jeton est traité comme révocable à tout moment, jamais comme acquis ; +- une tentative de restauration qui échoue déclenche un reconsentement, avec un message + qui explique pourquoi et ne culpabilise pas l'utilisateur ; +- TutoClic n'essaie pas de restaurer une session tant que `logind` indique la session + verrouillée ; il attend le déverrouillage. + +### 4.3 Déclencheurs + +#### 4.3.1 Manuels + +- Bouton **Capturer** de la fenêtre flottante. +- Minuterie configurable, de 1 à 60 secondes. +- `tutoclic capture` en CLI, ou la méthode D-Bus équivalente. Ce chemin est de première + classe : il rend TutoClic scriptable et pilotable par une touche de macro clavier. + +#### 4.3.2 Raccourci clavier global + +Le portail GlobalShortcuts n'existant pas sur GNOME, le seul chemin fonctionnel est +d'écrire un `custom-keybinding` dans +`org.gnome.settings-daemon.plugins.media-keys`, pointant sur `tutoclic capture`. + +Puisque ce raccourci est le **chemin principal** des contrôles de session (§3.3) et non +un confort optionnel, il est proposé au bon moment : **au démarrage de la première +session de capture**, pas enterré dans les préférences où personne ne le trouvera avant +d'en avoir eu besoin. + +C'est malgré tout une modification des réglages de l'utilisateur, donc elle reste +encadrée : + +- jamais faite au premier lancement de l'application, jamais implicite, jamais silencieuse ; +- proposée une fois, avec le **texte exact** de ce qui sera écrit et où, plus un bouton + « Continuer sans raccourci » qui n'est pas un piège : la session démarre quand même et + la notification persistante de §3.3 prend le relais ; +- retirable par un seul interrupteur des préférences, qui supprime réellement l'entrée ; +- si un raccourci identique existe déjà, TutoClic le signale, propose une combinaison + libre, et n'écrase jamais ; +- si le schéma GSettings est inaccessible (cas du Flatpak, §13.2), la fonction est masquée + et l'écran Diagnostic explique pourquoi. La notification persistante devient alors le + chemin principal, et c'est écrit dans le message. + +Ce compromis assume une tension avec §13.2 (« TutoClic ne doit pas modifier +automatiquement les paramètres de GNOME »). Elle est résolue par le mot +**automatiquement** : rien n'est écrit sans une action explicite de l'utilisateur devant +le texte exact du changement. La proposer plus tôt qu'en v1 change le moment, pas le +consentement. + +#### 4.3.3 Automatique : le moteur curseur + différence de frames + +C'est le mode qui reproduit l'expérience Folge, et il ne demande aucun privilège. + +Boucle, exécutée sur le thread de traitement, jamais sur le thread UI : + +1. Chaque frame arrivant de PipeWire est réduite à une vignette de travail (largeur + cible 320 px, niveaux de gris) dans un tampon réutilisé, sans allocation par frame. +2. La position du curseur est lue depuis `SPA_META_Cursor` de la même frame. +3. La vignette est comparée à la vignette de référence : différence absolue par bloc de + 16 × 16, agrégée en un score et en une **boîte englobante de la zone modifiée**. +4. Si le score dépasse le seuil, la frame est marquée *candidate* et l'horloge de + stabilisation démarre. +5. Quand deux vignettes consécutives séparées d'au moins 120 ms ne diffèrent plus au-delà + du seuil de repos, l'écran est considéré stable : **c'est cette frame-là qui devient + l'étape**, en pleine résolution. +6. La position du curseur retenue est celle de la **première** frame candidate, avant que + l'interface ne bouge, parce que c'est là que le pointeur était quand l'action a eu + lieu. +7. La boîte englobante de la zone modifiée est enregistrée dans les métadonnées d'étape : + elle sert à proposer automatiquement un recadrage, à placer un rectangle de mise en + évidence, et à alimenter les fonctions IA (§10). +8. La référence est remplacée par la nouvelle vignette. Un délai de garde + (500 ms par défaut) empêche deux étapes pour une seule action. + +Réglages exposés : + +| Réglage | Défaut | Plage | +|---|---|---| +| Seuil de changement | 2,0 % des blocs modifiés | 0,2 à 20 % | +| Seuil de repos | 0,3 % | 0,05 à 5 % | +| Fenêtre de stabilisation | 120 ms | 50 à 1000 ms | +| Délai maximal d'attente de stabilité | 2000 ms | 500 à 10000 ms | +| Délai de garde entre étapes | 500 ms | 100 à 5000 ms | +| Images par seconde demandées au flux | 10 | 2 à 30 | + +Cas particuliers traités explicitement : + +- **Animations et contenu vidéo** : un changement continu qui ne se stabilise jamais ne + doit pas produire une étape par frame. Au bout du délai maximal, une étape unique est + créée et un avertissement s'affiche : « Zone en mouvement continu détectée. Le mode + automatique est peu fiable ici. » +- **Curseur clignotant, horloge, notifications** : les blocs dont le changement est + périodique et de faible surface sont ignorés par un filtre de surface minimale + (0,05 % de l'écran par défaut). +- **Menus qui disparaissent** : un mode « capture immédiate » désactive la stabilisation + pour la prochaine capture, accessible par un raccourci de la fenêtre flottante. +- **Ce que le mode automatique ne peut pas savoir** : le type de bouton (gauche, droit, + milieu) et le fait qu'un clic ait eu lieu plutôt qu'un raccourci clavier. L'interface + ne prétend pas le contraire ; le type d'action est un champ éditable de l'étape, vide + par défaut, et non une valeur inventée. +- **Le flux meurt en pleine session** : l'utilisateur révoque le partage d'écran depuis + les Réglages GNOME, le portail ferme la session, ou PipeWire tombe. Règle normative : + **chaque étape est validée sur disque avant que la frame suivante soit traitée.** Un + flux qui meurt ne perd alors jamais plus que la frame en cours, et le projet reste + cohérent avec les N étapes déjà capturées. L'application affiche ce qui a été conservé + et propose de reprendre sur une nouvelle session. + +Ce dernier point doit être dit dans l'interface, une fois, au premier usage du mode +automatique. C'est la ligne honnête entre TutoClic et Folge, et la cacher produirait +des guides faux. + +### 4.4 Mémoire et débit + +Contraintes normatives, vérifiables en test : + +- **Une seule frame pleine résolution est conservée à la fois**, plus une seconde + uniquement si « conserver l'image avant action » est actif. Aucun tampon circulaire + d'historique. +- Les vignettes de travail sont deux tampons préalloués, échangés, jamais réalloués. +- Les frames PipeWire arrivant en DMA-BUF ne sont mappées en mémoire CPU que lorsqu'une + étape est effectivement produite, ou pour la vignette de travail réduite. +- L'écriture disque de la capture pleine résolution est asynchrone et ne bloque jamais + la boucle de détection. +- Objectif mesuré : **RSS stable sous 400 Mo** pendant une session automatique de + 30 minutes sur deux écrans 1920 × 1080, sans croissance monotone. + +### 4.5 Multi-écrans et mise à l'échelle + +Le moteur manipule **quatre** repères et ne les confond jamais dans le code : coordonnées +logiques du bureau, coordonnées physiques du moniteur, coordonnées en pixels de l'image +capturée, et coordonnées normalisées sur l'asset original (§5.2). La conversion est +centralisée dans un seul module, avec des types distincts (`LogicalPoint`, +`PhysicalPoint`, `ImagePoint`, `NormalizedPoint`) pour que le compilateur refuse un +mélange. + +**Ce module est le seul endroit du code où une conversion existe.** Règle explicite parce +que l'alternative se produit toute seule : des divisions par largeur et hauteur +éparpillées dans le rendu d'annotations, les quatre backends d'export et le comparateur de +dérive, chacune avec son propre arrondi. Aucun autre module n'a le droit de diviser une +coordonnée par une dimension d'image. + +Cas obligatoirement gérés : + +- facteurs d'échelle différents par moniteur, y compris fractionnaires (125 %, 150 %) ; +- moniteurs à coordonnées négatives ; +- rotation d'écran ; +- branchement et débranchement d'un moniteur pendant une session ; +- changement de résolution pendant une session ; +- le moniteur capturé disparaît : la session se met en pause avec un message clair, ne + plante pas, et propose de reprendre sur une autre source. + +### 4.6 Métadonnées d'étape + +Enregistrées quand disponibles, et **chacune désactivable individuellement** : + +| Champ | Défaut | Source | +|---|---|---| +| horodatage | activé | horloge locale | +| géométrie du moniteur, facteur d'échelle | activé | portail / Mutter | +| région capturée | activé | flux | +| position du curseur | activé | `SPA_META_Cursor` | +| boîte de la zone modifiée | activé | moteur de détection | +| provenance du déclencheur | activé | interne | +| délais appliqués | activé | interne | +| identifiant d'application | **désactivé** | `org.gnome.Shell.Introspect` | +| titre de fenêtre | **désactivé** | `org.gnome.Shell.Introspect` | +| nom et rôle accessibles au point | **désactivé** | `Atspi`, en interrogation ponctuelle | + +Les trois derniers champs sont désactivés par défaut parce qu'ils fuient le contexte de +travail de l'utilisateur. Une bannière non modale, affichée à la première activation, +explique exactement ce qui sera enregistré. + +Le nom accessible obtenu par **interrogation** ponctuelle d'AT-SPI au point du curseur +fonctionne sous Wayland ; c'est l'écoute d'événements et le contrôleur de périphérique +qui ne fonctionnent pas. Cette distinction est la raison pour laquelle AT-SPI reste +utile ici alors qu'il est inutilisable comme déclencheur. + +### 4.7 Exclusions et bouton d'urgence + +L'utilisateur peut exclure des applications, des titres correspondant à une expression, +et des régions rectangulaires de l'écran (masquées avant toute écriture disque). + +Exclusions **appliquées d'office, non désactivables** : + +- la fenêtre flottante et toutes les fenêtres de TutoClic ; +- l'écran de verrouillage : la capture est suspendue dès que `logind` signale le + verrouillage, et ne reprend qu'après déverrouillage plus confirmation ; +- les dialogues du portail et les invites d'authentification polkit. + +Une liste embarquée de gestionnaires de mots de passe connus (par identifiant +d'application) est **proposée** en exclusion à la création de session, cochée par défaut, +et modifiable. Elle est présentée comme une commodité, jamais comme une garantie : elle +ne peut pas être exhaustive, et le dire évite une fausse confiance. + +**Bouton d'urgence :** un raccourci unique et un bouton de la fenêtre flottante +suspendent immédiatement le flux, jettent la frame en cours avant écriture, et affichent +un panneau permettant de supprimer les N dernières étapes. Le chemin doit être +inconditionnel : il fonctionne même si le reste de l'interface est occupé. + +--- + +## 5. Gestion des étapes + +### 5.1 Modèle + +Chaque étape porte au minimum : + +- `id` : UUID v4 ; +- `order` : entier, position dans la section ; +- `title` : chaîne courte ; +- `bodyFile` : chemin relatif vers `content/.md`, le corps de l'étape en CommonMark + (§6, §8.1). **Le texte ne vit pas dans le manifeste** ; +- `asset` : référence SHA-256 vers l'image originale, ou `null` pour une étape textuelle ; +- `crop` : rectangle en coordonnées normalisées sur l'original, ou `null` ; +- `annotations` : tableau (§7.2) ; +- `alt_text` : texte alternatif, plus un drapeau `alt_text_source` valant + `human`, `ai_suggested` ou `intentionally_empty` ; +- `capture_meta` : métadonnées (§4.6) ; +- `visible` : booléen ; +- `needs_review` : booléen, posé par la détection de dérive (§10.6) ; +- `created_at`, `updated_at`. + +### 5.2 Contrat de coordonnées (normatif) + +**Toutes les coordonnées d'annotation sont normalisées entre 0 et 1 par rapport à +l'image originale non recadrée.** Le recadrage est un rectangle stocké séparément. Le +moteur de rendu compose : il applique le recadrage, puis projette les annotations. + +Cette règle existe parce que l'alternative (normaliser sur la zone recadrée) fait dériver +toutes les annotations au premier changement de recadrage. La v1 spécifiait des +coordonnées normalisées sans dire par rapport à quoi ; c'était une ambiguïté suffisante +pour produire un bug difficile à diagnostiquer. + +`NormalizedPoint` est le quatrième type du module de conversion de §4.5, et ce module est +le seul autorisé à passer d'un repère à l'autre. + +**Limite du repère normalisé, à dire clairement.** Normaliser préserve les proportions, pas +les cibles. Un badge à (0,82 ; 0,14) désignait un bouton en haut à droite ; si la même +étape est recapturée sur un moniteur de rapport différent ou avec une fenêtre +redimensionnée, le badge reste en haut à droite mais ne désigne plus rien. Le repère +normalisé absorbe un changement de résolution à rapport constant, et rien de plus. Voir +la règle de §5.3. + +### 5.3 Opérations + +Ajout manuel, duplication, suppression avec annulation, réorganisation par glisser-déposer +**et au clavier** (Ctrl+Shift+Flèches, avec annonce accessible du déplacement), fusion de +deux étapes, séparation d'une étape, masquage sans suppression, sections, recherche, +remplacement d'image, recadrage non destructif, historique annuler/rétablir sur au moins +100 opérations. + +Deux opérations que la v1 n'avait pas et qui sont indispensables à l'usage réel : + +- **Recapturer cette étape** : rouvre une session de capture ciblée sur une seule étape et + remplace l'image, en conservant titre, texte et annotations. Sans ça, corriger une + capture ratée sur 40 impose de tout refaire. +- **Insérer une étape ici** pendant une session en cours, sans sortir du mode capture. + +**Règle normative après tout remplacement d'image** (recapture ou remplacement manuel) : + +1. si le rapport largeur/hauteur de la nouvelle image est identique à l'ancien à 1 % près, + les annotations sont conservées telles quelles ; +2. sinon, elles sont conservées **et** l'étape est marquée `needs_review`, le même drapeau + que celui posé par la détection de dérive (§5.1, §10.6) ; +3. dans les deux cas, l'éditeur affiche les annotations en surbrillance « à revérifier » + jusqu'à ce que l'utilisateur valide ou les déplace. + +Ce que le logiciel ne fait jamais : affirmer que les annotations restent valides. Elles +restent **positionnées**, ce qui n'est pas la même chose (§5.2). + +La hiérarchie est volontairement plate : `section → étapes`. Pas d'imbrication +arbitraire, parce qu'aucun format d'export cible ne la rend proprement. + +--- + +## 6. Contenu textuel : CommonMark + +Le contenu d'une étape est stocké en **Markdown CommonMark**, dans **un fichier `.md` par +étape**, sous `content/.md` (§8.1). Le manifeste ne contient qu'une référence. + +Ce choix découle du choix de pile, et il est meilleur pour ce produit : + +- l'export Markdown devient sans perte et sans conversion ; +- toute la surface d'attaque « assainir le HTML importé » disparaît, avec elle une + bonne partie de §12.1 de la v1 ; +- le format est lisible, diffable et versionnable dans Git, ce qui compte pour un + outil de documentation ; +- un utilisateur peut éditer un projet à la main si l'application casse. + +**Pourquoi un fichier par étape plutôt qu'un champ du manifeste.** Du Markdown dans une +chaîne JSON donne `"body": "Étape 1\n\n- clique sur **Fichier**\n"`. Techniquement +diffable, illisible en pratique : `git diff` affiche une seule ligne modifiée de plusieurs +centaines de caractères pleine de `\n` échappés. Les deux dernières puces ci-dessus +seraient alors fausses. Avec un fichier par étape, `git diff` montre le texte réel, étape +par étape, ce qui sert directement le cas d'usage de régénération en CI de §9.5. + +Contrepartie assumée : deux sources à garder synchronisées. Le manifeste est la source de +vérité pour la liste des étapes ; `content/` en est le contenu. La validation +(`tutoclic validate`) détecte les deux dérives possibles : un `bodyFile` référencé mais +absent, et un fichier de `content/` qu'aucune étape ne référence. Le second est réparable +sans perte, le premier est signalé comme corruption. + +Sous-ensemble supporté en V1 : paragraphes, gras, italique, code inline, blocs de code +avec langage, listes ordonnées et non ordonnées, listes de tâches, liens, citations. + +Deux extensions locales, syntaxiquement valides en CommonMark et dégradant proprement +chez un lecteur qui ne les connaît pas : + +- **Encarts** : `> [!NOTE]`, `> [!AVERTISSEMENT]`, `> [!ATTENTION]`, la syntaxe + d'alerte GitHub. +- **Touches clavier** : `Ctrl+S`, rendues comme des touches dans + tous les exports. + +Les touches clavier méritent une note : TutoClic ne lit pas le clavier, par conception. +Une étape « appuyez sur Ctrl+S » est donc **toujours** rédigée par l'utilisateur. +L'interface propose un bouton d'insertion de touche pour rendre ça rapide, et ne +prétend jamais l'avoir détecté. + +**Variables** : `{{nom_variable}}`, résolues à l'export depuis un dictionnaire du projet. +Sert à produire le même guide pour plusieurs clients ou environnements. + +Édition dans `GtkSourceView` 5 avec coloration Markdown, barre d'outils de formatage, +raccourcis standards (Ctrl+B, Ctrl+I, Ctrl+K), collage nettoyé en texte, et aperçu du +rendu final dans un panneau latéral. + +--- + +## 7. Annotations + +### 7.1 Outils + +Flèche, ligne, rectangle, ellipse, texte, badge numéroté à numérotation automatique, +surbrillance, recadrage, loupe, flou, pixelisation, **occultation opaque**, et +**caviardage définitif**. + +Le badge numéroté se place automatiquement à la position du curseur enregistrée +(§4.3.3, point 6) quand elle existe, et se déplace ensuite librement. + +### 7.2 Modèle non destructif + +Chaque annotation porte : `id`, `type`, géométrie en coordonnées normalisées sur +l'original (§5.2), `style`, `color`, `opacity`, `stroke_width`, `rotation`, `z_order`, +et `text` le cas échéant. L'image originale n'est jamais modifiée, sauf par la commande +explicite de caviardage définitif. + +### 7.3 Données sensibles : dire la vérité + +Trois primitives, présentées avec une hiérarchie de garantie claire dans l'interface, +pas seulement dans la documentation : + +| Primitive | Garantie | Présentation | +|---|---|---| +| **Occultation opaque** | rectangle plein, pixels d'origine toujours présents dans le projet | choix par défaut de l'outil de masquage | +| **Flou / pixelisation** | cosmétique, potentiellement réversible | étiqueté « effet visuel, ne protège pas » dans l'infobulle | +| **Caviardage définitif** | pixels détruits dans l'asset original, opération irréversible | confirmation explicite, badge visible sur l'étape | + +Le flou est très largement utilisé et très largement mal compris. L'infobulle doit le +dire en une phrase, pas renvoyer à une page d'aide. + +#### Mode confidentiel + +Option de session : **l'original n'est jamais écrit sur disque**. Les zones exclues sont +masquées et les caviardages appliqués en mémoire, avant la première écriture. C'est la +seule configuration où une capture d'écran contenant un secret ne laisse pas de trace, +et c'est pour ça qu'elle est une option de session et pas un traitement après coup. + +**Le mode confidentiel désactive aussi l'OCR et l'indexation.** Ce point est normatif et +non évident : ne pas écrire l'image ne suffit pas. L'OCR de §10.4 alimente la recherche +plein texte, donc l'index FTS5 de `cache/` (§3.1). Un mot de passe visible dans un terminal +capturé finirait en clair dans la base d'index alors que l'image n'a jamais touché le +disque. En mode confidentiel : + +- aucun texte OCR n'est produit ni indexé pour les étapes de la session ; +- aucune miniature n'est écrite dans `thumbnails/` ; +- aucune variante n'est écrite dans `derived/` avant un export explicite ; +- aucune suggestion IA n'est envoyée à un fournisseur, même local, sans confirmation par + étape. + +Ces quatre conséquences sont affichées dans le dialogue d'activation du mode, avec ce que +l'utilisateur perd en échange : pas de recherche dans les captures de cette session. + +#### Commande « Créer une copie assainie » + +1. Applique définitivement tous les caviardages. +2. Supprime les assets originaux correspondants. +3. Purge `derived/`, `thumbnails/` et `cache/` de toute variante dérivée d'un asset + caviardé. **C'est l'étape que la plupart des outils oublient**, et c'est par là que + les pixels fuient. +4. Nettoie les métadonnées : EXIF, `tEXt` et `iTXt` PNG, XMP, et les champs de + métadonnées d'étape marqués sensibles (titre de fenêtre, identifiant d'application, + nom accessible). +5. Recalcule toutes les empreintes et réécrit le manifeste. +6. Produit un **rapport** listant nommément ce qui a été supprimé, affiché avant + confirmation et enregistré dans la copie. + +La commande refuse de s'exécuter en place : elle produit toujours une nouvelle copie, +et l'original reste intact tant que l'utilisateur ne le supprime pas lui-même. + +--- + +## 8. Format de projet + +### 8.1 Répertoire de travail + +```text +MonGuide.tutoclic-project/ +├── manifest.json # structure : sections, étapes, annotations, métadonnées +├── manifest.json.lock # verrou d'ouverture, PID + horodatage +├── content/ # .md, un corps d'étape par fichier (§6) +├── assets/ +│ ├── original/ # .png, jamais modifiés +│ ├── derived/ # aplatis, reconstructibles, nommés par clé de cache +│ └── thumbnails/ # reconstructibles +├── templates/ +├── cache/ # index FTS5, OCR, hachages — reconstructible +└── backups/ # sauvegardes tournantes du manifeste +``` + +Tout ce qui est sous `cache/`, `derived/` et `thumbnails/` est reconstructible à partir +de `manifest.json`, `content/` et `assets/original/`. Supprimer ces trois répertoires ne +perd aucune donnée : c'est une propriété testée (§15.3), pas une intention. + +Le point d'entrée de reconstruction est explicite : **`tutoclic rebuild `** +(§9.5). C'est aussi la commande que le test de §15.3 appelle. Une propriété qui n'a pas de +commande n'est pas vérifiable. + +#### Clé de cache de `derived/` + +Chaque image aplatie est nommée par l'empreinte du triplet **(empreinte de l'asset +original, empreinte du jeu d'annotations de l'étape, rectangle de recadrage)**. + +Sans cette clé, modifier une annotation sur une seule étape invalide tout `derived/`, et +le prochain export PDF réaplatit les 200 images au lieu d'une. Avec elle, l'objectif +« export PDF de 200 étapes < 30 s » de §14 tient aussi au deuxième export. `derived/` +étant entièrement reconstructible, ce choix ne crée aucune contrainte de compatibilité. + +### 8.2 Manifeste + +```json +{ + "format": "org.tutotech.tutoclic.project", + "schemaVersion": 1, + "projectId": "uuid-v4", + "title": "Titre du guide", + "uiLanguage": "fr", + "contentLanguage": "fr", + "createdAt": "2026-08-03T18:00:00Z", + "updatedAt": "2026-08-03T18:42:00Z", + "guideVersion": "1.0", + "variables": {}, + "sections": [], + "steps": [ + { + "id": "8f1c…", + "order": 1, + "title": "Ouvrir les paramètres réseau", + "bodyFile": "content/8f1c….md", + "asset": "sha256:a91b…", + "crop": null, + "annotations": [], + "altText": "", + "altTextSource": "intentionally_empty", + "captureMeta": {}, + "visible": true, + "needsReview": false, + "createdAt": "2026-08-03T18:10:00Z", + "updatedAt": "2026-08-03T18:10:00Z" + } + ], + "assets": {}, + "exportSettings": {}, + "privacySettings": {} +} +``` + +Le corps de l'étape est dans `content/`, pas dans le manifeste (§6). Le manifeste reste +donc de taille modeste et lisible même sur un guide de 200 étapes, et un `git diff` sur un +projet montre séparément ce qui a changé dans la structure et ce qui a changé dans le +texte. + +`contentLanguage` est distinct de `uiLanguage` : un utilisateur francophone doit pouvoir +produire un guide en anglais sans changer la langue de son bureau. La v1 ne traitait que +la langue de l'interface. + +`guideVersion` sert au versionnement du guide lui-même, pas du format. Une procédure +change ; les captures se périment. Voir §10.6. + +Un schéma JSON officiel accompagne chaque `schemaVersion` et est publié dans le dépôt. +La CI valide chaque projet de test contre son schéma. + +### 8.3 Sauvegarde et intégrité + +- Sauvegarde automatique, intervalle configurable, désactivable. +- Écriture dans un fichier temporaire du même système de fichiers, `fsync`, puis + `rename` atomique. Jamais d'écriture en place sur `manifest.json`. +- Sauvegardes tournantes dans `backups/`, 10 générations par défaut. +- Récupération après plantage : au démarrage, si un manifeste est illisible, TutoClic + propose la dernière sauvegarde valide en nommant sa date, et ne l'applique pas seul. +- Détection de modification externe par horodatage plus empreinte. +- Verrou d'ouverture : à l'ouverture d'un projet déjà verrouillé, TutoClic vérifie si le + PID est vivant. Si non, il propose de récupérer. Si oui, il ouvre en lecture seule. + +### 8.4 Archive portable + +Extension `.tutoclic`, archive ZIP contenant le manifeste, les assets nécessaires, les +informations de version, les empreintes d'intégrité et les modèles utilisés. + +L'import se protège contre : Zip Slip (chemins remontants), chemins absolus, liens +symboliques, bombes de décompression (ratio et taille décompressée totale plafonnés), +fichiers individuels excessivement volumineux, noms en doublon différant par la casse, +JSON malformé, et `schemaVersion` supérieure à celle supportée. Chaque refus produit un +message qui nomme la cause. + +--- + +## 9. Exports + +### 9.1 Markdown + +```text +guide/ +├── README.md +└── images/ +``` + +Options : CommonMark strict ou GitHub Flavored Markdown, numérotation des étapes, texte +alternatif, images aplaties, liens relatifs, front matter YAML optionnel, résolution des +variables. + +L'export est **sans perte** dans le sens inverse aussi : un `README.md` produit par +TutoClic et réimporté redonne les mêmes étapes. C'est possible parce que le stockage +interne est déjà du Markdown. + +### 9.2 HTML autonome + +Dossier autonome sans aucune ressource distante, ou fichier unique avec images en +data-URI. HTML sémantique, navigation entre étapes, mise en page imprimable, CSS +personnalisable, thème clair et sombre. + +Aucun JavaScript quand il n'est pas nécessaire, et il ne l'est que pour la recherche dans +le guide, qui est optionnelle. + +**C'est ici que WCAG 2.2 AA s'applique**, parce que c'est du vrai contenu web : structure +de titres correcte, texte alternatif sur chaque image, contraste suffisant, navigation +au clavier, aucune information portée par la seule couleur. L'export accessible refuse +de s'exécuter si une image manque de texte alternatif, sauf si l'étape est marquée +`intentionally_empty`. + +Le CSS personnalisé est traité comme une donnée : il est écrit dans un fichier, jamais +interprété par l'application. + +### 9.3 PDF + +Moteur : le crate **Typst**, Apache-2.0, compatible GPLv3. + +Le risque réel est la volatilité de l'API d'intégration, pas la licence. Mitigations +normatives : + +- version de `typst` et `typst-pdf` épinglée exactement dans `Cargo.lock` ; +- implémentation `World` minimale et isolée dans un seul module, avec ses propres tests + de rendu ; +- un test de non-régression compare l'empreinte du PDF produit pour un projet de + référence, ce qui rend visible tout changement de rendu à la mise à jour ; +- repli documenté : si l'intégration Typst devient intenable, la surface PDF est + réimplémentée sur la surface PDF de Cairo, déjà liée via GTK. Le coût est la + pagination et la table des matières, à écrire à la main. + +Fonctions : A4 et Letter, marges configurables, couverture, logo, auteur et métadonnées, +table des matières, en-tête et pied de page, pagination, choix de police, contrôle des +coupures d'étape, profil d'export accessible, texte sélectionnable, rendu identique à +partir des mêmes entrées. + +Les polices embarquées doivent avoir une licence de redistribution. Par défaut : une +famille libre embarquée dans le paquet, plus les polices système détectées. + +### 9.4 JSON + +Export stable et documenté, distinct du manifeste interne, destiné aux intégrations, aux +migrations et aux générateurs externes. Schéma versionné indépendamment de +`schemaVersion`. + +### 9.5 CLI : deux familles de sous-commandes, à ne pas confondre + +La CLI n'est pas un extra. Elle permet de régénérer la documentation dans une CI à chaque +modification du guide, ce qui est la façon dont la documentation reste vivante. Mais elle +recouvre deux natures de commandes que le document doit distinguer, sous peine de +spécifier une commande qui ne peut pas fonctionner. + +**Famille A, documentaires. Autonomes, sans affichage, sans D-Bus.** + +```bash +tutoclic export mon-guide.tutoclic --format md --out ./docs/ +tutoclic export mon-guide.tutoclic --format pdf --profile accessible +tutoclic validate mon-guide.tutoclic +tutoclic rebuild mon-guide.tutoclic-project # reconstruit cache/, derived/, thumbnails/ +tutoclic drift mon-guide.tutoclic # voir §10.6 +``` + +Elles ouvrent le projet elles-mêmes, prennent un **verrou partagé en lecture** (donc +fonctionnent même si l'interface graphique tient le projet ouvert, §8.3), et ne requièrent +ni `WAYLAND_DISPLAY` ni `DISPLAY`. `tutoclic drift` est la seule exception partielle : sa +comparaison est autonome, mais la recapture qui l'alimente exige une session. + +**Famille B, session. Clients D-Bus de l'instance qui tourne.** + +```bash +tutoclic capture # équivaut à org.tutotech.TutoClic.Session.Capture() +tutoclic pause +tutoclic resume +tutoclic stop +tutoclic panic +``` + +Elles n'ouvrent aucun projet et ne prennent aucun verrou : elles envoient un message à +l'instance qui détient la session (§3.4). Si aucune instance ne tourne, l'activation D-Bus +démarre l'application sans session active, et la commande échoue avec un message explicite +plutôt que de déclencher une capture non consentie. + +Cette séparation est normative parce qu'elle décide de choses observables : `tutoclic +export` doit tourner dans un conteneur de CI sans bureau, et `tutoclic capture` ne peut +pas. Un binaire unique dont le comportement dépend de la sous-commande, avec `--help` +qui le dit. + +### 9.6 Exports ultérieurs + +SCORM 1.2, ODT et ODP, DOCX et PPTX, paquet de site statique, intégration MkDocs, +Docusaurus et Hugo. LibreOffice peut servir de convertisseur externe optionnel ; son +absence ne doit jamais empêcher un export de base. + +--- + +## 10. Fonctions IA + +L'IA est un accélérateur de rédaction, jamais une autorité. Toute sortie est une +suggestion, éditable, refusable, et le module entier est désactivable. + +### 10.1 Moteur local prioritaire + +Connexion HTTP à un endpoint local : Ollama, `llama.cpp`, ou tout serveur exposant une +API documentée. Aucun téléchargement automatique de modèle. Taille, licence et provenance +affichées avant installation. + +**Cadrage matériel.** La machine de référence a une GTX 1060 6 Go. Règle de +dimensionnement retenue : les poids doivent tenir sous **5 Go** pour laisser du contexte +et du cache. En pratique, cela signifie un modèle texte de 7 à 8 milliards de paramètres +en quantification Q4, ou un modèle vision de 3 à 4 milliards en Q4. TutoClic affiche la +VRAM détectée et signale un modèle qui n'y tiendra pas, plutôt que de laisser +l'utilisateur découvrir le débordement par la lenteur. + +Un mode CPU est fonctionnel mais lent ; il est signalé comme tel, pas masqué. + +### 10.2 Fonctions texte + +Reformulation professionnelle, correction orthographique, résumé, traduction du guide +vers `contentLanguage`, génération de titre, homogénéisation du ton sur tout le guide, +détection de formulations ambiguës, regroupement suggéré des étapes. + +Une fonction spécifique au produit : **homogénéiser l'impératif**. Un guide écrit en +plusieurs sessions mélange « cliquez sur », « on clique sur », « cliquer sur ». Un passage +sur tout le document règle ça en une action. + +### 10.3 Vision locale : le texte alternatif et les titres + +C'est la fonction IA à la plus forte valeur du produit, et la raison pour laquelle un +modèle vision entre au périmètre. + +Un modèle vision local lit la capture et produit : + +- un **texte alternatif** décrivant ce que montre l'image ; +- un **titre d'étape** proposé ; +- une **description de l'élément** situé dans la boîte de changement (§4.3.3, point 7). + +Pourquoi ça compte : un export accessible exige un texte alternatif sur chaque image. +Sur un guide de 200 étapes, l'écrire à la main ne se fait pas. Sans cette fonction, +l'exigence d'accessibilité de §9.2 reste théorique. Avec elle, elle devient atteignable. + +Le résultat est **toujours** marqué `ai_suggested` dans `alt_text_source`, visible dans +l'interface, et l'export accessible peut être configuré pour exiger une relecture +humaine de chaque suggestion avant de passer. + +La boîte de changement et le contexte accessible (§4.6) sont fournis au modèle en même +temps que l'image : ils ancrent la description sur l'élément réellement concerné plutôt +que sur l'écran entier. + +### 10.4 OCR local + +Tesseract, avec les paquets linguistiques installés localement (`tesseract-ocr-fra`, +`tesseract-ocr-eng` au minimum). Usages : recherche plein texte dans les captures, +proposition de titre, aide au texte alternatif, et détection de données sensibles. + +Le résultat OCR est toujours une suggestion et n'est jamais écrit dans un journal +(§12.2). + +### 10.5 Détection de données sensibles + +Détection locale, optionnelle, activée par défaut en analyse **sans action** : + +- adresses électroniques, numéros de téléphone, adresses IP privées et publiques ; +- jetons et clés à motif connu (préfixes de fournisseurs courants, JWT, clés SSH) ; +- numéros de carte bancaire, avec validation de Luhn pour limiter les faux positifs ; +- IBAN ; +- motifs personnalisables par expression régulière, définis par l'utilisateur. + +TutoClic **propose** des rectangles à vérifier et ne caviarde jamais tout seul. Une +suppression irréversible déclenchée par une heuristique est un bug par conception, pas +une fonctionnalité. + +Les deux sources se combinent : OCR pour les motifs textuels, modèle vision pour les +zones qui ressemblent à un champ de mot de passe, un panneau de configuration ou une +fenêtre de terminal. + +### 10.6 Détection de dérive + +Fonction que Folge n'a pas, et qui répond au vrai problème des procédures : elles +pourrissent. + +`tutoclic drift mon-guide.tutoclic` rejoue le guide, recapture chaque étape, et compare : + +1. empreinte perceptuelle de la nouvelle capture contre celle enregistrée ; +2. si l'écart dépasse un seuil, le modèle vision décrit **ce qui a changé** ; +3. l'étape est marquée `needs_review` avec la description. + +L'interface affiche alors une liste « 6 étapes sur 40 ont probablement changé », avec un +avant/après. L'utilisateur recapture les six (§5.3) au lieu de refaire le guide entier. + +Deux limites à dire clairement : le rejeu ne peut pas être automatique, puisque TutoClic +n'injecte pas d'entrée ; c'est l'utilisateur qui refait la manipulation, TutoClic ne fait +que comparer. Et le seuil produira des faux positifs sur un simple changement de thème. +La fonction se présente donc comme une aide à la relecture, pas comme un test. + +### 10.7 Fournisseurs distants + +Un fournisseur distant n'est utilisable qu'après, dans cet ordre : + +1. activation explicite dans les préférences ; +2. configuration de la clé, stockée dans le Secret Service via `libsecret`, jamais dans + le manifeste, ni dans un fichier de configuration, ni dans un journal ; +3. affichage du contenu exact qui sera envoyé, avant chaque envoi ; +4. confirmation de l'utilisateur ; +5. affichage du fournisseur et de son adresse dans l'interface pendant l'envoi. + +Une option « ne jamais envoyer d'image » reste disponible et permet d'utiliser un +fournisseur distant pour le texte seul. + +### 10.8 Limites + +- Aucune modification destructive sans confirmation. +- Aucune publication automatique. +- Toute sortie marquée comme suggestion, avec sa provenance. +- Module entièrement désactivable, et alors **masqué** plutôt que grisé. +- Aucun téléchargement automatique de modèle. + +--- + +## 11. Accessibilité et internationalisation + +### 11.1 Accessibilité de l'application + +Cible : **GNOME Human Interface Guidelines** et conformité AT-SPI, pas WCAG. Une +application GTK4 n'est pas du contenu web, et se mesurer à WCAG produirait des critères +inapplicables tout en manquant les vrais. + +Exigences vérifiables : + +- navigation complète au clavier, sans exception, y compris la réorganisation d'étapes + et l'outil d'annotation ; +- ordre de focus cohérent, pas de piège de focus ; +- libellés accessibles sur chaque contrôle, vérifiés avec `accerciser` ; +- alternative clavier documentée pour chaque geste de glisser-déposer ; +- contraste suffisant, respect du thème à contraste élevé du système ; +- aucune information transmise par la seule couleur ; +- annonces accessibles pour les opérations longues (export, OCR, IA) ; +- respect du réglage système de réduction des animations. + +**Test obligatoire à chaque release** : parcours complet de création d'un guide de trois +étapes avec Orca activé et sans souris. C'est un critère d'acceptation, pas une +recommandation. + +### 11.2 Accessibilité du contenu produit + +Distincte de la précédente, et c'est là que WCAG 2.2 AA s'applique (§9.2). Le profil +d'export accessible impose le texte alternatif, une structure de titres correcte, et un +contraste vérifié sur les annotations de texte. + +### 11.3 Langues + +Architecture i18n dès la première version, via `gettext`, fichiers `.po` versionnés dans +le dépôt et compatibles avec une plateforme libre de traduction (Weblate). + +Langues initiales : **français** et **anglais**, les deux maintenues par le projet. +Ensuite : allemand, espagnol, italien, portugais, par contribution. + +Rappel de §8.2 : la langue de l'interface et la langue du guide produit sont deux +réglages indépendants. + +--- + +## 12. Sécurité + +### 12.1 Surface d'attaque + +Le choix de GTK4 supprime la surface web entière de la v1 : pas de CSP, pas de +webview, pas de HTML à assainir, pas de script provenant d'un projet. Ce qui reste : + +- **Fichiers de projet non fiables** : un `.tutoclic` reçu par courriel est une entrée + hostile. Validation stricte du manifeste contre le schéma, refus de tout chemin + d'asset qui n'est pas un nom de fichier simple sous `assets/`, plafonds de taille et + de nombre d'éléments, durcissement ZIP de §8.4. +- **Images non fiables** : le décodage passe par le crate `image`, en Rust, sans + `unsafe` ajouté par le projet. Les dimensions sont plafonnées avant allocation. +- **Modèles d'export** : traités comme des données. Un modèle Typst provenant d'un projet + importé n'est pas exécuté sans confirmation explicite, parce que Typst est un langage. + Par défaut, les modèles embarqués dans un projet importé sont **ignorés**. +- **Endpoint IA** : l'URL est limitée à `localhost` et aux adresses de boucle locale + tant qu'un fournisseur distant n'a pas été explicitement activé. +- **Toute entrée validée côté Rust**, avec des types qui rendent l'état invalide non + représentable plutôt que des vérifications dispersées. + +### 12.2 Journaux + +Les journaux ne contiennent par défaut **jamais** : contenu OCR, texte des étapes, +titres de fenêtres, chemins personnels complets, clés d'API, captures, et évidemment +aucun événement clavier puisque aucun n'est lu. + +Un paquet de diagnostic est générable, **inspectable avant partage** dans un visualiseur +intégré, et expurgé par défaut. Il liste ce qu'il contient. + +### 12.3 Chaîne de dépendances + +La CI exécute, et échoue en cas d'échec : + +- `cargo audit` pour les vulnérabilités connues ; +- `cargo deny` pour les licences et les doublons ; +- génération et publication du SBOM CycloneDX ; +- détection de secrets sur le diff ; +- une suite de fichiers de projet malveillants (Zip Slip, bombe, chemins absolus, liens + symboliques, JSON malformé, dimensions d'image aberrantes) qui doivent **tous** être + refusés proprement, sans panique du processus. + +### 12.4 Écran Diagnostic + +Page des préférences listant chaque capacité de §3.2 avec son état, la raison quand elle +est absente, et une action quand il y en a une. C'est le pendant concret du principe de +§2.5, et le premier endroit où regarder quand un utilisateur ouvre un ticket. + +--- + +## 13. Distribution + +Une application que personne ne peut installer n'existe pas. Cette section est un +livrable, pas une intention. + +### 13.1 Paquet Debian, chemin principal + +- Construit **en CI** par GitHub Actions, pas à la main, pour Ubuntu 24.04 et 26.04. +- Publié en artefact de GitHub Release, signé. +- Dépendances déclarées : `libgtk-4-1`, `libadwaita-1-0`, `libpipewire-0.3-0`, + `xdg-desktop-portal`, `xdg-desktop-portal-gnome`, `libsecret-1-0`, + `tesseract-ocr` recommandé, `tesseract-ocr-fra` suggéré. +- Un dépôt APT hébergé sur GitHub Pages est ajouté dès que la cadence de release le + justifie, pour que la mise à jour ne soit pas manuelle. + +### 13.2 Flatpak et Flathub + +Cible dès la V1.1. Les permissions demandées sont **minimales et justifiées une par une** +dans le manifeste Flatpak : + +| Permission | Justification | +|---|---| +| `--socket=wayland` | affichage | +| `--socket=pipewire` | flux de capture, après consentement du portail | +| `--talk-name=org.freedesktop.secrets` | jeton de session et clés API | +| `--filesystem=xdg-documents` ou portail de fichiers | ouverture et sauvegarde de projets | +| `--share=network` | **non demandé** ; ajouté seulement si l'utilisateur active un fournisseur IA distant | + +L'accès à `org.gnome.settings-daemon` pour le raccourci global n'est pas demandé : sous +Flatpak, la capacité est simplement absente et l'écran Diagnostic l'explique. C'est une +dégradation acceptable, pas une raison d'élargir le bac à sable. + +Le fait que TutoClic n'exige aucun privilège élevé (§2.2) est ce qui rend ce paquet +Flatpak possible. C'est la contrepartie concrète du refus du helper libinput. + +### 13.3 Snap + +Non planifié. Le nom du produit a été choisi en partie pour éviter la confusion, et +Flatpak plus `.deb` couvre la cible. + +### 13.4 Mises à jour + +Paquets signés, notes de version publiées, migrations de schéma réversibles quand +possible, sauvegarde automatique du projet avant toute migration, et aucun mécanisme de +mise à jour propriétaire ou obligatoire. TutoClic ne vérifie pas les mises à jour sur +le réseau ; le gestionnaire de paquets s'en charge. + +--- + +## 14. Performances + +Objectifs mesurés sur la machine de référence, à valider et ajuster en fin de Phase 0. + +| Métrique | Objectif | +|---|---| +| Démarrage à froid, hors premier lancement | < 1,5 s | +| Interface réactive pendant un export | oui, aucun blocage du thread UI > 50 ms | +| RSS pendant une session automatique de 30 min, deux écrans 1080p | < 400 Mo, stable | +| Frames pleine résolution conservées simultanément | 1, ou 2 si « avant action » actif | +| Latence de détection d'un changement d'écran | < 200 ms | +| Projet de 200 étapes : édition | aucun ralentissement perceptible | +| Historique annuler/rétablir | ≥ 100 opérations | +| Export Markdown de 200 étapes | < 5 s | +| Export PDF de 200 étapes | < 30 s | +| Arrêt de session après erreur PipeWire | flux libéré en < 1 s, dans tous les cas | + +Les miniatures et l'OCR sont traités en arrière-plan, jamais sur le thread UI, avec +progression annoncée de façon accessible. + +--- + +## 15. Tests et critères d'acceptation + +### 15.1 Matrice + +| Axe | Valeurs | +|---|---| +| OS | Ubuntu 24.04 (GNOME 46), Ubuntu 26.04 (GNOME 48+) | +| Session | Wayland (référence), X11 (dégradé) | +| Écrans | un écran ; deux écrans ; deux écrans à échelles différentes | +| Échelle | 100 %, 125 %, 150 %, 200 % | +| Pilote graphique | Mesa, NVIDIA propriétaire | +| Portail | accordé ; refusé ; révoqué en cours de session | +| Verrouillage | verrouillé pendant une session, puis déverrouillé | +| Moniteur | débranché pendant une session | +| IA | absente ; locale ; distante | +| OCR | absent ; présent sans `fra` ; présent avec `fra` | + +Le pilote NVIDIA propriétaire est dans la matrice **parce que** c'est le chemin qui a +disqualifié la pile de la v1. Un test de rendu au démarrage sur cette configuration est +un test de non-régression permanent. + +### 15.2 Critères du moteur de capture (portes de la Phase 0) + +Le PoC est accepté si, et seulement si : + +1. l'utilisateur sélectionne une source via le dialogue du portail ; +2. le flux PipeWire est reçu et lisible **par le chemin préféré de §3.1** (tampons CPU), + ou, à défaut, par le repli GStreamer, la décision étant documentée dans un ADR ; +3. `cursor_mode = metadata` est accepté et `SPA_META_Cursor` fournit une position par + frame. Critère de précision : après conversion vers le repère de l'image, l'écart avec + le pointeur réel est mesuré, documenté, et **inférieur à 24 px logiques**, soit la + taille minimale d'une cible cliquable au sens du GNOME HIG. Un écart plus grand + placerait le repère de clic sur le mauvais élément ; +4. **dix captures successives sont produites sans nouvelle demande d'autorisation** ; +5. rien n'est capturé avant consentement, vérifié par l'absence de toute écriture disque + et de toute frame reçue avant `Start` ; +6. la détection de changement produit une étape par action sur un scénario scripté de + dix actions, sans doublon et sans manque ; +7. la position du curseur retenue tombe dans l'élément d'interface effectivement + actionné, sur les quatre facteurs d'échelle ; +8. la conversion de coordonnées est correcte sur deux écrans à échelles différentes, + y compris avec un écran à coordonnées négatives ; +9. pause et arrêt prennent effet en moins de 200 ms ; +10. la session est libérée après arrêt, après plantage contrôlé, et après `SIGTERM` ; +11. le RSS reste stable sur 30 minutes de session automatique ; +12. le verrouillage d'écran suspend la capture, et le déverrouillage soit restaure la + session, soit explique pourquoi il faut reconsentir. + +**Aucun développement de l'éditeur ne commence avant que ces douze points passent.** + +### 15.3 Critères de persistance + +Aucune corruption après `SIGKILL` pendant une sauvegarde, vérifié par un test qui tue le +processus à intervalles aléatoires pendant 100 sauvegardes. Récupération de la dernière +version valide. Migration testée depuis chaque `schemaVersion` antérieure. Refus propre +de chaque archive malveillante de §12.3. Intégrité vérifiée par empreinte. Ouverture +possible sans réseau. Suppression de `cache/`, `derived/` et `thumbnails/` sans perte. + +### 15.4 Critères d'export + +Rendu reproductible (même entrée, même empreinte de sortie). **Aucun pixel original +d'un asset caviardé présent dans un export aplati**, vérifié par recherche d'empreinte +sur l'arborescence produite. Liens relatifs valides en Markdown. HTML utilisable hors +ligne, fichier par fichier, sans requête réseau (vérifié en coupant le réseau). +PDF à texte sélectionnable et cherchable. Texte alternatif conservé. Caractères +français et accents corrects dans les quatre formats. Métadonnées privées exclues par +défaut. Aller-retour Markdown sans perte. + +### 15.5 Corpus et tests unitaires obligatoires + +Les sections précédentes décrivent des tests de scénario. Deux composants ne peuvent pas +être couverts correctement par des scénarios, et ce sont les deux où une erreur produit +silencieusement un guide faux au lieu d'un plantage visible. + +#### Corpus de fixtures du détecteur de changement + +§4.3.3 expose six seuils réglables et l'annexe B admet qu'aucun n'est mesuré. Régler six +nombres sans jeu de référence n'est pas de l'ingénierie, et le détecteur est le composant +dont dépend toute la valeur du mode automatique. + +Le corpus est constitué en Phase 0, au moment où des flux sont de toute façon capturés +pour tester le portail. Dix séquences d'images enregistrées d'interactions GNOME réelles, +chacune accompagnée du **nombre d'étapes attendu** et des positions de curseur attendues : + +| # | Séquence | Ce qu'elle piège | +|---|---|---| +| 1 | ouvrir un menu déroulant | apparition rapide, doit produire 1 étape | +| 2 | changer d'onglet | changement de grande surface, 1 étape | +| 3 | cocher une case | changement de très faible surface, 1 étape, ne doit pas être filtré | +| 4 | taper du texte dans un champ | changements répétés et minuscules, doit produire 0 étape | +| 5 | animation continue (barre de progression) | ne doit jamais produire une étape par frame | +| 6 | changement de fenêtre active | 1 étape, pas deux | +| 7 | ouvrir un dialogue modal | 1 étape, la boîte doit être dans la boîte englobante | +| 8 | faire défiler une liste | mouvement continu puis arrêt, 1 étape à l'arrêt | +| 9 | redimensionner une fenêtre | changement de géométrie, 1 étape | +| 10 | notification qui apparaît puis disparaît | 0 étape, doit être filtré comme périphérique | + +Le réglage des seuils devient alors une optimisation mesurable, et tout changement de seuil +se régresse automatiquement. Limite connue : les fixtures se périment quand GNOME change +d'animations ou de thème par défaut. Elles sont donc datées, versionnées avec la version de +GNOME sur laquelle elles ont été enregistrées, et rafraîchies à chaque version majeure +d'Ubuntu prise en charge. + +#### Test de propriété sur la conversion de coordonnées + +§15.2 point 8 teste la conversion sur un scénario manuel à deux écrans. C'est un point de +l'espace. Le module de §4.5 reçoit en plus un test de propriété (`proptest`) qui génère des +configurations d'écran aléatoires (nombre de moniteurs, échelles fractionnaires, +coordonnées négatives, rotations, rapports d'image variés) et vérifie l'invariant +d'aller-retour `LogicalPoint → PhysicalPoint → ImagePoint → NormalizedPoint → ImagePoint`, +à la tolérance d'arrondi près, documentée. + +#### Quatre tests dérivés des règles ajoutées + +| Test | Vérifie la règle de | +|---|---| +| révocation du partage d'écran en pleine session, 12 étapes déjà capturées | §4.3.3, validation par étape avant la frame suivante | +| session en mode confidentiel puis inspection de `cache/` et de l'index FTS5 | §7.3, aucun texte OCR indexé | +| copie assainie puis recherche d'empreinte dans `derived/` et `thumbnails/` | §7.3, purge des variantes dérivées | +| `tutoclic export` et `tutoclic validate` sans `DISPLAY` ni `WAYLAND_DISPLAY` en CI | §9.5, autonomie de la famille A | + +--- + +## 16. Phases de développement + +### Phase 0 — Validation technique (bloquante) + +Objectif unique : répondre par oui ou non aux douze points de §15.2. + +**Dans cet ordre, le premier point d'abord.** + +1. **PoC `pipewire-rs` : quel type de tampon Mutter accepte-t-il de négocier ?** + `MemFd` ou `MemPtr` lisibles par le CPU, ou DMA-BUF imposé (§3.1). Ce point décide si + GStreamer entre dans les dépendances, donc il vient avant tout le reste. Une + soixantaine de lignes suffisent pour l'établir. +2. PoC `ashpd` : session ScreenCast persistante avec `restore_token`, `persist_mode` + `ExplicitlyRevoked`, et lecture de `SPA_META_Cursor` en `cursor_mode = metadata`. +3. Vérification du rendu GTK4 sur pilote NVIDIA propriétaire. Rapide, et c'est le point + qui a disqualifié la pile de la v1 : le vérifier tôt évite de le découvrir tard. +4. PoC conversion de coordonnées multi-écrans multi-échelles, avec le test de propriété + de §15.5. +5. **Constitution du corpus de fixtures** de §15.5, les dix séquences. À faire ici parce + que des flux sont de toute façon capturés à cette étape : le coût marginal est faible + et tout le réglage du détecteur en dépend. +6. PoC détection de changement, calibré contre le corpus : seuils, boîte englobante, + mesure de latence. +7. Mesure mémoire sur 30 minutes, cible RSS de §14. +8. Vérification de la disponibilité de `org.gnome.Shell.Introspect`, hors puis sous + Flatpak. +9. ADR sur chaque décision restée ouverte : `relm4` ou composants maison, GStreamer ou + pas, Typst ou surface PDF de Cairo. + +**Aucune ligne d'éditeur, aucune interface au-delà d'une fenêtre de test, avant que +cette phase passe.** Si le point 2 échoue sur les métadonnées de curseur, le mode +automatique perd le repère de clic et le périmètre doit être renégocié avant d'aller plus +loin. Si le point 1 impose le DMA-BUF, GStreamer entre dans les dépendances et la liste +de §13.1 est mise à jour avant la Phase 1a. + +### Phase 1a — Le premier outil utilisable + +Le plus petit ensemble qui produit déjà un guide. Rien n'est retiré du périmètre : ce +découpage ajoute un point de contrôle, il ne coupe pas de fonctionnalité. + +- format de projet complet : manifeste, `content/`, verrou, écriture atomique, + sauvegardes tournantes, migrations, `tutoclic validate`, `tutoclic rebuild` ; +- **import de captures existantes** ; +- capture manuelle par le portail Screenshot, sans session persistante ; +- liste d'étapes avec réorganisation à la souris **et au clavier** ; +- éditeur Markdown avec aperçu ; +- export Markdown, et la famille A de la CLI (§9.5) ; +- français et anglais ; +- fonctionnement intégral hors ligne. + +L'import de captures existantes est ici, et non en phase ultérieure comme dans la v1 : +c'est le chemin le moins cher vers de la valeur, et c'est par là que la plupart des +utilisateurs commencent. + +Propriété utile de cette tranche : **elle ne dépend ni du portail ScreenCast ni de +PipeWire**. Elle avance donc même si la Phase 0 révèle un problème sur le type de tampon +ou sur les métadonnées de curseur, et elle est testable intégralement sans bureau. + +### Phase 1b — La capture automatique et la sortie complète + +- session ScreenCast persistante et `restore_token` (§4.2) ; +- **mode automatique** curseur plus différence de frames (§4.3.3), calibré contre le + corpus de §15.5 ; +- raccourci clavier global, notification persistante, fenêtre flottante (§3.3) ; +- service et interface D-Bus, famille B de la CLI (§3.4, §9.5) ; +- annotations essentielles : flèche, rectangle, texte, badge numéroté, occultation opaque, + recadrage ; +- **recapturer une étape** et insérer une étape en cours de session ; +- exports HTML, PDF et JSON ; +- écran Diagnostic ; +- paquet `.deb` construit en CI et publié en GitHub Release. + +C'est la fin de ce que la v1 appelait « Phase 1 ». Le produit est alors complet au sens du +périmètre d'origine. + +### Phase 2 — V1.1 + +OCR local et recherche plein texte, IA locale texte, **IA vision pour texte alternatif +et titres**, détection de données sensibles, mode confidentiel, copie assainie, modèles +d'export, Flatpak sur Flathub, rapport d'accessibilité, raccourci global GSettings, +variables de projet, langue de contenu distincte de la langue d'interface. + +### Phase 3 — V2 + +Détection de dérive (§10.6), SCORM, formats bureautiques, greffons contrôlés, imports +depuis d'autres outils, helper d'entrée privilégié optionnel (§17) si et seulement si +la demande le justifie, collaboration avec verrouillage et gestion de conflits. + +--- + +## 17. Extension optionnelle hors paquet principal : helper d'entrée + +Documentée ici pour que la décision soit tracée, **pas planifiée pour la V1**. + +Un binaire séparé, `tutoclic-input-helper`, autorisé par polkit, lisant libinput et ne +transmettant que les événements de bouton de pointeur sur une socket Unix locale, +donnerait l'horodatage exact du clic et le type de bouton, donc la parité fonctionnelle +complète avec Folge. + +Conditions non négociables si ce composant est un jour développé : + +1. binaire séparé, dépôt séparé, revue de sécurité séparée ; +2. filtrage des codes d'événement **dans le helper**, avant toute sortie de processus, + avec un test qui échoue si un événement clavier peut sortir ; +3. jamais installé par défaut, jamais suggéré au premier lancement ; +4. actif uniquement pendant une session de capture, arrêté avec elle ; +5. absent du Flatpak, par construction ; +6. l'interface indique visiblement quand il est actif. + +Raison de ne pas le faire en V1 : il crée un canal capable de lire toutes les entrées, +ce qui contredit §2.2 et §2.3, et il rend le Flatpak impossible pour la fonctionnalité +concernée. Le moteur de §4.3.3 couvre le besoin réel sans cette contrepartie. + +--- + +## 18. Hors périmètre + +- Collaboration simultanée en temps réel. +- Stockage cloud TutoClic, sous quelque forme que ce soit. +- Application mobile. +- Enregistrement vidéo complet, et donc GStreamer dans le chemin de capture. +- Lecture du clavier, par quelque mécanisme que ce soit. +- Injection d'entrée, et donc rejeu automatique d'une procédure. +- Contournement des permissions Wayland. +- Publication automatique sur Internet. +- Compatibilité garantie avec tous les environnements Linux. +- Import des fichiers propriétaires de Folge. +- DOCX et PPTX parfaits dès la première version. +- Extension GNOME Shell, écartée sur base technique (§0.1) et non par manque de temps. + Une extension minimale limitée à un indicateur de barre et à l'enregistrement du + raccourci reste la seule solution complète au problème des contrôles de session (§3.3), + et elle est écartée pour éviter la matrice de compatibilité GNOME, pas parce qu'elle + serait impossible. +- `gtk4-layer-shell` pour maintenir la fenêtre flottante au-dessus : ne fonctionne pas sur + GNOME Wayland (annexe A). +- Évaluation notée de la qualité du texte alternatif généré par IA : nécessaire dès que + §10.3 devient load-bearing pour l'export accessible, donc en Phase 2, pas avant que la + fonction existe. +- Cible de performance en 4K : §14 ne chiffre que deux écrans 1080p. Le coût du chemin + vignette en 4K est en annexe B point 5 et sera chiffré en Phase 0 avant d'être promu en + objectif. +- Framework de migration de schéma : remplacé par une liste ordonnée de fonctions de + transformation (annexe C). + +--- + +## 19. Instructions pour l'agent de développement + +1. **Commencer par la Phase 0 et rien d'autre.** Produire un registre des risques et un + ADR par décision structurante avant d'écrire du code d'application. +2. Livrer les mesures de §15.2 sous forme de chiffres, pas d'affirmations. Un critère + sans mesure n'est pas passé. +3. Faire valider les limites techniques trouvées avant de contourner quoi que ce soit. +4. Implémenter le format de projet et ses migrations avant l'éditeur : le format est ce + qui a une compatibilité à préserver. +5. Puis le moteur de capture, puis l'éditeur, puis les exports, puis seulement l'OCR + et l'IA. +6. Écrire les tests d'archive malveillante en même temps que le code d'import, pas après. +7. Documenter chaque dépendance ajoutée : licence, raison, et ce qu'on ferait sans elle. +8. Ne jamais remplacer une fonction impossible sous Wayland par une solution intrusive. + Toute limitation est remontée avec au moins une solution de repli, et affichée dans + l'écran Diagnostic. +9. Quand une contrainte de la plateforme bloque une fonctionnalité, chercher d'abord si + la contrainte peut devenir une meilleure conception. C'est ce qui a produit §4.3.3. + +--- + +## Annexe A — Sources vérifiées + +Les affirmations techniques de §0 s'appuient sur ces sources, consultées le 3 août 2026. + +- Portée des événements du `global.stage` d'une extension GNOME Shell : + [GNOME Discourse](https://discourse.gnome.org/t/how-to-bind-modifier-mousebutton-in-gjs/3743) +- Absence d'événements souris AT-SPI sous Wayland, et « mouse review » d'Orca cassé : + [Fedora Project Wiki, Wayland features](https://fedoraproject.org/wiki/Wayland_features), + [documentation Ubuntu, org.a11y.atspi.DeviceEventController](https://documentation.ubuntu.com/desktop/en/latest/reference/accessibility/dbus/org.a11y.atspi.DeviceEventController/) +- Portail GlobalShortcuts non implémenté sur GNOME : + [spécification du portail](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html), + [discussion Fedora](https://discussion.fedoraproject.org/t/xdg-global-keybinds-portal-in-gnome/121019) +- WebKitGTK, DMA-BUF et pilote NVIDIA : + [Tauri, Linux Graphics Issues](https://v2.tauri.app/develop/debug/linux-graphics/), + [tauri-apps/tauri#9394](https://github.com/tauri-apps/tauri/issues/9394), + [bug Ubuntu webkit2gtk 2041664](https://bugs.launchpad.net/bugs/2041664) +- `cursor_mode = metadata` et `SPA_META_Cursor` : + [documentation du portail ScreenCast](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html) +- Persistance de session et `restore_token` : + [ashpd, PersistMode](https://bilelmoussaoui.github.io/ashpd/ashpd/desktop/enum.PersistMode.html), + [ashpd, module screencast](https://docs.rs/ashpd/latest/ashpd/desktop/screencast/index.html) +- Portail InputCapture, déclenchement par barrière et saisie exclusive : + [documentation du portail InputCapture](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.InputCapture.html) +- Lecture privilégiée de libinput, motif de référence : + [showmethekey](https://github.com/AlynxZhou/showmethekey) +- Référence d'implémentation GTK4 + Rust + portail + PipeWire : + [Kooha](https://github.com/SeaDve/Kooha) +- Impossibilité de maintenir une fenêtre au-dessus sur GNOME Wayland, retrait de + `set_keep_above` en GTK4, et non-prise en charge de layer-shell par GNOME : + [GNOME Discourse](https://discourse.gnome.org/t/any-way-to-set-window-always-on-top-programmatically/31579), + [gtk4-layer-shell](https://wmww.github.io/gtk4-layer-shell/gtk4-layer-shell-GTK4-Layer-Shell.html) + +## Annexe B — Points à confirmer en Phase 0 + +Ces points n'ont pas été vérifiés pour ce document et ne doivent pas être traités comme +acquis : + +1. **Type de tampon négociable** avec `pipewire-rs` sur Mutter des versions cibles : + `MemFd` ou `MemPtr` obtenables, ou DMA-BUF imposé. C'est le point qui décide si + GStreamer entre ou non dans les dépendances (§3.1). À traiter en premier, avant tout + le reste de la Phase 0, parce que c'est celui qui change la liste des dépendances. +2. Disponibilité de `org.gnome.Shell.Introspect.GetWindows` pour une application non + sandboxée sur GNOME 46 et 48, puis sous Flatpak. Impact si absent : nul sur les + fonctions principales, les métadonnées concernées étant désactivées par défaut. +3. Comportement exact de `SPA_META_Cursor` sur `xdg-desktop-portal-gnome` des versions + cibles, en session Wayland **et** en session X11 : fréquence de mise à jour, présence + sur chaque frame, précision. +4. Seuils réels de détection de changement sur du contenu d'interface GNOME. Les valeurs + de §4.3.3 sont des points de départ à calibrer, pas des mesures. +5. Coût CPU du chemin vignette plus comparaison à 10 images par seconde en 4K. +6. Volatilité de l'API du crate Typst sur la durée du projet, et coût réel du `World` + minimal. +7. Empreinte VRAM effective d'un modèle vision 3B en Q4 avec le contexte nécessaire pour + une capture 1080p, sur 6 Go. +8. Licence de Satty, en vue de réutiliser son modèle d'annotation plutôt que de le + réécrire. Non vérifiée pour ce document. + +--- + +## Annexe C — Ce qui existe déjà et que ce document réutilise + +Distinction utile : une **référence** se lit, une **dépendance** se compile dans le +binaire, un **candidat** demande une vérification avant de trancher. + +| Existant | Licence | Statut | Ce qu'il couvre | +|---|---|---|---| +| `ashpd` | MIT | dépendance | portails XDG : ScreenCast, Screenshot, FileChooser, Settings | +| `pipewire-rs` | MIT | dépendance | consommation du flux, `SPA_META_Cursor` | +| `gtk4-rs`, `libadwaita-rs` | LGPL-2.1+ | dépendance | interface, rendu d'annotations via Snapshot/Cairo | +| `pulldown-cmark` | MIT | dépendance | parsing CommonMark | +| `rusqlite` + FTS5 | MIT | dépendance | index de recherche plein texte | +| `image`, `fast_image_resize` | MIT/Apache-2.0 | dépendance | décodage, miniatures, redimensionnement | +| `typst`, `typst-pdf` | Apache-2.0 | dépendance | rendu PDF paginé (§9.3) | +| Tesseract | Apache-2.0 | dépendance externe | OCR (§10.4) | +| `img_hash` ou `blockhash` | MIT | dépendance | empreinte perceptuelle pour la détection de dérive (§10.6). **Ne pas écrire de hachage perceptuel maison** | +| `thiserror` | MIT/Apache-2.0 | dépendance | taxonomie d'erreurs de §2.5 | +| `proptest` | MIT/Apache-2.0 | dépendance de test | test de propriété de §15.5 | +| [Kooha](https://github.com/SeaDve/Kooha) | GPL-3.0 | **référence et code adaptable** | gestion de session ScreenCast et consommation PipeWire en GTK4 + Rust. Même licence que TutoClic, donc adaptable et pas seulement lisible | +| Satty | à vérifier | **candidat** | modèle d'annotation : flèche, rectangle, flou, texte, badges numérotés. Vérifier la licence avant de s'appuyer dessus (annexe B pt 8) | +| GStreamer `pipewiresrc` | LGPL-2.1+ | **repli conditionnel** | uniquement si la Phase 0 montre que le DMA-BUF est imposé (§3.1) | + +Ce qui est délibérément **écrit à la main** plutôt que repris : le détecteur de changement +de frame (une comparaison par blocs sur vignette réduite, quelques dizaines de lignes, dont +la valeur est dans le corpus de calibration de §15.5 et non dans l'algorithme), et les +migrations de schéma, qui sont une liste ordonnée de fonctions de transformation, une par +incrément de `schemaVersion`, chacune testée sur un fichier figé. **Pas de framework de +migration** : la complexité y serait entièrement auto-infligée. + +--- + +## Annexe D — Parallélisation de l'implémentation + +Deux couloirs sont réellement indépendants après la Phase 0. + +| Étape | Modules touchés | Dépend de | +|---|---|---| +| P0 validation technique | poc/ | — | +| Format de projet | store/, cli/ (famille A) | — | +| Éditeur et liste d'étapes | ui/, store/ | Format de projet | +| Moteur de capture | capture/, coords/ | P0 | +| Contrôles de session | ui/, dbus/, cli/ (famille B) | Moteur de capture | +| Annotations | annot/, coords/ | Éditeur, Format de projet | +| Exports | export/ | Format de projet | +| OCR et IA | ai/, ocr/ | Format de projet | + +**Couloir A** : Format de projet → Éditeur → Annotations (séquentiel, `store/` et `ui/` +partagés). +**Couloir B** : Moteur de capture → Contrôles de session (séquentiel, dépend de P0). +**Couloir C** : Exports (indépendant dès que le format existe). + +Ordre d'exécution : P0 seul d'abord. Puis A et C en parallèle. B démarre en parallèle de A +dès que P0 passe. Fusionner A et B avant les Contrôles de session. + +**Conflit à surveiller :** les couloirs A et B touchent tous les deux `coords/`, A par les +annotations et B par la conversion de capture. C'est voulu, `coords/` étant le module +unique de §4.5, mais deux couloirs parallèles qui l'éditent produiront un conflit de +fusion. Écrire `coords/` en premier, dans P0, et le figer avant d'ouvrir A et B. + +--- + +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — | +| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | — | +| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | ISSUES_FOLDED | 17 trouvailles, 0 lacune critique | +| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — | +| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — | + +Détail de la revue d'ingénierie du 2026-08-03, mode SCOPE_REDUCED (Phase 1 découpée en +1a et 1b) : + +| Section | Trouvailles | Résultat | +|---|---|---| +| Étape 0, défi de périmètre | 4 | fenêtre toujours-au-dessus impossible, Phase 1 découpée, réutilisations promues en dépendances, pas de framework de migration | +| 1. Architecture | 5 | 3 P1, 2 P2, toutes intégrées | +| 2. Qualité de structure | 4 | 2 P2, 2 P3, toutes intégrées | +| 3. Tests | 2 | corpus de fixtures et test de propriété ajoutés en §15.5 | +| 4. Performance | 2 | FTS5 et clé de cache `derived/` | + +Couverture de test planifiée après correctifs : 22 chemins sur 22 ont un test défini, dont +1 corpus de fixtures, 1 test de propriété et 1 évaluation IA repoussée en Phase 2. +Lacunes critiques (aucun test **et** aucune gestion d'erreur **et** échec silencieux) : 0. +Les deux qui en étaient (fuite du mode confidentiel par l'index, flux mort en pleine +session) ont désormais une règle normative et un test. + +**VERDICT :** ENG REVIEW PASSÉE — les 17 trouvailles sont intégrées au document, aucune +lacune critique ne subsiste. Prêt pour la Phase 0. Le document n'est pas prêt pour un +développement au-delà de la Phase 0 tant que les douze portes de §15.2 ne sont pas +franchies avec des chiffres. + +**UNRESOLVED DECISIONS:** +- Voix extérieure non exécutée. Codex n'est pas installé sur cette machine et le repli par + sous-agent est désactivé par consigne utilisateur. Ce document n'a donc reçu qu'une seule + perspective de modèle. Pour l'obtenir : `npm install -g @openai/codex` puis relancer + `/plan-eng-review`, ou demander explicitement un sous-agent. +- Quatre TODO proposés n'ont pas de `TODOS.md` où atterrir, le dépôt local n'existant pas + encore : évaluation de la qualité du texte alternatif IA (Phase 2) ; extension GNOME + minimale pour un indicateur de barre, écartée en faveur du raccourci clavier mais + toujours la seule solution complète ; vérification de la licence de Satty ; cible de + performance 4K absente de §14. From 4a3a8ebe076d0b88e13f4a89a8e726e5c244fd19 Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Mon, 3 Aug 2026 21:21:48 +0200 Subject: [PATCH 02/15] chore: remplace le .gitignore VisualStudio par un .gitignore Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le modèle créé à l'initialisation du dépôt visait VisualStudio. Le projet est en Rust, avec GTK4. Co-Authored-By: Claude Opus 5 --- .gitignore | 437 ++--------------------------------------------------- 1 file changed, 13 insertions(+), 424 deletions(-) diff --git a/.gitignore b/.gitignore index d5a18de..4a8b995 100644 --- a/.gitignore +++ b/.gitignore @@ -1,429 +1,18 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates -*.env - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ - -[Dd]ebug/x64/ -[Dd]ebugPublic/x64/ -[Rr]elease/x64/ -[Rr]eleases/x64/ -bin/x64/ -obj/x64/ - -[Dd]ebug/x86/ -[Dd]ebugPublic/x86/ -[Rr]elease/x86/ -[Rr]eleases/x86/ -bin/x86/ -obj/x86/ - -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -[Aa][Rr][Mm]64[Ee][Cc]/ -bld/ -[Oo]bj/ -[Oo]ut/ -[Ll]og/ -[Ll]ogs/ - -# Build results on 'Bin' directories -**/[Bb]in/* -# Uncomment if you have tasks that rely on *.refresh files to move binaries -# (https://github.com/github/gitignore/pull/3736) -#!**/[Bb]in/*.refresh - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* -*.trx - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Approval Tests result files -*.received.* - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ -.artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.idb -*.iobj -*.pch +# Rust +/target/ +**/*.rs.bk *.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -# but not Directory.Build.rsp, as it configures directory-level build defaults -!Directory.Build.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef -# Microsoft Azure Emulator -ecf/ -rcf/ +# Sortie de la sonde et état local +/*.png +/*.tutoclic +/*.tutoclic-project/ -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* +# Éditeurs +.vscode/ +.idea/ +*.swp *~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -**/.paket/paket.exe -paket-files/ - -# FAKE - F# Make -**/.fake/ - -# CodeRush personal settings -**/.cr/personal - -# Python Tools for Visual Studio (PTVS) -**/__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -#tools/** -#!tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog -MSBuild_Logs/ - -# AWS SAM Build and Temporary Artifacts folder -.aws-sam - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -**/.mfractor/ - -# Local History for Visual Studio -**/.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -**/.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets - -# Local History for Visual Studio Code -.history/ - -# Built Visual Studio Code Extensions -*.vsix -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp +# Système +.DS_Store From 971fdf13323482b1766ad51bb2b791917a600cea Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Mon, 3 Aug 2026 21:22:12 +0200 Subject: [PATCH 03/15] =?UTF-8?q?feat(coords):=20convertit=20entre=20les?= =?UTF-8?q?=20quatre=20rep=C3=A8res=20de=20coordonn=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC.md §4.5 exige un module unique autorisé à convertir, avec des types distincts pour que le compilateur refuse un mélange : LogicalPoint, PhysicalPoint, ImagePoint, NormalizedPoint. Couvre les échelles fractionnaires, les origines négatives, les moniteurs multiples, les rotations et la capture de région. Un point hors de la région capturée renvoie None plutôt que (0, 0) : rendre l'origine placerait le repère de clic dans le coin de la mauvaise image. Le test de propriété exigé par §15.5 a trouvé une vraie perte d'un pixel sur l'aller-retour de normalisation. Pour une image de 3133 px de large, 1607 / 3133 * 3133 vaut 1606.9999999999998 en f64, donc floor rendait 1606. Corrigé par une marge de 1e-9 avant l'arrondi vers le bas, largement au-dessus de l'erreur relative de la division f64 et très en dessous d'une frontière de pixel légitime. Aucune dépendance système : ce crate se teste sur un runner nu, ce qui est la propriété que §16 demande à la Phase 1a. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 343 ++++++++++++++++++ Cargo.toml | 29 ++ crates/tutoclic-coords/Cargo.toml | 16 + crates/tutoclic-coords/src/lib.rs | 197 ++++++++++ .../tutoclic-coords/tests/logical_to_image.rs | 87 +++++ .../tests/normalization.proptest-regressions | 7 + crates/tutoclic-coords/tests/normalization.rs | 211 +++++++++++ 7 files changed, 890 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/tutoclic-coords/Cargo.toml create mode 100644 crates/tutoclic-coords/src/lib.rs create mode 100644 crates/tutoclic-coords/tests/logical_to_image.rs create mode 100644 crates/tutoclic-coords/tests/normalization.proptest-regressions create mode 100644 crates/tutoclic-coords/tests/normalization.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1c02ac4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,343 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tutoclic-coords" +version = "0.0.1" +dependencies = [ + "proptest", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9a888b3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[workspace] +resolver = "2" +members = ["crates/tutoclic-coords"] + +[workspace.package] +version = "0.0.1" +edition = "2021" +rust-version = "1.80" +license = "GPL-3.0-or-later" +repository = "https://github.com/TutoTech/TutoClic" +authors = ["TutoTech"] + +# Le workspace sépare volontairement ce qui a des dépendances système de ce qui +# n'en a pas (SPEC.md §16, propriété de la Phase 1a) : +# +# tutoclic-coords logique pure, zéro dépendance système, testable partout +# y compris en CI sans bureau ni GTK ni PipeWire. +# tutoclic-probe sonde de la Phase 0, exige libpipewire et une session +# Wayland pour produire un résultat utile. +# +# `cargo test -p tutoclic-coords` doit passer sur n'importe quelle machine. + +[workspace.dependencies] +# ashpd sans ses fonctionnalités par défaut : elles tirent GTK pour la +# recherche d'identifiant de fenêtre parente, dont la sonde n'a pas besoin. +ashpd = { version = "0.9", default-features = false, features = ["async-std"] } +pipewire = "0.8" +anyhow = "1" +proptest = "1" diff --git a/crates/tutoclic-coords/Cargo.toml b/crates/tutoclic-coords/Cargo.toml new file mode 100644 index 0000000..1c86830 --- /dev/null +++ b/crates/tutoclic-coords/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "tutoclic-coords" +description = "Conversion de coordonnées entre les quatre repères de TutoClic (SPEC.md §4.5)" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +# Aucune dépendance système, volontairement. Ce crate doit se compiler et se +# tester sur n'importe quelle machine, sans bureau, sans GTK, sans PipeWire. +[dependencies] + +[dev-dependencies] +proptest.workspace = true diff --git a/crates/tutoclic-coords/src/lib.rs b/crates/tutoclic-coords/src/lib.rs new file mode 100644 index 0000000..81709c4 --- /dev/null +++ b/crates/tutoclic-coords/src/lib.rs @@ -0,0 +1,197 @@ +//! Conversion de coordonnées pour TutoClic. +//! +//! Voir SPEC.md §4.5. + +/// Rotation appliquée à un moniteur par le compositeur. +/// +/// Informationnel pour la conversion : Mutter rapporte la géométrie logique +/// déjà tournée et le flux ScreenCast arrive dans cette même orientation, donc +/// aucune rotation n'est à appliquer ici. Le champ est conservé pour les +/// métadonnées d'étape (SPEC.md §4.6). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transform { + Normal, + Rotate90, + Rotate180, + Rotate270, +} + +/// Un moniteur tel que le compositeur le décrit. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Monitor { + pub logical_x: i32, + pub logical_y: i32, + pub logical_width: u32, + pub logical_height: u32, + pub scale: f64, + pub transform: Transform, +} + +/// Point dans le repère logique du bureau. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LogicalPoint { + pub x: f64, + pub y: f64, +} + +/// Point en pixels de l'image capturée. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ImagePoint { + pub x: u32, + pub y: u32, +} + +/// Point normalisé entre 0 (inclus) et 1 (exclu) sur l'image originale non +/// recadrée. +/// +/// SPEC.md §5.2 : c'est le repère de stockage des annotations. La borne haute +/// est exclue parce que la normalisation est prise au coin du pixel, ce qui +/// rend l'aller-retour exact : `floor(px / w * w) == px`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct NormalizedPoint { + pub x: f64, + pub y: f64, +} + +/// Rectangle en coordonnées logiques du bureau. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LogicalRect { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +/// Ce qui a été capturé : un moniteur, et la région de ce moniteur réellement +/// dans le flux. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CaptureGeometry { + monitor: Monitor, + region: LogicalRect, +} + +impl CaptureGeometry { + /// Le moniteur entier est capturé. + pub fn whole_monitor(monitor: Monitor) -> Self { + let region = LogicalRect { + x: monitor.logical_x, + y: monitor.logical_y, + width: monitor.logical_width, + height: monitor.logical_height, + }; + Self { monitor, region } + } + + /// Une sous-région du moniteur est capturée (SPEC.md §4.1). + pub fn region(monitor: Monitor, region: LogicalRect) -> Self { + Self { monitor, region } + } + + /// Taille de l'image produite, en pixels. + pub fn image_size(&self) -> (u32, u32) { + ( + scale_len(self.region.width, self.monitor.scale), + scale_len(self.region.height, self.monitor.scale), + ) + } + + /// Convertit un point du repère logique du bureau vers le pixel de l'image + /// qui le contient. + /// + /// Renvoie `None` si le point est hors de la région capturée. C'est + /// volontaire : rendre (0, 0) placerait le repère de clic dans le coin de + /// l'image alors que le pointeur était sur un autre écran. + pub fn logical_to_image(&self, point: LogicalPoint) -> Option { + let dx = point.x - f64::from(self.region.x); + let dy = point.y - f64::from(self.region.y); + if dx < 0.0 || dy < 0.0 { + return None; + } + + let px = (dx * self.monitor.scale).floor(); + let py = (dy * self.monitor.scale).floor(); + let (width, height) = self.image_size(); + if px >= f64::from(width) || py >= f64::from(height) { + return None; + } + + Some(ImagePoint { + x: px as u32, + y: py as u32, + }) + } + + /// Convertit un pixel de l'image vers le repère normalisé de stockage. + /// + /// Renvoie `None` si le pixel est hors de l'image. + pub fn image_to_normalized(&self, point: ImagePoint) -> Option { + let (width, height) = self.image_size(); + if point.x >= width || point.y >= height { + return None; + } + Some(NormalizedPoint { + x: f64::from(point.x) / f64::from(width), + y: f64::from(point.y) / f64::from(height), + }) + } + + /// Convertit un point normalisé vers le pixel de l'image qui le contient. + /// + /// Renvoie `None` si l'image est vide. Un point normalisé hors de [0, 1) + /// est ramené dans l'image plutôt que refusé : une annotation légèrement + /// débordante après un changement de recadrage doit rester affichable + /// (SPEC.md §5.3). + pub fn normalized_to_image(&self, point: NormalizedPoint) -> Option { + let (width, height) = self.image_size(); + if width == 0 || height == 0 { + return None; + } + Some(ImagePoint { + x: denormalize(point.x, width), + y: denormalize(point.y, height), + }) + } +} + +/// Marge ajoutée avant l'arrondi vers le bas de la dénormalisation. +/// +/// Sans elle, l'aller-retour perd un pixel sur certaines largeurs d'image, et +/// silencieusement. Contre-exemple trouvé par le test de propriété de +/// SPEC.md §15.5, pour une image de 3133 px de large : +/// +/// ```text +/// px = 1607 +/// n = 1607 / 3133 = 0.5129269071177784 +/// n * 3133 = 1606.9999999999998 (et non 1607) +/// floor(1606.9999999999998) = 1606 <- un pixel perdu +/// ``` +/// +/// L'erreur relative de la division f64 est majorée par 2^-52, donc l'écart +/// absolu reste très inférieur à 1e-9 pour toute dimension d'image réaliste. +/// La marge récupère le pixel exact sans jamais franchir une frontière +/// légitime : elle ne déplace un point continu que s'il est déjà à moins de +/// 1e-9 d'un bord de pixel, ce qui est invisible au rendu. +const DENORMALIZE_EPSILON: f64 = 1e-9; + +/// Dénormalise vers l'indice du pixel qui contient le point, ramené dans +/// `0..len`. +fn denormalize(value: f64, len: u32) -> u32 { + let scaled = value * f64::from(len) + DENORMALIZE_EPSILON; + if scaled < 0.0 { + return 0; + } + let max = len - 1; + if scaled > f64::from(max) { + return max; + } + scaled as u32 +} + +/// Une longueur logique convertie en pixels, arrondie au plus proche. +/// +/// L'arrondi au plus proche, et non la troncature, parce qu'une échelle +/// fractionnaire de 1,25 sur 1080 donne 1350,0 mais que les erreurs de virgule +/// flottante peuvent produire 1349,9999. +fn scale_len(logical: u32, scale: f64) -> u32 { + (f64::from(logical) * scale).round() as u32 +} diff --git a/crates/tutoclic-coords/tests/logical_to_image.rs b/crates/tutoclic-coords/tests/logical_to_image.rs new file mode 100644 index 0000000..7c23e68 --- /dev/null +++ b/crates/tutoclic-coords/tests/logical_to_image.rs @@ -0,0 +1,87 @@ +//! Conversion coordonnées logiques du bureau -> pixels de l'image capturée. +//! +//! SPEC.md §4.5 : quatre repères, un seul module autorisé à convertir. +//! SPEC.md §15.2 pt 7 et 8 : le point retenu doit tomber dans l'élément +//! réellement actionné, sur les quatre facteurs d'échelle et sur deux écrans +//! à échelles différentes, y compris avec un écran à coordonnées négatives. + +use tutoclic_coords::{CaptureGeometry, LogicalPoint, LogicalRect, Monitor, Transform}; + +/// Un moniteur 1920x1080 sans mise à l'échelle, capturé en entier. +fn simple_monitor() -> Monitor { + Monitor { + logical_x: 0, + logical_y: 0, + logical_width: 1920, + logical_height: 1080, + scale: 1.0, + transform: Transform::Normal, + } +} + +#[test] +fn pointer_at_monitor_origin_maps_to_image_origin() { + let geometry = CaptureGeometry::whole_monitor(simple_monitor()); + + let image = geometry + .logical_to_image(LogicalPoint { x: 0.0, y: 0.0 }) + .expect("l'origine du moniteur est dans la région capturée"); + + assert_eq!((image.x, image.y), (0, 0)); +} + +#[test] +fn pointer_left_of_monitor_is_outside_the_capture() { + // Cas réel : deux écrans, celui de gauche à coordonnées négatives, et le + // pointeur est sur l'écran qu'on ne capture pas. Renvoyer (0, 0) placerait + // le repère de clic dans le coin de la mauvaise image (SPEC.md §4.5). + let geometry = CaptureGeometry::whole_monitor(simple_monitor()); + + assert_eq!( + geometry.logical_to_image(LogicalPoint { x: -1.0, y: 540.0 }), + None + ); +} + +#[test] +fn pointer_past_the_right_edge_is_outside_the_capture() { + let geometry = CaptureGeometry::whole_monitor(simple_monitor()); + + assert_eq!( + geometry.logical_to_image(LogicalPoint { x: 1920.0, y: 0.0 }), + None + ); +} + +#[test] +fn region_capture_is_relative_to_the_region_origin() { + // SPEC.md §4.1 : la source peut être une région, pas seulement un moniteur. + let geometry = CaptureGeometry::region( + simple_monitor(), + LogicalRect { + x: 100, + y: 50, + width: 800, + height: 600, + }, + ); + + let image = geometry + .logical_to_image(LogicalPoint { x: 150.0, y: 80.0 }) + .expect("le point est dans la région"); + + assert_eq!((image.x, image.y), (50, 30)); +} + +#[test] +fn image_size_accounts_for_the_scale_factor() { + // Un moniteur logique de 1920x1080 à 200 % produit une image de 3840x2160. + let monitor = Monitor { + scale: 2.0, + ..simple_monitor() + }; + + let geometry = CaptureGeometry::whole_monitor(monitor); + + assert_eq!(geometry.image_size(), (3840, 2160)); +} diff --git a/crates/tutoclic-coords/tests/normalization.proptest-regressions b/crates/tutoclic-coords/tests/normalization.proptest-regressions new file mode 100644 index 0000000..701be0e --- /dev/null +++ b/crates/tutoclic-coords/tests/normalization.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 1906fe34cef88cb15a237d1f4ebce45ecc54482771254ae6d3efa8c0a774e6e5 # shrinks to logical_width = 2678, logical_height = 240, scale_pct = 117, origin_x = 0, origin_y = 0, fx = 0.5132232427721249, fy = 0.0 diff --git a/crates/tutoclic-coords/tests/normalization.rs b/crates/tutoclic-coords/tests/normalization.rs new file mode 100644 index 0000000..5b68fbf --- /dev/null +++ b/crates/tutoclic-coords/tests/normalization.rs @@ -0,0 +1,211 @@ +//! Repère normalisé et invariant d'aller-retour. +//! +//! SPEC.md §5.2 : toutes les coordonnées d'annotation sont normalisées entre 0 +//! et 1 par rapport à l'image originale non recadrée. +//! SPEC.md §15.5 : ce module reçoit un test de propriété, parce qu'un scénario +//! manuel ne couvre qu'un point de l'espace des géométries d'écran. + +use proptest::prelude::*; +use tutoclic_coords::{ + CaptureGeometry, ImagePoint, LogicalPoint, Monitor, NormalizedPoint, Transform, +}; + +fn monitor(width: u32, height: u32, scale: f64) -> Monitor { + Monitor { + logical_x: 0, + logical_y: 0, + logical_width: width, + logical_height: height, + scale, + transform: Transform::Normal, + } +} + +#[test] +fn image_origin_normalizes_to_zero() { + let geometry = CaptureGeometry::whole_monitor(monitor(1920, 1080, 1.0)); + + let n = geometry + .image_to_normalized(ImagePoint { x: 0, y: 0 }) + .expect("l'origine est dans l'image"); + + assert_eq!((n.x, n.y), (0.0, 0.0)); +} + +#[test] +fn image_centre_normalizes_to_one_half() { + let geometry = CaptureGeometry::whole_monitor(monitor(1920, 1080, 1.0)); + + let n = geometry + .image_to_normalized(ImagePoint { x: 960, y: 540 }) + .expect("le centre est dans l'image"); + + assert_eq!((n.x, n.y), (0.5, 0.5)); +} + +#[test] +fn a_point_outside_the_image_has_no_normalized_form() { + let geometry = CaptureGeometry::whole_monitor(monitor(1920, 1080, 1.0)); + + assert_eq!( + geometry.image_to_normalized(ImagePoint { x: 1920, y: 0 }), + None + ); +} + +#[test] +fn fractional_scale_of_150_percent_maps_exactly() { + // SPEC.md §15.1 : les échelles 125 % et 150 % sont dans la matrice de test. + let geometry = CaptureGeometry::whole_monitor(monitor(1920, 1080, 1.5)); + + assert_eq!(geometry.image_size(), (2880, 1620)); + + let image = geometry + .logical_to_image(LogicalPoint { x: 100.0, y: 100.0 }) + .expect("le point est dans la région"); + + assert_eq!((image.x, image.y), (150, 150)); +} + +#[test] +fn second_monitor_with_a_different_scale_converts_from_its_own_origin() { + // SPEC.md §15.1 : deux écrans à échelles différentes. Le deuxième commence + // à x = 1920 en coordonnées logiques et tourne à 200 %. + let second = Monitor { + logical_x: 1920, + logical_y: 0, + logical_width: 1280, + logical_height: 720, + scale: 2.0, + transform: Transform::Normal, + }; + + let geometry = CaptureGeometry::whole_monitor(second); + + let image = geometry + .logical_to_image(LogicalPoint { x: 1930.0, y: 5.0 }) + .expect("le point est sur le deuxième écran"); + + assert_eq!((image.x, image.y), (20, 10)); +} + +#[test] +fn a_monitor_at_a_negative_origin_converts_correctly() { + // Un écran placé à gauche du principal a une abscisse logique négative. + let left = Monitor { + logical_x: -1920, + logical_y: 0, + logical_width: 1920, + logical_height: 1080, + scale: 1.0, + transform: Transform::Normal, + }; + + let geometry = CaptureGeometry::whole_monitor(left); + + let image = geometry + .logical_to_image(LogicalPoint { + x: -1910.0, + y: 10.0, + }) + .expect("le point est sur l'écran de gauche"); + + assert_eq!((image.x, image.y), (10, 10)); +} + +#[test] +fn a_rotated_monitor_uses_its_post_transform_logical_size() { + // Mutter rapporte la géométrie logique DÉJÀ tournée, et le flux ScreenCast + // arrive dans cette même orientation. La conversion n'a donc aucune + // rotation à appliquer : un écran 1920x1080 tourné de 90° se présente + // comme 1080x1920. Le champ `transform` est conservé pour les + // métadonnées d'étape (SPEC.md §4.6), pas pour le calcul. + let rotated = Monitor { + logical_x: 0, + logical_y: 0, + logical_width: 1080, + logical_height: 1920, + scale: 1.0, + transform: Transform::Rotate90, + }; + + let geometry = CaptureGeometry::whole_monitor(rotated); + + assert_eq!(geometry.image_size(), (1080, 1920)); + assert_eq!( + geometry.logical_to_image(LogicalPoint { + x: 1079.0, + y: 1919.0 + }), + Some(ImagePoint { x: 1079, y: 1919 }) + ); + assert_eq!( + geometry.logical_to_image(LogicalPoint { x: 1080.0, y: 0.0 }), + None + ); +} + +proptest! { + /// L'invariant d'aller-retour de SPEC.md §15.5. + /// + /// Tout pixel de l'image doit survivre au passage par le repère normalisé + /// et revenir identique, sur des géométries d'écran arbitraires : échelles + /// fractionnaires, origines négatives, rapports d'image variés. + #[test] + fn image_to_normalized_round_trips_exactly( + logical_width in 320u32..7680, + logical_height in 240u32..4320, + scale_pct in 100u32..300, + origin_x in -8000i32..8000, + origin_y in -8000i32..8000, + fx in 0.0f64..1.0, + fy in 0.0f64..1.0, + ) { + let m = Monitor { + logical_x: origin_x, + logical_y: origin_y, + logical_width, + logical_height, + scale: f64::from(scale_pct) / 100.0, + transform: Transform::Normal, + }; + let geometry = CaptureGeometry::whole_monitor(m); + let (w, h) = geometry.image_size(); + prop_assume!(w > 0 && h > 0); + + // Un pixel quelconque de l'image, tiré des fractions générées. + let px = ((fx * f64::from(w)) as u32).min(w - 1); + let py = ((fy * f64::from(h)) as u32).min(h - 1); + let start = ImagePoint { x: px, y: py }; + + let n = geometry.image_to_normalized(start).unwrap(); + prop_assert!((0.0..1.0).contains(&n.x), "n.x hors [0,1) : {}", n.x); + prop_assert!((0.0..1.0).contains(&n.y), "n.y hors [0,1) : {}", n.y); + + let back = geometry.normalized_to_image(n).unwrap(); + prop_assert_eq!(start, back); + } + + /// Un point normalisé arbitraire tombe toujours dans l'image. + #[test] + fn normalized_to_image_always_lands_inside_the_image( + logical_width in 320u32..7680, + logical_height in 240u32..4320, + scale_pct in 100u32..300, + nx in 0.0f64..1.0, + ny in 0.0f64..1.0, + ) { + let geometry = CaptureGeometry::whole_monitor( + monitor(logical_width, logical_height, f64::from(scale_pct) / 100.0), + ); + let (w, h) = geometry.image_size(); + prop_assume!(w > 0 && h > 0); + + let p = geometry + .normalized_to_image(NormalizedPoint { x: nx, y: ny }) + .unwrap(); + + prop_assert!(p.x < w, "x={} hors largeur {}", p.x, w); + prop_assert!(p.y < h, "y={} hors hauteur {}", p.y, h); + } +} From a91ac44950e159cefccbcbd50407a5d95b9a5f54 Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Mon, 3 Aug 2026 21:22:16 +0200 Subject: [PATCH 04/15] feat(probe): ajoute la sonde de la Phase 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elle répond aux deux questions qui décident de l'architecture, et à elles seules (SPEC.md §16, annexe B points 1 et 3) : 1. quel type de tampon PipeWire Mutter négocie-t-il ? MemFd ou MemPtr sont lisibles par le CPU et GStreamer reste hors des dépendances ; DmaBuf impose GStreamer ou un import EGL ; 2. SPA_META_Cursor arrive-t-il, et sur combien de frames ? Sans lui, le mode automatique perd le repère de clic. Vérifie au passage le jeton de restauration, donc le critère « dix captures sans nouvelle autorisation » de §15.2 point 4 : un second lancement ne doit plus rien demander. Ne capture rien, n'écrit aucune image, ne conserve que le jeton de restauration. Le seul bloc unsafe emprunte le tampon brut le temps de lire son type et la position du curseur, puis le rend immédiatement. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2200 ++++++++++++++++++++++++++--- Cargo.toml | 2 +- crates/tutoclic-probe/Cargo.toml | 15 + crates/tutoclic-probe/src/main.rs | 428 ++++++ 4 files changed, 2474 insertions(+), 171 deletions(-) create mode 100644 crates/tutoclic-probe/Cargo.toml create mode 100644 crates/tutoclic-probe/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 1c02ac4..08539e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,341 +3,2201 @@ version = 3 [[package]] -name = "autocfg" -version = "1.5.1" +name = "aho-corasick" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] [[package]] -name = "bit-set" -version = "0.8.0" +name = "annotate-snippets" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "ccaf7e9dfbb6ab22c82e473cd1a8a7bd313c19a5b7e40970f3d89ef5a5c9e81e" dependencies = [ - "bit-vec", + "unicode-width", + "yansi-term", ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "anyhow" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] -name = "bitflags" -version = "2.13.1" +name = "ashpd" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "4f8bd58b44ea371b48d21cdc217380cfcafd4b2bb1ad50d27514ec5beca71a2d" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.8.7", + "serde", + "serde_repr", + "url", + "zbus", +] [[package]] -name = "cfg-if" -version = "1.0.4" +name = "async-attributes" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] [[package]] -name = "errno" -version = "0.3.14" +name = "async-broadcast" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "libc", - "windows-sys", + "event-listener 5.4.2", + "event-listener-strategy", + "futures-core", + "pin-project-lite", ] [[package]] -name = "fastrand" +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] [[package]] -name = "fnv" -version = "1.0.7" +name = "async-executor" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] [[package]] -name = "getrandom" -version = "0.3.4" +name = "async-fs" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", + "async-lock", + "blocking", + "futures-lite", ] [[package]] -name = "getrandom" -version = "0.4.3" +name = "async-global-executor" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", ] [[package]] -name = "libc" -version = "0.2.189" +name = "async-io" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "async-lock" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "pin-project-lite", +] [[package]] -name = "num-traits" -version = "0.2.19" +name = "async-net" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" dependencies = [ - "autocfg", + "async-io", + "blocking", + "futures-lite", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "async-process" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.2", + "futures-lite", + "rustix", +] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "async-recursion" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ - "zerocopy", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "async-signal" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ - "unicode-ident", + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", ] [[package]] -name = "proptest" -version = "1.11.0" +name = "async-std" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" dependencies = [ - "bit-set", - "bit-vec", - "bitflags", - "num-traits", - "rand", - "rand_chacha", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", ] [[package]] -name = "quick-error" -version = "1.2.3" +name = "async-task" +version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] -name = "quote" -version = "1.0.47" +name = "async-trait" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "r-efi" -version = "6.0.0" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "rand" -version = "0.9.5" +name = "bindgen" +version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "rand_chacha", - "rand_core", + "annotate-snippets", + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "lazy_static", + "lazycell", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "bit-set" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "ppv-lite86", - "rand_core", + "bit-vec", ] [[package]] -name = "rand_core" -version = "0.9.5" +name = "bit-vec" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "getrandom 0.3.4", + "generic-array", ] [[package]] -name = "rand_xorshift" -version = "0.4.0" +name = "blocking" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "rand_core", + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", ] [[package]] -name = "regex-syntax" -version = "0.8.11" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "rustix" -version = "1.1.4" +name = "cc" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "find-msvc-tools", + "shlex 2.0.1", ] [[package]] -name = "rusty-fork" -version = "0.3.1" +name = "cexpr" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", + "nom", ] [[package]] -name = "syn" -version = "2.0.119" +name = "cfg-expr" +version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "smallvec", + "target-lexicon", ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys", + "glob", + "libc", + "libloading", ] [[package]] -name = "tutoclic-coords" -version = "0.0.1" +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ - "proptest", + "crossbeam-utils", ] [[package]] -name = "unarray" -version = "0.1.4" +name = "convert_case" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "cookie-factory" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] [[package]] -name = "wait-timeout" -version = "0.2.1" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" +name = "crossbeam-utils" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "wit-bindgen", + "generic-array", + "typenum", ] [[package]] -name = "windows-link" -version = "0.2.1" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] [[package]] -name = "windows-sys" -version = "0.61.2" +name = "displaydoc" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ - "windows-link", + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] -name = "wit-bindgen" -version = "0.57.1" +name = "either" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] -name = "zerocopy" -version = "0.8.55" +name = "endi" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ - "zerocopy-derive", + "enumflags2_derive", + "serde", ] [[package]] -name = "zerocopy-derive" -version = "0.8.55" +name = "enumflags2_derive" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.2", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libspa" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65f3a4b81b2a2d8c7f300643676202debd1b7c929dbf5c9bb89402ea11d19810" +dependencies = [ + "bitflags", + "cc", + "convert_case", + "cookie-factory", + "libc", + "libspa-sys", + "nix 0.27.1", + "nom", + "system-deps", +] + +[[package]] +name = "libspa-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0d9716420364790e85cbb9d3ac2c950bde16a7dd36f3209b7dfdfc4a24d01f" +dependencies = [ + "bindgen", + "cc", + "system-deps", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +dependencies = [ + "value-bag", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pipewire" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08e645ba5c45109106d56610b3ee60eb13a6f2beb8b74f8dc8186cf261788dda" +dependencies = [ + "anyhow", + "bitflags", + "libc", + "libspa", + "libspa-sys", + "nix 0.27.1", + "once_cell", + "pipewire-sys", + "thiserror", +] + +[[package]] +name = "pipewire-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "849e188f90b1dda88fe2bfe1ad31fe5f158af2c98f80fb5d13726c44f3f01112" +dependencies = [ + "bindgen", + "libspa-sys", + "system-deps", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tutoclic-coords" +version = "0.0.1" +dependencies = [ + "proptest", +] + +[[package]] +name = "tutoclic-probe" +version = "0.0.1" +dependencies = [ + "anyhow", + "ashpd", + "async-std", + "pipewire", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "value-bag" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "yansi-term" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5c30ade05e61656247b2e334a031dfd0cc466fadef865bdcdea8d537951bf1" +dependencies = [ + "winapi", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener 5.4.2", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.7", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "url", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] diff --git a/Cargo.toml b/Cargo.toml index 9a888b3..5c2525c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/tutoclic-coords"] +members = ["crates/tutoclic-coords", "crates/tutoclic-probe"] [workspace.package] version = "0.0.1" diff --git a/crates/tutoclic-probe/Cargo.toml b/crates/tutoclic-probe/Cargo.toml new file mode 100644 index 0000000..f434c5b --- /dev/null +++ b/crates/tutoclic-probe/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "tutoclic-probe" +description = "Sonde de la Phase 0 : type de tampon PipeWire et métadonnée de curseur (SPEC.md §16)" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +ashpd.workspace = true +pipewire.workspace = true +anyhow.workspace = true +async-std = { version = "1", features = ["attributes"] } diff --git a/crates/tutoclic-probe/src/main.rs b/crates/tutoclic-probe/src/main.rs new file mode 100644 index 0000000..150d9ec --- /dev/null +++ b/crates/tutoclic-probe/src/main.rs @@ -0,0 +1,428 @@ +//! Sonde de la Phase 0 de TutoClic. +//! +//! Elle répond aux deux questions qui décident de l'architecture, et à elles +//! seules. Voir SPEC.md §16 (Phase 0) et l'annexe B points 1 et 3. +//! +//! 1. **Quel type de tampon PipeWire Mutter accepte-t-il de négocier ?** +//! `MemFd` ou `MemPtr` sont lisibles directement par le CPU. `DmaBuf` vit +//! sur le GPU et impose un import EGL, ou GStreamer. C'est ce point qui +//! décide si GStreamer entre dans les dépendances (SPEC.md §3.1). +//! +//! 2. **`cursor_mode = metadata` est-il accepté, et `SPA_META_Cursor` arrive-t-il +//! sur chaque frame ?** Sans cette métadonnée, le mode automatique perd le +//! repère de clic et le périmètre doit être renégocié (SPEC.md §4.3.3). +//! +//! Accessoirement elle vérifie le jeton de restauration, donc le critère +//! « dix captures successives sans nouvelle autorisation » de SPEC.md §15.2 +//! point 4 : relancer la sonde une deuxième fois ne doit plus afficher de +//! dialogue de consentement. +//! +//! Cette sonde ne capture rien, n'écrit aucune image, et ne conserve que le +//! jeton de restauration. + +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use ashpd::desktop::screencast::{CursorMode, Screencast, SourceType}; +use ashpd::desktop::PersistMode; +use ashpd::WindowIdentifier; +use pipewire as pw; +use pw::spa; + +/// Nombre de frames observées avant de conclure. +const FRAMES_TO_OBSERVE: u32 = 30; + +/// Garde-fou : si le flux ne démarre pas, on ne bloque pas indéfiniment. +const TIMEOUT: Duration = Duration::from_secs(20); + +/// Ce que la sonde a effectivement observé. +#[derive(Debug, Default)] +struct Report { + frames: u32, + /// Types de tampon vus, dans l'ordre d'apparition. + data_types: Vec, + /// Nombre de frames portant une métadonnée `SPA_META_Cursor`. + frames_with_cursor_meta: u32, + /// Première et dernière position de curseur observées. + first_cursor: Option<(i32, i32)>, + last_cursor: Option<(i32, i32)>, + /// Nombre de métadonnées présentes sur la première frame. + metas_on_first_frame: u32, + negotiated_size: Option<(u32, u32)>, +} + +fn state_file() -> PathBuf { + let base = std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| { + let mut home = PathBuf::from(std::env::var_os("HOME").unwrap_or_default()); + home.push(".local/state"); + home + }); + base.join("tutoclic/probe-restore-token") +} + +fn read_restore_token() -> Option { + std::fs::read_to_string(state_file()) + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) +} + +fn write_restore_token(token: &str) -> Result<()> { + let path = state_file(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, token)?; + Ok(()) +} + +#[async_std::main] +async fn main() -> Result<()> { + println!("== Sonde Phase 0 de TutoClic =="); + let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| "inconnue".into()); + println!("session = {session_type}"); + if session_type != "wayland" { + println!( + " ATTENTION : la plateforme de référence est Wayland (SPEC.md §1.3).\n\ + \x20 Un résultat obtenu hors session Wayland ne conclut rien pour la Phase 0." + ); + } + + // --- Étape 1 : le portail -------------------------------------------- + let previous_token = read_restore_token(); + println!( + "\n[1/3] Portail ScreenCast — jeton de restauration : {}", + if previous_token.is_some() { + "présent, on tente la restauration" + } else { + "absent, premier passage" + } + ); + + let proxy = Screencast::new() + .await + .context("le portail ScreenCast est injoignable")?; + let session = proxy + .create_session() + .await + .context("CreateSession a échoué")?; + + proxy + .select_sources( + &session, + CursorMode::Metadata, + SourceType::Monitor.into(), + false, + previous_token.as_deref(), + PersistMode::ExplicitlyRevoked, + ) + .await + .context( + "SelectSources a échoué. Si le portail refuse CursorMode::Metadata, \ + c'est la réponse à l'annexe B point 3, et le mode automatique perd \ + le repère de clic", + )?; + + let response = proxy + .start(&session, &WindowIdentifier::default()) + .await + .context("Start a échoué")? + .response() + .context("l'utilisateur a refusé le partage, ou le portail a annulé")?; + + println!(" CursorMode::Metadata accepté par le portail : OUI"); + + match response.restore_token() { + Some(token) => { + write_restore_token(token).context("enregistrement du jeton de restauration")?; + println!(" jeton de restauration reçu et enregistré"); + println!( + " -> relance la sonde : elle ne doit plus rien demander (SPEC.md §15.2 pt 4)" + ); + } + None => println!( + " AUCUN jeton de restauration renvoyé. PersistMode::ExplicitlyRevoked \ + n'est pas honoré ici : chaque session redemandera le consentement." + ), + } + + let streams = response.streams(); + let stream_info = streams + .first() + .context("le portail n'a renvoyé aucun flux")?; + let node_id = stream_info.pipe_wire_node_id(); + println!( + " flux : node id {}, taille {:?}, position {:?}", + node_id, + stream_info.size(), + stream_info.position() + ); + + let fd = proxy + .open_pipe_wire_remote(&session) + .await + .context("OpenPipeWireRemote a échoué")?; + + // --- Étape 2 : le flux PipeWire -------------------------------------- + println!("\n[2/3] Flux PipeWire — observation de {FRAMES_TO_OBSERVE} frames au plus"); + let report = observe_stream(fd, node_id)?; + + // --- Étape 3 : le verdict -------------------------------------------- + print_verdict(&report); + Ok(()) +} + +/// Se connecte au flux et observe les tampons et les métadonnées. +fn observe_stream(fd: std::os::fd::OwnedFd, node_id: u32) -> Result { + pw::init(); + let mainloop = pw::main_loop::MainLoop::new(None).context("MainLoop")?; + let context = pw::context::Context::new(&mainloop).context("Context")?; + let core = context + .connect_fd(fd, None) + .context("connexion au démon PipeWire par le descripteur du portail")?; + + let report = Rc::new(RefCell::new(Report::default())); + + let stream = pw::stream::Stream::new( + &core, + "tutoclic-probe", + pw::properties::properties! { + *pw::keys::MEDIA_TYPE => "Video", + *pw::keys::MEDIA_CATEGORY => "Capture", + *pw::keys::MEDIA_ROLE => "Screen", + }, + ) + .context("création du flux")?; + + let quit_on_done = mainloop.clone(); + let report_for_process = Rc::clone(&report); + let report_for_params = Rc::clone(&report); + + let _listener = stream + .add_local_listener_with_user_data(()) + .param_changed(move |_stream, _data, id, param| { + let Some(param) = param else { return }; + if id != spa::param::ParamType::Format.as_raw() { + return; + } + let Ok((media_type, media_subtype)) = spa::param::format_utils::parse_format(param) + else { + return; + }; + if media_type != spa::param::format::MediaType::Video + || media_subtype != spa::param::format::MediaSubtype::Raw + { + return; + } + let mut info = spa::param::video::VideoInfoRaw::new(); + if info.parse(param).is_ok() { + let size = info.size(); + report_for_params.borrow_mut().negotiated_size = Some((size.width, size.height)); + } + }) + .process(move |stream, _data| { + // SAFETY : on emprunte le tampon brut le temps de lire ses + // métadonnées, puis on le rend immédiatement avec + // queue_raw_buffer. Aucune donnée d'image n'est lue, copiée ni + // conservée : seuls le type de tampon et la position du curseur + // sont extraits. + unsafe { + let raw = stream.dequeue_raw_buffer(); + if raw.is_null() { + return; + } + let spa_buffer = (*raw).buffer; + if !spa_buffer.is_null() { + let mut r = report_for_process.borrow_mut(); + r.frames += 1; + if r.frames == 1 { + r.metas_on_first_frame = (*spa_buffer).n_metas; + } + + // Question 1 : le type de tampon. + let n_datas = (*spa_buffer).n_datas as usize; + if n_datas > 0 && !(*spa_buffer).datas.is_null() { + let datas = std::slice::from_raw_parts((*spa_buffer).datas, n_datas); + for d in datas { + let label = format!("{:?}", spa::buffer::DataType::from_raw(d.type_)); + if !r.data_types.contains(&label) { + r.data_types.push(label); + } + } + } + + // Question 2 : la métadonnée de curseur. + let cursor = spa::sys::spa_buffer_find_meta_data( + spa_buffer, + spa::sys::SPA_META_Cursor, + std::mem::size_of::(), + ) as *const spa::sys::spa_meta_cursor; + if !cursor.is_null() { + r.frames_with_cursor_meta += 1; + let p = (*cursor).position; + let pos = (p.x, p.y); + if r.first_cursor.is_none() { + r.first_cursor = Some(pos); + } + r.last_cursor = Some(pos); + } + + if r.frames >= FRAMES_TO_OBSERVE { + quit_on_done.quit(); + } + } + stream.queue_raw_buffer(raw); + } + }) + .register() + .context("enregistrement des rappels du flux")?; + + // Format demandé : vidéo brute, en laissant le serveur choisir la taille et + // la cadence. Une sonde n'a pas à contraindre la négociation. + let obj = spa::pod::object! { + spa::utils::SpaTypes::ObjectParamFormat, + spa::param::ParamType::EnumFormat, + spa::pod::property!( + spa::param::format::FormatProperties::MediaType, + Id, + spa::param::format::MediaType::Video + ), + spa::pod::property!( + spa::param::format::FormatProperties::MediaSubtype, + Id, + spa::param::format::MediaSubtype::Raw + ), + spa::pod::property!( + spa::param::format::FormatProperties::VideoFormat, + Choice, + Enum, + Id, + spa::param::video::VideoFormat::BGRx, + spa::param::video::VideoFormat::BGRx, + spa::param::video::VideoFormat::RGBx, + spa::param::video::VideoFormat::BGRA, + spa::param::video::VideoFormat::RGBA + ), + }; + let values: Vec = spa::pod::serialize::PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &spa::pod::Value::Object(obj), + ) + .context("sérialisation du format")? + .0 + .into_inner(); + let mut params = [spa::pod::Pod::from_bytes(&values).context("pod de format invalide")?]; + + stream + .connect( + spa::utils::Direction::Input, + Some(node_id), + pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS, + &mut params, + ) + .context("connexion au node du portail")?; + + // Garde-fou de temps : si aucune frame n'arrive, on sort quand même. + let quit_on_timeout = mainloop.clone(); + let timer = mainloop.loop_().add_timer(move |_| quit_on_timeout.quit()); + let _ = timer.update_timer(Some(TIMEOUT), None); + + mainloop.run(); + + let observed = report.borrow(); + Ok(Report { + frames: observed.frames, + data_types: observed.data_types.clone(), + frames_with_cursor_meta: observed.frames_with_cursor_meta, + first_cursor: observed.first_cursor, + last_cursor: observed.last_cursor, + metas_on_first_frame: observed.metas_on_first_frame, + negotiated_size: observed.negotiated_size, + }) +} + +fn print_verdict(r: &Report) { + println!("\n[3/3] Verdict"); + println!(" frames observées : {}", r.frames); + + if r.frames == 0 { + println!( + " AUCUNE frame reçue avant expiration du délai. Le portail a accordé la\n\ + \x20 session mais le flux n'a rien livré. À investiguer avant de conclure :\n\ + \x20 pw-top, pw-dump, et les journaux de xdg-desktop-portal-gnome." + ); + return; + } + + if let Some((w, h)) = r.negotiated_size { + println!(" format négocié : {w}x{h}"); + } + println!( + " métadonnées sur la 1re frame : {}", + r.metas_on_first_frame + ); + + println!("\n QUESTION 1 — type de tampon (annexe B pt 1, SPEC.md §3.1)"); + println!(" types vus : {:?}", r.data_types); + let cpu_readable = r + .data_types + .iter() + .any(|t| t.contains("MemFd") || t.contains("MemPtr")); + let dmabuf = r.data_types.iter().any(|t| t.contains("DmaBuf")); + match (cpu_readable, dmabuf) { + (true, false) => println!( + " -> CHEMIN PRÉFÉRÉ DISPONIBLE. Tampons lisibles par le CPU.\n\ + \x20 GStreamer reste HORS des dépendances. §3.1 tient tel quel." + ), + (false, true) => println!( + " -> DMA-BUF IMPOSÉ. Le repli de §3.1 s'applique : pipewiresrc plus\n\ + \x20 videoconvert plus appsink de GStreamer. Mettre à jour §3.1 et la\n\ + \x20 liste de dépendances de §13.1 avant la Phase 1a." + ), + (true, true) => println!( + " -> LES DEUX SONT OFFERTS. Négocier explicitement MemFd ou MemPtr en\n\ + \x20 restreignant le paramètre Buffers, et GStreamer reste dehors." + ), + (false, false) => println!(" -> type inattendu, à investiguer avant de trancher."), + } + + println!("\n QUESTION 2 — SPA_META_Cursor (annexe B pt 3, SPEC.md §4.3.3)"); + println!( + " frames portant la métadonnée : {} sur {}", + r.frames_with_cursor_meta, r.frames + ); + match (r.first_cursor, r.last_cursor) { + (Some(first), Some(last)) if r.frames_with_cursor_meta > 0 => { + println!(" première position : {first:?}"); + println!(" dernière position : {last:?}"); + if first == last { + println!( + " NOTE : position identique du début à la fin. Bouge la souris\n\ + \x20 pendant la sonde pour vérifier que la position suit réellement." + ); + } + if r.frames_with_cursor_meta == r.frames { + println!( + " -> MÉTADONNÉE PRÉSENTE SUR CHAQUE FRAME. Le moteur de §4.3.3 est\n\ + \x20 constructible tel qu'il est spécifié." + ); + } else { + println!( + " -> MÉTADONNÉE INTERMITTENTE. §4.3.3 doit retenir la dernière\n\ + \x20 position connue plutôt que d'en exiger une par frame." + ); + } + } + _ => println!( + " -> AUCUNE MÉTADONNÉE DE CURSEUR. Le mode automatique perd le repère de\n\ + \x20 clic. Renégocier le périmètre avant d'aller plus loin (SPEC.md §16)." + ), + } + + println!("\n Reporte ces réponses dans SPEC.md annexe B, points 1 et 3."); +} From e2737da204476fd7b9f4780c852598fea3302b7e Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Mon, 3 Aug 2026 21:22:31 +0200 Subject: [PATCH 05/15] =?UTF-8?q?ci:=20teste=20coords=20sans=20d=C3=A9pend?= =?UTF-8?q?ance=20syst=C3=A8me,=20construit=20le=20workspace=20avec=20Pipe?= =?UTF-8?q?Wire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux jobs séparés, qui reflètent la séparation du workspace. Le job coords tourne sur un runner nu : si un jour il exige GTK ou PipeWire, c'est que la propriété d'indépendance de la Phase 1a a été cassée et la CI le dira. La sonde n'est pas exécutée en CI : elle a besoin d'un portail et d'une session graphique. Elle se lance à la main, cf. docs/phase0-results.md. Reste à ajouter, cf. SPEC.md §12.3 : cargo audit, cargo deny, SBOM CycloneDX, détection de secrets, et la suite de fichiers de projet malveillants. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 56 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4532d18 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + +jobs: + # tutoclic-coords n'a aucune dépendance système (SPEC.md §16, propriété de la + # Phase 1a). Il doit se tester sur un runner nu, sans bureau ni PipeWire. + coords: + name: coords (sans dépendance système) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: format + run: cargo fmt -p tutoclic-coords -- --check + - name: clippy + run: cargo clippy -p tutoclic-coords --all-targets -- -D warnings + - name: tests + run: cargo test -p tutoclic-coords + - name: tests de propriété, plus de cas + run: cargo test -p tutoclic-coords + env: + PROPTEST_CASES: "20000" + + probe: + name: probe (avec PipeWire) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: dépendances système + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config libpipewire-0.3-dev libclang-dev + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: format + run: cargo fmt --all -- --check + - name: clippy + run: cargo clippy --workspace --all-targets -- -D warnings + - name: construction + run: cargo build --workspace + # La sonde n'est pas exécutée en CI : elle exige un portail et une session + # graphique. Elle se lance à la main, cf. docs/phase0-results.md. From 7c1e242cf920e600a55ee98353155fc4ef525e33 Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Mon, 3 Aug 2026 21:22:31 +0200 Subject: [PATCH 06/15] =?UTF-8?q?docs:=20consigne=20la=20premi=C3=A8re=20e?= =?UTF-8?q?x=C3=A9cution=20de=20la=20sonde=20et=20r=C3=A9=C3=A9crit=20le?= =?UTF-8?q?=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La sonde a tourné de bout en bout : portail joignable, CursorMode::Metadata accepté, jeton de restauration délivré, flux PipeWire connecté, format négocié, 30 frames livrées et inspectées. Tampon MemFd, et aucune métadonnée de curseur sur les 30 frames. Mais l'exécution a eu lieu en session X11, alors que la plateforme de référence est Wayland. Les deux questions de l'annexe B restent donc ouvertes, et la Phase 0 n'est pas franchie. docs/phase0-results.md dit exactement ce que la mesure établit et ce qu'elle n'établit pas. Co-Authored-By: Claude Opus 5 --- README.md | 86 +++++++++++++++++++++++++++++++++- docs/phase0-results.md | 103 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 docs/phase0-results.md diff --git a/README.md b/README.md index d6082eb..7f7c6ae 100644 --- a/README.md +++ b/README.md @@ -1 +1,85 @@ -# TutoClic \ No newline at end of file +# TutoClic + +Générateur de tutoriels pas-à-pas libre pour Ubuntu GNOME. Tu effectues une +procédure, TutoClic en fait un document illustré, annoté et exportable. + +Équivalent libre de [Folge](https://folge.me), pour Linux, sans compte, sans +serveur, sans télémétrie, et sans privilège élevé. + +**État : Phase 0.** Aucune interface, aucun format de fichier figé, rien +d'utilisable. Le dépôt contient les spécifications complètes et les premiers +composants vérifiables. Voir [SPEC.md](SPEC.md) §16 pour le découpage des phases. + +## Pourquoi c'est intéressant techniquement + +Wayland interdit délibérément l'espionnage global des entrées. Un clone de Folge +devrait donc être impossible : pas de détection de clic, pas d'auto-capture. + +Trois contournements évidents ont été vérifiés et sont tous morts. Une extension +GNOME Shell ne voit pas les clics des fenêtres clientes. AT-SPI ne livre pas +d'événements souris sous Wayland. Le portail `GlobalShortcuts` n'est pas +implémenté sur GNOME. + +Le chemin qui reste n'a pas besoin des clics du tout. Le portail ScreenCast +accepte `cursor_mode = metadata`, qui livre la **position exacte du pointeur pour +chaque frame** en métadonnée PipeWire, sans aucun privilège. Il ne manque que +l'instant de l'appui bouton, et pour un tutoriel ce n'est pas ce qu'on veut : on +veut la frame où l'interface a répondu. Une détection de changement de frame +donne ce signal-là, et gratuitement, puisque le flux est déjà reçu. + +Position du curseur plus différence de frames égale auto-capture réelle, sans +extension, sans helper privilégié, sans keylogger. Détail complet en +[SPEC.md](SPEC.md) §0.3 et §4.3.3, sources vérifiées en annexe A. + +## Contenu du dépôt + +| Chemin | Rôle | +|---|---| +| [SPEC.md](SPEC.md) | spécifications complètes, sources vérifiées, questions ouvertes | +| [docs/phase0-results.md](docs/phase0-results.md) | ce que la sonde a **réellement mesuré**, machine par machine | +| `crates/tutoclic-coords` | conversion entre les quatre repères de coordonnées (§4.5) | +| `crates/tutoclic-probe` | sonde de la Phase 0 : type de tampon PipeWire et métadonnée de curseur | + +## Construire et tester + +Chaîne d'outils : Rust stable 1.80 ou plus. + +```bash +cargo test -p tutoclic-coords +``` + +`tutoclic-coords` n'a **aucune dépendance système**. Il se compile et se teste +sur n'importe quelle machine, sans bureau, sans GTK, sans PipeWire. C'est +volontaire : SPEC.md §16 exige que la Phase 1a avance même si la Phase 0 révèle +un problème. + +La sonde, elle, a besoin de PipeWire et d'une session graphique : + +```bash +sudo apt install build-essential pkg-config libpipewire-0.3-dev libclang-dev +cargo run -p tutoclic-probe +``` + +## La sonde de la Phase 0 + +Elle répond aux deux questions qui décident de l'architecture, et à elles seules. + +1. **Quel type de tampon PipeWire Mutter négocie-t-il ?** `MemFd` ou `MemPtr` sont + lisibles par le CPU et GStreamer reste hors des dépendances. `DmaBuf` vit sur + le GPU et impose GStreamer ou un import EGL. +2. **`SPA_META_Cursor` arrive-t-il, et sur combien de frames ?** Sans cette + métadonnée, le mode automatique perd le repère de clic. + +Elle ne capture rien, n'écrit aucune image, et ne conserve que le jeton de +restauration du portail. Résultats mesurés dans +[docs/phase0-results.md](docs/phase0-results.md). + +**La Phase 0 n'est pas franchie.** La seule exécution à ce jour a eu lieu en +session X11, alors que la plateforme de référence est Wayland. Les deux +questions restent ouvertes. + +## Licence + +GPL-3.0-or-later pour le code, CC BY-SA 4.0 pour la documentation. Toutes les +dépendances doivent être libres et compatibles : inventaire et statut en +annexe C de [SPEC.md](SPEC.md). diff --git a/docs/phase0-results.md b/docs/phase0-results.md new file mode 100644 index 0000000..6c95d04 --- /dev/null +++ b/docs/phase0-results.md @@ -0,0 +1,103 @@ +# Phase 0 — mesures + +Ce fichier enregistre ce que la sonde a réellement mesuré, machine par machine. +Il n'enregistre pas d'hypothèse. Une ligne n'entre ici qu'après une exécution. + +SPEC.md §16 définit la Phase 0 et §15.2 ses douze portes. L'annexe B liste les +points à confirmer. Tant qu'une porte n'a pas de chiffre en face, elle n'est pas +franchie. + +--- + +## Exécution 1 — 2026-08-03, session X11, non concluante pour la Phase 0 + +**Environnement** + +| | | +|---|---| +| `XDG_SESSION_TYPE` | `x11` | +| Portails présents | `gnome.portal`, `gtk.portal`, `gnome-keyring.portal` | +| GTK4 | 4.14.5 | +| libadwaita | 1.5.0 | +| libpipewire | 1.0.5 | +| Rust | 1.97.1 | + +**Résultat brut** + +``` +[1/3] Portail ScreenCast — jeton de restauration : absent, premier passage + CursorMode::Metadata accepté par le portail : OUI + jeton de restauration reçu et enregistré + flux : node id 98, taille Some((1920, 1080)), position Some((0, 98)) + +[2/3] Flux PipeWire — observation de 30 frames au plus + +[3/3] Verdict + frames observées : 30 + format négocié : 1920x1080 + métadonnées sur la 1re frame : 1 + + QUESTION 1 — type de tampon + types vus : ["DataType::MemFd"] + -> CHEMIN PRÉFÉRÉ DISPONIBLE + + QUESTION 2 — SPA_META_Cursor + frames portant la métadonnée : 0 sur 30 + -> AUCUNE MÉTADONNÉE DE CURSEUR +``` + +**Ce que ça établit** + +La chaîne complète fonctionne de bout en bout : portail joignable, session +créée, `CursorMode::Metadata` accepté sans erreur, jeton de restauration +délivré, flux PipeWire connecté au node du portail, format négocié, et +30 frames livrées et inspectées. Le code de la sonde est donc juste, et +`ashpd` plus `pipewire-rs` suffisent à parler au portail sans GStreamer. + +**Ce que ça n'établit pas, et c'est l'essentiel** + +Cette exécution a eu lieu sur une **session X11**, alors que la plateforme de +référence est Wayland (SPEC.md §1.3). Aucune des deux questions de l'annexe B +n'est donc tranchée : + +- **Tampon `MemFd`** : encourageant, mais le backend X11 du portail passe par un + chemin différent de celui de Mutter en Wayland. Sur une vraie session Wayland + avec pilote NVIDIA propriétaire, le DMA-BUF est le résultat le plus probable. + Annexe B point 1 **reste ouvert**. +- **`SPA_META_Cursor` absent sur les 30 frames** alors que le portail a accepté + `CursorMode::Metadata` : c'est exactement le scénario que l'annexe B point 3 + redoutait, mais le mesurer sur X11 ne conclut rien pour Wayland. Le compteur + de métadonnées valait 1 sur la première frame, donc une métadonnée est bien + livrée, vraisemblablement `SPA_META_Header`. Annexe B point 3 **reste ouvert**. +- Aucun dialogue de consentement n'est apparu. Cet environnement approuve donc + automatiquement, ce qui veut dire que le critère de consentement de + §15.2 point 5 **n'a pas été testé**. +- `position` vaut `Some((0, 98))`, avec une ordonnée égale au numéro de node. + À revérifier sur Wayland avant d'en tirer quoi que ce soit. + +**Verdict** : la sonde est validée, la Phase 0 ne l'est pas. + +--- + +## Exécution 2 — à faire sur Ubuntu 24.04 GNOME Wayland + +C'est celle qui compte. Sur la machine de référence, en session Wayland : + +```bash +cargo run -p tutoclic-probe +``` + +Bouger la souris pendant l'exécution, pour que la position du curseur change +et que l'on distingue « métadonnée présente mais figée » de « métadonnée +présente et vivante ». + +Puis relancer une seconde fois sans rien changer : le dialogue de consentement +ne doit plus apparaître, ce qui vérifie §15.2 point 4. + +À reporter ici, puis dans l'annexe B de SPEC.md : + +- [ ] type de tampon négocié sur Wayland (annexe B pt 1) ; +- [ ] `SPA_META_Cursor` présent, et sur combien de frames (annexe B pt 3) ; +- [ ] la position suit-elle réellement le pointeur ; +- [ ] le second lancement se passe-t-il sans dialogue ; +- [ ] comportement avec le pilote NVIDIA propriétaire. From 4b9b9b49aabb69cbf2e1b7ac5ce7e726926a0077 Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Mon, 3 Aug 2026 22:07:16 +0200 Subject: [PATCH 07/15] =?UTF-8?q?fix(probe):=20demande=20SPA=5FMETA=5FCurs?= =?UTF-8?q?or,=20et=20durcit=20l'=C3=A9criture=20du=20jeton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux changements dans le même fichier, donc dans le même commit. 1. LE CORRECTIF DE FOND Le verdict « aucune métadonnée de curseur » de la première exécution était un bug de la sonde, pas un refus de la plateforme. PipeWire n'attache à un tampon que les métadonnées qu'un client a explicitement réclamées, via des paramètres SPA_PARAM_Meta poussés avec update_params une fois le format fixé. La sonde n'envoyait qu'un paramètre EnumFormat. Le serveur n'avait donc aucune raison d'attacher SPA_META_Cursor, et il ne signale pas l'omission. Le « métadonnées sur la 1re frame : 1 » le montrait déjà : une métadonnée arrivait, vraisemblablement SPA_META_Header, mais pas le curseur. Demande désormais SPA_META_Header et SPA_META_Cursor, ce dernier avec une plage de tailles plutôt qu'une taille fixe, puisque le serveur y écrit éventuellement un spa_meta_bitmap suivi des pixels du curseur. Redéclare les métadonnées à chaque changement de format, sans garde « une seule fois » : un format peut être renégocié quand la résolution du moniteur change (SPEC.md §4.5), et les métadonnées doivent alors être redemandées. Nomme aussi les types de métadonnées réellement livrés et le résultat de update_params, pour qu'un « 0 sur 30 » ne soit plus ambigu entre « pas demandé » et « pas honoré ». NON EXÉCUTÉ : l'environnement de développement ne fournit plus de portail fonctionnel. Compile et passe clippy en -D warnings, mais n'a jamais tourné. 2. RETOURS DE REVUE AUTOMATIQUE SUR LA PR - Le chemin d'état retombait sur un chemin relatif quand ni XDG_STATE_HOME ni HOME n'étaient définis, écrivant silencieusement dans le répertoire courant. Échoue maintenant explicitement. - Le jeton de restauration était écrit avec les permissions de l'umask, mesurées à 0664. Un jeton de restauration rouvre une session de partage d'écran sans redemander le consentement, donc 0600 forcé à la création et sur un fichier préexistant. SPEC.md §4.2 exige de toute façon que l'application, elle, passe par le Secret Service : la sonde documente pourquoi elle ne le fait pas. Co-Authored-By: Claude Opus 5 --- crates/tutoclic-probe/src/main.rs | 206 +++++++++++++++++++++++++++--- 1 file changed, 189 insertions(+), 17 deletions(-) diff --git a/crates/tutoclic-probe/src/main.rs b/crates/tutoclic-probe/src/main.rs index 150d9ec..4e11028 100644 --- a/crates/tutoclic-probe/src/main.rs +++ b/crates/tutoclic-probe/src/main.rs @@ -51,33 +51,70 @@ struct Report { last_cursor: Option<(i32, i32)>, /// Nombre de métadonnées présentes sur la première frame. metas_on_first_frame: u32, + /// Noms des types de métadonnées réellement livrés, dans l'ordre. + meta_types_seen: Vec, + /// Les paramètres `SPA_PARAM_Meta` ont-ils été acceptés par le serveur. + meta_params_pushed: bool, negotiated_size: Option<(u32, u32)>, } -fn state_file() -> PathBuf { - let base = std::env::var_os("XDG_STATE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| { - let mut home = PathBuf::from(std::env::var_os("HOME").unwrap_or_default()); - home.push(".local/state"); - home - }); - base.join("tutoclic/probe-restore-token") +/// Emplacement du jeton de restauration. +/// +/// Échoue plutôt que de retomber sur un chemin relatif : sans `XDG_STATE_HOME` +/// ni `HOME`, écrire dans le répertoire courant serait silencieux et surprenant. +fn state_file() -> Result { + let base = match std::env::var_os("XDG_STATE_HOME") { + Some(dir) if !dir.is_empty() => PathBuf::from(dir), + _ => { + let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).context( + "ni XDG_STATE_HOME ni HOME ne sont définis : impossible de situer \ + le jeton de restauration", + )?; + PathBuf::from(home).join(".local/state") + } + }; + Ok(base.join("tutoclic/probe-restore-token")) } fn read_restore_token() -> Option { - std::fs::read_to_string(state_file()) + std::fs::read_to_string(state_file().ok()?) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) } +/// Enregistre le jeton, lisible par son seul propriétaire. +/// +/// SPEC.md §4.2 exige que l'application, elle, place ce jeton dans le Secret +/// Service et jamais dans un fichier en clair. La sonde ne dépend +/// volontairement pas de `libsecret`, donc elle écrit un fichier, mais en 0600 +/// et en le disant : un jeton de restauration rouvre une session de partage +/// d'écran sans redemander le consentement. fn write_restore_token(token: &str) -> Result<()> { - let path = state_file(); + use std::io::Write; + + let path = state_file()?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(&path, token)?; + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&path)?; + file.write_all(token.as_bytes())?; + + // `mode` ne s'applique qu'à la création : forcer aussi sur un fichier + // préexistant créé par une version antérieure avec un umask permissif. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } Ok(()) } @@ -177,6 +214,100 @@ async fn main() -> Result<()> { Ok(()) } +/// Taille du bloc `SPA_META_Cursor` pour un curseur de `w` par `h` pixels. +/// +/// La structure est suivie, quand un nouveau bitmap est disponible, d'un +/// `spa_meta_bitmap` puis des pixels. Le serveur refuse la métadonnée si la +/// taille annoncée ne peut pas contenir ce qu'il veut y écrire, d'où la plage +/// plutôt qu'une taille fixe. +fn cursor_meta_size(w: i32, h: i32) -> i32 { + let base = std::mem::size_of::() + + std::mem::size_of::(); + base as i32 + w * h * 4 +} + +/// Déclare au serveur les métadonnées que la sonde veut recevoir. +/// +/// À appeler quand le format vient d'être fixé. C'est le mécanisme +/// `SPA_PARAM_Meta` de PipeWire : le serveur n'attache une métadonnée à un +/// tampon que si un client l'a explicitement demandée. +fn push_meta_params(stream: &pw::stream::StreamRef) -> Result<()> { + let header = spa::pod::Object { + type_: spa::utils::SpaTypes::ObjectParamMeta.as_raw(), + id: spa::param::ParamType::Meta.as_raw(), + properties: vec![ + spa::pod::Property::new( + spa::sys::SPA_PARAM_META_type, + spa::pod::Value::Id(spa::utils::Id(spa::sys::SPA_META_Header)), + ), + spa::pod::Property::new( + spa::sys::SPA_PARAM_META_size, + spa::pod::Value::Int(std::mem::size_of::() as i32), + ), + ], + }; + + let cursor = spa::pod::Object { + type_: spa::utils::SpaTypes::ObjectParamMeta.as_raw(), + id: spa::param::ParamType::Meta.as_raw(), + properties: vec![ + spa::pod::Property::new( + spa::sys::SPA_PARAM_META_type, + spa::pod::Value::Id(spa::utils::Id(spa::sys::SPA_META_Cursor)), + ), + spa::pod::Property::new( + spa::sys::SPA_PARAM_META_size, + spa::pod::Value::Choice(spa::pod::ChoiceValue::Int(spa::utils::Choice( + spa::utils::ChoiceFlags::empty(), + spa::utils::ChoiceEnum::Range { + default: cursor_meta_size(64, 64), + min: cursor_meta_size(1, 1), + max: cursor_meta_size(256, 256), + }, + ))), + ), + ], + }; + + let mut buffers = Vec::new(); + for obj in [header, cursor] { + let bytes: Vec = spa::pod::serialize::PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &spa::pod::Value::Object(obj), + ) + .context("sérialisation d'un paramètre Meta")? + .0 + .into_inner(); + buffers.push(bytes); + } + + let mut params: Vec<&spa::pod::Pod> = Vec::with_capacity(buffers.len()); + for bytes in &buffers { + params.push(spa::pod::Pod::from_bytes(bytes).context("pod Meta invalide")?); + } + + stream + .update_params(&mut params) + .context("update_params a refusé les paramètres Meta")?; + Ok(()) +} + +/// Nom lisible d'un type de métadonnée SPA. +fn meta_type_name(t: u32) -> String { + let name = match t { + spa::sys::SPA_META_Header => "Header", + spa::sys::SPA_META_VideoCrop => "VideoCrop", + spa::sys::SPA_META_VideoDamage => "VideoDamage", + spa::sys::SPA_META_Bitmap => "Bitmap", + spa::sys::SPA_META_Cursor => "Cursor", + spa::sys::SPA_META_Control => "Control", + spa::sys::SPA_META_Busy => "Busy", + spa::sys::SPA_META_VideoTransform => "VideoTransform", + other => return format!("inconnu({other})"), + }; + name.to_owned() +} + /// Se connecte au flux et observe les tampons et les métadonnées. fn observe_stream(fd: std::os::fd::OwnedFd, node_id: u32) -> Result { pw::init(); @@ -205,7 +336,7 @@ fn observe_stream(fd: std::os::fd::OwnedFd, node_id: u32) -> Result { let _listener = stream .add_local_listener_with_user_data(()) - .param_changed(move |_stream, _data, id, param| { + .param_changed(move |stream, _data, id, param| { let Some(param) = param else { return }; if id != spa::param::ParamType::Format.as_raw() { return; @@ -224,6 +355,21 @@ fn observe_stream(fd: std::os::fd::OwnedFd, node_id: u32) -> Result { let size = info.size(); report_for_params.borrow_mut().negotiated_size = Some((size.width, size.height)); } + + // Le format est fixé : c'est ICI qu'on déclare les métadonnées + // voulues. PipeWire n'attache que celles que le client demande, et + // ne signale rien s'il n'en demande aucune. Sans ce bloc, + // SPA_META_Cursor n'arrive jamais, quoi que le portail ait accepté + // pour cursor_mode. + // + // Redéclaré à CHAQUE changement de format, sans garde « une seule + // fois ». Un format peut être renégocié en cours de session, par + // exemple quand la résolution du moniteur change (SPEC.md §4.5), et + // les métadonnées doivent alors être redemandées. + match push_meta_params(stream) { + Ok(()) => report_for_params.borrow_mut().meta_params_pushed = true, + Err(e) => eprintln!(" échec de la demande de métadonnées : {e:#}"), + } }) .process(move |stream, _data| { // SAFETY : on emprunte le tampon brut le temps de lire ses @@ -244,6 +390,20 @@ fn observe_stream(fd: std::os::fd::OwnedFd, node_id: u32) -> Result { r.metas_on_first_frame = (*spa_buffer).n_metas; } + // Quelles métadonnées le serveur a-t-il réellement + // attachées. Sans cette liste, un « 0 sur 30 » ne dit pas + // si la métadonnée n'a pas été demandée ou pas honorée. + let n_metas = (*spa_buffer).n_metas as usize; + if n_metas > 0 && !(*spa_buffer).metas.is_null() { + let metas = std::slice::from_raw_parts((*spa_buffer).metas, n_metas); + for m in metas { + let label = meta_type_name(m.type_); + if !r.meta_types_seen.contains(&label) { + r.meta_types_seen.push(label); + } + } + } + // Question 1 : le type de tampon. let n_datas = (*spa_buffer).n_datas as usize; if n_datas > 0 && !(*spa_buffer).datas.is_null() { @@ -342,6 +502,8 @@ fn observe_stream(fd: std::os::fd::OwnedFd, node_id: u32) -> Result { first_cursor: observed.first_cursor, last_cursor: observed.last_cursor, metas_on_first_frame: observed.metas_on_first_frame, + meta_types_seen: observed.meta_types_seen.clone(), + meta_params_pushed: observed.meta_params_pushed, negotiated_size: observed.negotiated_size, }) } @@ -363,8 +525,12 @@ fn print_verdict(r: &Report) { println!(" format négocié : {w}x{h}"); } println!( - " métadonnées sur la 1re frame : {}", - r.metas_on_first_frame + " métadonnées sur la 1re frame : {} {:?}", + r.metas_on_first_frame, r.meta_types_seen + ); + println!( + " paramètres SPA_PARAM_Meta acceptés : {}", + if r.meta_params_pushed { "OUI" } else { "NON" } ); println!("\n QUESTION 1 — type de tampon (annexe B pt 1, SPEC.md §3.1)"); @@ -418,9 +584,15 @@ fn print_verdict(r: &Report) { ); } } + _ if !r.meta_params_pushed => println!( + " -> INDÉTERMINÉ. La sonde n'a pas réussi à demander SPA_META_Cursor, donc\n\ + \x20 son absence ne dit rien de la plateforme. Corriger la sonde d'abord." + ), _ => println!( - " -> AUCUNE MÉTADONNÉE DE CURSEUR. Le mode automatique perd le repère de\n\ - \x20 clic. Renégocier le périmètre avant d'aller plus loin (SPEC.md §16)." + " -> AUCUNE MÉTADONNÉE DE CURSEUR alors qu'elle a été demandée et que le\n\ + \x20 portail avait accepté cursor_mode = metadata. C'est un vrai refus de\n\ + \x20 la plateforme : le mode automatique perd le repère de clic.\n\ + \x20 Renégocier le périmètre avant d'aller plus loin (SPEC.md §16)." ), } From 191743a779dc66c7bdd36f4a32309f95a06a0164 Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Mon, 3 Aug 2026 22:08:00 +0200 Subject: [PATCH 08/15] =?UTF-8?q?fix:=20corrige=20le=20MSRV,=20qui=20?= =?UTF-8?q?=C3=A9tait=20faux,=20et=20le=20fait=20v=C3=A9rifier=20par=20la?= =?UTF-8?q?=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le workspace annonçait rust-version = 1.80. Vérification faite, le workspace ne compile pas en 1.80, ni en 1.82, ni en 1.85. Le plancher réel est 1.86, et il n'est pas fixé par le code de TutoClic mais par son arbre de dépendances : ashpd -> zbus -> url -> idna -> idna_adapter -> icu_* (exige rustc 1.86) proptest tire par ailleurs getrandom, qui exige aussi plus que 1.80. Le code de tutoclic-coords compile, lui, dès 1.80 : c'est sa bibliothèque seule qui tient, pas ses tests. Vérifié : cargo +1.86 build --workspace --locked puis cargo +1.86 test passent, 14 tests verts. Ajoute un job de CI qui construit ET teste à la version exacte annoncée. Un job sur `stable` ne vérifie rien : il laisserait passer l'usage d'une API stabilisée après le MSRV déclaré et rendrait la ligne fausse sans que personne ne le voie. C'est exactement ce qui s'est produit ici. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ Cargo.toml | 7 ++++++- SPEC.md | 10 +++++++++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4532d18..94c0cc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,26 @@ jobs: env: PROPTEST_CASES: "20000" + # Un job sur `stable` ne vérifie pas l'engagement rust-version : il laisserait + # passer l'usage d'une API stabilisée après le MSRV déclaré. Ce job construit + # ET teste à la version exacte annoncée dans Cargo.toml. + msrv: + name: MSRV 1.86 + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: dépendances système + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config libpipewire-0.3-dev libclang-dev + - uses: dtolnay/rust-toolchain@1.86 + - uses: Swatinem/rust-cache@v2 + - name: construction du workspace au MSRV + run: cargo build --workspace --locked + - name: tests au MSRV + run: cargo test --workspace --locked + probe: name: probe (avec PipeWire) runs-on: ubuntu-24.04 diff --git a/Cargo.toml b/Cargo.toml index 5c2525c..f6b6070 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,12 @@ members = ["crates/tutoclic-coords", "crates/tutoclic-probe"] [workspace.package] version = "0.0.1" edition = "2021" -rust-version = "1.80" +# MSRV mesuré, pas souhaité. Le plancher n'est pas fixé par le code de TutoClic +# mais par son arbre de dépendances : ashpd -> zbus -> url -> idna -> icu_* +# exige rustc 1.86, et proptest tire getrandom qui exige aussi plus que 1.80. +# Le code de tutoclic-coords compile, lui, dès 1.80. Vérifié par le job MSRV +# de la CI, qui construit ET teste le workspace à cette version exacte. +rust-version = "1.86" license = "GPL-3.0-or-later" repository = "https://github.com/TutoTech/TutoClic" authors = ["TutoTech"] diff --git a/SPEC.md b/SPEC.md index 1d276f1..20bdbd0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -269,13 +269,21 @@ Sans ça, le `[Réessayer]` des exemples ci-dessus ne peut pas être décidé pa | Couche | Choix | Version minimale | |---|---|---| -| Langage | Rust stable, édition 2021 | 1.80 | +| Langage | Rust stable, édition 2021 | 1.86 (mesuré, voir note) | | Interface | GTK 4 via `gtk4-rs` | GTK 4.14 | | Widgets et style | libadwaita via `libadwaita-rs` | 1.5 | | Description d'UI | Blueprint (`.blp`) compilé par `blueprint-compiler`, ou `.ui` XML | — | | Boucle principale | GLib main loop, `async` via `glib::spawn_future_local` | — | | Build | Meson pour l'intégration GNOME, Cargo pour Rust | Meson 1.0 | +**Note sur le MSRV.** La version 1.86 est mesurée, pas souhaitée. Le plancher est +fixé par l'arbre de dépendances et non par le code de TutoClic : `ashpd` tire +`zbus`, puis `url`, `idna` et la famille `icu_*`, qui exige rustc 1.86. Le code de +`tutoclic-coords` compile dès 1.80. Un job de CI construit et teste le workspace +à la version exacte annoncée ici, parce qu'un job sur `stable` laisserait passer +l'usage d'une API stabilisée plus tard et rendrait cette ligne fausse sans que +personne ne le voie. + Le patron d'architecture est **composants avec état explicite** : `relm4` est autorisé mais non imposé. La décision finale est prise à la fin de la Phase 0 sur la base du PoC, et documentée dans un ADR. From 17768099aee05b2bf5a64bde61c7ee398f48af49 Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Mon, 3 Aug 2026 22:08:18 +0200 Subject: [PATCH 09/15] docs: consigne la mesure Wayland et corrige le compte rendu de la question 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exécution sur pc-fixe, vraie session Wayland, moniteur 2560x1440 à la position logique (1920, 0). QUESTION 1 DE L'ANNEXE B : TRANCHÉE. Tampons MemFd, lisibles par le CPU, sur une vraie session Wayland. GStreamer reste hors des dépendances, le repli de §3.1 point 2 n'a pas à être activé, et la liste de §13.1 tient telle quelle. C'était le point qui changeait la liste des dépendances. QUESTION 3 DE L'ANNEXE B : ROUVERTE. Le « 0 sur 30 » n'était pas un refus de la plateforme, la sonde ne demandait pas la métadonnée. La mesure est invalide et reste à refaire avec la sonde corrigée. Consigne aussi ce qui n'a PAS été testé et que la sortie pouvait laisser croire testé : le consentement, puisque les deux lancements ont réutilisé un jeton de restauration et qu'aucun dialogue n'est apparu, donc §15.2 point 5 n'est pas vérifié. L'exécution 1 est déclassée : le type de session rapporté a changé au cours de la même séance, l'environnement n'était pas fiable, aucune conclusion n'en est tirée. Co-Authored-By: Claude Opus 5 --- README.md | 13 ++-- docs/phase0-results.md | 156 ++++++++++++++++++++++++++--------------- 2 files changed, 107 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 7f7c6ae..6633726 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,9 @@ extension, sans helper privilégié, sans keylogger. Détail complet en ## Construire et tester -Chaîne d'outils : Rust stable 1.80 ou plus. +Chaîne d'outils : Rust stable 1.86 ou plus. Ce plancher vient de l'arbre de +dépendances (`ashpd` tire `icu_*` qui exige 1.86), pas du code de TutoClic : le +code de `tutoclic-coords` compile dès 1.80. ```bash cargo test -p tutoclic-coords @@ -74,9 +76,12 @@ Elle ne capture rien, n'écrit aucune image, et ne conserve que le jeton de restauration du portail. Résultats mesurés dans [docs/phase0-results.md](docs/phase0-results.md). -**La Phase 0 n'est pas franchie.** La seule exécution à ce jour a eu lieu en -session X11, alors que la plateforme de référence est Wayland. Les deux -questions restent ouvertes. +**La Phase 0 n'est pas franchie.** La question du type de tampon est tranchée : +`MemFd` sur une vraie session Wayland, donc GStreamer reste hors des +dépendances. La question de la métadonnée de curseur est en revanche **rouverte** +et c'est celle qui décide du produit : la première sonde ne demandait pas +`SPA_META_Cursor`, donc son absence ne prouvait rien. La sonde corrigée n'a pas +encore été exécutée. ## Licence diff --git a/docs/phase0-results.md b/docs/phase0-results.md index 6c95d04..9510eb0 100644 --- a/docs/phase0-results.md +++ b/docs/phase0-results.md @@ -9,32 +9,43 @@ franchie. --- -## Exécution 1 — 2026-08-03, session X11, non concluante pour la Phase 0 +## État des questions de l'annexe B -**Environnement** +| # | Question | État | Réponse | +|---|---|---|---| +| 1 | type de tampon PipeWire négociable | **TRANCHÉE** | `MemFd`, lisible par le CPU. **GStreamer reste hors des dépendances.** | +| 3 | `SPA_META_Cursor` présent | **ROUVERTE** | la sonde ne demandait pas la métadonnée. Mesure invalide, à refaire. | +| 2, 4, 5, 6, 7, 8 | — | ouvertes | pas encore mesurées | -| | | -|---|---| -| `XDG_SESSION_TYPE` | `x11` | -| Portails présents | `gnome.portal`, `gtk.portal`, `gnome-keyring.portal` | -| GTK4 | 4.14.5 | -| libadwaita | 1.5.0 | -| libpipewire | 1.0.5 | -| Rust | 1.97.1 | +--- + +## Exécution 1 — 2026-08-03, environnement de développement + +À ignorer. `XDG_SESSION_TYPE` a rapporté `x11` puis `wayland` au cours de la même +séance, et le portail a fini par refuser `CreateSession`. Environnement non +fiable, aucune conclusion n'en est tirée. + +--- -**Résultat brut** +## Exécution 2 — 2026-08-03, `pc-fixe`, session Wayland + +**Environnement** : Ubuntu, GNOME, `XDG_SESSION_TYPE=wayland`, moniteur capturé +2560x1440 à la position logique (1920, 0), donc second écran d'une configuration +multi-moniteurs. + +**Résultat brut**, deux lancements consécutifs, sorties identiques : ``` -[1/3] Portail ScreenCast — jeton de restauration : absent, premier passage +[1/3] Portail ScreenCast — jeton de restauration : présent, on tente la restauration CursorMode::Metadata accepté par le portail : OUI jeton de restauration reçu et enregistré - flux : node id 98, taille Some((1920, 1080)), position Some((0, 98)) + flux : node id 111, taille Some((2560, 1440)), position Some((1920, 0)) [2/3] Flux PipeWire — observation de 30 frames au plus [3/3] Verdict frames observées : 30 - format négocié : 1920x1080 + format négocié : 2560x1440 métadonnées sur la 1re frame : 1 QUESTION 1 — type de tampon @@ -43,61 +54,90 @@ franchie. QUESTION 2 — SPA_META_Cursor frames portant la métadonnée : 0 sur 30 - -> AUCUNE MÉTADONNÉE DE CURSEUR ``` -**Ce que ça établit** - -La chaîne complète fonctionne de bout en bout : portail joignable, session -créée, `CursorMode::Metadata` accepté sans erreur, jeton de restauration -délivré, flux PipeWire connecté au node du portail, format négocié, et -30 frames livrées et inspectées. Le code de la sonde est donc juste, et -`ashpd` plus `pipewire-rs` suffisent à parler au portail sans GStreamer. - -**Ce que ça n'établit pas, et c'est l'essentiel** - -Cette exécution a eu lieu sur une **session X11**, alors que la plateforme de -référence est Wayland (SPEC.md §1.3). Aucune des deux questions de l'annexe B -n'est donc tranchée : - -- **Tampon `MemFd`** : encourageant, mais le backend X11 du portail passe par un - chemin différent de celui de Mutter en Wayland. Sur une vraie session Wayland - avec pilote NVIDIA propriétaire, le DMA-BUF est le résultat le plus probable. - Annexe B point 1 **reste ouvert**. -- **`SPA_META_Cursor` absent sur les 30 frames** alors que le portail a accepté - `CursorMode::Metadata` : c'est exactement le scénario que l'annexe B point 3 - redoutait, mais le mesurer sur X11 ne conclut rien pour Wayland. Le compteur - de métadonnées valait 1 sur la première frame, donc une métadonnée est bien - livrée, vraisemblablement `SPA_META_Header`. Annexe B point 3 **reste ouvert**. -- Aucun dialogue de consentement n'est apparu. Cet environnement approuve donc - automatiquement, ce qui veut dire que le critère de consentement de - §15.2 point 5 **n'a pas été testé**. -- `position` vaut `Some((0, 98))`, avec une ordonnée égale au numéro de node. - À revérifier sur Wayland avant d'en tirer quoi que ce soit. - -**Verdict** : la sonde est validée, la Phase 0 ne l'est pas. +### Question 1 : tranchée + +**`MemFd`, sur une vraie session Wayland, en 2560x1440.** Les tampons sont +lisibles directement par le CPU. Le chemin préféré de SPEC.md §3.1 est +disponible : + +- **GStreamer n'entre pas dans les dépendances.** Le repli documenté en §3.1 + point 2 n'a pas à être activé. +- `ashpd` plus `pipewire-rs` suffisent à consommer le flux. +- La liste de dépendances de §13.1 tient telle quelle. + +C'était le premier point de la Phase 0 et celui qui changeait la liste des +dépendances. Il est réglé. + +### Question 2 : mesure invalide, c'était un bug de la sonde + +Le verdict « aucune métadonnée de curseur » n'était **pas** un refus de la +plateforme. La sonde ne demandait jamais la métadonnée. + +PipeWire n'attache à un tampon que les métadonnées qu'un client a explicitement +réclamées, via des paramètres `SPA_PARAM_Meta` poussés avec `update_params` une +fois le format fixé. La première version de la sonde n'envoyait qu'un paramètre +`EnumFormat`. Le serveur n'avait donc aucune raison d'attacher `SPA_META_Cursor`, +et il ne signale pas l'omission. + +Le compteur « métadonnées sur la 1re frame : 1 » le confirmait déjà, sans que +ce soit lisible : une métadonnée était bien livrée, vraisemblablement +`SPA_META_Header`, mais pas le curseur. La sonde nomme désormais les types +livrés, pour qu'un « 0 sur 30 » ne soit plus ambigu. + +Ce que ça veut dire : **`cursor_mode = metadata` n'est ni confirmé ni infirmé.** +Le portail l'a accepté sans erreur, ce qui est de bon augure, mais le seul test +qui compte reste à faire. + +### Ce qui n'a pas été testé + +- **Le consentement.** Les deux lancements ont réutilisé un jeton de + restauration, donc le dialogue du portail n'est apparu à aucun des deux, et + §15.2 point 5 (« rien n'est capturé avant consentement ») n'est pas vérifié. + À tester en supprimant `~/.local/state/tutoclic/probe-restore-token`. +- **Le pilote NVIDIA propriétaire.** Le résultat `MemFd` peut dépendre du pilote. + Confirmer que la machine de référence tournait bien sur le pilote propriétaire + au moment de la mesure. --- -## Exécution 2 — à faire sur Ubuntu 24.04 GNOME Wayland +## Exécution 3 — à faire, sonde corrigée + +La sonde demande maintenant `SPA_META_Header` et `SPA_META_Cursor` avant de lire +les tampons, et affiche les types de métadonnées réellement livrés plus le +résultat de `update_params`. -C'est celle qui compte. Sur la machine de référence, en session Wayland : +**Attention : ce correctif n'a pas pu être exécuté.** L'environnement de +développement a cessé de fournir un portail fonctionnel. Il compile, passe +clippy en `-D warnings`, et le mécanisme employé est celui documenté par +PipeWire, mais il n'a jamais tourné. ```bash +git pull +rm -f ~/.local/state/tutoclic/probe-restore-token # pour tester aussi le consentement cargo run -p tutoclic-probe ``` -Bouger la souris pendant l'exécution, pour que la position du curseur change -et que l'on distingue « métadonnée présente mais figée » de « métadonnée -présente et vivante ». +Bouger la souris pendant l'exécution, pour distinguer « métadonnée présente mais +figée » de « métadonnée présente et vivante ». + +À lire dans la sortie : + +- [ ] `paramètres SPA_PARAM_Meta acceptés : OUI` — sinon le correctif est mauvais + et rien d'autre ne compte ; +- [ ] la liste des types de métadonnées contient-elle `Cursor` ; +- [ ] `frames portant la métadonnée` sur 30 ; +- [ ] la position change-t-elle quand la souris bouge ; +- [ ] un dialogue de consentement est-il apparu au premier lancement. -Puis relancer une seconde fois sans rien changer : le dialogue de consentement -ne doit plus apparaître, ce qui vérifie §15.2 point 4. +Puis relancer une seconde fois : plus aucun dialogue (§15.2 point 4). -À reporter ici, puis dans l'annexe B de SPEC.md : +### Si `Cursor` n'apparaît toujours pas -- [ ] type de tampon négocié sur Wayland (annexe B pt 1) ; -- [ ] `SPA_META_Cursor` présent, et sur combien de frames (annexe B pt 3) ; -- [ ] la position suit-elle réellement le pointeur ; -- [ ] le second lancement se passe-t-il sans dialogue ; -- [ ] comportement avec le pilote NVIDIA propriétaire. +Alors c'est un vrai refus de la plateforme, et §0.3 de SPEC.md perd son +fondement. Le mode automatique produirait des étapes sans repère de clic. Il +faudra renégocier le périmètre avant d'écrire l'application, et les options +seraient : accepter des étapes sans repère de clic, déduire la position du +pointeur de la boîte englobante du changement de frame, ou remonter d'un cran +vers le helper libinput de §17 avec tout ce que ça coûte. From 2705e4d938549bbf9f8a901f7347dafbcfa87df5 Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Mon, 3 Aug 2026 22:32:05 +0200 Subject: [PATCH 10/15] fix(probe): interroge AvailableCursorModes, et demande le curseur en taille fixe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux corrections, toutes deux issues de la mesure sur pc-fixe qui renvoyait ["Busy", "Header"] sans Cursor, sans erreur et sans fermeture de session. 1. LA VÉRIFICATION QUI MANQUAIT DEPUIS LE DÉBUT Le portail expose AvailableCursorModes, un masque des modes qu'il implémente réellement. La sonde ne l'interrogeait pas et affichait « CursorMode::Metadata accepté par le portail : OUI » sur la seule base d'un SelectSources sans erreur. C'était faux : ça ne prouvait que « pas rejeté ». La sonde affiche désormais les modes annoncés, et les types de source, avant même de créer la session. Le verdict distingue trois cas au lieu d'un : - mode non annoncé : cause identifiée, ce bureau ne l'implémente pas, et les replis sont listés dans la sortie ; - annoncé mais non livré : anomalie du compositeur, à remonter en amont ; - absent alors que la sonde n'a pas su le demander : indéterminé. À noter : la spécification du portail dit que demander un mode non annoncé ferme la session. Or la session de pc-fixe n'a pas été fermée et a livré 30 frames, ce qui laisse penser que Metadata EST annoncé. Cette exécution le dira. 2. LE DIFFÉRENTIEL QUI ACCUSE LA SONDE Dans la même liste de paramètres, Header demandé avec une taille fixe a été honoré, tandis que Cursor demandé avec une Choice::Range a été ignoré en silence. La seule variable qui différait était Int contre Choice. Cursor est donc demandé avec une taille fixe, dimensionnée pour un curseur de 256 par 256, ce qui couvre l'échelle 200 %. Un tampon plus grand que nécessaire ne gêne pas le serveur. NON EXÉCUTÉ : l'environnement de développement ne fournit plus de portail fonctionnel. Compile, passe clippy en -D warnings, jamais tourné. Co-Authored-By: Claude Opus 5 --- crates/tutoclic-probe/src/main.rs | 80 +++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/crates/tutoclic-probe/src/main.rs b/crates/tutoclic-probe/src/main.rs index 4e11028..0af4263 100644 --- a/crates/tutoclic-probe/src/main.rs +++ b/crates/tutoclic-probe/src/main.rs @@ -144,6 +144,37 @@ async fn main() -> Result<()> { let proxy = Screencast::new() .await .context("le portail ScreenCast est injoignable")?; + + // LE point à interroger avant tout le reste. Le portail ANNONCE les modes de + // curseur qu'il implémente. SelectSources, lui, n'echoue pas sur un mode non + // supporté : il l'ignore en silence. Demander Metadata sans vérifier cette + // propriété, c'est confondre « accepté » avec « pas rejeté ». + let available_modes = proxy.available_cursor_modes().await.ok(); + let metadata_advertised = available_modes + .map(|m| m.contains(CursorMode::Metadata)) + .unwrap_or(false); + match available_modes { + Some(modes) => { + let mut names = Vec::new(); + if modes.contains(CursorMode::Hidden) { + names.push("Hidden"); + } + if modes.contains(CursorMode::Embedded) { + names.push("Embedded"); + } + if modes.contains(CursorMode::Metadata) { + names.push("Metadata"); + } + println!(" modes de curseur ANNONCÉS par le portail : {names:?}"); + } + None => { + println!(" le portail n'expose pas AvailableCursorModes (interface trop ancienne)") + } + } + if let Ok(types) = proxy.available_source_types().await { + println!(" types de source annoncés : {types:?}"); + } + let session = proxy .create_session() .await @@ -172,7 +203,10 @@ async fn main() -> Result<()> { .response() .context("l'utilisateur a refusé le partage, ou le portail a annulé")?; - println!(" CursorMode::Metadata accepté par le portail : OUI"); + println!( + " CursorMode::Metadata demandé, SelectSources n'a pas renvoyé d'erreur.\n\ + \x20 Ça ne prouve rien : seule la ligne « modes annoncés » ci-dessus compte." + ); match response.restore_token() { Some(token) => { @@ -210,7 +244,7 @@ async fn main() -> Result<()> { let report = observe_stream(fd, node_id)?; // --- Étape 3 : le verdict -------------------------------------------- - print_verdict(&report); + print_verdict(&report, metadata_advertised); Ok(()) } @@ -255,16 +289,21 @@ fn push_meta_params(stream: &pw::stream::StreamRef) -> Result<()> { spa::sys::SPA_PARAM_META_type, spa::pod::Value::Id(spa::utils::Id(spa::sys::SPA_META_Cursor)), ), + // Taille FIXE, et non une plage. + // + // Différentiel observé sur pc-fixe : dans la même liste de + // paramètres, Header demandé avec une taille fixe a été honoré, + // tandis que Cursor demandé avec une Choice::Range a été ignoré en + // silence. Le serveur a renvoyé ["Busy", "Header"] sans Cursor et + // sans erreur. La seule variable qui différait entre les deux + // paramètres était Int contre Choice. + // + // On demande donc une taille fixe assez grande pour un curseur de + // 256 par 256, ce qui couvre un curseur à l'échelle 200 %. Un + // tampon plus grand que nécessaire ne gêne pas le serveur. spa::pod::Property::new( spa::sys::SPA_PARAM_META_size, - spa::pod::Value::Choice(spa::pod::ChoiceValue::Int(spa::utils::Choice( - spa::utils::ChoiceFlags::empty(), - spa::utils::ChoiceEnum::Range { - default: cursor_meta_size(64, 64), - min: cursor_meta_size(1, 1), - max: cursor_meta_size(256, 256), - }, - ))), + spa::pod::Value::Int(cursor_meta_size(256, 256)), ), ], }; @@ -508,7 +547,7 @@ fn observe_stream(fd: std::os::fd::OwnedFd, node_id: u32) -> Result { }) } -fn print_verdict(r: &Report) { +fn print_verdict(r: &Report, metadata_advertised: bool) { println!("\n[3/3] Verdict"); println!(" frames observées : {}", r.frames); @@ -588,11 +627,22 @@ fn print_verdict(r: &Report) { " -> INDÉTERMINÉ. La sonde n'a pas réussi à demander SPA_META_Cursor, donc\n\ \x20 son absence ne dit rien de la plateforme. Corriger la sonde d'abord." ), + _ if !metadata_advertised => println!( + " -> LE PORTAIL N'ANNONCE PAS LE MODE Metadata. Cause identifiée, et ce\n\ + \x20 n'est pas un bug : ce bureau n'implémente simplement pas ce mode.\n\ + \x20 Demander SPA_META_Cursor ne pouvait rien donner.\n\ + \x20 §0.3 de SPEC.md perd son fondement. Replis, du moins coûteux au plus :\n\ + \x20 1. cursor_mode = Embedded, le curseur est composité dans l'image ;\n\ + \x20 l'information reste visible pour le lecteur du tutoriel ;\n\ + \x20 2. placer le repère au centre de la boîte de changement, que le\n\ + \x20 moteur de §4.3.3 calcule déjà pour déclencher la capture ;\n\ + \x20 3. le helper libinput de §17, avec tout ce qu'il coûte." + ), _ => println!( - " -> AUCUNE MÉTADONNÉE DE CURSEUR alors qu'elle a été demandée et que le\n\ - \x20 portail avait accepté cursor_mode = metadata. C'est un vrai refus de\n\ - \x20 la plateforme : le mode automatique perd le repère de clic.\n\ - \x20 Renégocier le périmètre avant d'aller plus loin (SPEC.md §16)." + " -> ANNONCÉ MAIS NON LIVRÉ. Le portail déclare supporter Metadata, la\n\ + \x20 métadonnée a bien été demandée, et elle n'arrive pas. C'est une\n\ + \x20 anomalie du compositeur ou du portail, à remonter en amont avant\n\ + \x20 de renoncer au repère de clic." ), } From fbbe0644d3f699aeb26a289ff7396f9bc6755d3a Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Mon, 3 Aug 2026 22:39:48 +0200 Subject: [PATCH 11/15] =?UTF-8?q?docs:=20consigne=20le=20consentement=20v?= =?UTF-8?q?=C3=A9rifi=C3=A9=20et=20tranche=20l'annexe=20B=20point=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONSENTEMENT, observé directement par l'utilisateur sur la machine de référence en session Wayland : un dialogue de partage d'écran apparaît aux lancements sans jeton enregistré, et pas au lancement avec jeton. Le mécanisme restore_token plus PersistMode::ExplicitlyRevoked de §4.2 fonctionne comme spécifié. Conséquences sur §15.2 : - point 5, rien n'est capturé avant consentement : franchi au niveau du portail ; - point 4, captures sans nouvelle autorisation : franchi au niveau du portail. Le décompte littéral des dix étapes relève de la Phase 1b, la sonde observant des frames et n'écrivant pas d'étapes ; - points 1 à 3 : franchis, source sélectionnée, flux reçu, 30 frames décodées. ANNEXE B POINT 1 : TRANCHÉ. Tampons MemFd sur Wayland en 2560x1440, trois exécutions identiques. GStreamer n'entre pas dans les dépendances, le repli de §3.1 point 2 n'est pas activé, la liste de §13.1 tient telle quelle. Marqué comme résolu dans l'annexe plutôt que laissé en question ouverte. ANNEXE B POINT 3 : toujours ouvert, mais le champ des causes s'est resserré à deux, et la sonde de l'exécution 4 les distingue. Co-Authored-By: Claude Opus 5 --- SPEC.md | 26 +++++++++++++---- docs/phase0-results.md | 65 +++++++++++++++++++++++++++++++++++------- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/SPEC.md b/SPEC.md index 20bdbd0..48be258 100644 --- a/SPEC.md +++ b/SPEC.md @@ -478,6 +478,21 @@ un flux serait disproportionné. de créer ou de restaurer un screencast, et chaque tentative pendant le verrouillage tend à consommer le jeton enregistré. Conséquences normatives : +**Mesuré le 2026-08-03 sur la machine de référence, Ubuntu GNOME Wayland.** Le mécanisme +fonctionne comme spécifié ici : + +- session sans jeton enregistré : le dialogue du portail apparaît, l'utilisateur doit + accorder le partage. Deux exécutions, deux dialogues ; +- session avec jeton enregistré : **aucun dialogue**, la session est restaurée + silencieusement ; +- `PersistMode::ExplicitlyRevoked` renvoie bien un `restore_token` exploitable. + +Cela confirme §15.2 point 5 au niveau du portail (rien ne démarre avant consentement) et +la faisabilité de §15.2 point 4. Le décompte littéral des dix captures relève de la +Phase 1b : la sonde observe des frames, elle n'écrit pas d'étapes. + +Le comportement reste malgré tout traité comme révocable : + - le jeton est traité comme révocable à tout moment, jamais comme acquis ; - une tentative de restauration qui échoue déclenche un reconsentement, avec un message qui explique pourquoi et ne culpabilise pas l'utilisateur ; @@ -1718,12 +1733,13 @@ Les affirmations techniques de §0 s'appuient sur ces sources, consultées le 3 ## Annexe B — Points à confirmer en Phase 0 Ces points n'ont pas été vérifiés pour ce document et ne doivent pas être traités comme -acquis : +acquis. Les mesures sont consignées dans `docs/phase0-results.md`. -1. **Type de tampon négociable** avec `pipewire-rs` sur Mutter des versions cibles : - `MemFd` ou `MemPtr` obtenables, ou DMA-BUF imposé. C'est le point qui décide si - GStreamer entre ou non dans les dépendances (§3.1). À traiter en premier, avant tout - le reste de la Phase 0, parce que c'est celui qui change la liste des dépendances. +1. ~~**Type de tampon négociable** avec `pipewire-rs` sur Mutter des versions cibles.~~ + **TRANCHÉ le 2026-08-03 : `MemFd`**, mesuré sur Ubuntu GNOME Wayland, moniteur + 2560x1440, trois exécutions identiques. Les tampons sont lisibles par le CPU, donc le + chemin préféré de §3.1 s'applique et **GStreamer n'entre pas dans les dépendances**. + Le repli §3.1 point 2 n'a pas à être activé et la liste de §13.1 tient telle quelle. 2. Disponibilité de `org.gnome.Shell.Introspect.GetWindows` pour une application non sandboxée sur GNOME 46 et 48, puis sous Flatpak. Impact si absent : nul sur les fonctions principales, les métadonnées concernées étant désactivées par défaut. diff --git a/docs/phase0-results.md b/docs/phase0-results.md index 9510eb0..9112ae2 100644 --- a/docs/phase0-results.md +++ b/docs/phase0-results.md @@ -14,9 +14,17 @@ franchie. | # | Question | État | Réponse | |---|---|---|---| | 1 | type de tampon PipeWire négociable | **TRANCHÉE** | `MemFd`, lisible par le CPU. **GStreamer reste hors des dépendances.** | -| 3 | `SPA_META_Cursor` présent | **ROUVERTE** | la sonde ne demandait pas la métadonnée. Mesure invalide, à refaire. | +| 3 | `SPA_META_Cursor` présent | **OUVERTE** | deux causes possibles écartées, une troisième à tester. Voir exécution 4. | | 2, 4, 5, 6, 7, 8 | — | ouvertes | pas encore mesurées | +Portes de §15.2 franchies au niveau du portail : + +| Porte | État | Preuve | +|---|---|---| +| pt 5, rien avant consentement | **franchie** | sans jeton enregistré, le dialogue du portail apparaît et doit être accepté. Deux exécutions, deux dialogues. | +| pt 4, captures sans nouvelle autorisation | **franchie au niveau du portail** | avec jeton, aucun dialogue, session restaurée silencieusement. Le décompte littéral des dix étapes relève de la Phase 1b. | +| pt 1, 2, 3 | franchies | source sélectionnée, flux reçu, 30 frames décodées | + --- ## Exécution 1 — 2026-08-03, environnement de développement @@ -102,11 +110,48 @@ qui compte reste à faire. --- -## Exécution 3 — à faire, sonde corrigée +## Exécution 3 — 2026-08-03, `pc-fixe`, sonde avec demande de métadonnées + +Trois lancements. Les deux premiers après suppression du jeton, le troisième avec +le jeton en place. + +``` + métadonnées sur la 1re frame : 2 ["Busy", "Header"] + paramètres SPA_PARAM_Meta acceptés : OUI + types vus : ["DataType::MemFd"] + frames portant la métadonnée : 0 sur 30 +``` + +**Consentement, confirmé par observation directe de l'utilisateur** : un dialogue +de partage d'écran est apparu aux deux premiers lancements, sans jeton, et pas au +troisième, avec jeton. Le mécanisme de §4.2 fonctionne comme spécifié. + +**Le mécanisme de demande de métadonnées fonctionne** : `update_params` accepté, et +`Header`, que la sonde demandait, est bien apparu. `Busy` a été ajouté par le +serveur sans avoir été demandé. + +**`Cursor` a été ignoré en silence.** Deux causes restent possibles, et la +différence entre les deux décide du produit : + +1. le portail n'annonce pas le mode `Metadata`, et la sonde ne l'a jamais vérifié. + Elle affichait « accepté par le portail : OUI » sur la seule base d'un + `SelectSources` sans erreur, ce qui ne prouve que « pas rejeté » ; +2. la taille demandée pour `SPA_META_Cursor` était une `Choice::Range` alors que + `Header`, honoré, utilisait une taille fixe. C'est la seule variable qui + différait entre le paramètre honoré et le paramètre ignoré. + +Un indice pousse vers la cause 2 : la spécification du portail précise que demander +un mode de curseur non annoncé **ferme la session**. Or la session n'a pas été +fermée et a livré 30 frames, trois fois de suite. + +--- + +## Exécution 4 — à faire, sonde corrigée deux fois -La sonde demande maintenant `SPA_META_Header` et `SPA_META_Cursor` avant de lire -les tampons, et affiche les types de métadonnées réellement livrés plus le -résultat de `update_params`. +La sonde interroge maintenant `AvailableCursorModes`, la propriété du portail qui +liste les modes de curseur réellement implémentés, et l'affiche avant même de +créer la session. Elle demande par ailleurs `SPA_META_Cursor` avec une taille +**fixe** et non une plage, par symétrie avec `Header` qui, lui, a été honoré. **Attention : ce correctif n'a pas pu être exécuté.** L'environnement de développement a cessé de fournir un portail fonctionnel. Il compile, passe @@ -124,14 +169,14 @@ figée » de « métadonnée présente et vivante ». À lire dans la sortie : -- [ ] `paramètres SPA_PARAM_Meta acceptés : OUI` — sinon le correctif est mauvais - et rien d'autre ne compte ; +- [ ] `modes de curseur ANNONCÉS par le portail` contient-il `Metadata` — c'est la + ligne qui tranche entre les deux causes ; - [ ] la liste des types de métadonnées contient-elle `Cursor` ; - [ ] `frames portant la métadonnée` sur 30 ; -- [ ] la position change-t-elle quand la souris bouge ; -- [ ] un dialogue de consentement est-il apparu au premier lancement. +- [ ] la position change-t-elle quand la souris bouge. -Puis relancer une seconde fois : plus aucun dialogue (§15.2 point 4). +Le verdict de la sonde distingue désormais les trois cas : mode non annoncé, +annoncé mais non livré, ou pas correctement demandé. ### Si `Cursor` n'apparaît toujours pas From deecf082dc06607f6173e9ff1210caff2749f385 Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Mon, 3 Aug 2026 23:41:38 +0200 Subject: [PATCH 12/15] fix(probe): observe pendant 15 s et dit sur quelle zone bouger la souris MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Metadata EST annoncé par le portail sur la machine de référence, et la taille fixe n'a rien changé. Trois causes sont donc écartées : métadonnée non demandée, mode non supporté, paramètre mal formé. Reste une cause instrumentale, et c'est un défaut de la sonde. Le moniteur capturé est celui à la position logique (1920, 0), soit l'écran secondaire, alors que le terminal de lancement est sur l'écran principal. Mutter n'a de position de curseur à rapporter que si le curseur se trouve SUR la zone capturée. La consigne « bouge la souris » était par ailleurs inapplicable : la fenêtre d'observation valait 30 frames, soit environ une seconde. Corrigé : - fenêtre d'observation en TEMPS, 15 secondes, au lieu d'un compte de frames ; - affichage de la géométrie exacte de la zone capturée avant l'observation, avec la consigne d'y amener le pointeur, et la marche à suivre pour capturer un autre écran ; - sortie anticipée dès que 5 frames portent la métadonnée, pour qu'une réponse positive soit immédiate ; - le verdict négatif demande maintenant explicitement si le pointeur était bien sur la zone, au lieu d'accuser la plateforme. NON EXÉCUTÉ : l'environnement de développement ne fournit plus de portail. Compile, passe clippy en -D warnings, jamais tourné. Co-Authored-By: Claude Opus 5 --- crates/tutoclic-probe/src/main.rs | 62 ++++++++++++++++++++++------ docs/phase0-results.md | 68 +++++++++++++++++++++---------- 2 files changed, 97 insertions(+), 33 deletions(-) diff --git a/crates/tutoclic-probe/src/main.rs b/crates/tutoclic-probe/src/main.rs index 0af4263..98e2e88 100644 --- a/crates/tutoclic-probe/src/main.rs +++ b/crates/tutoclic-probe/src/main.rs @@ -32,11 +32,16 @@ use ashpd::WindowIdentifier; use pipewire as pw; use pw::spa; -/// Nombre de frames observées avant de conclure. -const FRAMES_TO_OBSERVE: u32 = 30; +/// Durée d'observation du flux. +/// +/// Une fenêtre en temps, et non en nombre de frames. La version précédente +/// s'arrêtait au bout de 30 frames, soit environ une seconde : elle demandait à +/// l'utilisateur de bouger la souris sans lui laisser le temps de le faire, ce +/// qui rendait l'absence de métadonnée de curseur inexploitable. +const OBSERVE_WINDOW: Duration = Duration::from_secs(15); -/// Garde-fou : si le flux ne démarre pas, on ne bloque pas indéfiniment. -const TIMEOUT: Duration = Duration::from_secs(20); +/// Assez de frames porteuses pour conclure et sortir plus tôt. +const CURSOR_FRAMES_ENOUGH: u32 = 5; /// Ce que la sonde a effectivement observé. #[derive(Debug, Default)] @@ -240,7 +245,28 @@ async fn main() -> Result<()> { .context("OpenPipeWireRemote a échoué")?; // --- Étape 2 : le flux PipeWire -------------------------------------- - println!("\n[2/3] Flux PipeWire — observation de {FRAMES_TO_OBSERVE} frames au plus"); + println!( + "\n[2/3] Flux PipeWire — observation pendant {} secondes", + OBSERVE_WINDOW.as_secs() + ); + + // Instruction explicite, avec la géométrie réelle de la zone capturée. + // Mutter n'a de position de curseur à rapporter que si le curseur est SUR + // cette zone. Sans cette consigne, une souris restée sur un autre écran + // produit un « 0 sur N » qui ne dit rien de la plateforme. + match (stream_info.position(), stream_info.size()) { + (Some((x, y)), Some((w, h))) => { + println!( + " >>> BOUGE LA SOURIS SUR LA ZONE CAPTURÉE MAINTENANT <<<\n\ + \x20 zone : {w}x{h} à la position logique ({x}, {y}).\n\ + \x20 Si ce n'est pas l'écran où se trouve ton pointeur, amène-le dessus.\n\ + \x20 Pour capturer un autre écran : supprime\n\ + \x20 ~/.local/state/tutoclic/probe-restore-token puis relance." + ); + } + _ => println!(" >>> BOUGE LA SOURIS SUR LA ZONE CAPTURÉE MAINTENANT <<<"), + } + let report = observe_stream(fd, node_id)?; // --- Étape 3 : le verdict -------------------------------------------- @@ -471,7 +497,11 @@ fn observe_stream(fd: std::os::fd::OwnedFd, node_id: u32) -> Result { r.last_cursor = Some(pos); } - if r.frames >= FRAMES_TO_OBSERVE { + // Sortie anticipée seulement quand la question est répondue + // par l'affirmative. Sinon on laisse la fenêtre s'écouler, + // pour donner le temps d'amener le curseur sur la zone + // capturée. + if r.frames_with_cursor_meta >= CURSOR_FRAMES_ENOUGH { quit_on_done.quit(); } } @@ -526,10 +556,11 @@ fn observe_stream(fd: std::os::fd::OwnedFd, node_id: u32) -> Result { ) .context("connexion au node du portail")?; - // Garde-fou de temps : si aucune frame n'arrive, on sort quand même. + // Fin de la fenêtre d'observation. Sert aussi de garde-fou si aucune frame + // n'arrive du tout. let quit_on_timeout = mainloop.clone(); let timer = mainloop.loop_().add_timer(move |_| quit_on_timeout.quit()); - let _ = timer.update_timer(Some(TIMEOUT), None); + let _ = timer.update_timer(Some(OBSERVE_WINDOW), None); mainloop.run(); @@ -639,10 +670,17 @@ fn print_verdict(r: &Report, metadata_advertised: bool) { \x20 3. le helper libinput de §17, avec tout ce qu'il coûte." ), _ => println!( - " -> ANNONCÉ MAIS NON LIVRÉ. Le portail déclare supporter Metadata, la\n\ - \x20 métadonnée a bien été demandée, et elle n'arrive pas. C'est une\n\ - \x20 anomalie du compositeur ou du portail, à remonter en amont avant\n\ - \x20 de renoncer au repère de clic." + " -> ANNONCÉ MAIS NON LIVRÉ pendant cette fenêtre d'observation.\n\ + \x20 AVANT de conclure, réponds à une question : le pointeur était-il\n\ + \x20 réellement SUR la zone capturée pendant les {} secondes ?\n\ + \x20 Mutter n'a rien à rapporter si le curseur est sur un autre écran,\n\ + \x20 et la zone capturée n'est pas forcément celle de ton terminal.\n\ + \x20 - non, ou tu n'en es pas sûr : relance en amenant le pointeur\n\ + \x20 sur la zone dont la géométrie est affichée plus haut ;\n\ + \x20 - oui, pointeur bien dessus, et toujours 0 : alors c'est une\n\ + \x20 anomalie du compositeur ou du portail, à remonter en amont\n\ + \x20 avant de renoncer au repère de clic.", + OBSERVE_WINDOW.as_secs() ), } diff --git a/docs/phase0-results.md b/docs/phase0-results.md index 9112ae2..93626f3 100644 --- a/docs/phase0-results.md +++ b/docs/phase0-results.md @@ -14,7 +14,7 @@ franchie. | # | Question | État | Réponse | |---|---|---|---| | 1 | type de tampon PipeWire négociable | **TRANCHÉE** | `MemFd`, lisible par le CPU. **GStreamer reste hors des dépendances.** | -| 3 | `SPA_META_Cursor` présent | **OUVERTE** | deux causes possibles écartées, une troisième à tester. Voir exécution 4. | +| 3 | `SPA_META_Cursor` présent | **OUVERTE** | trois causes écartées. Reste à écarter le protocole de test lui-même : voir exécution 5. | | 2, 4, 5, 6, 7, 8 | — | ouvertes | pas encore mesurées | Portes de §15.2 franchies au niveau du portail : @@ -146,37 +146,63 @@ fermée et a livré 30 frames, trois fois de suite. --- -## Exécution 4 — à faire, sonde corrigée deux fois +## Exécution 4 — 2026-08-03, `pc-fixe`, sonde interrogeant AvailableCursorModes -La sonde interroge maintenant `AvailableCursorModes`, la propriété du portail qui -liste les modes de curseur réellement implémentés, et l'affiche avant même de -créer la session. Elle demande par ailleurs `SPA_META_Cursor` avec une taille -**fixe** et non une plage, par symétrie avec `Header` qui, lui, a été honoré. +``` + modes de curseur ANNONCÉS par le portail : ["Hidden", "Embedded", "Metadata"] + types de source annoncés : Monitor | Window | Virtual + métadonnées sur la 1re frame : 2 ["Busy", "Header"] + paramètres SPA_PARAM_Meta acceptés : OUI + frames portant la métadonnée : 0 sur 30 +``` + +**`Metadata` EST annoncé par le portail.** La cause « ce bureau ne l'implémente +pas » est écartée. La taille fixe au lieu de la plage n'a rien changé non plus : +la cause « paramètre mal formé » est écartée aussi. + +Trois causes écartées à ce stade : métadonnée non demandée, mode non supporté, +taille mal formée. + +**Mais le protocole de test était vicié, et c'est un défaut de la sonde.** Le +moniteur capturé est celui à la position logique (1920, 0), soit l'écran +secondaire. Le terminal d'où la sonde est lancée est sur l'écran principal. +Mutter n'a de position de curseur à rapporter que si le curseur se trouve **sur +la zone capturée**. Et la fenêtre d'observation valait 30 frames, soit environ une +seconde : la consigne « bouge la souris » était matériellement inapplicable. + +Ce « 0 sur 30 » ne dit donc toujours rien de la plateforme. + +--- + +## Exécution 5 — à faire, protocole corrigé + +La fenêtre d'observation passe de 30 frames à **15 secondes**, et la sonde +affiche la géométrie exacte de la zone capturée avec une consigne explicite avant +de commencer à observer. Elle sort plus tôt dès que 5 frames portent la +métadonnée, donc une réponse positive est immédiate. -**Attention : ce correctif n'a pas pu être exécuté.** L'environnement de -développement a cessé de fournir un portail fonctionnel. Il compile, passe -clippy en `-D warnings`, et le mécanisme employé est celui documenté par -PipeWire, mais il n'a jamais tourné. +**Non exécuté** : l'environnement de développement ne fournit plus de portail +fonctionnel. Compile, passe clippy en `-D warnings`, jamais tourné. ```bash -git pull -rm -f ~/.local/state/tutoclic/probe-restore-token # pour tester aussi le consentement -cargo run -p tutoclic-probe +git pull && cargo run -p tutoclic-probe ``` -Bouger la souris pendant l'exécution, pour distinguer « métadonnée présente mais -figée » de « métadonnée présente et vivante ». +Puis, pendant les 15 secondes, **amener le pointeur sur la zone dont la sonde +affiche la géométrie**, et le bouger dessus. Si cette zone n'est pas l'écran où +se trouve le terminal, supprimer +`~/.local/state/tutoclic/probe-restore-token` et relancer pour choisir un autre +écran au dialogue du portail. À lire dans la sortie : -- [ ] `modes de curseur ANNONCÉS par le portail` contient-il `Metadata` — c'est la - ligne qui tranche entre les deux causes ; - [ ] la liste des types de métadonnées contient-elle `Cursor` ; -- [ ] `frames portant la métadonnée` sur 30 ; -- [ ] la position change-t-elle quand la souris bouge. +- [ ] `frames portant la métadonnée` sur le total ; +- [ ] les positions première et dernière diffèrent-elles. -Le verdict de la sonde distingue désormais les trois cas : mode non annoncé, -annoncé mais non livré, ou pas correctement demandé. +C'est la dernière cause instrumentale possible. Si le pointeur était bien sur la +zone capturée et que `Cursor` n'apparaît toujours pas, alors c'est une anomalie +en amont, et il faut basculer sur les replis. ### Si `Cursor` n'apparaît toujours pas From e862c3f88719c0b432024324c89fef9625d2875f Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Mon, 3 Aug 2026 23:55:26 +0200 Subject: [PATCH 13/15] =?UTF-8?q?fix(probe):=20ajoute=20un=20t=C3=A9moin?= =?UTF-8?q?=20de=20contr=C3=B4le=20au=20m=C3=A9canisme=20de=20demande=20de?= =?UTF-8?q?=20m=C3=A9tadonn=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cause instrumentale précédente éliminée : 1923 frames observées sur l'écran où se trouve le terminal, donc pointeur bien sur la zone capturée, et toujours zéro métadonnée de curseur. Mais une inférence gratuite traînait depuis trois exécutions. Je concluais que la demande de métadonnées fonctionnait parce que Header figurait dans les métadonnées reçues. Or Busy y figurait aussi sans avoir jamais été demandé : le serveur attache donc des métadonnées de son propre chef, et Header pouvait très bien être dans ce lot. update_params renvoyant Ok ne prouve que « les pods ont été analysés », pas « le serveur a honoré la demande ». Header n'est donc plus demandé, et VideoCrop l'est à sa place, comme témoin. Lecture du résultat : - VideoCrop présent : le mécanisme fonctionne, et Cursor est refusé spécifiquement, ce qui pointe une anomalie en amont ; - VideoCrop absent et Header toujours présent : update_params n'a aucun effet observable, et tout verdict sur Cursor rendu jusqu'ici est sans valeur. La sonde affiche ce diagnostic AVANT le verdict sur le curseur, parce que le second n'a aucun sens si le premier dit « inopérant ». NON EXÉCUTÉ : l'environnement de développement ne fournit plus de portail. Compile, passe clippy en -D warnings, jamais tourné. Co-Authored-By: Claude Opus 5 --- crates/tutoclic-probe/src/main.rs | 44 ++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/tutoclic-probe/src/main.rs b/crates/tutoclic-probe/src/main.rs index 98e2e88..3bc2805 100644 --- a/crates/tutoclic-probe/src/main.rs +++ b/crates/tutoclic-probe/src/main.rs @@ -292,17 +292,32 @@ fn cursor_meta_size(w: i32, h: i32) -> i32 { /// `SPA_PARAM_Meta` de PipeWire : le serveur n'attache une métadonnée à un /// tampon que si un client l'a explicitement demandée. fn push_meta_params(stream: &pw::stream::StreamRef) -> Result<()> { - let header = spa::pod::Object { + // TÉMOIN DE CONTRÔLE. `VideoCrop` est demandé uniquement pour savoir si ce + // mécanisme fait quoi que ce soit. + // + // Les exécutions précédentes recevaient ["Busy", "Header"] et j'en concluais + // que la demande de métadonnées fonctionnait, puisque Header y figurait. + // C'était une inférence gratuite : Busy n'avait jamais été demandé et + // arrivait quand même, donc PipeWire ou Mutter attache des métadonnées de + // son propre chef. Header pouvait très bien être dans ce lot. + // + // `Header` n'est donc plus demandé, et `VideoCrop` l'est. Lecture du + // résultat : + // - VideoCrop présent -> le mécanisme fonctionne, et Cursor est refusé + // spécifiquement : anomalie en amont ; + // - VideoCrop absent et Header toujours présent + // -> update_params n'a aucun effet, le bug est ici. + let video_crop = spa::pod::Object { type_: spa::utils::SpaTypes::ObjectParamMeta.as_raw(), id: spa::param::ParamType::Meta.as_raw(), properties: vec![ spa::pod::Property::new( spa::sys::SPA_PARAM_META_type, - spa::pod::Value::Id(spa::utils::Id(spa::sys::SPA_META_Header)), + spa::pod::Value::Id(spa::utils::Id(spa::sys::SPA_META_VideoCrop)), ), spa::pod::Property::new( spa::sys::SPA_PARAM_META_size, - spa::pod::Value::Int(std::mem::size_of::() as i32), + spa::pod::Value::Int(std::mem::size_of::() as i32), ), ], }; @@ -335,7 +350,7 @@ fn push_meta_params(stream: &pw::stream::StreamRef) -> Result<()> { }; let mut buffers = Vec::new(); - for obj in [header, cursor] { + for obj in [video_crop, cursor] { let bytes: Vec = spa::pod::serialize::PodSerializer::serialize( std::io::Cursor::new(Vec::new()), &spa::pod::Value::Object(obj), @@ -603,6 +618,27 @@ fn print_verdict(r: &Report, metadata_advertised: bool) { if r.meta_params_pushed { "OUI" } else { "NON" } ); + // Diagnostic du mécanisme lui-même, avant tout verdict sur le curseur. + // La sonde demande VideoCrop et Cursor, et ne demande PAS Header. + let saw = |name: &str| r.meta_types_seen.iter().any(|t| t == name); + println!("\n MÉCANISME DE DEMANDE DE MÉTADONNÉES (témoin de contrôle)"); + println!(" demandées par la sonde : [\"VideoCrop\", \"Cursor\"], PAS \"Header\""); + if saw("VideoCrop") { + println!( + " -> OPÉRANT. VideoCrop a été demandé et livré. Le mécanisme fonctionne,\n\ + \x20 donc l'absence de Cursor est un refus qui lui est propre." + ); + } else if saw("Header") { + println!( + " -> INOPÉRANT. VideoCrop demandé et absent, Header non demandé et présent.\n\ + \x20 update_params n'a aucun effet observable : les métadonnées reçues sont\n\ + \x20 celles que le serveur attache de lui-même. Tout verdict sur Cursor est\n\ + \x20 donc sans valeur, et le bug est dans la sonde, pas dans la plateforme." + ); + } else { + println!(" -> INDÉTERMINÉ. Ni VideoCrop ni Header reçus, cas non prévu."); + } + println!("\n QUESTION 1 — type de tampon (annexe B pt 1, SPEC.md §3.1)"); println!(" types vus : {:?}", r.data_types); let cpu_readable = r From 91740fa24974408aa9967bba1dd625062b0affe9 Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Mon, 3 Aug 2026 23:59:59 +0200 Subject: [PATCH 14/15] =?UTF-8?q?docs:=20tranche=20l'annexe=20B=20point=20?= =?UTF-8?q?3,=20n=C3=A9gativement,=20avec=20t=C3=A9moin=20de=20contr=C3=B4?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mutter n'attache jamais SPA_META_Cursor, alors que le portail annonce le mode Metadata. Établi, et non plus supposé. Ce qui rend cette exécution concluante là où les cinq précédentes ne l'étaient pas : le témoin de contrôle. Header, cessant d'être demandé, a disparu des métadonnées reçues. VideoCrop, demandé à sa place, est apparu. Le mécanisme SPA_PARAM_Meta est donc démontré opérant par un témoin, dans la même liste de paramètres, par le même code, au même instant que la demande de Cursor. La seule variable qui diffère entre le paramètre honoré et le paramètre ignoré est le type de métadonnée. Cinq explications instrumentales éliminées une par une : métadonnée non demandée, mode non annoncé, taille malformée, pointeur hors de la zone capturée, mécanisme inopérant. 1326 frames observées sur l'écran du terminal. Ce que §0.3 perd, et ce qu'il garde : la thèse avait deux moitiés et une seule tombe. Le déclencheur par différence de frames, qui décide QUAND capturer, ne dépend pas du curseur et repose sur des tampons MemFd confirmés disponibles. C'était la moitié difficile. Seul le repère de clic, qui décide OÙ placer le badge, n'est plus calculable. Le mode automatique reste constructible ; c'est la précision du repère qui se dégrade. Quatre contournements consignés, du changement d'une constante au helper privilégié, à trancher avec l'utilisateur avant de modifier la spécification. Co-Authored-By: Claude Opus 5 --- docs/phase0-results.md | 77 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/docs/phase0-results.md b/docs/phase0-results.md index 93626f3..5bbc8b7 100644 --- a/docs/phase0-results.md +++ b/docs/phase0-results.md @@ -14,7 +14,7 @@ franchie. | # | Question | État | Réponse | |---|---|---|---| | 1 | type de tampon PipeWire négociable | **TRANCHÉE** | `MemFd`, lisible par le CPU. **GStreamer reste hors des dépendances.** | -| 3 | `SPA_META_Cursor` présent | **OUVERTE** | trois causes écartées. Reste à écarter le protocole de test lui-même : voir exécution 5. | +| 3 | `SPA_META_Cursor` présent | **TRANCHÉE, NÉGATIVEMENT** | annoncé par le portail, jamais livré par Mutter. Démontré par témoin de contrôle. | | 2, 4, 5, 6, 7, 8 | — | ouvertes | pas encore mesurées | Portes de §15.2 franchies au niveau du portail : @@ -212,3 +212,78 @@ faudra renégocier le périmètre avant d'écrire l'application, et les options seraient : accepter des étapes sans repère de clic, déduire la position du pointeur de la boîte englobante du changement de frame, ou remonter d'un cran vers le helper libinput de §17 avec tout ce que ça coûte. + +--- + +## Exécution 6 — 2026-08-03, `pc-fixe`, avec témoin de contrôle. CONCLUANTE. + +``` + modes de curseur ANNONCÉS par le portail : ["Hidden", "Embedded", "Metadata"] + flux : 1920x1080 à la position logique (0, 228) <- écran du terminal + frames observées : 1326 + métadonnées sur la 1re frame : 2 ["Busy", "VideoCrop"] + demandées par la sonde : ["VideoCrop", "Cursor"], PAS "Header" + -> OPÉRANT + frames portant la métadonnée : 0 sur 1326 +``` + +### Le raisonnement, cette fois complet + +| Fait mesuré | Ce qu'il élimine | +|---|---| +| Le portail annonce `Metadata` dans `AvailableCursorModes` | « ce bureau n'implémente pas le mode » | +| `Header`, non demandé cette fois, a **disparu** des métadonnées reçues | « `Header` arrivait tout seul, donc la sonde ne prouvait rien » | +| `VideoCrop`, demandé, a **apparu** | « `update_params` n'a aucun effet » | +| Taille demandée en `Int` fixe comme `VideoCrop`, qui passe | « le paramètre `Cursor` est malformé » | +| 1326 frames sur l'écran où se trouve le terminal | « le pointeur n'était pas sur la zone capturée » | +| `Cursor` absent sur les 1326 frames | — | + +Le témoin de contrôle est ce qui rend cette exécution concluante et les cinq +précédentes non concluantes. `VideoCrop` a été demandé et livré dans la même +liste de paramètres, par le même code, au même instant que `Cursor`. La seule +variable qui diffère entre le paramètre honoré et le paramètre ignoré est le type +de métadonnée. + +### Conclusion + +**`xdg-desktop-portal-gnome` annonce le mode `Metadata` et Mutter ne livre jamais +`SPA_META_Cursor`.** Sur cette version, sur cette machine, la position du curseur +n'est pas obtenable par ce chemin. + +Aucune explication instrumentale ne subsiste. C'est une lacune en amont, et +§0.3 de SPEC.md perd la moitié de son fondement. + +### Ce que la thèse de §0.3 perd, et ce qu'elle garde + +Elle avait deux moitiés. Une seule tombe. + +- **Le déclencheur survit intact.** La détection de changement de frame, qui + décide QUAND capturer, ne dépend pas du curseur. C'était la moitié difficile, + et elle repose sur des frames `MemFd` dont on a confirmé la disponibilité. +- **Le repère de clic tombe.** On ne sait plus OÙ placer le badge numéroté par + le calcul. + +Le mode automatique reste donc constructible. C'est la précision du repère qui +se dégrade, pas la capture automatique elle-même. + +### Contournements, à trancher + +1. **`cursor_mode = Embedded`.** Le curseur est composité dans l'image. La + position n'est plus connue du programme, mais elle est visible du lecteur du + tutoriel, ce qui est déjà ce que font la plupart des guides écrits à la main. + Coût : changer une constante. +2. **Centre ou coin de la boîte de changement.** Le moteur de §4.3.3 la calcule + déjà pour déclencher la capture. Un menu ou un dialogue s'ouvre vers le bas à + droite du point cliqué, donc son coin haut-gauche approche le clic. Coût nul, + la donnée existe. +3. **Les deux.** Curseur composité pour la vérité visuelle, boîte de changement + pour placer automatiquement le badge, et l'utilisateur le déplace quand + l'heuristique se trompe. +4. **Helper libinput de §17.** Parité complète, au prix du privilège, de + l'impossibilité en Flatpak et d'une contradiction avec §2.2 et §2.3. + +### À faire en amont + +Signaler la lacune à Mutter, avec cette sonde comme cas de reproduction minimal : +le portail annonce `Metadata`, le mécanisme `SPA_PARAM_Meta` est démontré opérant +par un témoin, et `SPA_META_Cursor` n'est jamais attaché. From 9b65248a3151b0eb7bff1bd1c043f32d66f14a2d Mon Sep 17 00:00:00 2001 From: NicoBOD Date: Tue, 4 Aug 2026 00:23:24 +0200 Subject: [PATCH 15/15] =?UTF-8?q?feat:=20place=20le=20rep=C3=A8re=20de=20c?= =?UTF-8?q?lic=20par=20heuristique,=20la=20position=20exacte=20=C3=A9tant?= =?UTF-8?q?=20indisponible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applique la décision prise après la mesure : boîte de changement pour placer le badge, curseur caché par défaut, Embedded en option de session. Huit passages de SPEC.md mis en accord avec ce qui a été mesuré : - §0.3, la thèse : elle avait deux moitiés et une seule tombe. Le déclencheur par différence de frames, qui décide QUAND capturer, survit intact et repose sur des tampons MemFd confirmés. Seul le repère de clic, qui décide OÙ, n'est plus calculable. La section le dit maintenant au lieu de promettre la position exacte. - §4.1, option de curseur : hidden par défaut, embedded en option de session, metadata tenté puis abandonné automatiquement. hidden est le défaut parce qu'un curseur composité est cuit dans les pixels de l'original, ce qui contredirait le modèle non destructif de §7.2. - §4.2 : demander Metadata d'abord, vérifier sur la première frame, basculer en silence. Consigne explicite de ne jamais se fier à AvailableCursorModes seul. - §4.3.3 point 6 : le repère est le coin haut-gauche de la boîte de changement, un menu ou un dialogue s'ouvrant vers le bas à droite du point cliqué. Au-delà de 40 % de l'image modifiée, aucun repère n'est placé et l'étape demande un placement manuel, plutôt que de recevoir un badge faux. - §4.6, §7.1, §15.2 point 3, annexe B point 3 : alignés, avec les critères historiques conservés pour mémoire plutôt que supprimés. La sonde continue de tester Metadata à chaque exécution : si une version future de GNOME corrige la lacune, la position exacte remplacera l'heuristique sans autre changement, et la sonde le signalera. Ajoute docs/upstream-mutter-cursor-meta.md, brouillon de signalement en amont, en anglais, non publié. Il contient le cas de reproduction minimal, le témoin de contrôle, et le tableau des cinq hypothèses éliminées avant de conclure. Co-Authored-By: Claude Opus 5 --- SPEC.md | 95 +++++++++++++++++------- crates/tutoclic-probe/src/main.rs | 19 +++-- docs/upstream-mutter-cursor-meta.md | 108 ++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 36 deletions(-) create mode 100644 docs/upstream-mutter-cursor-meta.md diff --git a/SPEC.md b/SPEC.md index 48be258..6c3db5d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -39,20 +39,41 @@ ne délivrent rien. ### 0.3 L'ouverture qui rend le produit possible -Le portail ScreenCast accepte `cursor_mode = metadata`. Dans ce mode le curseur n'est pas -composité dans le flux : il arrive en métadonnée PipeWire `SPA_META_Cursor`, obligatoire -dans ce mode, contenant **la position exacte du pointeur pour chaque frame**. Aucun -privilège supplémentaire, une seule autorisation utilisateur. - -Il ne manque donc que l'instant de l'appui bouton. Or pour un tutoriel, cet instant n'est -pas ce qu'on veut : on veut la frame où l'interface **a répondu**. La détection de -changement de frame, calculable gratuitement sur un flux déjà reçu, est un meilleur signal -que le clic lui-même, et elle résout au passage tout le problème de délai post-clic -que la v1 traitait en §4.4 par des temporisations empiriques. - -**Position du curseur + différence de frames = capture automatique réelle, sans extension, -sans helper privilégié, sans keylogger.** C'est la contrainte Wayland retournée en avantage -de conception, et c'est le cœur technique de TutoClic. +L'instant de l'appui bouton n'est pas ce qu'on veut pour un tutoriel : on veut la frame où +l'interface **a répondu**. La détection de changement de frame, calculable gratuitement sur +un flux déjà reçu, est donc un meilleur signal que le clic lui-même, et elle résout au +passage tout le problème de délai post-clic que la v1 traitait en §4.4 par des +temporisations empiriques. + +**Différence de frames = capture automatique réelle, sans extension, sans helper +privilégié, sans keylogger.** C'est la contrainte Wayland retournée en avantage de +conception, et c'est le cœur technique de TutoClic. + +#### La moitié de cette thèse qui est tombée à la mesure + +La rédaction initiale ajoutait une seconde moitié : `cursor_mode = metadata` du portail +ScreenCast devait livrer la position exacte du pointeur pour chaque frame, en métadonnée +PipeWire `SPA_META_Cursor`, sans privilège. + +**Mesuré le 2026-08-03 sur la machine de référence : ça ne fonctionne pas.** +`xdg-desktop-portal-gnome` annonce bien le mode `Metadata` dans `AvailableCursorModes`, et +Mutter n'attache jamais `SPA_META_Cursor`. Démontré par témoin de contrôle : dans la même +liste de paramètres, au même instant, `VideoCrop` demandé est livré et `Cursor` demandé ne +l'est pas. 1326 frames observées, pointeur sur la zone capturée. Détail complet dans +`docs/phase0-results.md`, exécution 6. + +Ce que cela change, et ce que cela ne change pas : + +- **le déclencheur survit intact.** Décider QUAND capturer ne dépend pas du curseur, et + repose sur des tampons `MemFd` dont la disponibilité est confirmée. C'était la moitié + difficile ; +- **le repère de clic n'est plus calculable exactement.** Décider OÙ poser le badge + numéroté passe désormais par l'heuristique de §4.3.3 point 6. + +Le mode automatique reste donc constructible. C'est la précision du repère qui se dégrade, +pas la capture automatique. La sonde continue de tester `Metadata` à chaque exécution, pour +qu'une version future de GNOME qui corrigerait la lacune soit détectée sans rien changer au +code. ### 0.4 Corrections issues de la revue d'architecture @@ -450,8 +471,14 @@ Avant de démarrer, l'utilisateur choisit : - **Source** : écran entier, moniteur précis, ou fenêtre, selon ce que le portail propose. La sélection réelle est faite par le dialogue du portail, pas par TutoClic. - **Mode** : manuel, automatique, ou minuterie. -- **Curseur** : `metadata` (recommandé, repère de clic reconstitué avec un style - cohérent), `embedded` (curseur système visible dans l'image), ou `hidden`. +- **Curseur** : `hidden` (défaut, images propres, repère de clic placé par l'heuristique + de §4.3.3 point 6) ou `embedded` (curseur système composité dans l'image par le + compositeur). Le mode `metadata` est demandé en premier et retombe automatiquement sur + `hidden` : il est annoncé par GNOME mais jamais honoré (§0.3). + + `hidden` est le défaut parce qu'un curseur composité est **cuit dans les pixels de + l'original**, ce qui contredit le modèle non destructif de §7.2. `embedded` reste un + choix par session, jamais imposé. - **Régions et applications exclues** (§4.7). - **Sensibilité de détection** en mode automatique (§4.4), trois presets plus un mode expert. @@ -462,8 +489,11 @@ Avant de démarrer, l'utilisateur choisit : Une session de capture ouvre **un seul** flux PipeWire et le garde jusqu'à l'arrêt. 1. `ScreenCast.CreateSession`. -2. `SelectSources` avec `cursor_mode = metadata`, `multiple = false`, - `persist_mode = ExplicitlyRevoked`. +2. `SelectSources` avec `multiple = false`, `persist_mode = ExplicitlyRevoked`, et le + `cursor_mode` choisi en §4.1. L'application demande `Metadata` d'abord, vérifie sur la + première frame si `SPA_META_Cursor` arrive réellement, et bascule silencieusement sur le + mode retenu par l'utilisateur sinon. Ne jamais se fier à `AvailableCursorModes` seul : + GNOME y annonce `Metadata` sans le livrer (§0.3). 3. `Start` : l'utilisateur voit le dialogue du portail et choisit sa source. C'est le seul moment où il est sollicité. 4. Le `restore_token` retourné est enregistré dans le Secret Service, jamais dans le @@ -555,9 +585,16 @@ Boucle, exécutée sur le thread de traitement, jamais sur le thread UI : 5. Quand deux vignettes consécutives séparées d'au moins 120 ms ne diffèrent plus au-delà du seuil de repos, l'écran est considéré stable : **c'est cette frame-là qui devient l'étape**, en pleine résolution. -6. La position du curseur retenue est celle de la **première** frame candidate, avant que - l'interface ne bouge, parce que c'est là que le pointeur était quand l'action a eu - lieu. +6. **Le repère de clic est placé par heuristique**, la position réelle du pointeur n'étant + pas obtenable (§0.3). Règle : le coin haut-gauche de la boîte englobante de la zone + modifiée de la **première** frame candidate, parce qu'un menu, une liste déroulante ou + un dialogue s'ouvre vers le bas à droite du point cliqué. Si la boîte couvre plus de + 40 % de l'image, le changement est trop global pour dire quoi que ce soit du point + cliqué : aucun repère n'est placé, et l'étape est marquée comme ayant besoin d'un + placement manuel plutôt que de recevoir un badge faux. + + Si une version future de GNOME livre `SPA_META_Cursor`, la position exacte remplace + l'heuristique sans autre changement. 7. La boîte englobante de la zone modifiée est enregistrée dans les métadonnées d'étape : elle sert à proposer automatiquement un recadrage, à placer un rectangle de mise en évidence, et à alimenter les fonctions IA (§10). @@ -650,7 +687,8 @@ Enregistrées quand disponibles, et **chacune désactivable individuellement** : | horodatage | activé | horloge locale | | géométrie du moniteur, facteur d'échelle | activé | portail / Mutter | | région capturée | activé | flux | -| position du curseur | activé | `SPA_META_Cursor` | +| boîte de la zone modifiée, origine du repère | activé | moteur de détection | +| position exacte du curseur | **indisponible** | `SPA_META_Cursor`, annoncé par GNOME mais jamais livré (§0.3) | | boîte de la zone modifiée | activé | moteur de détection | | provenance du déclencheur | activé | interne | | délais appliqués | activé | interne | @@ -825,7 +863,7 @@ Flèche, ligne, rectangle, ellipse, texte, badge numéroté à numérotation aut surbrillance, recadrage, loupe, flou, pixelisation, **occultation opaque**, et **caviardage définitif**. -Le badge numéroté se place automatiquement à la position du curseur enregistrée +Le badge numéroté se place automatiquement selon l'heuristique de §4.3.3 point 6 (§4.3.3, point 6) quand elle existe, et se déplace ensuite librement. ### 7.2 Modèle non destructif @@ -1436,7 +1474,11 @@ Le PoC est accepté si, et seulement si : 1. l'utilisateur sélectionne une source via le dialogue du portail ; 2. le flux PipeWire est reçu et lisible **par le chemin préféré de §3.1** (tampons CPU), ou, à défaut, par le repli GStreamer, la décision étant documentée dans un ADR ; -3. `cursor_mode = metadata` est accepté et `SPA_META_Cursor` fournit une position par +3. ~~`cursor_mode = metadata` fournit une position par frame.~~ **MESURÉ NÉGATIF, porte + levée** : GNOME annonce le mode et ne livre jamais la métadonnée (§0.3). Le critère + devient : le repère heuristique de §4.3.3 point 6 tombe dans l'élément actionné sur un + scénario d'ouverture de menu, de dialogue et de changement d'onglet. Critère historique, + conservé pour mémoire : `SPA_META_Cursor` fournissait une position par frame. Critère de précision : après conversion vers le repère de l'image, l'écart avec le pointeur réel est mesuré, documenté, et **inférieur à 24 px logiques**, soit la taille minimale d'une cible cliquable au sens du GNOME HIG. Un écart plus grand @@ -1743,7 +1785,10 @@ acquis. Les mesures sont consignées dans `docs/phase0-results.md`. 2. Disponibilité de `org.gnome.Shell.Introspect.GetWindows` pour une application non sandboxée sur GNOME 46 et 48, puis sous Flatpak. Impact si absent : nul sur les fonctions principales, les métadonnées concernées étant désactivées par défaut. -3. Comportement exact de `SPA_META_Cursor` sur `xdg-desktop-portal-gnome` des versions +3. ~~Comportement exact de `SPA_META_Cursor`.~~ **TRANCHÉ NÉGATIVEMENT le 2026-08-03** : + annoncé dans `AvailableCursorModes`, jamais attaché aux tampons. Témoin de contrôle et + détail dans `docs/phase0-results.md` exécution 6, rapport amont dans + `docs/upstream-mutter-cursor-meta.md`. Question historique : comportement sur les versions cibles, en session Wayland **et** en session X11 : fréquence de mise à jour, présence sur chaque frame, précision. 4. Seuils réels de détection de changement sur du contenu d'interface GNOME. Les valeurs diff --git a/crates/tutoclic-probe/src/main.rs b/crates/tutoclic-probe/src/main.rs index 3bc2805..b0559e7 100644 --- a/crates/tutoclic-probe/src/main.rs +++ b/crates/tutoclic-probe/src/main.rs @@ -706,17 +706,14 @@ fn print_verdict(r: &Report, metadata_advertised: bool) { \x20 3. le helper libinput de §17, avec tout ce qu'il coûte." ), _ => println!( - " -> ANNONCÉ MAIS NON LIVRÉ pendant cette fenêtre d'observation.\n\ - \x20 AVANT de conclure, réponds à une question : le pointeur était-il\n\ - \x20 réellement SUR la zone capturée pendant les {} secondes ?\n\ - \x20 Mutter n'a rien à rapporter si le curseur est sur un autre écran,\n\ - \x20 et la zone capturée n'est pas forcément celle de ton terminal.\n\ - \x20 - non, ou tu n'en es pas sûr : relance en amenant le pointeur\n\ - \x20 sur la zone dont la géométrie est affichée plus haut ;\n\ - \x20 - oui, pointeur bien dessus, et toujours 0 : alors c'est une\n\ - \x20 anomalie du compositeur ou du portail, à remonter en amont\n\ - \x20 avant de renoncer au repère de clic.", - OBSERVE_WINDOW.as_secs() + " -> ANNONCÉ MAIS NON LIVRÉ. Résultat déjà établi le 2026-08-03 par témoin\n\ + \x20 de contrôle : GNOME annonce le mode et Mutter n'attache jamais la\n\ + \x20 métadonnée. Voir docs/phase0-results.md exécution 6.\n\ + \x20 Ce n'est donc pas une surprise, et §4.3.3 point 6 place désormais le\n\ + \x20 repère par heuristique sur la boîte de changement.\n\ + \x20 Si cette ligne devient positive un jour, c'est qu'une version de\n\ + \x20 GNOME a corrigé la lacune : la position exacte peut alors remplacer\n\ + \x20 l'heuristique sans autre changement." ), } diff --git a/docs/upstream-mutter-cursor-meta.md b/docs/upstream-mutter-cursor-meta.md new file mode 100644 index 0000000..32ddc6a --- /dev/null +++ b/docs/upstream-mutter-cursor-meta.md @@ -0,0 +1,108 @@ +# Rapport amont : `SPA_META_Cursor` annoncé mais jamais livré + +**Non publié.** Ce fichier est le brouillon d'un signalement à faire en amont. Le +relire, puis le poster soi-même sous son propre compte. Cible probable : +`https://gitlab.gnome.org/GNOME/mutter/-/issues`, avec une mention de +`xdg-desktop-portal-gnome` si les mainteneurs le renvoient là. + +Le texte ci-dessous est en anglais, langue de travail de ces projets, et peut être +copié tel quel. + +--- + +## Title + +ScreenCast portal advertises `Metadata` cursor mode but `SPA_META_Cursor` is never +attached to buffers + +## Description + +`xdg-desktop-portal-gnome` advertises `Metadata` in the ScreenCast portal's +`AvailableCursorModes` property, and `SelectSources` accepts +`cursor_mode = Metadata` without error. However, `SPA_META_Cursor` is never +attached to any buffer in the resulting PipeWire stream, even when the pointer is +demonstrably inside the captured area. + +A control witness in the same `SPA_PARAM_Meta` request rules out a client-side +mistake: `SPA_META_VideoCrop`, requested in the same `update_params` call, at the +same moment, by the same code path, **is** delivered. Only the cursor metadata is +missing. + +## Environment + +- Ubuntu, GNOME, `XDG_SESSION_TYPE=wayland` +- PipeWire 1.0.5 +- GTK 4.14.5, libadwaita 1.5.0 +- Client written in Rust with `ashpd` 0.9.3 and `pipewire-rs` 0.8.0 +- Captured source: a monitor, 1920x1080 at logical position (0, 228), and + separately 2560x1440 at (1920, 0). Same result on both. + +## Steps to reproduce + +Minimal reproducer, MIT-compatible and self-contained: + — `cargo run -p tutoclic-probe`. + +1. Read the portal's `AvailableCursorModes` property. +2. `CreateSession`, then `SelectSources` with `cursor_mode = Metadata`, + `persist_mode = ExplicitlyRevoked`, source type `Monitor`. +3. `Start`, then `OpenPipeWireRemote`, and connect a PipeWire stream to the + returned node id. +4. In the stream's `param_changed` callback, once the `Format` param is set, call + `update_params` with two `SPA_PARAM_Meta` objects: + - `SPA_PARAM_META_type = SPA_META_VideoCrop`, + `SPA_PARAM_META_size = sizeof(struct spa_meta_region)` + - `SPA_PARAM_META_type = SPA_META_Cursor`, + `SPA_PARAM_META_size = sizeof(struct spa_meta_cursor) + sizeof(struct spa_meta_bitmap) + 256*256*4` + + Note that `SPA_META_Header` is deliberately **not** requested, so that its + presence or absence is itself informative. +5. In the `process` callback, walk `spa_buffer->metas` and record every + `spa_meta.type_` seen. +6. Keep the pointer moving inside the captured monitor for the whole observation + window, 15 seconds. + +## Expected + +`SPA_META_Cursor` present on buffers, carrying `spa_meta_cursor.position`, since +`Metadata` is advertised and was requested. + +## Actual + +``` +modes advertised by the portal : ["Hidden", "Embedded", "Metadata"] +source types advertised : Monitor | Window | Virtual +stream : 1920x1080 at logical position (0, 228) +frames observed : 1326 +metas on the first frame : 2 ["Busy", "VideoCrop"] +requested by the client : ["VideoCrop", "Cursor"], NOT "Header" +frames carrying SPA_META_Cursor: 0 out of 1326 +``` + +`SPA_META_Header` is absent, which confirms the client's meta requests are what +determine the attached set. `SPA_META_VideoCrop` is present, which confirms the +request mechanism works. `SPA_META_Busy` is present without being requested, so +the compositor does add some metadata on its own. `SPA_META_Cursor` is never +attached. + +## What was ruled out before filing + +| Hypothesis | How it was ruled out | +|---|---| +| Client never requested the metadata | `SPA_PARAM_Meta` sent from `param_changed` on `Format`; `update_params` returns success | +| The desktop does not implement the mode | `AvailableCursorModes` advertises `Metadata` | +| Malformed size parameter | Tried both a `Choice::Range` and a fixed `Int`; `VideoCrop` uses the same fixed-`Int` shape and is honoured | +| Request mechanism has no effect | Control witness: `Header` disappears when not requested, `VideoCrop` appears when requested | +| Pointer outside the captured area | 1326 frames observed on the monitor holding the terminal the reproducer was launched from, pointer moving on it throughout | + +## Impact + +Any client that needs the pointer position without compositing the cursor into the +frames has no working path. Advertising a cursor mode that is never honoured is +worse than not advertising it: `SelectSources` succeeds, the stream runs, and the +client has no way to detect the gap other than observing buffers and finding +nothing. Per the portal specification, requesting a cursor mode that is *not* +advertised closes the session, so a client is entitled to treat advertisement as a +contract. + +A client-visible signal would already help, even without an implementation: either +stop advertising `Metadata`, or fail `SelectSources` when it is requested.