diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..94c0cc9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +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" + + # 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 + 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. 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 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..08539e8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2203 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "annotate-snippets" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccaf7e9dfbb6ab22c82e473cd1a8a7bd313c19a5b7e40970f3d89ef5a5c9e81e" +dependencies = [ + "unicode-width", + "yansi-term", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ashpd" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8bd58b44ea371b48d21cdc217380cfcafd4b2bb1ad50d27514ec5beca71a2d" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.8.7", + "serde", + "serde_repr", + "url", + "zbus", +] + +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +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 = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "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 = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "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 = "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 = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f6b6070 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,34 @@ +[workspace] +resolver = "2" +members = ["crates/tutoclic-coords", "crates/tutoclic-probe"] + +[workspace.package] +version = "0.0.1" +edition = "2021" +# 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"] + +# 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/README.md b/README.md index d6082eb..6633726 100644 --- a/README.md +++ b/README.md @@ -1 +1,90 @@ -# 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.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 +``` + +`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 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 + +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/SPEC.md b/SPEC.md new file mode 100644 index 0000000..6c3db5d --- /dev/null +++ b/SPEC.md @@ -0,0 +1,1908 @@ +# 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 + +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 + +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.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. + +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** : `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. +- **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 `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 + 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 : + +**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 ; +- 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. **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). +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 | +| 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 | +| 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 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 + +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` 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 + 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. Les mesures sont consignées dans `docs/phase0-results.md`. + +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. +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 + 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. 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); + } +} 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..b0559e7 --- /dev/null +++ b/crates/tutoclic-probe/src/main.rs @@ -0,0 +1,721 @@ +//! 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; + +/// 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); + +/// 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)] +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, + /// 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)>, +} + +/// 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().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<()> { + use std::io::Write; + + let path = state_file()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + 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(()) +} + +#[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")?; + + // 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 + .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 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) => { + 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 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 -------------------------------------------- + print_verdict(&report, metadata_advertised); + 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<()> { + // 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_VideoCrop)), + ), + 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)), + ), + // 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::Int(cursor_meta_size(256, 256)), + ), + ], + }; + + let mut buffers = Vec::new(); + 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), + ) + .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(); + 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)); + } + + // 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 + // 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; + } + + // 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() { + 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); + } + + // 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(); + } + } + 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")?; + + // 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(OBSERVE_WINDOW), 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, + meta_types_seen: observed.meta_types_seen.clone(), + meta_params_pushed: observed.meta_params_pushed, + negotiated_size: observed.negotiated_size, + }) +} + +fn print_verdict(r: &Report, metadata_advertised: bool) { + 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, r.meta_types_seen + ); + println!( + " paramètres SPA_PARAM_Meta acceptés : {}", + 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 + .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." + ); + } + } + _ 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." + ), + _ 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!( + " -> 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." + ), + } + + println!("\n Reporte ces réponses dans SPEC.md annexe B, points 1 et 3."); +} diff --git a/docs/phase0-results.md b/docs/phase0-results.md new file mode 100644 index 0000000..5bbc8b7 --- /dev/null +++ b/docs/phase0-results.md @@ -0,0 +1,289 @@ +# 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. + +--- + +## État des questions de l'annexe B + +| # | 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 | **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 : + +| 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 + +À 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. + +--- + +## 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 : présent, on tente la restauration + CursorMode::Metadata accepté par le portail : OUI + jeton de restauration reçu et enregistré + 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é : 2560x1440 + 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 +``` + +### 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 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 — 2026-08-03, `pc-fixe`, sonde interrogeant AvailableCursorModes + +``` + 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. + +**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 && cargo run -p tutoclic-probe +``` + +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 : + +- [ ] la liste des types de métadonnées contient-elle `Cursor` ; +- [ ] `frames portant la métadonnée` sur le total ; +- [ ] les positions première et dernière diffèrent-elles. + +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 + +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. + +--- + +## 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é. 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.