diff --git a/.csharpierignore b/.csharpierignore deleted file mode 100644 index 1d688b3..0000000 --- a/.csharpierignore +++ /dev/null @@ -1,9 +0,0 @@ -bin/ -obj/ -build/ -src/terminal/clink-profile/ -*.csproj -*.xaml -*.props -*.targets -*.xml diff --git a/.csharpierrc.json b/.csharpierrc.json deleted file mode 100644 index 13ce24b..0000000 --- a/.csharpierrc.json +++ /dev/null @@ -1 +0,0 @@ -{"printWidth": 120} diff --git a/.editorconfig b/.editorconfig index f71df3c..987fe28 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,33 +1,31 @@ -# EditorConfig: https://editorconfig.org -# Line endings are deliberately not set: the index stores LF, and Windows checkouts may convert (core.autocrlf). root = true [*] charset = utf-8 -insert_final_newline = true -trim_trailing_whitespace = true -# Advisory only: many existing files exceed it. -max_line_length = 120 - -[*.md] -trim_trailing_whitespace = false - -[*.{cs,py,ps1,qml,cpp,h}] +end_of_line = lf indent_style = space indent_size = 4 +max_line_length = 140 -[*.{csproj,props,targets,xml,xaml,json,yml,yaml,iss}] +[*.cs] indent_style = space -indent_size = 2 +indent_size = 4 +max_line_length = 140 +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true +csharp_preserve_single_line_statements = false +csharp_preserve_single_line_blocks = false +csharp_space_around_binary_operators = before_and_after +csharp_indent_case_contents = true +csharp_indent_switch_labels = true -# C# analyzers: suggestions only, nothing here may break a build. -[*.cs] -dotnet_analyzer_diagnostic.severity = suggestion -dotnet_style_qualification_for_field = false:suggestion -csharp_style_var_elsewhere = true:suggestion -dotnet_analyzer_diagnostic.category-Reliability.severity = suggestion -dotnet_diagnostic.CA2000.severity = suggestion -dotnet_diagnostic.CA1816.severity = suggestion -dotnet_diagnostic.CA2012.severity = suggestion -dotnet_diagnostic.CA2016.severity = suggestion -dotnet_diagnostic.CA1849.severity = suggestion +[*.{py,pyi}] +indent_size = 4 + +[*.qml] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0c3799b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +* text=auto +*.cs text eol=lf +*.qml text eol=lf +*.py text eol=lf +*.ps1 text eol=lf +*.json text eol=lf +*.md text eol=lf +*.cmd text eol=crlf +*.bat text eol=crlf +*.iss text eol=crlf +*.ico binary +*.png binary +*.dll binary +*.exe binary diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 0933b55..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: build - -on: - push: - branches: [main] - pull_request: - -jobs: - build: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - - uses: ilammy/msvc-dev-cmd@v1 - with: - arch: x64 - - name: Build native components - shell: pwsh - run: ./tools/build-native.ps1 - - name: Build src projects - shell: pwsh - run: | - foreach ($p in 'src/backend/Panneaux.Glass.csproj','src/files/Winlestia.Files.csproj','src/cli/winlestia.csproj') { - dotnet build $p -c Release --nologo - if ($LASTEXITCODE -ne 0) { throw "Build failed: $p" } - } - - name: Run pure-logic tests - shell: pwsh - run: | - $tests = 'AppCatalogueWatch','AppNameNormalizer','AtomicFile','AudioBars','AutostartPolicy','BrightnessPolicy','CliCommands','DesktopReassert','FileSearch','FilesHandler','FilesPinMigration','FilesPreloadPolicy','FramePolicy','MediaIdentity','SecurityHardening','SocialPolicy','StoreRecovery','StringsTable','WifiPolicy' - $failed = @() - foreach ($t in $tests) { - dotnet run --project "tests/$t/$t.csproj" -c Release --nologo - if ($LASTEXITCODE -ne 0) { $failed += $t } - } - if ($failed.Count -gt 0) { throw "Failed: $($failed -join ', ')" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1c720a5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,165 @@ +name: CI + +on: + push: + pull_request: + +jobs: + # Style : longueur de ligne (.editorconfig, 140) et espaces blancs. Les fins de ligne sont verifiees telles que + # commitees : autocrlf est donc desactive avant le checkout. + lint: + runs-on: windows-latest + steps: + - name: Checkout sans conversion de fins de ligne + shell: pwsh + run: git config --global core.autocrlf false + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Longueur de ligne (140) + shell: pwsh + run: | + python tools/check_line_length.py + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: dotnet format whitespace + shell: pwsh + run: | + foreach ($p in 'src/backend/Panneaux.Glass.csproj', 'src/files/Winlestia.Files.csproj', 'src/cli/winlestia.csproj') { + dotnet format whitespace $p --verify-no-changes + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + + build-and-test: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + # Composants natifs MSVC (ProcessAudio.dll...) compilés AVANT .NET : le csproj copie ProcessAudio.dll dans la sortie. + - name: Composants natifs + shell: pwsh + run: ./tools/build-native.ps1 + + - name: Build + shell: pwsh + run: | + foreach ($p in 'src/backend/Panneaux.Glass.csproj', 'src/files/Winlestia.Files.csproj', 'src/cli/winlestia.csproj') { + dotnet restore $p --locked-mode + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet build $p -c Release --no-restore + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + + # Tests xUnit (tests/Winlestia.Tests). La categorie Desktop (Audio, Explorateur, icones de dossiers, tiling, + # bureaux virtuels, interop native, plein ecran) touche au bureau : elle reste locale. + - name: Tests .NET (avec couverture) + shell: pwsh + run: | + dotnet restore tests/Winlestia.Tests --locked-mode + dotnet test tests/Winlestia.Tests -c Release --no-restore --filter "Category!=Desktop" ` + --collect:"XPlat Code Coverage" --settings tests/coverage.runsettings --results-directory TestResults + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + # Resume de couverture + plancher. Valeur mesuree le 2026-11-09 : 17,61 % des lignes (backend compile dans les tests, + # beaucoup de code WPF/shell non testable sans bureau). Plancher = valeur arrondie a l'entier inferieur ; a relever + # quand la couverture monte. + - name: Resume de couverture + shell: pwsh + run: | + $floor = 17 + $file = Get-ChildItem TestResults -Recurse -Filter coverage.cobertura.xml | Select-Object -First 1 + if (-not $file) { throw 'coverage.cobertura.xml introuvable.' } + $root = ([xml](Get-Content $file.FullName -Raw)).coverage + $pct = [math]::Round([double]$root.'line-rate' * 100, 2) + $line = "Couverture des lignes : $pct % ($($root.'lines-covered')/$($root.'lines-valid')), plancher $floor %" + Write-Host $line + Add-Content $env:GITHUB_STEP_SUMMARY "### Couverture`n$line" + if ($pct -lt $floor) { throw "Couverture sous le plancher ($pct < $floor)." } + + - uses: actions/upload-artifact@v4 + with: + name: coverage + path: TestResults/**/coverage.cobertura.xml + + # Verifications statiques sans execution : syntaxe des scripts PowerShell (installateur, outils) et compilation Python. + - name: Syntaxe PowerShell (installer, tests) + shell: pwsh + run: | + $bad = 0 + foreach ($f in Get-ChildItem installer, tests/FilesHarness -Recurse -Filter *.ps1) { + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) + foreach ($e in $errors) { Write-Host "$($f.FullName): $($e.Message)"; $bad++ } + } + if ($bad -gt 0) { exit 1 } + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Tests Python + shell: pwsh + run: | + python -m pip install -r src/frontend/requirements.txt pytest + pytest -q + + - name: Compilation Python (compileall) + shell: pwsh + run: | + python -m compileall -q src/frontend src/terminal tools tests conftest.py + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + # Test de fumee de l'installateur : fabrique le setup avec installer/build.ps1 -CI (composants natifs MSVC compiles + # par tools/build-native.ps1). L'artefact prouve que la chaine payload + Inno Setup passe ; + # il n'est jamais une release. + installer-smoke: + needs: build-and-test + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - name: Composants natifs + shell: pwsh + run: ./tools/build-native.ps1 + + - name: Inno Setup 6 + shell: pwsh + run: | + $iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" + if (-not (Test-Path $iscc)) { + choco install innosetup --no-progress -y + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + if (-not (Test-Path $iscc)) { throw 'ISCC.exe introuvable apres installation.' } + + - name: Fabrication du setup + shell: pwsh + run: ./installer/build.ps1 -CI + + - uses: actions/upload-artifact@v4 + with: + name: winlestia-setup-smoke + path: build/out/Winlestia-Setup-*.exe + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 025cab7..4cd7475 100644 --- a/.gitignore +++ b/.gitignore @@ -40,14 +40,6 @@ catalog-workspace* # Sorties de build de vérification buildverify-*/ clink_errorlevel_*.txt - -# Artefacts generated by the verification/benchmark switches and by dotnet build (outputs, never inputs) -src/backend/performance-*.json -src/backend/verification*.json -src/backend/audio-mixer-verification.json -src/backend/motion-*.json -src/backend/preuve-secours.json -src/backend/taskbar-state.json -src/backend/taskbar-session.json -src/backend/Panneaux.Glass.deps.json -src/backend/Panneaux.Glass.runtimeconfig.json +.freebuff/ +buildverify-fullscreen/ +TestResults/ diff --git a/CHANGELOG.md b/CHANGELOG.md index cbe8260..67d3d4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,31 +1,79 @@ # Changelog -Earlier releases are described in `docs/RELEASE-*.md`. Release notes for each version are also published on the GitHub Releases page. +Format inspiré de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/). Les notes de chaque version sont aussi publiées sur la page GitHub Releases. Les entrées 0.9.0 et antérieures sont conservées dans la langue où elles ont été écrites. -## 0.9.1 - 2026-11-04 +## [Non publié] -Changes since 0.9.0. +### Modifications -### Fixed +- Intégration continue : contrôle de longueur de ligne (140), `dotnet format whitespace`, test de fumee de l'installateur, plancher de couverture documenté (règle du cliquet). +- Dépendances Python verrouillées par hachage (`src/frontend/requirements.lock.txt`), installées avec `--require-hashes` par `installer/build.ps1`. +- Historique du portage Caelestia condensé dans `docs/history/caelestia-windows.md`. -- **Media panel and visualiser** (#1): readable browser names, track information re-read on change, playback position advances while the dashboard is visible, visualiser follows the playing browser and its child processes. -- **Social rail** (#2): the dashboard moves aside by the width the rail covers and scales its panes. On a very small screen width the rail can still overlay it. The dashboard reopens on the last tab used (session only). -- **User data** is written atomically; a corrupt preferences file is kept as `.corrupt.bak` and settings are restored from `.bak`. -- Bookmarks only open `http` and `https` addresses. +## [0.9.2] - 2026-11-06 -### Changed +### Ajouts + +- Éditeur de palette dans les réglages (couleurs personnalisées). +- Page « Raccourcis clavier » : liste de tous les raccourcis globaux ; les raccourcis configurables peuvent être réaffectés (combinaisons réservées par Windows ou en conflit refusées avec explication, changement échoué annulé). Les raccourcis fixes indiquent la raison. +- Win+Alt+S ouvre les Réglages (réaffectable). Autres accès : menu de la zone de notification, `winlestia panel settings`. +- Icônes dans le dock pour les applications empaquetées. +- Mise à jour en direct du catalogue d'applications. +- Dock, panneau des signets et panneau social masquables séparément. Masquer le dock quand il remplace la barre des tâches rend la barre Windows. + +### Modifications + +- Réglages redessinés en huit pages (Apparence, Interface, Docks et panneaux, Raccourcis clavier, Média et audio, Apps/signets/liens, Fichiers et Windows, Général), avec recherche et colonne de navigation. Textes traduits en anglais et en espagnol. +- Le terminal suit le mode du thème (clair ou sombre). +- Gestionnaire de fichiers redessiné. +- « Ajouter un site web » retiré. +- L'instantané des réglages contient le dossier de données et la version. +- `WINLESTIA_DATA_DIR` est aussi pris en compte par le frontend (langue, météo, miniatures, journaux) et par le cache et les lancements récents du backend. +- L'installateur vérifie l'empreinte du Python téléchargé. + +### Corrections + +- Dictée vocale : Whisper.net revient sur .NET 8. +- Flèches affichées comme des cases vides dans la liste des raccourcis de mosaïque. +- Un raccourci non enregistré au démarrage était signalé « inchangé » ; l'enregistrement est retenté et le résultat réel affiché. +- Un raccourci Réglages identique à une autre combinaison est remplacé au chargement par la première libre parmi Win+Alt+S, Ctrl+Alt+F10, Ctrl+Alt+F11. +- Dock, signets et panneau social n'étaient plus masqués avant l'arrivée du premier instantané des réglages. + +### Interne + +- Source unique de la version : `Directory.Build.props`. +- CI : build, tests xUnit et pytest. +- Tests regroupés dans `tests/Winlestia.Tests` (xUnit) et pytest. +- Blocs `catch` vides classés : helper `Safe` et journalisation. +- Auto-tests du gestionnaire de fichiers exclus des builds Release. +- Logique pure du bail de barre des tâches extraite (`TaskbarLeasePolicy`) et testée (bail, repli « toujours visible », décision du gardien). +- Tests xUnit ajoutés (calculatrice, maths de couleur et palettes, options de lancement, mode silencieux, trame du tube de Fichiers, validation de cibles) ; helper `TempDir` à la place des nettoyages `catch { }`. +- CI : couverture coverlet avec résumé et plancher, syntaxe PowerShell de l'installateur, `compileall` Python. +- L'installateur refuse de se compiler sans `AppVersion` (plus de version par défaut codée en dur). +- Plans et journaux d'avancement terminés retirés de `docs/` (le texte de la release reste dans `docs/release-notes-0.9.2.md`, qui résume ce qui précède sans le reprendre). +- Code reformaté (`.editorconfig`, dotnet format, ruff, qmlformat), sans changement de comportement. +- Backend découpé en dossiers ; `frontend.py` découpé. +- Dépendances épinglées (fichiers de verrouillage, `global.json`). -- All projects target .NET 10; the installer stays self-contained. -- Interface text moved to `Views/Strings.cs` (French, English, Spanish; Spanish partial, English fallback). Language order: `Caelestia/language.json`, system culture, English. -- Code quality: no line over 500 characters, backend split into 12 namespaces, formatters and analyzers added, generated artefacts untracked. +## [0.9.1] - 2026-11-04 -### Installer +### Corrections -- The setup wizard is in English by default and detects the Windows display language (French and Spanish kept). -- The Caelestia licence and notices are installed with the application. -- Still not code-signed; code signing is being looked into. +- Panneau média et visualiseur (#1) : navigateurs affichés sous un nom lisible, informations de piste relues au changement, position de lecture mise à jour tant que le tableau de bord est visible, visualiseur calé sur le navigateur qui joue (processus enfants compris). +- Rail social (#2) : le tableau de bord se décale de la largeur couverte par le rail et adapte ses volets ; sur une très petite largeur logique, le surplus passe par-dessus. +- Le tableau de bord rouvre sur le dernier onglet utilisé (durée de la session). +- Données utilisateur écrites de façon atomique (préférences, historique du presse-papiers, compteurs sociaux, éléments récents, bail de la barre des tâches, exclusions de la mosaïque, fichiers d'apparence d'Explorer, langue, fonds masqués, ville météo). +- Préférences : un fichier corrompu est conservé en `.corrupt.bak` et les réglages sont restaurés depuis `.bak` ; les valeurs par défaut ne servent que si les deux échouent. Une erreur de lecture transitoire est retentée une fois. +- Les signets n'ouvrent que des adresses `http` et `https`. Le XML de la tâche est écrit dans un dossier privé. -## 0.9.0 - 2026-10-31 +### Modifications + +- Installateur en anglais par défaut, avec détection de la langue d'affichage de Windows ; français et espagnol conservés. +- Licence et notices de Caelestia installées avec l'application. +- Projet passé à .NET 10. +- Qualité du code : aucune ligne de plus de 500 caractères ; backend réparti en 12 espaces de noms ; textes français retirés du code (chaînes d'interface dans `Views/Strings.cs` en français, anglais et espagnol, journaux en anglais) ; normaliseur de noms d'applications à table de règles ordonnée ; CSharpier et ruff format ; `.editorconfig` et analyseurs ajoutés. + +## [0.9.0] - 2026-10-31 Changes since 0.8.0. @@ -73,3 +121,189 @@ Changes since 0.8.0. - Upgrading no longer aborts while the shell is running. - The dashboard now shows the display name chosen at install (it used to show the Windows account name). - Interface strings completed in English and Spanish; the dashboard calendar follows the app language. + +### Known limitations + +- The installer wizard is in French only. +- English and Spanish are translated for the shell panels; some rarer system-derived labels may still appear in French. The language follows the Windows display language and can be forced with the `language` key (`auto`/`fr`/`en`/`es`) in `%LOCALAPPDATA%\Panneaux\Caelestia\language.json`. Fallback text is French. +- Not tested on a clean machine or VM, nor after a real reboot or sign-in (the scheduled-task launch was tested manually). +- Binaries are **not signed**, so SmartScreen may warn. +- Windows 11 x64 only. Temperature is in Celsius only. +- There is **no notification centre**: only the on-screen indicator and toasts. Windows notifications can be switched off from the tools panel. +- Window preview quality in the dock depends on what each application exposes to Windows. +- Weather: with no city set, your approximate location comes from your IP address through wttr.in; the forecast comes from Open-Meteo. Temperature is in °C only. +- Bundled wallpapers: the 10 images were supplied by the maintainer and are not covered by the GPL-3.0 licence; rights remain with their owners (see `assets/wallpapers/CREDITS.txt`). A rights holder can ask for removal through a GitHub issue. +- Automatic tiling is **off by default**. +- Tested on a real Windows 11 Home machine with a single 3440x1440 display, not on a clean virtual machine. Other display layouts, scalings and Windows editions have had less testing. +- Enterprise (802.1X) and hidden Wi-Fi networks are not handled in the Wi-Fi panel. +- The optional terminal integration prints a welcome banner in French, showing your session and computer names. +- When you confirm a wallpaper in the switcher, the previous wallpaper can flash briefly before the new one appears. +- The wallpaper switcher also lists images from `Pictures\Wallpapers`, `Pictures` and the Windows wallpaper folders, not only the bundled set. +- CPU temperature can show N/A, depending on the hardware. + +### What was tested + +Tested by hand and by script on one Windows 11 Home machine (single 3440x1440 display), with the installer built from a clean checkout: + +| Check | Result | +| --- | --- | +| Install, first start, dock replaces the taskbar | Pass | +| Upgrade from 0.8.0 with the shell running | Pass | +| Main panels open (settings, clipboard, tools, audio, mixer, capture, palette, dashboard) | Pass | +| Theme change and wallpaper-driven palette | Pass | +| Global shortcuts Ctrl+F1, Ctrl+F2, Ctrl+Alt+Space, Ctrl+Alt+W | Pass | +| Autostart task created, launched manually with the shell closed | Pass | +| Taskbar restored after normal close, after a killed process, and by `--restore-taskbar` and Ctrl+Alt+Backspace | Pass | +| Uninstall with the shell running (user profile and wallpaper kept) | Pass | +| Clean machine or VM; real reboot and sign-in | Not run | +| Other display layouts and scalings | Not run | + +These tests ran from an MSIX-packaged parent process, which redirects `%LOCALAPPDATA%` writes for its child processes; autostart was exercised from Task Scheduler, outside that redirection. + +## 0.8.0 - 2026-10-29 + +Nouveautés : panneaux social et favoris, palette automatique, page « Tout ton espace », gains de performance. Correctifs : onglets des Préférences défilants, badge WhatsApp, température CPU, masquage du dock, icône Ethernet, icône Fichiers dans le dock, aperçu des fenêtres au survol. Détail : page GitHub Releases (v0.8.0). + +## 0.7.6 - date non relevée + +- Réorganisation automatique animée sur 240 ms avec ralentissement progressif. Le minuteur d’animation s’arrête au repos ; aucun hook de souris permanent. Une manipulation, une réduction ou un agrandissement interrompt le mouvement concerné. +- Arrondis des fenêtres agrandies alignés sur leur surface visible DWM. Le découpage est réappliqué si une application le réinitialise, puis restauré au retour en fenêtre. Les vidéos et jeux en plein écran réel restent exclus. +- Logo du Bureau agrandi (116 px au lieu de 76 px, transparence et clics traversants conservés). +- Organisation automatique désactivée par défaut pour les nouvelles préférences. Les choix déjà enregistrés sont conservés. + +Validation : tests natifs sur dix fenêtres dédiées (ouverture, réduction, restauration, fermeture, agrandissement, retour aux positions originales), partitions sur coordonnées négatives, réparation d’une région effacée par l’application, tests QML du logo et des interactions, 32 tests Python. Compilation autonome et installeur reconstruits. + +Limites : le rendu des transitions dépend de la vitesse de redessin des applications. Les fenêtres protégées ou exécutées avec des privilèges supérieurs peuvent refuser un déplacement ou un découpage ; aucun résultat universel n’est garanti. + +## 0.7.5 - date non relevée (organisation automatique du bureau) + +### Mosaïque +Les fenetres redimensionnables se repartissent automatiquement dans la zone de travail de chaque moniteur, avec espacement et ordre stables. L'ouverture, la fermeture, la reduction et la restauration recalculent les emplacements. La disposition est activee par defaut ; un interrupteur dans Personnaliser > Bureau permet de la desactiver. + +Le double-clic sur la barre de titre conserve l'agrandissement Windows. Tant qu'une fenetre geree est agrandie, sa disposition est preservee ; le bouton Restaurer permet de reprendre la mosaique. Une nouvelle application peut reouvrir la disposition partagee. Les jeux et videos qui couvrent vraiment un moniteur sont exclus du placement. + +Les positions d'origine sont memorisees et restaurees a la desactivation ou a la fermeture normale pour les fenetres encore a leur emplacement gere. Les deplacements manuels, les fenetres reduites ou agrandies ne sont pas annules de force. Les boites de dialogue, outils flottants, fenetres fixes, bureaux virtuels masques et interfaces Winlestia sont exclus. + +### Logo +Logo fourni affiche en blanc discret dans le coin superieur gauche de chaque ecran. Option Logo Winlestia, desactivee par defaut. Fond transparent, aucun clic intercepte, opacite attenuee suivant le verre. Masque en plein ecran et pendant les captures/pipette. + +### Fluidite et limites +Detection legere toutes les 700 ms, sans hook souris global. Les placements qui ne changent pas ne sont pas reappliques ; un glisser-deposer en cours n'est pas interrompu. Les fenetres soumises a une taille minimale ne sont pas redimensionnees en boucle. + +Le nombre gere simultanement est borne selon la taille du moniteur, jusqu'a 16 fenetres recentes par ecran. Les applications imposant de grandes tailles minimales ou un niveau d'administration superieur peuvent refuser un placement ; une organisation parfaite de toutes les applications Windows ne peut pas etre garantie. La restauration en cas de terminaison forcee du processus n'est pas garantie. + +### Verification +Fixture native isolee : 3, 4 puis 10 vraies fenetres sans chevauchement, agrandir/restaurer, reduire/restaurer, fermer/reorganiser et restauration des positions initiales. Partitionnement de 1 a 16 cases avec coordonnees negatives. 30 cycles stables moyens sous 25 ms sur la machine de test ; ce n'est pas une mesure du PC du collegue. + +Tests QML : logo visible, transparent aux clics et masque en plein ecran ; regressions grille et menus, plein ecran et DPI mixtes. Editeur de palette/raccourcis et 32 tests Python de regression passes. Aucun autre logiciel du bureau n'a ete deplace par la fixture : sa source ne contient que les fenetres de test. + +## 0.7.0 - date non relevée (theme personnalise) + +Quatrieme theme Personnalise, en plus de Porcelaine, Nuit et Automatique. Le studio reprend les cartes, arrondis et typographie du panneau Personnaliser. + +- Cinq couleurs : fond, texte, principal, secondaire et troisieme accent. Les surfaces, contours et couleurs de contraste des boutons sont derives de ces roles. Palette initiale inspiree de Porcelaine. +- Roue HSV avec luminosite, saisie hexadecimale #RRGGBB et apercu avant application. +- Pipette d'ecran : interface masquee pendant l'echantillonnage d'un pixel ; clic intercepte temporairement sur un fil Windows dedie, sans hook permanent ni enregistrement d'image. Echap ou clic droit annule. Le hook est retire a la fin et a la fermeture. +- Verre optionnel : desactive, le fond choisi est opaque et le curseur disparait ; active, un curseur regle sa transparence. Le cadre, le dashboard et le dock utilisent les memes roles et le meme materiau. +- Couleurs et transparence personnalisees sauvegardees. Annuler l'editeur ne sauvegarde pas le brouillon. Le theme Automatique conserve son propre reglage de verre. +- Les terminaux reprennent la palette personnalisee ; Windows Terminal adapte aussi son opacite et son effet acrylique. Le mode allege conserve son comportement pour les configurations modestes. + +Tests : palette et validation hexadecimale, roue/editeur QML sans erreur, modes opaque/verre, selection et annulation d'un pixel deterministe, persistance native des cinq roles et du verre, 32 regressions Python, import du service terminal et cycle de vie reel du hook temporaire dans le runtime embarque. Capture du studio inspectee. Les couleurs visibles peuvent dependre de la gestion HDR du bureau ; le rendu sur le second PC reste a tester. + +## 0.6.5.2 - date non relevée + +### Personnaliser +Refonte inspiree des composants, arrondis et couleurs de Caelestia : https://github.com/caelestia-dots/shell . Trois cartes Apparence / Bureau / Au quotidien, apercus de themes, interrupteurs, langues compactes et hierarchie typographique. Le selecteur d'animations est retire. La detection superieure dispose d'un bouton Calibrer unique ; aucun curseur redondant dans les parametres. + +### Applications +Les onglets En cours / Arriere-plan utilisent une grille d'icones avec noms au survol. Clic gauche ou droit ouvre les actions. Les fermetures et arrets avec confirmation reviennent a la grille, dans le meme onglet, sans fermer le panneau. Retour et Echap restent disponibles. Le clic exterieur ferme le panneau ; aucun hook souris global n'est ajoute. + +### Plein ecran +Le dock devient une capsule flottante complete quand son ecran accueille un vrai plein ecran. Le dessin, le verre et le masque de clic utilisent la meme forme detachee. Le cadre reste masque lorsque le dock ou son menu prend le focus. + +La detection utilise les coordonnees physiques et le nom du moniteur Windows, sans multiplication des origines Qt par un facteur DPI. Une video plein ecran peut etre reconnue meme si le navigateur conserve son etat maximise. Une fenetre simplement maximisee avec barre de titre reste distincte. Le clip arrondi impose aux fenetres maximisees est retire lorsque leur client couvre le moniteur entier ; retour a l'arrondi a la sortie. + +### Verification +32 tests Python de geometrie/recherche ; tests QML dans le runtime embarque : grille, clic droit, confirmation, maintien du panneau, clic exterieur, bouton de calibration, cadre masque, capsule et cas de DPI melanges simule ; fixture native : arrondis/restauration, epingles, recents. Aucune erreur QML sur ces parcours. Une capture du nouveau panneau a ete inspectee. + +Le cas Firefox/Chrome du second PC reste a retester sur cette machine ; les tests locaux et simules ne couvrent pas toutes les versions de pilotes ou tous les lecteurs video. Les terminaux ne sont pas modifies par cette version. + +## 0.6.5.1 - date non relevée + +- Reparation de l'installation du theme PowerShell : Set-ExecutionPolicy pouvait modifier CurrentUser puis signaler ExecutionPolicyOverride a cause de la politique du processus du setup. Le helper verifie maintenant la valeur relue et accepte uniquement une modification effectivement reussie. Les restrictions de groupe restent respectees. +- Le theme CMD/PowerShell est inclus automatiquement, y compris lors d'une mise a jour ayant conserve une ancienne liste de taches. +- Synchronisation initiale de la palette avant le lancement du worker ; arret de l'ancien worker enregistre ; mutex propre au dossier d'installation. Les fichiers de couleurs sont publies meme si Windows Terminal n'a pas encore de configuration. +- Windows Terminal nouvellement initialise : ajout du schema Winlestia manquant et activation de son apparence. Les editions Preview sont aussi detectees. Le CMD classique utilise les couleurs RVB du theme ; Windows Terminal conserve ses couleurs indexees adaptatives. +- Ctrl+F2 : extraction des icones des applications et fichiers/dossiers recents, et demande de chargement des la creation des lignes. +- Echecs d'installation : stderr du helper est conserve dans install-result.json pour un diagnostic exploitable. + +### Verification + +PowerShell 5.1 interactif dans un vrai pseudo-terminal : logo ASCII, panneau systeme et prompt affiches, couleur rouge RVB verifiee, fermeture propre. Test reproduisant ExecutionPolicyOverride avec changement applique, puis restauration de la valeur precedente. Tests de palettes rouge/bleue et de configuration Terminal neuve ; test d'import du worker avec le Python embarque ; installation/reinstallation/restauration isolees ; regressions QML sans erreur. + +Le rendu du fond transparent exige un hote compatible tel que Windows Terminal. Une strategie d'entreprise interdisant les profils non signes doit etre geree par son administrateur. Le PC du collegue reste a retester ; les tests locaux ne constituent pas une verification sur cette machine. + +## 0.6.5 - date non relevée (correctifs et audit) + +### Correctifs +- Raccourcis : fenetre independante, panneau principal masque, combinaison affichee pendant la saisie, validation backend, fermeture automatique uniquement apres succes et libelle synchronise. Echap annule. +- Langues : quatre cartes graphiques Windows/France/Royaume-Uni/Espagne, assets SVG locaux, selection persistante. +- Icones Workspace : demandes a la demande avec coalescence et nouvelles tentatives ; les images vectorielles de secours sont aussi rendues pour Qt ; encodage PNG hors du fil de saisie. +- Recents : applications observees au premier plan et raccourcis Windows Recent sont fusionnes par horodatage UTC ; dernier acces conserve, ordre decroissant, historique local borne. La collecte commence avec Winlestia et ne reconstitue pas un historique Windows absent ou desactive. +- Terminal : installation du profil accompagnee de RemoteSigned pour le compte courant lorsque necessaire, ancienne valeur sauvegardee puis restauree a la desinstallation. CMD lance son helper avec une politique limitee au processus. Les strategies imposees par une organisation restent prioritaires et sont signalees. Les settings Windows Terminal avec commentaires/virgules finales sont acceptes. +- Mise a jour/desinstallation : fermeture propre, puis nettoyage des seuls processus frontend/theme du dossier installe. Suppression du taskkill global par nom : les autres installations et applications Python restent intactes. + +### Audit des performances +Defauts identifies : enumeration de tous les processus sur le dispatcher du dock ; icones demandees uniquement pour les favoris/dock ; encodeur d'images sur le dispatcher ; arret force du backend pouvant laisser son frontend orphelin ; listes recentes concatenees sans tri commun. + +Corrections : inventaire des processus uniquement a l'ouverture du panneau dedie, sur une tache de fond ; un seul worker STA pour les recents, cache des liens et file bornee ; pas de Shell COM dans les requetes d'etat ; ecriture des icones en arriere-plan ; nettoyage du cycle de vie. + +Mode allege automatique jusqu'a 8 Gio de memoire disponible pour le runtime, reglable dans Personnalisation : flou natif desactive, transparence conservee, sondes materielles moins frequentes. Le test sur le PC du collegue reste necessaire : aucune mesure sur cette machine n'est disponible et aucune garantie de fluidite universelle n'est annoncee. + +### Verifications +- 32 tests Python de geometrie, recherche et palettes. +- QML du runtime embarque : popup seul, touche Ctrl+K capturee, succes simule, retour aux parametres/libelle, demandes d'icones coalescees, quatre cartes de langue ; aucune erreur QML. +- Fixture native : arrondis/restauration, ordre des epingles, recents mixtes tries et dedupliques. +- Capture audio : FFT variable et isolation du processus validees avec signal de test. +- Installation terminal isolee : profils, chemins portables, CMD AutoRun, reinstal idempotente, restauration, JSON avec commentaires ; banner reel lance sous environnement Restricted avec son option de processus. +- Nettoyage reel sur deux processus factices : seul le processus du dossier choisi est arrete. + +Les dialogues secondaires herites ne sont pas tous traduits. L'estimation de ville reste reseau/IP. Les tests automatises ne remplacent pas une ouverture de session Windows ni l'essai sur la petite configuration. + +Reference des politiques PowerShell : https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies/ + +## 0.6.0 - date non relevée + +La numerotation passe de 9.5.0 a 0.6.0 ; le setup accepte la mise a jour avec le meme identifiant. + +- Spectre FFT dynamique a 48 bandes : capture WASAPI du processus du lecteur et de ses enfants, sans repli sur le son global. Analyse en memoire, aucun enregistrement. Test reel avec signal sinusoidal et processus exclu reussi. +- Menu alimentation : cartes avec icones, confirmation, position au-dessus du dock. +- Fluidite : retrait du hook souris global de fermeture des panneaux, remplacement par detection des transitions de boutons ; suppression des abonnements globaux bruyants aux changements de position/etat ; cadence du mixeur reduite. Le ralentissement du PC du collegue reste a confirmer sur cette machine, sa cause exacte n'est pas prouvee. +- Langues : detection de la langue d'affichage Windows (FR/EN/ES, anglais sinon), choix manuel persistant dans Personnalisation et premier catalogue de traduction de l'interface principale. Certains libelles secondaires et dialogues Windows/herites restent dans leur langue initiale ; la couverture n'est pas encore exhaustive. +- Meteo : en absence de ville manuelle, estimation de ville par adresse IP (wttr.in), puis meteo Open-Meteo. Ce n'est pas une localisation GPS ; VPN/reseau peuvent affecter la ville. +- Ctrl+F2 : raccourcis recents du dossier Windows Recent en tete ; applications observees au premier plan depuis le lancement sont aussi prises en compte. Les historiques Windows desactives/proteges ne sont pas contournes. +- Calibration : bouton Calibrer dans Personnalisation, panneau principal masque, reglage isole et ligne rouge non interactive sur l'ecran. Valider avec la coche ; Echap annule. + +Tests : 32 tests Python ; runtime QML/menus sans erreur ; FFT/isolation native ; choix/persistance des langues et ordre recent d'abord. Le PC du collegue et une vraie ouverture de session restent a tester. + +Implementation WASAPI : https://learn.microsoft.com/en-us/samples/microsoft/windows-classic-samples/applicationloopbackaudio-sample/ + +## 9.5.0 - date non relevée + +- L'installateur embarque le theme CMD/PowerShell et propose son installation. Profils interactifs, ASCII Winlestia, Clink, couleurs, verre Windows Terminal et adaptation au fond d'ecran. Les reglages existants sont sauvegardes. Les sessions elevees du meme compte utilisent les memes profils. +- Media : bouton volume cible a la place du bouton de deplacement ; pochette tournante pendant la lecture, angle conserve en pause. +- Le visualiseur utilise le niveau des sessions audio du lecteur selectionne, sans repli sur la sortie globale. C'est un indicateur d'amplitude, pas une analyse FFT par frequence. Le volume d'un navigateur concerne ses sessions, pas un onglet individuel. +- Dock : separation des groupes epingles/ouverts, deplacement des epingles par glissement, largeur reglable au bord droit et centrage conserve. +- Applications : onglets en cours/arriere-plan ; executables accessibles de la session, groupes par chemin. Les processus proteges ou inaccessibles ne sont pas listes. +- Alimentation : veille, redemarrage et arret avec confirmation. +- Reglages : hauteur de detection du bord superieur de 0 a 160 pixels, persistante. +- Fenetres maximisees : decoupe arrondie dans la zone de travail ; region precedente restauree au retour en mode normal ou a la fermeture du shell. Les pleins ecrans exclusifs restent geres par leur application. +- Curseurs : protection des valeurs choisies contre les retours d'etat anciens pendant les commandes audio et luminosite. +- Mise a jour : conservation du catalogue et des preferences backend ; correctif Explorer 9.4 conserve. + +### Verification + +32 tests Python ; chargement QML et menus avec etat de test ; protection contre etat obsolete ; installation/restauration terminal dans un environnement isole ; fixture native pour arrondis et ordre des epingles ; verification Core Audio avec session silencieuse dediee. Aucune commande d'arret, redemarrage ou veille n'est executee par ces tests. Le demarrage de session Windows et un second ordinateur restent a tester manuellement. + +Le setup est gratuit, Windows 11 x64. Le depot reste prive : les testeurs doivent disposer d'un acces GitHub. + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b597bca..382b2ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,17 +6,17 @@ Thanks for your interest. Winlestia is a GPL-3.0 project; contributions are acce | Path | Content | |---|---| -| `src/backend` | .NET 10 backend (`Panneaux.Glass.csproj`, C#), native helpers (`*.cpp`) and their `build-*.cmd` | +| `src/backend` | .NET 8 backend (`Panneaux.Glass.csproj`, C#), native helpers (`*.cpp`) and their `build-*.cmd` | | `src/frontend` | Qt Quick interface (QML) and Python 3.12 / PySide6 host (`frontend.py`) | -| `src/cli` | The `winlestia` command (.NET 10) | -| `src/files` | Winlestia Files (.NET 10) | +| `src/cli` | The `winlestia` command (.NET 8) | +| `src/files` | Winlestia Files (.NET 8) | | `src/terminal` | Terminal integration (Clink theme scripts) | | `installer` | Inno Setup script and `build.ps1` | | `tests` | Small console test projects, one per area (`tests/WifiPolicy`, `tests/CliCommands`, ...), plus Python scripts | ## Building from source -Requirements: Windows 11 x64, .NET 10 SDK, Python 3.12 (for the Qt frontend and for `pip`), PowerShell 7, Inno Setup 6 (to build the installer), and MSVC only if you rebuild the native helpers. +Requirements: Windows 11 x64, .NET 8 SDK, Python 3.12 (for the Qt frontend and for `pip`), PowerShell 7, Inno Setup 6 (to build the installer), and MSVC only if you rebuild the native helpers. **Backend** @@ -68,19 +68,3 @@ $env:WINLESTIA_DATA_DIR = "$env:TEMP\winlestia-test" Describe what changed and why, how you tested it (which test project or manual steps, on which Windows build), and note anything touching restore paths, installer or autostart. By contributing you agree your work is released under GPL-3.0. Third-party code must be listed in `THIRD_PARTY_NOTICES.md` with its licence. - -## Development setup and conventions - -See `docs/ARCHITECTURE.md` (module map and names that must not change), `docs/BUILD-AND-TEST.md` (exact commands), `docs/WINDOWS-INTEGRATIONS.md` and `docs/RESTORE.md`. - -- **Setup.** .NET SDK 8.0.x; Python 3.12 with a PySide6 virtual environment in `src/frontend/.venv` (`pip install -r src/frontend/requirements.txt`). -- **Single test.** `dotnet run --project tests/ -c Release` prints `TOTAL x/y`. Do not run the whole suite for a small change. `tests/FullscreenWatcher` currently does not terminate (known issue). -- **QML lint.** Run `pyside6-qmllint` from the venv (`-I src/frontend/qml`) on each QML file you touched and fix what you introduced. -- **Style.** Target 120 columns (advisory, see `.editorconfig`); English identifiers and comments; match the surrounding file's indentation and layout. Do not reformat code you are not changing. -- **User-visible strings.** Wrap QML text in `qsTr(...)` and add a `fr|en|es` row to `ROWS` in `src/frontend/localization.py`, keyed by the exact French text. -- **No hard-coded colours.** Use the wallpaper-derived palette through `Colours` / `Appearance` tokens (`src/frontend/qml/qs/services/Colours.qml`, `qs/config/Appearance.qml`). -- **No idle cost.** No timers, polling or animations that run while nothing happens; prefer events and stop work when hidden. -- **Do not rename** preferences JSON properties, pipe names or IPC JSON keys, `Bridge.*` members used by QML, `ROWS` keys, command-line switches, or `catalog.json` fields (`docs/ARCHITECTURE.md`). -- **Generated files.** Verification and benchmark outputs (`src/backend/verification*.json`, `performance-*.json`, `motion-*.json`, ...) are git-ignored; do not commit them. -- **Commits.** One concern per commit, imperative subject line, explain why in the body; no unrelated formatting or merge noise. Never commit personal data (`preferences-glass.json`, logs, `catalog.json`). -- **Licences.** Files derived from Caelestia keep their attribution: `src/backend/CAELESTIA-NOTICES.md`, `src/backend/LICENSE-CAELESTIA.txt`, `src/frontend/LICENSE`, `src/frontend/licenses/`, and the root `THIRD_PARTY_NOTICES.md`. Add any new third-party code there. diff --git a/Directory.Build.props b/Directory.Build.props index 8e21e72..a3da2bc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - latest - true + 0.9.2 + true diff --git a/README.md b/README.md index e292fd6..5041618 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ Join the Winlestia Discord server -[![Release](https://img.shields.io/github/v/release/lilidoxdev/winlestia?style=flat-square)](https://github.com/lilidoxdev/winlestia/releases/tag/v0.9.1) +[![Release](https://img.shields.io/github/v/release/lilidoxdev/winlestia?style=flat-square)](https://github.com/lilidoxdev/winlestia/releases/tag/v0.9.2) [![License](https://img.shields.io/github/license/lilidoxdev/winlestia?style=flat-square)](LICENSE) [![Windows 11](https://img.shields.io/badge/Windows-11-0078D4?style=flat-square)](docs/INSTALL.md) -[Download](https://github.com/lilidoxdev/winlestia/releases/tag/v0.9.1) · [Demo](#demo) · [Install guide](docs/INSTALL.md) · [Shortcuts](docs/SHORTCUTS.md) · [Troubleshooting](docs/TROUBLESHOOTING.md) · [Discord](https://discord.gg/5jXv7NRj3Q) +[Download](https://github.com/lilidoxdev/winlestia/releases/tag/v0.9.2) · [Demo](#demo) · [Install guide](docs/INSTALL.md) · [Shortcuts](docs/SHORTCUTS.md) · [Troubleshooting](docs/TROUBLESHOOTING.md) · [Discord](https://discord.gg/5jXv7NRj3Q) Winlestia on a Windows 11 desktop @@ -28,7 +28,7 @@ These visuals are motion-design illustrations made in HTML/CSS. They are not scr ## What is Winlestia -Winlestia is a free, open-source desktop shell for Windows 11, bringing a Caelestia-inspired interface, dynamic themes, glass effects, a customizable dock and desktop tools. +Winlestia is a free, open-source desktop shell for Windows 11, bringing a Caelestia-inspired interface, dynamic themes, glass effects, a customizable dock and powerful desktop tools. It replaces the surface of Windows, not the system. The taskbar is swapped for a dock, and a dashboard and glass panels sit on top of your normal desktop. Your files, windows and wallpaper stay where they are. The interface is a port of [Caelestia](https://github.com/caelestia-dots/shell) (Qt Quick), and a .NET 8 service handles the Windows side. Panel colours can be derived from your wallpaper (see [Wallpaper and dynamic theme](#wallpaper-and-dynamic-theme)). @@ -59,7 +59,7 @@ Applications, Games, Projects and Documents boards, your favorites, recent short ### Media and quick controls Media panel -Quick controls include a Wi-Fi network list, per-display brightness (DDC/CI, external displays too) and a "Reduced effects" switch. Audio and per-application mixer panels are included. The media panel shows a readable source name for browsers, updates the playback position, and the audio visualiser follows the playing browser. +Quick controls include a Wi-Fi network list, per-display brightness (DDC/CI, external displays too) and a "Reduced effects" switch. Audio and per-application mixer panels are included. ### Performance Performance panel @@ -77,7 +77,13 @@ Screen capture with annotation (**Ctrl+F3**), text recognition (OCR) on a captur ### Settings Settings -Six pages with a search field: appearance, desktop and dock, file explorer, daily use, messengers and bookmarks. Screen frame, accent border on windows, desktop logo, quiet mode (dock and OSD hidden during fullscreen), on-screen indicators for volume, brightness and microphone, and optional automatic tiling (off by default; Ctrl+Alt+arrows). +Eight pages with a search field (a result opens its page and highlights the row): Appearance, Interface, Docks and panels, Keyboard shortcuts, Media and audio, Apps, bookmarks and links, Files and Windows, General. + +- **Docks and panels**: the dock, the bookmarks panel (left edge) and the social networks panel (right edge) can each be hidden. Hiding the dock while it replaces the Windows taskbar gives the Windows taskbar back, so there is always one bar. +- **Keyboard shortcuts**: every global shortcut in one page, grouped. "Modifier" opens a capture dialog; a combination reserved by Windows or already used by another action is refused with the reason. Fixed shortcuts (backup dashboard shortcut, desktop rescue, tiling keys, dictation key) show why they cannot be changed here. A shortcut that another application already holds at start-up is flagged "Inactif". +- **Opening Settings**: **Win+Alt+S** (reassignable), the tray icon menu ("Paramètres de Winlestia"), or `winlestia panel settings` from a terminal. + +Screen frame, accent border on windows, desktop logo, quiet mode (dock and OSD hidden during fullscreen), on-screen indicators for volume, brightness and microphone, and optional automatic tiling (off by default; Ctrl+Alt+arrows). ### Terminal Terminal with the Winlestia palette @@ -87,17 +93,17 @@ Opt-in at setup: Command Prompt through [Clink](https://github.com/chrisant996/c ### Panels Winlestia panels -Also: a Files explorer, messenger and bookmark side rails (the dashboard moves aside so the social rail no longer covers its tabs; it reopens on the last tab used), the Glass shell UI in English, French and Spanish (language detected from Windows), an installer that detects the system language, an optional push-to-talk dictation (hold AltGr, off by default), and a `winlestia` command line to script the running shell. +Also: a Files explorer, messenger and bookmark side rails, an optional push-to-talk dictation (hold AltGr, off by default), and a `winlestia` command line to script the running shell. ## Install Requires **Windows 11 (64-bit)**. No administrator rights needed. -1. Download [`Winlestia-Setup-0.9.1.exe`](https://github.com/lilidoxdev/winlestia/releases/download/v0.9.1/Winlestia-Setup-0.9.1.exe) from the [release](https://github.com/lilidoxdev/winlestia/releases/tag/v0.9.1). -2. Check it: run `Get-FileHash .\Winlestia-Setup-0.9.1.exe -Algorithm SHA256` in PowerShell and compare the result with the SHA-256 printed in the [release notes](https://github.com/lilidoxdev/winlestia/releases/tag/v0.9.1). -3. Run it. The installer is not code-signed yet, so Windows may show a SmartScreen warning: choose **More info**, then **Run anyway**. Code signing is being looked into (SignPath Foundation offers free signing for open-source projects). +1. Download [`Winlestia-Setup-0.9.2.exe`](https://github.com/lilidoxdev/winlestia/releases/download/v0.9.2/Winlestia-Setup-0.9.2.exe) from the [release](https://github.com/lilidoxdev/winlestia/releases/tag/v0.9.2). +2. Check it: run `Get-FileHash .\Winlestia-Setup-0.9.2.exe -Algorithm SHA256` in PowerShell and compare the result with the SHA-256 printed in the [release notes](https://github.com/lilidoxdev/winlestia/releases/tag/v0.9.2). +3. Run it. The binaries are **not code-signed**, so SmartScreen will likely warn: choose **More info**, then **Run anyway**. -After installing, move the pointer to the bottom edge of the screen to reveal the dock. The setup wizard is in English by default and uses French or Spanish when Windows is set to one of them. Full guide, options, updating and uninstalling: [docs/INSTALL.md](docs/INSTALL.md). +After installing, move the pointer to the bottom edge of the screen to reveal the dock. The setup wizard is in French only. Full guide, options, updating and uninstalling: [docs/INSTALL.md](docs/INSTALL.md). ## Usage @@ -126,7 +132,8 @@ All shortcuts (reassignable in Settings), palette prefixes and the `winlestia` c ## Known limitations -- The Glass UI is available in French, English and Spanish. The language comes from `Caelestia/language.json` (`auto`, `fr`, `en` or `es` under `%LOCALAPPDATA%\Panneaux\Caelestia\`), then the Windows UI language, then English. Spanish is partial: about 230 of 820 strings have a Spanish text, the others fall back to English. +- The installer wizard is in French only. +- The shell panels are translated into English and Spanish; some rarer labels derived from the system may still appear in French. The language is chosen automatically from the Windows display language. To force it, set the `language` key (`auto`, `fr`, `en` or `es`) in `%LOCALAPPDATA%\Panneaux\Caelestia\language.json`. Fallback text is French. - Not tested on a clean machine or virtual machine, and not tested after a real reboot or sign-in (the start-up scheduled task was launched manually). - Binaries are unsigned, so SmartScreen may warn. - Windows 11 x64 only. @@ -135,6 +142,10 @@ All shortcuts (reassignable in Settings), palette prefixes and the `winlestia` c - The optional terminal integration prints a welcome banner in French, with your session and computer names. - When you confirm a wallpaper in the switcher, the previous one can flash briefly before the new one appears. +## Tests + +`dotnet test tests/Winlestia.Tests --filter "Category!=Desktop"` and `pytest -q`. Details in [docs/TESTS.md](docs/TESTS.md). + ## Privacy and data - Weather: when no city is set, your approximate location is found from your IP address through wttr.in (which therefore sees your IP address); the forecast comes from Open-Meteo. Set a city to avoid the IP lookup. Temperature is in °C only. @@ -143,9 +154,9 @@ All shortcuts (reassignable in Settings), palette prefixes and the `winlestia` c ## Roadmap -**Shipped in 0.9.0:** Wi-Fi panel, per-display brightness, dashboard audio bars, fullscreen quiet mode, full-screen wallpaper switcher, richer command palette, settings search, tiling shortcuts, `winlestia` command line. See [CHANGELOG.md](CHANGELOG.md). +**Shipped in 0.9.2:** redesigned settings in eight pages with search, palette editor, terminal that follows the light or dark theme, keyboard shortcuts page with rebinding, redesigned file manager, dock icons for packaged apps, separately hideable dock, bookmarks and social panel. -**Shipped in 0.9.1:** media panel fixes, social rail spacing, English installer with language detection, safer writes of user data, .NET 10. See [docs/RELEASE-0.9.1.md](docs/RELEASE-0.9.1.md). +**Shipped in 0.9.0:** Wi-Fi panel, per-display brightness, dashboard audio bars, fullscreen quiet mode, full-screen wallpaper switcher, richer command palette, settings search, tiling shortcuts, `winlestia` command line. See [CHANGELOG.md](CHANGELOG.md). **Ideas, not promises:** a notification centre, support for enterprise and hidden Wi-Fi networks, more testing on other display layouts, and a code-signed installer. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5d08261..96a9c96 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,12 +1,13 @@ # Composants tiers -- **Caelestia Shell**, caelestia-dots et contributeurs : GPLv3, https://github.com/caelestia-dots/shell (tag v1.4.0 portée, voir `docs/history/caelestia-windows/PORTAGE.md`). Le portage vit dans `src/frontend` (livré dans `Caelestia-Windows/`) ; la correspondance des fichiers est dans `src/frontend/UPSTREAM.json` et `src/frontend/PORT_PATCHES.json`. Ce dépôt ne contient pas de copie de l'amont (pas de dossier `upstream/`) : l'original est sur GitHub. Licence : `LICENSE` et `src/backend/LICENSE-CAELESTIA.txt`. +- **Caelestia Shell**, caelestia-dots et contributeurs : GPLv3, https://github.com/caelestia-dots/shell (tag v1.4.0 portée, voir `docs/history/caelestia-windows.md`). Le portage vit dans `src/frontend` (livré dans `Caelestia-Windows/`) ; la correspondance des fichiers est dans `src/frontend/UPSTREAM.json` et `src/frontend/PORT_PATCHES.json`. Ce dépôt ne contient pas de copie de l'amont (pas de dossier `upstream/`) : l'original est sur GitHub. Licence : `LICENSE` et `src/backend/LICENSE-CAELESTIA.txt`. - **Qt / PySide6 / Shiboken6 6.10.3**, The Qt Company et contributeurs : licences du runtime Qt/PySide, dont LGPLv3/GPLv3 ; notices dans `licenses/` et les métadonnées du runtime `.venv/Lib/site-packages`. Modules dynamiques remplaçables, sans liaison statique. Sources : https://code.qt.io/ et https://download.qt.io/official_releases/QtForPython/ . - **Rubik**, Google Fonts et auteurs : SIL Open Font License, `assets/fonts/Rubik-OFL.txt`. - **Material Symbols Rounded**, Google : Apache 2.0, `assets/fonts/MaterialSymbols-LICENSE.txt`. - **NAudio 2.2.1**, Mark Heath et contributeurs : MIT, `licenses/NAudio-MIT.txt`, https://github.com/naudio/NAudio . - **LibreHardwareMonitor 0.9.6** : MPL 2.0 et dépendances du paquet NuGet, https://github.com/LibreHardwareMonitor/LibreHardwareMonitor . Le backend conserve ses références de paquets et sources de construction. - **Clink** (chrisant996/clink), Martin Ridgers, Christopher Antos et contributeurs : GPL-3.0 (texte vérifié dans `src/terminal/clink/LICENSE`), https://github.com/chrisant996/clink . Fichiers fournis dans `src/terminal/clink/` ; le binaire `clink_x64.exe` est ignoré par git et ajouté à la construction. +- **Thèmes Clink** (`src/terminal/clink/themes/`) : `*.clinktheme` et `*.clinkprompt` fournis avec Clink (GPL-3.0, voir ci-dessus). Les palettes `Dracula`, `Solarized Dark/Light` et `Tomorrow*` reprennent des schémas de couleurs tiers (Dracula Theme, Ethan Schoonover, Chris Kempson) ; leur licence propre (MIT) n'est PAS vérifiée par un fichier du dépôt. `agnoster`, `pure`, `starship`, `oh-my-posh`, `jonathan`, `darkblood`, `bureau` reproduisent l'apparence de prompts connus (noms cités pour référence) ; origine exacte non vérifiée. - **CaskaydiaCove Nerd Font Mono**, sous-ensemble WOFF2 (`src/terminal/clink/CaskaydiaCoveNerdFontMono-Regular-Subset.woff2`) : police dérivée de Cascadia Code (Microsoft) corrigée par le projet Nerd Fonts. Licence attendue : SIL OFL 1.1 ; NON VÉRIFIÉE (aucun fichier de licence dans le dépôt pour cette police). - **Whisper.net 1.9.1** et **Whisper.net.Runtime 1.9.1**, Sandro Hanea : MIT (métadonnées NuGet et fichier LICENSE du paquet), https://github.com/sandrohanea/whisper.net . Le modèle de dictée est téléchargé à l'exécution depuis https://huggingface.co/ggerganov/whisper.cpp ; sa licence n'a pas été vérifiée ici. - **Microsoft.Web.WebView2 1.0.4314.35**, Microsoft : licence BSD de Microsoft fournie dans le paquet (`LICENSE.txt`), https://aka.ms/webview . Le runtime WebView2 lui-même est installé par Windows/Microsoft. diff --git a/Winlestia.sln b/Winlestia.sln new file mode 100644 index 0000000..2e2f1f9 --- /dev/null +++ b/Winlestia.sln @@ -0,0 +1,95 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backend", "backend", "{FE360695-3F2B-1049-3887-35FBB5135923}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Panneaux.Glass", "src\backend\Panneaux.Glass.csproj", "{5065FDC4-DDE4-4CD0-BDC0-173FB1C5266E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "files", "files", "{B4367576-ED80-C199-DB9B-2EB28302B9E2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Winlestia.Files", "src\files\Winlestia.Files.csproj", "{E3860575-05D1-4882-8E37-A99AC9B209B6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cli", "cli", "{778427DF-EB2C-5228-964E-6D535DE253CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "winlestia", "src\cli\winlestia.csproj", "{16D1F48D-91F1-4F92-9062-3944A5E64B56}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Winlestia.Tests", "tests\Winlestia.Tests\Winlestia.Tests.csproj", "{B043B1C4-7D28-4328-84BE-849458C9C42B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5065FDC4-DDE4-4CD0-BDC0-173FB1C5266E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5065FDC4-DDE4-4CD0-BDC0-173FB1C5266E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5065FDC4-DDE4-4CD0-BDC0-173FB1C5266E}.Debug|x64.ActiveCfg = Debug|Any CPU + {5065FDC4-DDE4-4CD0-BDC0-173FB1C5266E}.Debug|x64.Build.0 = Debug|Any CPU + {5065FDC4-DDE4-4CD0-BDC0-173FB1C5266E}.Debug|x86.ActiveCfg = Debug|Any CPU + {5065FDC4-DDE4-4CD0-BDC0-173FB1C5266E}.Debug|x86.Build.0 = Debug|Any CPU + {5065FDC4-DDE4-4CD0-BDC0-173FB1C5266E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5065FDC4-DDE4-4CD0-BDC0-173FB1C5266E}.Release|Any CPU.Build.0 = Release|Any CPU + {5065FDC4-DDE4-4CD0-BDC0-173FB1C5266E}.Release|x64.ActiveCfg = Release|Any CPU + {5065FDC4-DDE4-4CD0-BDC0-173FB1C5266E}.Release|x64.Build.0 = Release|Any CPU + {5065FDC4-DDE4-4CD0-BDC0-173FB1C5266E}.Release|x86.ActiveCfg = Release|Any CPU + {5065FDC4-DDE4-4CD0-BDC0-173FB1C5266E}.Release|x86.Build.0 = Release|Any CPU + {E3860575-05D1-4882-8E37-A99AC9B209B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E3860575-05D1-4882-8E37-A99AC9B209B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E3860575-05D1-4882-8E37-A99AC9B209B6}.Debug|x64.ActiveCfg = Debug|Any CPU + {E3860575-05D1-4882-8E37-A99AC9B209B6}.Debug|x64.Build.0 = Debug|Any CPU + {E3860575-05D1-4882-8E37-A99AC9B209B6}.Debug|x86.ActiveCfg = Debug|Any CPU + {E3860575-05D1-4882-8E37-A99AC9B209B6}.Debug|x86.Build.0 = Debug|Any CPU + {E3860575-05D1-4882-8E37-A99AC9B209B6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E3860575-05D1-4882-8E37-A99AC9B209B6}.Release|Any CPU.Build.0 = Release|Any CPU + {E3860575-05D1-4882-8E37-A99AC9B209B6}.Release|x64.ActiveCfg = Release|Any CPU + {E3860575-05D1-4882-8E37-A99AC9B209B6}.Release|x64.Build.0 = Release|Any CPU + {E3860575-05D1-4882-8E37-A99AC9B209B6}.Release|x86.ActiveCfg = Release|Any CPU + {E3860575-05D1-4882-8E37-A99AC9B209B6}.Release|x86.Build.0 = Release|Any CPU + {16D1F48D-91F1-4F92-9062-3944A5E64B56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {16D1F48D-91F1-4F92-9062-3944A5E64B56}.Debug|Any CPU.Build.0 = Debug|Any CPU + {16D1F48D-91F1-4F92-9062-3944A5E64B56}.Debug|x64.ActiveCfg = Debug|Any CPU + {16D1F48D-91F1-4F92-9062-3944A5E64B56}.Debug|x64.Build.0 = Debug|Any CPU + {16D1F48D-91F1-4F92-9062-3944A5E64B56}.Debug|x86.ActiveCfg = Debug|Any CPU + {16D1F48D-91F1-4F92-9062-3944A5E64B56}.Debug|x86.Build.0 = Debug|Any CPU + {16D1F48D-91F1-4F92-9062-3944A5E64B56}.Release|Any CPU.ActiveCfg = Release|Any CPU + {16D1F48D-91F1-4F92-9062-3944A5E64B56}.Release|Any CPU.Build.0 = Release|Any CPU + {16D1F48D-91F1-4F92-9062-3944A5E64B56}.Release|x64.ActiveCfg = Release|Any CPU + {16D1F48D-91F1-4F92-9062-3944A5E64B56}.Release|x64.Build.0 = Release|Any CPU + {16D1F48D-91F1-4F92-9062-3944A5E64B56}.Release|x86.ActiveCfg = Release|Any CPU + {16D1F48D-91F1-4F92-9062-3944A5E64B56}.Release|x86.Build.0 = Release|Any CPU + {B043B1C4-7D28-4328-84BE-849458C9C42B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B043B1C4-7D28-4328-84BE-849458C9C42B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B043B1C4-7D28-4328-84BE-849458C9C42B}.Debug|x64.ActiveCfg = Debug|Any CPU + {B043B1C4-7D28-4328-84BE-849458C9C42B}.Debug|x64.Build.0 = Debug|Any CPU + {B043B1C4-7D28-4328-84BE-849458C9C42B}.Debug|x86.ActiveCfg = Debug|Any CPU + {B043B1C4-7D28-4328-84BE-849458C9C42B}.Debug|x86.Build.0 = Debug|Any CPU + {B043B1C4-7D28-4328-84BE-849458C9C42B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B043B1C4-7D28-4328-84BE-849458C9C42B}.Release|Any CPU.Build.0 = Release|Any CPU + {B043B1C4-7D28-4328-84BE-849458C9C42B}.Release|x64.ActiveCfg = Release|Any CPU + {B043B1C4-7D28-4328-84BE-849458C9C42B}.Release|x64.Build.0 = Release|Any CPU + {B043B1C4-7D28-4328-84BE-849458C9C42B}.Release|x86.ActiveCfg = Release|Any CPU + {B043B1C4-7D28-4328-84BE-849458C9C42B}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {FE360695-3F2B-1049-3887-35FBB5135923} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {5065FDC4-DDE4-4CD0-BDC0-173FB1C5266E} = {FE360695-3F2B-1049-3887-35FBB5135923} + {B4367576-ED80-C199-DB9B-2EB28302B9E2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E3860575-05D1-4882-8E37-A99AC9B209B6} = {B4367576-ED80-C199-DB9B-2EB28302B9E2} + {778427DF-EB2C-5228-964E-6D535DE253CA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {16D1F48D-91F1-4F92-9062-3944A5E64B56} = {778427DF-EB2C-5228-964E-6D535DE253CA} + {B043B1C4-7D28-4328-84BE-849458C9C42B} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..702691b --- /dev/null +++ b/conftest.py @@ -0,0 +1,4 @@ +"""pytest configuration: Qt must never open a real window during tests.""" +import os + +os.environ['QT_QPA_PLATFORM'] = 'offscreen' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f5a27ae..841b457 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,106 +1,53 @@ -# Architecture +# Architecture de Winlestia -Winlestia is a Windows 11 desktop shell (dock, dashboard, glass panels) and a Windows port of -[Caelestia](https://github.com/caelestia-dots/shell) (Quickshell/Linux). Attribution and licences: -`THIRD_PARTY_NOTICES.md`, `src/backend/CAELESTIA-NOTICES.md`, `src/backend/LICENSE-CAELESTIA.txt`, -`src/frontend/LICENSE`, `src/frontend/THIRD_PARTY_NOTICES.md`, `src/frontend/licenses/`. +Vue d'ensemble pour s'orienter dans le dépôt. Les détails vivent dans les documents cités en fin de page. -## Process map +## Processus -```mermaid -flowchart LR - Setup["installer/Winlestia.iss
(Inno Setup, built by installer/build.ps1)"] -->|installs| Backend - subgraph Install["%LOCALAPPDATA%\Programs\Winlestia"] - Backend["Glass-v5\Panneaux.Glass.exe
(.NET 10, WPF + WinForms)"] - Front["Caelestia-Windows\.venv\Scripts\pythonw.exe
frontend.py (PySide6 + QML)"] - Guard["Glass-v5\RETABLIR_BARRE_WINDOWS.exe
--watch-parent (taskbar watchdog)"] - Files["Glass-v5\Winlestia.Files.exe"] - Cli["Glass-v5\winlestia.exe"] - end - Backend -->|starts| Front - Backend -->|starts, lease file| Guard - Front <-->|"pipe PanneauxCaelestia (JSON lines) + event Local-PanneauxCaelestiaWake"| Backend - Cli -->|"pipe WinlestiaCli"| Backend - Files <-->|"single-instance pipe (FilesPipe)"| Backend -``` +| Processus | Source | Rôle | +|---|---|---| +| Glass (backend) | `src/backend/` (`Panneaux.Glass.exe`, C# .NET 8, WPF) | Chef d'orchestre : réglages, barre des tâches, dock, tuilage, audio, fond d'écran, télémétrie. Il est l'unique propriétaire de l'état. | +| Frontend | `src/frontend/` (PySide6 / QML) | Interface (tableau de bord, palette, réglages). Lancé et surveillé par le backend ; il n'a aucun droit sur le bureau. | +| Garde (watchdog) | `Panneaux.Glass.exe --taskbar-guard ` | Processus séparé qui rétablit la barre Windows si le backend s'arrête ou plante. | +| Secours | `SECOURS_BUREAU.exe` (`Secours.cpp`) | Outil indépendant (Ctrl+Alt+Retour arrière) qui arrête Winlestia et rend un bureau normal. | +| Winlestia Fichiers | `src/files/` (WPF) | Gestionnaire de fichiers. Préchargé masqué par le backend, joint par tube nommé (`src/backend/Files/`). | +| CLI `winlestia` | `src/cli/` | Petite commande qui parle au backend par le tube `WinlestiaCli` (`Bridge/CliServer.cs`). | +| Synchro terminal | `src/terminal/` (`sync_theme.py`) | Service local qui relit les réglages et le fond d'écran toutes les quatre secondes et applique la palette à Windows Terminal et à CMD. | -Start-up: the backend runs in Qt mode when `--qt-shell` is passed or `caelestia.enabled` sits next to the exe -(`Coordinator.cs`, `QtMode`), unless `--legacy-shell`. It launches -`\..\Caelestia-Windows\.venv\Scripts\pythonw.exe` on `frontend.py` (override with `--qt-root=`). -A second launch signals the first instance through the `Local\PanneauxGlassV5` mutex and show/quit events, then exits. +Instance unique : un mutex `Local\PanneauxGlassV5` et des événements nommés (afficher, quitter, restaurer la barre) permettent aux relances de signaler l'instance déjà active. -## Folders +## Flux de données -| Path | Responsibility | -|---|---| -| `src/backend` | `Panneaux.Glass.csproj` (net10.0-windows10.0.19041.0, WPF + WinForms). Windows integrations (taskbar hide/restore, audio, media, bookmarks, file search, hotkeys, autostart), the IPC servers, and the legacy "Glass" WPF shell (kept for compatibility, no new features). Native helpers `*.cpp` are built by `build-*.cmd` into `native/` (binaries are git-ignored). | -| `src/backend/Inventory.ps1`, `Install.ps1` | App inventory (writes `catalog.json`) and installation helper. | -| `src/frontend` | Qt Quick shell: `frontend.py` (Bridge, IPC client), `localization.py`, `interaction.py`, `search.py`, palette modules (`wallpaper_theme.py`, `chromatic_palette.py`, `custom_palette.py`), `qml/` (`qml/qs/config/Appearance.qml`, `qml/qs/services/Colours.qml`, panes, dock). Upstream tracking: `UPSTREAM.json`, `PORT_PATCHES.json`, `PORTAGE.md`. | -| `src/files` | Winlestia Files file manager (WPF, `Winlestia.Files.csproj`). | -| `src/cli` | `winlestia.exe` (`winlestia.csproj`, trimmed single file); talks to pipe `WinlestiaCli`. | -| `src/terminal` | Terminal theming (Clink, PowerShell profile, `sync_theme.py`). | -| `installer` | `build.ps1` (full chain) and `Winlestia.iss`. | -| `tests` | Console test projects (one per area) and Python scripts. | -| `docs` | User and developer documentation. | +1. `Program.Main` lit la ligne de commande une seule fois et fabrique un `RunOptions` immuable (mode de lancement normal, revue ou vérification). +2. Le `Coordinator` charge les réglages (`Store`, `Core/Models.cs`), crée les fenêtres WPF et, en mode Qt, le `QtShellBridge`. +3. Le pont démarre le frontend (rattaché à un Job Object : il meurt avec le backend) et ouvre le tube `PanneauxCaelestia`. +4. Le frontend envoie `{command, args}` ; le pont répond par l'état complet, les évènements en file, un éventuel message et des listes ponctuelles. +5. Chaque commande est traitée par une méthode `[BridgeAction("nom")]` (`Bridge/QtShellBridge.Actions.*.cs`). Un test de contrat vérifie que le schéma et les handlers coïncident. +6. Dans l'autre sens, le backend pousse des évènements (`Send("…")`) que le frontend récupère à son prochain échange ; le minuteur de 500 ms échantillonne audio, réseau et télémétrie seulement quand une page les affiche. +7. Les réglages persistent dans `%LOCALAPPDATA%\Panneaux` ; les outils externes (terminal, Fichiers) les relisent. -## Contracts that must not be renamed +## Découpage des gros fichiers -Renaming any of these breaks existing installs or the other side of an IPC boundary. +`Coordinator` et `QtShellBridge` sont des classes partielles découpées par domaine : `Coordinator..cs` (Shell, Tools, Views, Layout, Boards, Preview, Palette, Feedback, Dialogs, Desktop, Catalogue) et `QtShellBridge..cs` (Frontend, Pipe, Cli, Sampling, Icons, State, Actions). Aucun fichier `Coordinator.*` ne dépasse 350 lignes ; les fichiers sources restent sous 500 lignes. -- **Preferences JSON.** `preferences-glass.json` is `Store.State`, serialized from `Preferences` in - `src/backend/Storage/Models.cs` (`Store.Json`: case-insensitive read, indented). Property names are the file keys - (for example `ReplaceTaskbar`, `ShortcutPreferences.Modifiers/Key/View`). Add properties with defaults; never rename. -- **Pipes, events, mutexes.** `PanneauxCaelestia` (`QtShellBridge.cs` server, `frontend.py` client; one JSON object per - line, 128 KiB cap, current user only); `Local\PanneauxCaelestiaWake`; `WinlestiaCli` (`CliServer.cs`; allow-list in - `CliCommands.cs`; reply keys `code` and `text`); the Files pipe (`FilesPipe.cs`, `src/files/SingleInstance.cs`; - `WINLESTIA_FILES_PIPE` is test-only); `Local\PanneauxGlassV5`, `Local\PanneauxGlassTaskbarRestore`, - `Local\PanneauxGuardReady-*` / `Local\PanneauxGuardRelease-*`. -- **JSON keys in IPC messages** are shared by `QtShellBridge.cs` / `QtDockActions.cs` and `frontend.py`; change both - sides in one commit. `src/frontend/tests/check_bridge.py` exercises the pipe. -- **Bridge API used by QML.** The `Bridge(QObject)` class in `frontend.py` exposes `Property` / `Slot` names that - `qml/**/*.qml` reference as `Bridge.` (hundreds of uses). Grep the QML before touching one. -- **Localization.** `ROWS` in `src/frontend/localization.py` are `fr|en|es` lines keyed by the French text. The French - string passed to `qsTr(...)` / `QCoreApplication.translate` must match the first column exactly. -- **Command-line switches** (`Coordinator.cs`, `Main`): `--qt-shell`, `--legacy-shell`, `--qt-root=`, `--show`, - `--quit`, `--restore` (launches `SECOURS_BUREAU.exe`), `--restore-taskbar`, `--taskbar-guard ` - (managed watchdog), `--taskbar-state`, plus `--verify-*`, `--benchmark*` and `--preview*` developer switches. - `RETABLIR_BARRE_WINDOWS.exe` takes `--watch-parent` (`ShellTaskbar.cs`). -- **CLI commands** of `winlestia.exe`: `CliCommands.HelpText` and `tests/CliCommands`. -- **Catalogue.** `catalog.json` is written by `Inventory.ps1` (`Id`, `Name`, `Target`, `Section`, `Category`, - `IconPath`, `SearchText`, ...) and read by `Store.LoadCatalogue` (`Models.cs`) and `AppCatalogueWatcher.cs`. - Keep PowerShell and C# field names identical. -- **Partial classes** spread over several files: `QtShellBridge` (`QtShellBridge.cs`, `QtDockActions.cs`), `HomeWindow` - (+`HomeShellPages.cs`), `ExplorerAppearance` (+`.StableIcons.cs`), `MonitorBrightness` (+`MonitorDisplays.cs`), - `UI` (`UiTokens.cs`, `Visuals.cs`), `WindowTiler` (+`WindowTilerKeys.cs`), `MainWindow.*.cs` in `src/files`. Tests - link source files by name; update `tests/*/*.csproj` if you move or rename a file. -- **Legacy names.** Namespace `PanneauxGlass`, assembly `Panneaux.Glass`, folder `%LOCALAPPDATA%\Panneaux`, - `Glass-v5`, `Caelestia-Windows`, scheduled task `WinlestiaShell`. These are compatibility constraints. +Le Coordinator ne fait plus qu'orchestrer. Les collaborateurs vivent dans `Core/Services/` derrière des interfaces : -## Data locations +- `ShellState` : état partagé (ouvert, replié, détaillé, dialogue ouvert, épinglé…) lu par le Coordinator, le dialogue et la boucle d'horloge. +- `IDialogService` (`Scope`, `ScopeAsync`, `Layers`, `Confirm`, `Message`, `PickFiles`, `PickFolder`) : portée des dialogues modaux. +- `ILaunchService` (`Launch`, `Reveal`, `LaunchTarget`, `LaunchShortcut`, `WebSearch`, `AddPath`, `Rescue`) et `LaunchRules` (décisions pures). +- `ITickController` (`Tick`) : poignée latérale et repli automatique ; `TickRules` garde les seuils. +- `IShortcutBindings` : libellés, états, réaffectation et aiguillage des raccourcis globaux (`Resolve` renvoie un `HotkeyAction`). +- `QuickTabRules` (cibles `glass:`), `LayoutRules` (disposition des panneaux) et `ShellChrome` (construction de la poignée et de la barre d'outils). +- `ICatalogueService`, `IDesktopIconsService`, `ITrayService`, `IPreviewExportService` : catalogue, icônes du Bureau, zone de notification, export d'aperçu. -| What | Where | -|---|---| -| User data (preferences, `glass.log`, clipboard, pins) | `%LOCALAPPDATA%\Panneaux` (`Store.DataDir`, `Models.cs`); `WINLESTIA_DATA_DIR` overrides it (tests). Legacy install-folder data is migrated by `Store.MigrateLegacyData`. | -| Installed program | `%LOCALAPPDATA%\Programs\Winlestia` (`Glass-v5`, `Caelestia-Windows`, `Winlestia-Terminal`) | -| Taskbar lease (restore data) | `TaskbarNative.LeasePath` (`taskbar-session.json`) | -| Autostart | Scheduled task `WinlestiaShell` (see `docs/WINDOWS-INTEGRATIONS.md`) | +Les règles pures (`*Rules`) sont testées dans `tests/Winlestia.Tests` sans fenêtre ni processus. -## Restore paths (invariant) +## Où lire la suite -Every way back to the Windows taskbar must keep working. User instructions: `docs/RESTORE.md`. - -| Path | Code | -|---|---| -| Watchdog, started when the taskbar is hidden | `ShellTaskbar.Enable` starts `RETABLIR_BARRE_WINDOWS.exe --watch-parent` (source `src/backend/TaskbarRescue.cpp`); fallback `Panneaux.Glass.exe --taskbar-guard` -> `TaskbarNative.Guard` | -| `--restore-taskbar` | `Coordinator.cs` -> `TaskbarNative.RestoreSaved` | -| `RETABLIR_BARRE_WINDOWS.exe` | `src/backend/native/`, copied to `Glass-v5` by `installer/build.ps1` | -| `SECOURS_BUREAU.exe` | `src/backend/Secours.cpp`, `build-rescue.cmd` | -| Ctrl+Alt+Backspace | `GlobalShortcut.cs` (`RegisterHotKey`, modifiers 0x4003, key 0x08) | - -## Backend namespaces - -`src/backend` is split into one folder per area, each with its own namespace `PanneauxGlass.` (listed as global usings in `src/backend/GlobalUsings.cs`): Shell (coordinator, Qt bridge, CLI server), Media, Social, Explorer, Desktop (tiling, virtual desktops, wallpaper, fullscreen), Platform (network, power, telemetry, autostart, app catalogue), Storage (Store, Preferences, models), Interop (P/Invoke), Views (WPF windows and visual tokens), Tools (clipboard, capture, dictation, palette, bookmarks), SelfTest (verification harnesses) and Shared (`Winlestia.Shared`, also linked by `src/files`). `Program.cs` stays at the root. - -## Localisation of the Glass shell - -User-visible text of the WPF shell and of Winlestia Files lives in `src/backend/Views/Strings.cs` (`PanneauxGlass.Views`), linked into `src/files` as `Shared\Strings.cs`. It holds one table of `(Fr, En, Es?)` per key; French values are the original literals, English is the fallback, Spanish exists only where the Qt shell already has a translation and falls back to English otherwise. The language is resolved once, on first use, in this order: the `language` key (`auto`, `fr`, `en`, `es`) of `\Caelestia\language.json` (the file the Qt shell writes), then `CurrentUICulture`, then English. Strings with counts or names are format methods (`Strings.StatusLine`, `Strings.DeletePermanentlyConfirm`). Exception messages that reach a message box, toast or status label are thrown from `Strings`; log-only messages stay English. Category and folder names that double as data keys (board filters, tool categories) keep their key; only the displayed text goes through `Strings.CategoryLabel` or `Strings.KnownFolder`. `tests/StringsTable` checks the table. +- Carte des dossiers du backend : `src/backend/README.md`. +- Contrat du pont : `docs/BRIDGE.md` et `docs/bridge-schema.json` (test : `tests/Winlestia.Tests/BridgeContractTests.cs`). +- Règle de threading (thread UI, tâches, sondes) : `docs/THREADING.md`. +- Restauration de la barre des tâches, du bureau et du terminal : `docs/RESTORE.md`, `docs/TROUBLESHOOTING.md`. +- Explorateur natif : `docs/NATIVE-EXPLORER.md`. Raccourcis : `docs/SHORTCUTS.md`. +- Tests et commandes de vérification : `docs/TESTS.md`. +- Installation et désinstallation : `docs/INSTALL.md`. diff --git a/docs/AUTO-PALETTE-PROGRESS.md b/docs/AUTO-PALETTE-PROGRESS.md deleted file mode 100644 index b905635..0000000 --- a/docs/AUTO-PALETTE-PROGRESS.md +++ /dev/null @@ -1,7 +0,0 @@ -# Auto palette progress (DONE, not deployed) -- chromatic_palette.py rewritten: analyze_images(paths,key) -> seeds/lightness/colorfulness; palette_from_images(); generate_palette() kept as wrapper. -- Pipeline: pool pixels (<=96px per frame) -> 5-bit histogram -> deterministic weighted k-means (24, OKLab) -> HCT per cluster -> MCU-style score + 15deg hue dedup -> seed gets chroma of its hue family (dark bulk is low chroma, accents lift it). -- Scheme: own MD3 tonal assembly (primary C boosted from seed; secondary same hue lower C; tertiary = 2nd seed if >=40deg else hue+60; neutral tinted by primary), tones MD3 (dark surface 6, containers 4/10/12/17/22), contrast guard. -- Light/dark from area-weighted L* with hysteresis (>=62 light, <=50 dark, band keeps previous, default dark). frontend.py `dark` property now follows theme in auto mode. -- Tests: src/frontend/tests/test_chromatic_palette.py (11 OK). Timing 8x320px: 10.8 ms median; 1 image 4.5 ms. -- Sheets in scratchpad: palette-.png. Not verified: live shell with light auto palette, glass over light wallpapers. diff --git a/docs/BRIDGE.md b/docs/BRIDGE.md new file mode 100644 index 0000000..90c0037 --- /dev/null +++ b/docs/BRIDGE.md @@ -0,0 +1,157 @@ +# Pont backend C# <-> frontend PySide6 + +Contrat du canal entre `src/backend/Bridge/QtShellBridge*.cs` (serveur ; un handler `[BridgeAction("nom")]` par action, dans `QtShellBridge.Actions.*.cs`) et `src/frontend/bridge_client.py` (client). Version du protocole : **1** (`QtShellBridge.ProtocolVersion`, `bridge_client.PROTOCOL_VERSION`). La forme lisible par machine est `docs/bridge-schema.json` ; les tests `BridgeContractTests` (xUnit) et `tests/test_bridge_contract.py` (pytest) vérifient par extraction statique que chaque message émis ou traité y figure. + +## Cadrage + +- Canal nommé Windows `\\.\pipe\PanneauxCaelestia`, octets, un seul client, `CurrentUserOnly`. +- JSON UTF-8, **une ligne par message** terminée par ` +`. Requête client > 131072 octets : connexion fermée ; réponse lue côté client jusqu'à 8 Mio. +- Mode **requête/réponse strict** : le client écrit une requête, le serveur répond exactement une ligne. Le serveur n'écrit jamais de lui-même ; les notifications passent par la file `events` de la réponse suivante. +- Le client interroge en boucle (`state` toutes les 180 ms visible, 500 ms sinon, 1 s en mode silencieux ; `visualizer` 55 ms ; `audioBars` 33 ms) et intercale les commandes utilisateur. + +## Poignée de main / version + +Pas de message dédié : chaque requête porte `args.protocol` (entier). Au **premier message de chaque connexion**, le serveur compare à `ProtocolVersion` et écrit un avertissement dans le journal (`Qt IPC: version de protocole différente ...`) en cas de divergence ou d'absence (client ancien). La connexion n'est jamais refusée ; les commandes inconnues sont ignorées silencieusement. Un client récent sur un serveur ancien ne reçoit rien de plus (champ ignoré). + +## Requête (client -> serveur) + +`{"command": nom, "args": {...}}`. Les champs d'`args` absents prennent la valeur par défaut du serveur (`""`, `0`, `false`). Le client fusionne dans **chaque** requête l'abonnement courant : + +`pid:number, epoch:number, protocol:number, visible:boolean, page:number, dock:boolean, controls:boolean, mixer:boolean, wifi:boolean, bars:boolean` + +`pid` (processus du frontend), `epoch` (compteur de navigation ; les évènements `close` d'une époque révolue sont écartés), `visible/page/dock/controls/mixer/wifi/bars` (ce que le frontend affiche, pilote l'échantillonnage). `seq` n'est utile qu'à `audioBars`. + +Cas spéciaux traités avant le `switch` : `dictation` (renvoie `{"dictation": {state, level, levels, lang, text, error}}`), `audioBars` (`{"audioBars": {seq, idle, bars}}`), `visualizer` (`{"spectrum": [..]}`). Toutes les autres commandes renvoient la réponse générale. + +### Commandes + +| Commande | Champs `args` | Requis | +|---|---|---| +| `activate` | hwnd:number, id:string | hwnd | +| `addAccess` | board:string | - | +| `addFolder` | board:string | - | +| `audio` | - | - | +| `audioBars` | seq:number | - | +| `bookmarksBrowser` | id:string | - | +| `bookmarksDeleteInBrowser` | confirmSync:boolean, id:string | - | +| `bookmarksHide` | id:string | - | +| `bookmarksItems` | - | - | +| `bookmarksOpen` | id:string | - | +| `bookmarksRefresh` | ifDirty:boolean | - | +| `bookmarksUnhideAll` | - | - | +| `brightness` | id:string, value:number | - | +| `capture` | - | - | +| `captureExport` | path:string | path | +| `captureShortcutMode` | enabled:boolean | - | +| `clipboard` | - | - | +| `clipboardCopy` | id:string | - | +| `clipboardFiles` | paths:array | - | +| `clipboardPin` | id:string | - | +| `copyText` | text:string | text | +| `customTheme` | colors:object, glass:boolean, opacity:number | - | +| `desktopClose` | index:number | - | +| `desktopNew` | - | - | +| `desktopSwitch` | index:number, relative:number | - | +| `deviceMute` | id:string, muted:boolean | - | +| `deviceSelect` | id:string | - | +| `deviceVolume` | id:string, value:number | - | +| `dictation` | - | - | +| `dictationCapture` | enabled:boolean | - | +| `dockApp` | action:string, confirmed:boolean, hwnd:number, id:string, index:number | - | +| `dockPreview` | cx:number, cy:number, dpr:number, id:string, x:number, y:number | - | +| `dockPreviewEnd` | now:boolean | - | +| `edit` | id:string | - | +| `editEntry` | category:string, id:string, name:string | - | +| `emptyRecycleBin` | - | - | +| `entryIcon` | id:string, path:string | - | +| `explorerFolder` | - | - | +| `explorerOpen` | - | - | +| `explorerPalette` | accent:string, background:string, text:string | - | +| `explorerReport` | - | - | +| `explorerRestore` | - | - | +| `favorite` | id:string | - | +| `fileCancel` | - | - | +| `fileOpen` | gen:number, index:number | index | +| `fileResults` | - | - | +| `fileSearch` | gen:number, term:string | term | +| `icon` | id:string | - | +| `launch` | id:string | - | +| `masterMute` | muted:boolean | - | +| `media` | action:string, value:number | - | +| `mediaVolume` | value:number | - | +| `microphone` | - | - | +| `move` | board:string, id:string | - | +| `openFiles` | - | - | +| `palette` | - | - | +| `paletteRun` | id:string, kind:string, query:string | - | +| `preference` | enabled:boolean, key:string, value:string | key | +| `profiles` | - | - | +| `quit` | - | - | +| `radio` | kind:string | - | +| `raiseMedia` | - | - | +| `recycleFiles` | paths:array | - | +| `refreshCatalogue` | - | - | +| `restoreTaskbar` | - | - | +| `reveal` | id:string | - | +| `runningRefresh` | - | - | +| `sessionMute` | id:string, muted:boolean | - | +| `sessionVolume` | id:string, value:number | - | +| `settings` | section:string | - | +| `shortcut` | combo:string, id:any, key:number, kind:string, modifiers:number | - | +| `shortcut-reset` | id:string | - | +| `socialEnable` | enabled:boolean, id:string | - | +| `socialGlobalMute` | muted:boolean | - | +| `socialHistoryClear` | - | - | +| `socialLogin` | id:string | - | +| `socialLogout` | id:string | - | +| `socialMute` | id:string, muted:boolean | - | +| `socialOpen` | id:string | - | +| `socialSound` | enabled:boolean | - | +| `socialTestSound` | - | - | +| `socialVolume` | value:number | - | +| `state` | - | - | +| `systemAction` | confirmed:boolean, id:string | - | +| `theme` | dark:boolean, mode:string | - | +| `toolRun` | id:string | - | +| `tools` | - | - | +| `visualizer` | - | - | +| `volume` | value:number | - | +| `wallpaperApply` | display:number, path:string | path | +| `wallpaperCapture` | h:number, token:string, w:number, x:number, y:number | - | +| `wallpaperChanged` | - | - | +| `wallpaperPicker` | - | - | +| `website` | id:string | - | +| `websites` | - | - | +| `wifiConnect` | password:string, ssid:string | ssid | +| `wifiForget` | ssid:string | ssid | +| `wifiPortal` | - | - | +| `wifiSettings` | page:string | - | +| `windowList` | - | - | +| `workspace` | id:string | - | +| `zipFiles` | paths:array | - | + +### Clés de `preference` + +`args.key` choisit la préférence ; `enabled` (booléen) ou `value` (nombre / chaîne) porte la valeur. Clés : `accentBorders`, `activeBorderOnly`, `autoTile`, `autostart`, `bookmarksRailVisible`, `clipboardHistory`, `clipboardImages`, `clipboardLimit`, `customGlassOpacity`, `desktopIndicator`, `desktopLogo`, `dictationEnabled`, `dictationKey`, `dockDrawerOpen`, `dockVisible`, `dockWidth`, `explorerTheme`, `filesDefaultHandler`, `filesPreload`, `frameThickness`, `glassOpacity`, `iconSize`, `motion`, `osd`, `quietFullscreen`, `reducedEffects`, `replaceTaskbar`, `screenFrame`, `socialBackground`, `socialRailVisible`, `taskbar`, `tileExclusionRemove`, `topActivationMargin`. Le détail des champs par clé est dans le schéma (`preferenceKeys`). + +## Réponse générale (serveur -> client) + +`{"state", "events", "notice", "wallpaperCapture", "bookmarksItems", "windows", "fileResults", "wallpaperPicker"}` ; les clés `null` sont émises telles quelles. + +- `state` : objet instantané, clés de premier niveau : `systemRecent`, `wallpapers`, `wallpaperRevision`, `audioReadMs`, `audioMixer`, `quickTabs`, `social`, `bookmarks`, `settings`, `clipboard`, `diagnostics`, `dark`, `taskbarEnabled`, `networkKind`, `virtualDesktops`, `fullscreenMonitors`, `quiet`, `quietForced`, `performance`, `sysInfo`, `media`, `controls`, `apps`, `favorites`, `recent`, `websites`, `profiles`, `taskbarApps`, `runningApps`, `icons`, `iconRevision`. Le contenu des sous-objets est libre (lu avec `.get` côté Python, donc tolérant aux champs manquants) et n'est pas figé par ce contrat. +- `events` : tableau de chaînes, vidé à chaque réponse (32 maximum en file). +- `notice` : texte à afficher une fois (erreur ou confirmation). +- `wallpaperCapture`, `bookmarksItems`, `windows`, `fileResults`, `wallpaperPicker` : réponses ponctuelles aux commandes homonymes. + +### Évènements (`events`) + +Exacts : `toggle`, `access`, `media`, `performance`, `controls`, `palette`, `settings`, `settings:social`, `settings:bookmarks`, `clipboard`, `audio`, `microphone`, `tools`, `capture`, `capture-ocr`, `capture-select`, `wallpaper-switcher`, `close`, `bookmarks`, `social`, `files`, `dock`, `dictation:start`, `dictation:stop`, `dictation:keycancel`. +Préfixes (suffixe variable) : `workspace:`, `osd:volume:`, `osd:brightness:`, `shortcut-key:`, `shortcut-saved:`, `osd:mic:`, `dictation:key:` — `workspace:`, `osd:volume::`, `osd:brightness:`, `osd:mic::`, `shortcut-key::`, `shortcut-saved:`, `dictation:key:`. +Évènements gérés côté client seulement (jamais émis par le serveur actuel) : `mixer`. + +## Faire évoluer le contrat + +1. Ajouter le message dans `docs/bridge-schema.json` et ce document. +2. Changer d'incompatibilité (champ retiré, sens modifié) : incrémenter `ProtocolVersion` et `PROTOCOL_VERSION` ensemble (un test vérifie qu'ils sont égaux au schéma). +3. Ajouter un champ optionnel ou une commande ne change pas la version. diff --git a/docs/BUILD-AND-TEST.md b/docs/BUILD-AND-TEST.md deleted file mode 100644 index e56ed42..0000000 --- a/docs/BUILD-AND-TEST.md +++ /dev/null @@ -1,74 +0,0 @@ -# Build and test - -Requirements: Windows 11 x64, .NET SDK 8.0.x, Python 3.12, PowerShell 7, Inno Setup 6 (installer only), MSVC only -to rebuild the native helpers. Commands below come from `installer/build.ps1` and the `.csproj` files. - -## Backend - -`src/backend/Panneaux.Glass.csproj`: `net10.0-windows10.0.19041.0`, WPF + WinForms, `win-x64`, version in ``. - -```powershell -dotnet build src\backend\Panneaux.Glass.csproj -c Release -dotnet publish src\backend\Panneaux.Glass.csproj -c Release -r win-x64 --self-contained true ` - -p:PublishSingleFile=false -p:DebugType=none -p:DebugSymbols=false -o build\dev\Glass-v5 -``` - -Other .NET projects (the installer publishes all three into the same `Glass-v5` folder): - -```powershell -dotnet publish src\files\Winlestia.Files.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=false -o build\dev\Glass-v5 -dotnet publish src\cli\winlestia.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -o build\dev\Glass-v5 -``` - -## Native helpers - -`GlassBackdrop.dll`, `ProcessAudio.dll`, `SECOURS_BUREAU.exe`, `RETABLIR_BARRE_WINDOWS.exe` are not produced by -`dotnet publish`. Rebuild with MSVC from `src\backend`: `build-backdrop.cmd`, `build-process-audio.cmd`, -`build-rescue.cmd`, `build-taskbar-rescue.cmd`; results live in `src\backend\native\` (git-ignored, see its -`README.md`). `installer\build.ps1` stops if `RETABLIR_BARRE_WINDOWS.exe`, `SECOURS_BUREAU.exe` or -`GlassBackdrop.dll` is missing there. - -## Frontend (development) - -```powershell -python -m venv src\frontend\.venv -src\frontend\.venv\Scripts\pip install -r src\frontend\requirements.txt -``` - -Pinned in `src/frontend/requirements.txt`: PySide6 6.10.3, material-color-utilities, numpy, Pillow. The shipped -build uses embedded Python 3.12.10 (`$pythonVersion` in `installer/build.ps1`). - -## Installer - -```powershell -pwsh installer\build.ps1 -``` - -Steps: clean `build\payload`, publish backend/Files/CLI into `build\payload\Glass-v5`, copy native files and -`Inventory.ps1`, copy `src\frontend` to `build\payload\Caelestia-Windows`, unpack embedded Python into -`.venv\Scripts`, `pip install --target` the requirements, prune unused Qt modules, verify the runtime and -frontend import, copy `src\terminal`, then run `ISCC.exe installer\Winlestia.iss`. - -Output: `build\out\Winlestia-Setup-.exe` (`OutputDir=..\build\out` in `installer/Winlestia.iss`; the -version is `AppVersion` there and `` in the csproj; the final `Write-Host` in `build.ps1` also hard-codes -it). For a neutral release build follow the comment header of `installer/build.ps1` (build from a path without a -user name). - -## Tests - -Each `tests/` folder is a console project that links the source files it checks and prints `TOTAL x/y`. - -```powershell -dotnet run --project tests\WifiPolicy -c Release -``` - -Run only the project matching your change. Known issue: `tests/FullscreenWatcher` does not terminate. -Set `WINLESTIA_DATA_DIR` to a throwaway folder first so nothing touches `%LOCALAPPDATA%\Panneaux`: - -```powershell -$env:WINLESTIA_DATA_DIR = "$env:TEMP\winlestia-test" -``` - -Python checks: `tests\*.py` and `src\frontend\tests\*.py` (some need a running shell or a Qt display; read the -script header first). QML lint: `qmllint` from the PySide6 install, for example -`src\frontend\.venv\Scripts\pyside6-qmllint.exe -I src\frontend\qml `. diff --git a/docs/ESPACE-REDESIGN-PROGRESS.md b/docs/ESPACE-REDESIGN-PROGRESS.md deleted file mode 100644 index ef272df..0000000 --- a/docs/ESPACE-REDESIGN-PROGRESS.md +++ /dev/null @@ -1,10 +0,0 @@ -# Espace ("Tout ton espace") redesign progress -- File: src/frontend/qml/WorkspaceBrowser.qml (always instantiated in Main.qml, visible toggled by pageMode). -- Python: AppData/Local/Programs/Winlestia/Caelestia-Windows/.venv/Scripts/python.exe (PySide6 ok if version printed). -- Harness: src/frontend/tests/render_espace.py + EspaceHarness.qml (new). Scratch PNGs in session scratchpad. -## Log -- [1] Located panel, read it. Candidate causes: per-delegate C.ToolTip (Popup) + StateLayer, duplicate Bridge.action("icon") per delegate, full JS recompute (indexed/base/categories/entries) on becoming visible. -- [2] Harness done (tests/render_espace.py + EspaceHarness.qml, stub 372 items). BEFORE (orig WorkspaceBrowser, saved at scratchpad/WorkspaceBrowser.orig.qml): open->first-frame median 43.4 ms (runs 40.6-47.2), of which ~35 ms is synchronous inside setting visible (JS index/filter only 1-3 ms; rest = ~34 delegate creations, each with StateLayer+C.ToolTip Popup+dup Bridge.action("icon")); delegates destroyed on every hide (model:[] when hidden). ToolTip removal alone = -4 ms. -- [3] WorkspaceBrowser.qml rewritten (persistent ListModel with incremental diff, recycled delegates, shared tooltip, sliding nav pill, wrapped chips, springs, staggered reveal, fx gating). NEXT: run harness, fix warnings, shots, bench after. -- [4] File WAS written (coordinator note was stale). Harness check_espace.py: 16/16 PASS (hidden=0 frames, settled idle=0 frames, click/right-click/Ctrl+F/type/Down+Enter/chips/scope/list/empty/catalog change). AFTER bench median ~7.4 ms (before 43.4). Shot espace-before.png saved. Popup menu not captured by grabWindow (opened=true verified). NEXT: reduced-effects run, final shots red/teal/1366/search, final report. Do NOT touch Workspaces.qml/Content.qml/Models.cs. -- [5] DONE. Final: before median 43.5 ms, after 7.9 ms (fx) / 4.9 ms (reduced); check_espace 16/16 in fx and reduced; all PNGs read. Only report remains. diff --git a/docs/EXPLORER-MVP-PLAN.md b/docs/EXPLORER-MVP-PLAN.md deleted file mode 100644 index 144b062..0000000 --- a/docs/EXPLORER-MVP-PLAN.md +++ /dev/null @@ -1,85 +0,0 @@ -# Winlestia Fichiers — plan du MVP - -Statut : plan, rien d'implémenté. Local uniquement, pas de push ni de release. - -## Principes -- **Optionnel** : désactivé par défaut. Il s'active dans Personnaliser > Explorateur > « Ouvrir les dossiers avec Winlestia Fichiers ». -- **Aucune dépendance tierce, aucune injection.** Il n'utilise que les API shell publiques (COM). -- **Repli permanent sur Explorer.** Tout ce qui n'est pas un chemin de fichiers ordinaire, et tout échec, est renvoyé vers `explorer.exe`. -- **HKCU uniquement.** L'installateur reste en `PrivilegesRequired=lowest`, et c'est réversible à 100 %. -- **Identité unifiée.** Il reprend `Tokens.cs` et `UiTokens.cs` (palette M3, rayons 12/17/25, Rubik, Material Symbols, Effets allégés). - -## Architecture -- **Exécutable séparé** : `src/files/Winlestia.Files.csproj` (WPF, net10.0-windows10.0.19041.0), publié à côté de `Panneaux.Glass.exe`. Un plantage n'emporte donc ni le dock ni le backend. -- **Partage des jetons** : les fichiers `Tokens.cs`, `UiTokens.cs` et `Visuals.cs` sont liés (``), sans copie. -- **Choix de WPF plutôt que QML** : `IContextMenu3` et `IDropTarget` ont besoin d'un HWND et de COM dans le même processus. QML ne garantit pas la virtualisation des vues. -- **Instance unique** : une deuxième ouverture envoie le chemin par pipe nommé et ouvre un nouvel onglet. -- **Thème** : à l'ouverture, on lit le même fichier de réglages que le backend (palette, `reducedEffects`). Un changement de palette est reçu par le pipe. - -## Périmètre MVP -| # | Fonction | Mise en œuvre | -|---|---|---| -| 1 | Navigation : adresse (fil d'Ariane + saisie), précédent/suivant/parent, onglets | `IShellItem` / `SHCreateItemFromParsingName` | -| 2 | Volet latéral : Accès rapide (lecture), dossiers personnels, lecteurs | `SHGetKnownFolderPath`, `DriveInfo` | -| 3 | Liste et grille virtualisées, tri par nom/date/taille/type | `VirtualizingStackPanel` / `VirtualizingWrapPanel`, recyclage | -| 4 | Énumération asynchrone par lots de 256, annulable | `Directory.EnumerateFileSystemEntries` sur un thread de fond | -| 5 | Icônes et vignettes | `IShellItemImageFactory` sur un pool limité, cache LRU, annulation au défilement | -| 6 | Ouvrir, ouvrir avec | `ShellExecuteEx` (verbe par défaut) | -| 7 | Menu clic droit natif complet | `IContextMenu3` hébergé, avec en tête nos entrées stylées (Copier le chemin, Ouvrir dans Explorer) | -| 8 | Copier / couper / coller / supprimer (Corbeille) / renommer / nouveau dossier, avec annulation Windows | `IFileOperation` (progression native) | -| 9 | Glisser-déposer entrant et sortant | `DoDragDrop` / `IDropTarget` OLE | -| 10 | Rafraîchissement automatique | `SHChangeNotifyRegister`, avec mise à jour incrémentale sans reconstruction de liste | -| 11 | Filtre rapide sur le dossier courant (pas de recherche indexée) | Filtre en mémoire | -| 12 | États vides et d'erreur (accès refusé, dossier introuvable) | Bloc `EmptyState` | - -**Hors MVP** : recherche Windows Search, volet d'aperçu, téléphones (MTP), zip comme dossier, réseau en navigation, bibliothèques, Win+E, épingle de la barre des tâches. - -## Repli vers Explorer (`PathRouter`) -Le chemin passe par `explorer.exe ""` puis l'application se ferme, dans chacun de ces cas : -- l'argument commence par `shell:`, `::{`, `\\?\` ou `\\` (UNC), ou bien c'est un `.zip`, ou un chemin qui n'est pas un dossier existant ; -- le dossier est la Corbeille, le Panneau de configuration, Ce PC ou un ID shell virtuel ; -- l'énumération échoue pour une autre raison qu'un accès refusé. - -La commande « Ouvrir dans Explorer » est toujours présente, dans le menu et en `Ctrl+Maj+E`. - -**Anti-boucle** : le lancement d'Explorer utilise `explorer.exe` avec un chemin explicite et ne passe jamais par le verbe `Directory`. - -## Enregistrement comme gestionnaire par défaut -Clés écrites seulement à l'activation : -``` -HKCU\Software\Classes\Directory\shell\winlestia\command (Default) = "\Glass-v5\Winlestia.Files.exe" "%1" -HKCU\Software\Classes\Directory\shell (Default) = winlestia -HKCU\Software\Classes\Drive\shell\winlestia\command (Default) = idem -HKCU\Software\Classes\Drive\shell (Default) = winlestia -``` -- `Folder\shell` n'est **pas** touché, pour ne pas capturer les dossiers virtuels. -- Avant l'écriture, une sauvegarde des valeurs existantes est faite dans `%LOCALAPPDATA%\Winlestia\Files\handler-backup.json`. -- `--restore` remet ces valeurs exactement et supprime nos clés. Il est appelé à la désactivation, par l'installateur (`[UninstallRun]`) et par le garde-fou. - -**Garde-fou :** -- Au démarrage, le backend vérifie que l'exe existe. S'il est absent, il appelle `--restore`. -- Trois plantages au démarrage en moins de 60 s désactivent l'option, appellent `--restore` et affichent un toast. -- Un journal `%LOCALAPPDATA%\Winlestia\Files\crash.log` garde au plus 200 lignes. - -## Performance (critères mesurés, pas estimés) -- **Ouverture d'un dossier de 50 fichiers** : moins de 300 ms jusqu'à la première peinture, en démarrage à chaud. Le démarrage à froid est mesuré à part. -- **Dossier de 10 000 fichiers** : premières lignes en moins de 150 ms, aucun gel du fil d'interface de plus de 50 ms (minuteur de détection), mémoire privée sous 250 Mo. -- **Au repos** : 0 % de CPU et aucun minuteur actif quand la fenêtre est réduite. Les vignettes sont suspendues hors écran. -- **Fuites** : 50 ouvertures et fermetures d'onglets, puis 30 s de repos et un nouvel échantillon. Handles et threads doivent revenir à ±5 % de la mesure de départ. -- **Effets allégés** : pas d'animations ni de flou, comme le reste du shell. - -## Étapes et validation -Chaque étape est validée à l'écran sur cette machine avant de passer à la suivante. - -1. **Squelette** : projet, fenêtre unifiée, `PathRouter` et tests unitaires du routage (`tests/FilesRouter`). Ajout au `build.ps1` et à `Deploy-Local.ps1`. -2. **Navigation et liste** : énumération asynchrone, vue virtualisée, tri, onglets, volet latéral. Première mesure de performance. -3. **Icônes et vignettes** : pool de threads, cache, annulation. Nouvelle mesure. -4. **Actions** : ouvrir, `IFileOperation`, renommer, presse-papiers, menu natif, glisser-déposer. -5. **Rafraîchissement automatique**, états vides et d'erreur, filtre. -6. **Gestionnaire par défaut** : réglage dans Personnaliser, écriture des clés, sauvegarde, `--restore`, garde-fou. Test aller-retour sur une ruche de test, puis sur HKCU réel avec contrôle manuel du Bureau et de « Afficher dans le dossier ». -7. **Installateur** : fichiers dans `Winlestia.iss` et `--restore` à la désinstallation. Test d'installation et de désinstallation sur une session Windows propre (autre compte ou VM) : c'est le seul test « autre machine ». -8. **Revue** (sécurité du registre, fuites), puis rapport en quatre colonnes : modifié / testé / validé visuellement / imparfait ou dépendant de Windows. - -## Limites connues, même terminé -- Win+E, l'épingle Explorer de la barre des tâches, `explorer.exe /select,` appelé directement par une application, et les boîtes Ouvrir/Enregistrer restent natifs. -- Le contenu du menu clic droit natif (entrées des autres logiciels) garde le style Windows. Seuls le cadre et nos entrées sont stylés. diff --git a/docs/FOLDER-ICONS-PROGRESS.md b/docs/FOLDER-ICONS-PROGRESS.md deleted file mode 100644 index 8700c59..0000000 --- a/docs/FOLDER-ICONS-PROGRESS.md +++ /dev/null @@ -1,9 +0,0 @@ -# Folder icons: stable path scheme (progress) - -- Icons now live at `%LOCALAPPDATA%\Winlestia\Explorer\icons\-current.ico` (folder, desktop, download, document, picture, music, video); `icons\state.json` stores the current accent and the Migrated flag. -- Palette change: Sync arms a 1 s debounced regeneration (atomic temp + replace, off the UI thread, skipped if accent unchanged), then one SHCNE_ASSOCCHANGED and SHCNE_UPDATEITEM per manifest folder (64 per 250 ms; 32 per 500 ms with reduced effects). No desktop.ini rewrite. -- Migration: one background pass (MigrateAsync) rewrites manifest inis that point to legacy `--v2.ico`; skipped when ini hash != AppliedHash; then unreferenced legacy icons are deleted. -- Junctions/reparse points stay skipped (their target is themed when visited). -- ColourVisibleFolder(path): thread safe, 3 s per-folder debounce, 12 calls / 10 s budget (not wired to Files yet). -- Shell cache measurement: a fresh process sees the new colour at the same path immediately; a warm process (proxy for explorer.exe) did not refresh in 9 s in ANY case, including the alternate-path control, so the proxy is inconclusive for explorer.exe. To verify manually after redeploy. -- Tests: `tests/FolderIcons` (18/18), `tests/Explorer077` (36/36). diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 428283e..481dcb4 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -11,7 +11,7 @@ This guide is for people who just want Winlestia running, with no technical back ## 1. Download -Go to the [Releases page](https://github.com/lilidoxdev/winlestia/releases/latest) and download `Winlestia-Setup-0.9.1.exe`, the installer. The SHA-256 used to verify it is in the [v0.9.1 release notes](https://github.com/lilidoxdev/winlestia/releases/tag/v0.9.1). +Go to the [Releases page](https://github.com/lilidoxdev/winlestia/releases/latest) and download `Winlestia-Setup-0.9.0.exe`, the installer. The SHA-256 used to verify it is in the [v0.9.0 release notes](https://github.com/lilidoxdev/winlestia/releases/tag/v0.9.0). ## 2. Verify the download (recommended) @@ -21,10 +21,10 @@ The installer is not code-signed (see the next section). A checksum lets you con 2. Run, adjusting the folder if you did not save to Downloads: ```powershell - Get-FileHash "$env:USERPROFILE\Downloads\Winlestia-Setup-0.9.1.exe" -Algorithm SHA256 + Get-FileHash "$env:USERPROFILE\Downloads\Winlestia-Setup-0.9.0.exe" -Algorithm SHA256 ``` -3. Compare the `Hash` shown by PowerShell with the SHA-256 in the [release notes](https://github.com/lilidoxdev/winlestia/releases/tag/v0.9.1). It must match exactly (letter case does not matter). +3. Compare the `Hash` shown by PowerShell with the SHA-256 in the [release notes](https://github.com/lilidoxdev/winlestia/releases/tag/v0.9.0). It must match exactly (letter case does not matter). If the values differ, do not run the file. Download it again, and open an issue if it still differs. @@ -39,7 +39,7 @@ If the checksum matched and you want to continue: ## 4. Run the setup -The setup wizard is in English by default and picks French or Spanish when Windows uses one of them. The steps are: +The setup wizard is in French only (no other language is offered). The steps are: 1. **Welcome** and licence (GPL-3.0). 2. **Tasks** (checked on first install unless noted): @@ -69,7 +69,7 @@ If something looks wrong, jump to [TROUBLESHOOTING.md](TROUBLESHOOTING.md). The ## Updating from an older version -Run the new `Winlestia-Setup-0.9.1.exe`. The setup detects the existing install and offers to update (or reinstall, or uninstall). It closes the running shell itself before replacing files, so you do not need to quit Winlestia first. Your settings in `%LOCALAPPDATA%\Panneaux` are kept. If terminal integration was already enabled, it stays checked. +Run the new `Winlestia-Setup-0.9.0.exe`. The setup detects the existing install and offers to update (or reinstall, or uninstall). It closes the running shell itself before replacing files, so you do not need to quit Winlestia first. Your settings in `%LOCALAPPDATA%\Panneaux` are kept. If terminal integration was already enabled, it stays checked. ## Uninstalling diff --git a/docs/PERF-AUDIT-PROGRESS.md b/docs/PERF-AUDIT-PROGRESS.md deleted file mode 100644 index 5e118a9..0000000 --- a/docs/PERF-AUDIT-PROGRESS.md +++ /dev/null @@ -1,10 +0,0 @@ -# Dashboard perf audit - -Live idle, 30 s, read-only: pythonw 1.25 % of one core, WS 546 MB, 1601 handles, 155 threads; Panneaux.Glass 1.77 % core, WS 246 MB, 1150 handles, 32 threads. - -Harness: src/frontend/tests/perf_dashboard.py + DashHarness.qml (offscreen, software scenegraph, vsync-capped ~16 ms: frame intervals show dropped frames, not GPU cost). -Findings: hidden tabs idle (0 timers, 0 animations, none loaded until first visit); cold first switch costs 15 ms (Media), 3 ms (Performance), 26 ms (Workspaces) synchronous. -Prime suspect fixed: frontend.py update_state emitted Bridge.changed on every poll because performance/sysInfo (cpu %, etc.) differ each poll; every Bridge binding re-evaluated (1.6 ms/emit in stub, more with real getters). Poll is 55 ms (page 1 visible), 180 ms visible, 500 ms hidden. -Fix: perfChanged signal for performance/sysInfo; changed only for other keys. -Not changed: Wrapper.qml:48 layer.enabled on whole dashboard (not measurable offscreen), sync Loaders (Content sizing depends on currentItem). -Before/after: see final report. Backend signal rate not measured live (would require talking to the pipe). diff --git a/docs/PERSONNALISER-REDESIGN-PROGRESS.md b/docs/PERSONNALISER-REDESIGN-PROGRESS.md deleted file mode 100644 index 9c8260d..0000000 --- a/docs/PERSONNALISER-REDESIGN-PROGRESS.md +++ /dev/null @@ -1,41 +0,0 @@ -# Personnaliser redesign - progress - -Files: src/frontend/qml/ShellPreferences.qml (rewritten), src/frontend/qml/Main.qml (settings panel width 690 -> min(900, screenWidth-80)), tests/prefs_shots.py (offscreen screenshots, 60 s hard timeout). - -## Inventory (before = after) -Bridge key / slot, old location -> new page. -1. theme (4 cards light/dark/custom/auto) + custom editor, Apparence -> Apparence / Theme -2. Modifier ma palette (custom), Apparence -> Apparence / Theme -3. Wallpaper palette dots (auto, display) -> Apparence / Theme -4. glassOpacity slider -> Apparence / Rendu (now always visible, disabled outside Automatique) -5. reducedEffects ("Effets allégés") -> Apparence / Rendu -6. accentBorders (Liseré) -> Apparence / Rendu (moved from Explorateur) -7. explorerTheme -> Explorateur et fichiers / Explorateur Windows -8. filesDefaultHandler -> Explorateur et fichiers / Winlestia Fichiers -9. filesPreload -> Explorateur et fichiers / Winlestia Fichiers -10. explorerFolder (Ajouter un dossier), 11. explorerOpen, 12. explorerRestore -> Explorateur Windows - (the 4+ unlabeled icon buttons were decorative tiles, not controls: kept as a preview strip tied to "Icônes et contours assortis") -13. explorerStatus text + adaptive-icons note -> Explorateur Windows -14. autoTile -> Bureau et dock / Fenêtres et écran -15. desktopLogo -> idem -16. screenFrame -> idem -17. frameThickness slider -> idem (dimmed when frame off) -18. taskbar -> Bureau et dock / Dock et accès rapide -19. iconSize slider -> idem -20. Zone de détection + Calibrer (DetectionGuide) -> idem -21. setLanguage x4 (auto/fr/en/es) -> Au quotidien / Langue et services -22. shortcut panel (recorder) 23. paletteShortcut (recorder) -> Au quotidien / Raccourcis -24. autostart 25. clipboardHistory -> Au quotidien / Démarrage et données -26. chooseCity 27. refreshCatalogue (was an unlabeled refresh icon) -> Langue et services -28. restoreTaskbar, 29. navigate("dashboard") -> footer; 30. closePanel -> X (+ Esc via Main.qml Shortcut) -New: 31. motion (Animations Sans/Rapides/Caelestia = 0/.5/1; existing backend key "motion"/settings.motionScale, no backend change). -Count: 30 before, 31 after (the extra one is the Animations control; nothing removed). - -## Structure -Header (title, subtitle, X), 4 tabs (Apparence, Bureau et dock, Explorateur et fichiers, Au quotidien), page = 2-column grid of cards (icon, title, subtitle; rows with label + explanation), footer (Rétablir la barre Windows, Tableau de bord). -Pages load through a Loader (only the current one, only while visible); a page scrolls inside itself only if the screen is too small. Panel height min(640, screenHeight-100); fits at 1366x768. -Keyboard: tabs, theme cards, chips are focusable (Tab, Enter/Space, Left/Right on tabs) with focus ring; switches/sliders are Qt Controls (Tab) and their row shows a ring. -Colours: palette tokens only; the only literals are the fixed Porcelaine/Nuit swatch colours (they depict themes independent of the live palette). - -## Verification -tests/prefs_shots.py OUTDIR PREFIX WxH pages (python from installed frontend venv, preview bridge on its own pipe). Zero QML warnings (frontend QML_ISSUES empty). Bridge names grep-checked in QtShellBridge.cs / frontend.py. diff --git a/docs/RELEASE-0.6.0.md b/docs/RELEASE-0.6.0.md deleted file mode 100644 index 6a00b94..0000000 --- a/docs/RELEASE-0.6.0.md +++ /dev/null @@ -1,15 +0,0 @@ -# Winlestia 0.6.0 - -La numerotation passe de 9.5.0 a 0.6.0 ; le setup accepte la mise a jour avec le meme identifiant. - -- Spectre FFT dynamique a 48 bandes : capture WASAPI du processus du lecteur et de ses enfants, sans repli sur le son global. Analyse en memoire, aucun enregistrement. Test reel avec signal sinusoidal et processus exclu reussi. -- Menu alimentation : cartes avec icones, confirmation, position au-dessus du dock. -- Fluidite : retrait du hook souris global de fermeture des panneaux, remplacement par detection des transitions de boutons ; suppression des abonnements globaux bruyants aux changements de position/etat ; cadence du mixeur reduite. Le ralentissement du PC du collegue reste a confirmer sur cette machine, sa cause exacte n'est pas prouvee. -- Langues : detection de la langue d'affichage Windows (FR/EN/ES, anglais sinon), choix manuel persistant dans Personnalisation et premier catalogue de traduction de l'interface principale. Certains libelles secondaires et dialogues Windows/herites restent dans leur langue initiale ; la couverture n'est pas encore exhaustive. -- Meteo : en absence de ville manuelle, estimation de ville par adresse IP (wttr.in), puis meteo Open-Meteo. Ce n'est pas une localisation GPS ; VPN/reseau peuvent affecter la ville. -- Ctrl+F2 : raccourcis recents du dossier Windows Recent en tete ; applications observees au premier plan depuis le lancement sont aussi prises en compte. Les historiques Windows desactives/proteges ne sont pas contournes. -- Calibration : bouton Calibrer dans Personnalisation, panneau principal masque, reglage isole et ligne rouge non interactive sur l'ecran. Valider avec la coche ; Echap annule. - -Tests : 32 tests Python ; runtime QML/menus sans erreur ; FFT/isolation native ; choix/persistance des langues et ordre recent d'abord. Le PC du collegue et une vraie ouverture de session restent a tester. - -Implementation WASAPI : https://learn.microsoft.com/en-us/samples/microsoft/windows-classic-samples/applicationloopbackaudio-sample/ diff --git a/docs/RELEASE-0.6.5.1.md b/docs/RELEASE-0.6.5.1.md deleted file mode 100644 index 417be40..0000000 --- a/docs/RELEASE-0.6.5.1.md +++ /dev/null @@ -1,14 +0,0 @@ -# Winlestia 0.6.5.1 - -- Reparation de l'installation du theme PowerShell : Set-ExecutionPolicy pouvait modifier CurrentUser puis signaler ExecutionPolicyOverride a cause de la politique du processus du setup. Le helper verifie maintenant la valeur relue et accepte uniquement une modification effectivement reussie. Les restrictions de groupe restent respectees. -- Le theme CMD/PowerShell est inclus automatiquement, y compris lors d'une mise a jour ayant conserve une ancienne liste de taches. -- Synchronisation initiale de la palette avant le lancement du worker ; arret de l'ancien worker enregistre ; mutex propre au dossier d'installation. Les fichiers de couleurs sont publies meme si Windows Terminal n'a pas encore de configuration. -- Windows Terminal nouvellement initialise : ajout du schema Winlestia manquant et activation de son apparence. Les editions Preview sont aussi detectees. Le CMD classique utilise les couleurs RVB du theme ; Windows Terminal conserve ses couleurs indexees adaptatives. -- Ctrl+F2 : extraction des icones des applications et fichiers/dossiers recents, et demande de chargement des la creation des lignes. -- Echecs d'installation : stderr du helper est conserve dans install-result.json pour un diagnostic exploitable. - -## Verification - -PowerShell 5.1 interactif dans un vrai pseudo-terminal : logo ASCII, panneau systeme et prompt affiches, couleur rouge RVB verifiee, fermeture propre. Test reproduisant ExecutionPolicyOverride avec changement applique, puis restauration de la valeur precedente. Tests de palettes rouge/bleue et de configuration Terminal neuve ; test d'import du worker avec le Python embarque ; installation/reinstallation/restauration isolees ; regressions QML sans erreur. - -Le rendu du fond transparent exige un hote compatible tel que Windows Terminal. Une strategie d'entreprise interdisant les profils non signes doit etre geree par son administrateur. Le PC du collegue reste a retester ; les tests locaux ne constituent pas une verification sur cette machine. diff --git a/docs/RELEASE-0.6.5.2.md b/docs/RELEASE-0.6.5.2.md deleted file mode 100644 index b1791e0..0000000 --- a/docs/RELEASE-0.6.5.2.md +++ /dev/null @@ -1,17 +0,0 @@ -# Winlestia 0.6.5.2 - -## Personnaliser -Refonte inspiree des composants, arrondis et couleurs de Caelestia : https://github.com/caelestia-dots/shell . Trois cartes Apparence / Bureau / Au quotidien, apercus de themes, interrupteurs, langues compactes et hierarchie typographique. Le selecteur d'animations est retire. La detection superieure dispose d'un bouton Calibrer unique ; aucun curseur redondant dans les parametres. - -## Applications -Les onglets En cours / Arriere-plan utilisent une grille d'icones avec noms au survol. Clic gauche ou droit ouvre les actions. Les fermetures et arrets avec confirmation reviennent a la grille, dans le meme onglet, sans fermer le panneau. Retour et Echap restent disponibles. Le clic exterieur ferme le panneau ; aucun hook souris global n'est ajoute. - -## Plein ecran -Le dock devient une capsule flottante complete quand son ecran accueille un vrai plein ecran. Le dessin, le verre et le masque de clic utilisent la meme forme detachee. Le cadre reste masque lorsque le dock ou son menu prend le focus. - -La detection utilise les coordonnees physiques et le nom du moniteur Windows, sans multiplication des origines Qt par un facteur DPI. Une video plein ecran peut etre reconnue meme si le navigateur conserve son etat maximise. Une fenetre simplement maximisee avec barre de titre reste distincte. Le clip arrondi impose aux fenetres maximisees est retire lorsque leur client couvre le moniteur entier ; retour a l'arrondi a la sortie. - -## Verification -32 tests Python de geometrie/recherche ; tests QML dans le runtime embarque : grille, clic droit, confirmation, maintien du panneau, clic exterieur, bouton de calibration, cadre masque, capsule et cas de DPI melanges simule ; fixture native : arrondis/restauration, epingles, recents. Aucune erreur QML sur ces parcours. Une capture du nouveau panneau a ete inspectee. - -Le cas Firefox/Chrome du second PC reste a retester sur cette machine ; les tests locaux et simules ne couvrent pas toutes les versions de pilotes ou tous les lecteurs video. Les terminaux ne sont pas modifies par cette version. diff --git a/docs/RELEASE-0.6.5.md b/docs/RELEASE-0.6.5.md deleted file mode 100644 index e39d5d5..0000000 --- a/docs/RELEASE-0.6.5.md +++ /dev/null @@ -1,28 +0,0 @@ -# Winlestia 0.6.5 — correctifs et audit - -## Correctifs -- Raccourcis : fenetre independante, panneau principal masque, combinaison affichee pendant la saisie, validation backend, fermeture automatique uniquement apres succes et libelle synchronise. Echap annule. -- Langues : quatre cartes graphiques Windows/France/Royaume-Uni/Espagne, assets SVG locaux, selection persistante. -- Icones Workspace : demandes a la demande avec coalescence et nouvelles tentatives ; les images vectorielles de secours sont aussi rendues pour Qt ; encodage PNG hors du fil de saisie. -- Recents : applications observees au premier plan et raccourcis Windows Recent sont fusionnes par horodatage UTC ; dernier acces conserve, ordre decroissant, historique local borne. La collecte commence avec Winlestia et ne reconstitue pas un historique Windows absent ou desactive. -- Terminal : installation du profil accompagnee de RemoteSigned pour le compte courant lorsque necessaire, ancienne valeur sauvegardee puis restauree a la desinstallation. CMD lance son helper avec une politique limitee au processus. Les strategies imposees par une organisation restent prioritaires et sont signalees. Les settings Windows Terminal avec commentaires/virgules finales sont acceptes. -- Mise a jour/desinstallation : fermeture propre, puis nettoyage des seuls processus frontend/theme du dossier installe. Suppression du taskkill global par nom : les autres installations et applications Python restent intactes. - -## Audit des performances -Defauts identifies : enumeration de tous les processus sur le dispatcher du dock ; icones demandees uniquement pour les favoris/dock ; encodeur d'images sur le dispatcher ; arret force du backend pouvant laisser son frontend orphelin ; listes recentes concatenees sans tri commun. - -Corrections : inventaire des processus uniquement a l'ouverture du panneau dedie, sur une tache de fond ; un seul worker STA pour les recents, cache des liens et file bornee ; pas de Shell COM dans les requetes d'etat ; ecriture des icones en arriere-plan ; nettoyage du cycle de vie. - -Mode allege automatique jusqu'a 8 Gio de memoire disponible pour le runtime, reglable dans Personnalisation : flou natif desactive, transparence conservee, sondes materielles moins frequentes. Le test sur le PC du collegue reste necessaire : aucune mesure sur cette machine n'est disponible et aucune garantie de fluidite universelle n'est annoncee. - -## Verifications -- 32 tests Python de geometrie, recherche et palettes. -- QML du runtime embarque : popup seul, touche Ctrl+K capturee, succes simule, retour aux parametres/libelle, demandes d'icones coalescees, quatre cartes de langue ; aucune erreur QML. -- Fixture native : arrondis/restauration, ordre des epingles, recents mixtes tries et dedupliques. -- Capture audio : FFT variable et isolation du processus validees avec signal de test. -- Installation terminal isolee : profils, chemins portables, CMD AutoRun, reinstal idempotente, restauration, JSON avec commentaires ; banner reel lance sous environnement Restricted avec son option de processus. -- Nettoyage reel sur deux processus factices : seul le processus du dossier choisi est arrete. - -Les dialogues secondaires herites ne sont pas tous traduits. L'estimation de ville reste reseau/IP. Les tests automatises ne remplacent pas une ouverture de session Windows ni l'essai sur la petite configuration. - -Reference des politiques PowerShell : https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies/ diff --git a/docs/RELEASE-0.7.0.md b/docs/RELEASE-0.7.0.md deleted file mode 100644 index 6cc607d..0000000 --- a/docs/RELEASE-0.7.0.md +++ /dev/null @@ -1,12 +0,0 @@ -# Winlestia 0.7.0 — theme personnalise - -Quatrieme theme Personnalise, en plus de Porcelaine, Nuit et Automatique. Le studio reprend les cartes, arrondis et typographie du panneau Personnaliser. - -- Cinq couleurs : fond, texte, principal, secondaire et troisieme accent. Les surfaces, contours et couleurs de contraste des boutons sont derives de ces roles. Palette initiale inspiree de Porcelaine. -- Roue HSV avec luminosite, saisie hexadecimale #RRGGBB et apercu avant application. -- Pipette d'ecran : interface masquee pendant l'echantillonnage d'un pixel ; clic intercepte temporairement sur un fil Windows dedie, sans hook permanent ni enregistrement d'image. Echap ou clic droit annule. Le hook est retire a la fin et a la fermeture. -- Verre optionnel : desactive, le fond choisi est opaque et le curseur disparait ; active, un curseur regle sa transparence. Le cadre, le dashboard et le dock utilisent les memes roles et le meme materiau. -- Couleurs et transparence personnalisees sauvegardees. Annuler l'editeur ne sauvegarde pas le brouillon. Le theme Automatique conserve son propre reglage de verre. -- Les terminaux reprennent la palette personnalisee ; Windows Terminal adapte aussi son opacite et son effet acrylique. Le mode allege conserve son comportement pour les configurations modestes. - -Tests : palette et validation hexadecimale, roue/editeur QML sans erreur, modes opaque/verre, selection et annulation d'un pixel deterministe, persistance native des cinq roles et du verre, 32 regressions Python, import du service terminal et cycle de vie reel du hook temporaire dans le runtime embarque. Capture du studio inspectee. Les couleurs visibles peuvent dependre de la gestion HDR du bureau ; le rendu sur le second PC reste a tester. diff --git a/docs/RELEASE-0.7.5.md b/docs/RELEASE-0.7.5.md deleted file mode 100644 index 77919e4..0000000 --- a/docs/RELEASE-0.7.5.md +++ /dev/null @@ -1,21 +0,0 @@ -# Winlestia 0.7.5 — organisation automatique du bureau - -## Mosaïque -Les fenetres redimensionnables se repartissent automatiquement dans la zone de travail de chaque moniteur, avec espacement et ordre stables. L'ouverture, la fermeture, la reduction et la restauration recalculent les emplacements. La disposition est activee par defaut ; un interrupteur dans Personnaliser > Bureau permet de la desactiver. - -Le double-clic sur la barre de titre conserve l'agrandissement Windows. Tant qu'une fenetre geree est agrandie, sa disposition est preservee ; le bouton Restaurer permet de reprendre la mosaique. Une nouvelle application peut reouvrir la disposition partagee. Les jeux et videos qui couvrent vraiment un moniteur sont exclus du placement. - -Les positions d'origine sont memorisees et restaurees a la desactivation ou a la fermeture normale pour les fenetres encore a leur emplacement gere. Les deplacements manuels, les fenetres reduites ou agrandies ne sont pas annules de force. Les boites de dialogue, outils flottants, fenetres fixes, bureaux virtuels masques et interfaces Winlestia sont exclus. - -## Logo -Logo fourni affiche en blanc discret dans le coin superieur gauche de chaque ecran. Option Logo Winlestia, desactivee par defaut. Fond transparent, aucun clic intercepte, opacite attenuee suivant le verre. Masque en plein ecran et pendant les captures/pipette. - -## Fluidite et limites -Detection legere toutes les 700 ms, sans hook souris global. Les placements qui ne changent pas ne sont pas reappliques ; un glisser-deposer en cours n'est pas interrompu. Les fenetres soumises a une taille minimale ne sont pas redimensionnees en boucle. - -Le nombre gere simultanement est borne selon la taille du moniteur, jusqu'a 16 fenetres recentes par ecran. Les applications imposant de grandes tailles minimales ou un niveau d'administration superieur peuvent refuser un placement ; une organisation parfaite de toutes les applications Windows ne peut pas etre garantie. La restauration en cas de terminaison forcee du processus n'est pas garantie. - -## Verification -Fixture native isolee : 3, 4 puis 10 vraies fenetres sans chevauchement, agrandir/restaurer, reduire/restaurer, fermer/reorganiser et restauration des positions initiales. Partitionnement de 1 a 16 cases avec coordonnees negatives. 30 cycles stables moyens sous 25 ms sur la machine de test ; ce n'est pas une mesure du PC du collegue. - -Tests QML : logo visible, transparent aux clics et masque en plein ecran ; regressions grille et menus, plein ecran et DPI mixtes. Editeur de palette/raccourcis et 32 tests Python de regression passes. Aucun autre logiciel du bureau n'a ete deplace par la fixture : sa source ne contient que les fenetres de test. diff --git a/docs/RELEASE-0.7.6.md b/docs/RELEASE-0.7.6.md deleted file mode 100644 index 5178ada..0000000 --- a/docs/RELEASE-0.7.6.md +++ /dev/null @@ -1,10 +0,0 @@ -# Winlestia 0.7.6 - -- Réorganisation automatique animée sur 240 ms avec ralentissement progressif. Le minuteur d’animation s’arrête au repos ; aucun hook de souris permanent. Une manipulation, une réduction ou un agrandissement interrompt le mouvement concerné. -- Arrondis des fenêtres agrandies alignés sur leur surface visible DWM. Le découpage est réappliqué si une application le réinitialise, puis restauré au retour en fenêtre. Les vidéos et jeux en plein écran réel restent exclus. -- Logo du Bureau agrandi (116 px au lieu de 76 px, transparence et clics traversants conservés). -- Organisation automatique désactivée par défaut pour les nouvelles préférences. Les choix déjà enregistrés sont conservés. - -Validation : tests natifs sur dix fenêtres dédiées (ouverture, réduction, restauration, fermeture, agrandissement, retour aux positions originales), partitions sur coordonnées négatives, réparation d’une région effacée par l’application, tests QML du logo et des interactions, 32 tests Python. Compilation autonome et installeur reconstruits. - -Limites : le rendu des transitions dépend de la vitesse de redessin des applications. Les fenêtres protégées ou exécutées avec des privilèges supérieurs peuvent refuser un déplacement ou un découpage ; aucun résultat universel n’est garanti. diff --git a/docs/RELEASE-0.9.0.md b/docs/RELEASE-0.9.0.md deleted file mode 100644 index a1fc561..0000000 --- a/docs/RELEASE-0.9.0.md +++ /dev/null @@ -1,88 +0,0 @@ -# Winlestia 0.9.0 - -Winlestia is a free, open-source desktop shell for Windows 11, bringing a Caelestia-inspired interface, dynamic themes, glass effects, a customizable dock and powerful desktop tools. It replaces the surface of Windows, not the system: a dock instead of the taskbar, with a dashboard and glass panels on top of your normal desktop. The interface is a port of [Caelestia](https://github.com/caelestia-dots/shell). Licence: GPL-3.0. - -## Highlights - -- Dashboard with media (and audio bars), performance and workspaces pages. Ctrl+F1. -- Command palette (Ctrl+F2): calculator, emoji, wallpapers, open windows, Windows Settings pages, indexed file search, session actions with confirmation. -- Dock with virtual-desktop indicator, window previews and optional automatic tiling. -- Quick controls with a Wi-Fi network list and per-display brightness (DDC/CI). -- Wallpaper switcher with live preview and per-display targeting; the colour palette can follow the wallpaper (Settings, Appearance, Theme, Automatic; the default Porcelain theme is fixed and does not). -- `winlestia` command line to drive the running shell from scripts. - -## Since 0.8.0 - -**Added:** Wi-Fi panel; brightness per display, with OSD for brightness and microphone; dashboard audio bars; fullscreen quiet mode; full-screen wallpaper switcher; palette entries for windows, Windows Settings, files and session actions; settings search; dock desktop `+` button and desktop close; tiling keyboard shortcuts and exclusions; "active window only" accent border; the `winlestia` command; optional, reversible terminal integration. - -**Changed:** wallpaper collection replaced by 10 images supplied by the maintainer, shipped unmodified; Automatic theme default glass transparency is 5 %; panel text stays legible on bright wallpapers in the Automatic theme; data folder is `%LOCALAPPDATA%\Panneaux` (the former project name, kept on purpose), migrated at first launch; start-up through a per-user scheduled task; wallpaper applied through the Windows desktop wallpaper interface; messengers loaded on demand; several start-up and idle-time performance improvements; French, English and Spanish strings. - -**Fixed:** desktop indicator refresh, OSD icon, a settings tile read after its delegate was destroyed, wallpaper palette over a live-wallpaper configuration, a binding loop in the Media panel, start-up log errors; the dashboard now shows the display name chosen at install (it used to show the Windows account name); upgrading no longer aborts while the shell is running; interface strings completed in English and Spanish, and the dashboard calendar follows the app language. - -Full list: [CHANGELOG.md](../CHANGELOG.md). - -## Requirements - -- Windows 11, 64-bit (x64). Not for Windows 10. -- No administrator rights: per-user install in `%LOCALAPPDATA%\Programs\Winlestia`. -- Nothing else to install. An Internet connection is needed only if you tick the optional terminal integration (it downloads Clink 1.9.35 from its official page and checks a pinned SHA-256). - -## Install - -1. Download `Winlestia-Setup-0.9.0.exe` from the assets below. -2. In PowerShell: `Get-FileHash .\Winlestia-Setup-0.9.0.exe -Algorithm SHA256`. The value must match the installer SHA-256: `c9c406669bd9e7205aba2c3458fb23b727cd53b74b1991bc0456d06152442834`. -3. Run the setup (its wizard is in French only). Updating from an older version works the same way: the setup closes the running shell itself and keeps your settings. - -**SmartScreen.** The binaries are not code-signed, so Windows SmartScreen will probably show "Windows protected your PC". After checking the SHA-256, choose **More info**, then **Run anyway**. - -Step-by-step guide: [docs/INSTALL.md](INSTALL.md). Shortcuts: [docs/SHORTCUTS.md](SHORTCUTS.md). - -## If the taskbar does not come back - -Wait a few seconds (a watchdog restores it), press Ctrl+Alt+Backspace, use the Start menu shortcut "Restaurer la barre des tâches Windows", or run `RETABLIR_BARRE_WINDOWS.exe` in `Glass-v5`. All options: [docs/TROUBLESHOOTING.md](TROUBLESHOOTING.md). - -## Known limitations - -- The installer wizard is in French only. -- English and Spanish are translated for the shell panels; some rarer system-derived labels may still appear in French. The language follows the Windows display language and can be forced with the `language` key (`auto`/`fr`/`en`/`es`) in `%LOCALAPPDATA%\Panneaux\Caelestia\language.json`. Fallback text is French. -- Not tested on a clean machine or VM, nor after a real reboot or sign-in (the scheduled-task launch was tested manually). -- Binaries are **not signed**, so SmartScreen may warn. -- Windows 11 x64 only. Temperature is in Celsius only. -- There is **no notification centre**: only the on-screen indicator and toasts. Windows notifications can be switched off from the tools panel. -- Window preview quality in the dock depends on what each application exposes to Windows. -- Weather: with no city set, your approximate location comes from your IP address through wttr.in; the forecast comes from Open-Meteo. Temperature is in °C only. -- Bundled wallpapers: the 10 images were supplied by the maintainer and are not covered by the GPL-3.0 licence; rights remain with their owners (see `assets/wallpapers/CREDITS.txt`). A rights holder can ask for removal through a GitHub issue. -- Automatic tiling is **off by default**. -- Tested on a real Windows 11 Home machine with a single 3440x1440 display, not on a clean virtual machine. Other display layouts, scalings and Windows editions have had less testing. -- Enterprise (802.1X) and hidden Wi-Fi networks are not handled in the Wi-Fi panel. -- The optional terminal integration prints a welcome banner in French, showing your session and computer names. -- When you confirm a wallpaper in the switcher, the previous wallpaper can flash briefly before the new one appears. -- The wallpaper switcher also lists images from `Pictures\Wallpapers`, `Pictures` and the Windows wallpaper folders, not only the bundled set. -- CPU temperature can show N/A, depending on the hardware. - -## What was tested - -Tested by hand and by script on one Windows 11 Home machine (single 3440x1440 display), with the installer built from a clean checkout: - -| Check | Result | -| --- | --- | -| Install, first start, dock replaces the taskbar | Pass | -| Upgrade from 0.8.0 with the shell running | Pass | -| Main panels open (settings, clipboard, tools, audio, mixer, capture, palette, dashboard) | Pass | -| Theme change and wallpaper-driven palette | Pass | -| Global shortcuts Ctrl+F1, Ctrl+F2, Ctrl+Alt+Space, Ctrl+Alt+W | Pass | -| Autostart task created, launched manually with the shell closed | Pass | -| Taskbar restored after normal close, after a killed process, and by `--restore-taskbar` and Ctrl+Alt+Backspace | Pass | -| Uninstall with the shell running (user profile and wallpaper kept) | Pass | -| Clean machine or VM; real reboot and sign-in | Not run | -| Other display layouts and scalings | Not run | - -These tests ran from an MSIX-packaged parent process, which redirects `%LOCALAPPDATA%` writes for its child processes; autostart was exercised from Task Scheduler, outside that redirection. - -## Reporting a bug - -Use the [bug report form](https://github.com/lilidoxdev/winlestia/issues/new/choose) with your Windows build, display setup and log lines with personal data removed. Security issues: see [SECURITY.md](../SECURITY.md), not a public issue. - -## Notes - -Native helper binaries (`GlassBackdrop.dll`, `ProcessAudio.dll`, `RETABLIR_BARRE_WINDOWS.exe`, `SECOURS_BUREAU.exe`) are built from the C++ sources in `src/backend/*.cpp`. Third-party components are listed in `THIRD_PARTY_NOTICES.md`. diff --git a/docs/RELEASE-9.5.0.md b/docs/RELEASE-9.5.0.md deleted file mode 100644 index 2612cc1..0000000 --- a/docs/RELEASE-9.5.0.md +++ /dev/null @@ -1,18 +0,0 @@ -# Winlestia 9.5.0 - -- L'installateur embarque le theme CMD/PowerShell et propose son installation. Profils interactifs, ASCII Winlestia, Clink, couleurs, verre Windows Terminal et adaptation au fond d'ecran. Les reglages existants sont sauvegardes. Les sessions elevees du meme compte utilisent les memes profils. -- Media : bouton volume cible a la place du bouton de deplacement ; pochette tournante pendant la lecture, angle conserve en pause. -- Le visualiseur utilise le niveau des sessions audio du lecteur selectionne, sans repli sur la sortie globale. C'est un indicateur d'amplitude, pas une analyse FFT par frequence. Le volume d'un navigateur concerne ses sessions, pas un onglet individuel. -- Dock : separation des groupes epingles/ouverts, deplacement des epingles par glissement, largeur reglable au bord droit et centrage conserve. -- Applications : onglets en cours/arriere-plan ; executables accessibles de la session, groupes par chemin. Les processus proteges ou inaccessibles ne sont pas listes. -- Alimentation : veille, redemarrage et arret avec confirmation. -- Reglages : hauteur de detection du bord superieur de 0 a 160 pixels, persistante. -- Fenetres maximisees : decoupe arrondie dans la zone de travail ; region precedente restauree au retour en mode normal ou a la fermeture du shell. Les pleins ecrans exclusifs restent geres par leur application. -- Curseurs : protection des valeurs choisies contre les retours d'etat anciens pendant les commandes audio et luminosite. -- Mise a jour : conservation du catalogue et des preferences backend ; correctif Explorer 9.4 conserve. - -## Verification - -32 tests Python ; chargement QML et menus avec etat de test ; protection contre etat obsolete ; installation/restauration terminal dans un environnement isole ; fixture native pour arrondis et ordre des epingles ; verification Core Audio avec session silencieuse dediee. Aucune commande d'arret, redemarrage ou veille n'est executee par ces tests. Le demarrage de session Windows et un second ordinateur restent a tester manuellement. - -Le setup est gratuit, Windows 11 x64. Le depot reste prive : les testeurs doivent disposer d'un acces GitHub. diff --git a/docs/RESTORE.md b/docs/RESTORE.md index 30bb306..cc71d62 100644 --- a/docs/RESTORE.md +++ b/docs/RESTORE.md @@ -1,18 +1,31 @@ -# Getting the Windows taskbar back - -Winlestia hides the Windows taskbar only while it runs with "Replace the taskbar" on. A separate watchdog process -restores it when the shell stops or crashes. If it is still missing, try these in order (more detail in -`docs/TROUBLESHOOTING.md`): - -1. Wait a few seconds for the watchdog. -2. Press **Ctrl+Alt+Backspace**. The independent rescue tool stops Winlestia and brings the desktop back - (`src/backend/Tools/GlobalShortcut.cs`, `src/backend/Secours.cpp`). -3. Run `RETABLIR_BARRE_WINDOWS.exe` from `%LOCALAPPDATA%\Programs\Winlestia\Glass-v5`. -4. From a terminal: - `"%LOCALAPPDATA%\Programs\Winlestia\Glass-v5\Panneaux.Glass.exe" --restore-taskbar` - (reads the saved lease and shows `Shell_TrayWnd` again; `TaskbarNative.RestoreSaved`). -5. Run `SECOURS_BUREAU.exe` (or `Panneaux.Glass.exe --restore`) if desktop icons are hidden too. -6. Last resort: restart Explorer (Task Manager, Run new task, `explorer.exe`), or uninstall Winlestia; the - uninstaller restores the taskbar. - -To stop Winlestia from starting at logon, delete its scheduled task: `schtasks /delete /tn WinlestiaShell /f`. +# Restauration de la barre des tâches Windows + +Winlestia masque la barre Windows quand le dock Qt la remplace. Ce document fixe les invariants qui garantissent qu'elle revient, y compris après un arrêt brutal. Analyse faite par lecture du code ; rien n'a été exécuté. + +## Acteurs + +| Acteur | Fichier | Rôle | +|---|---|---| +| Bail `taskbar-session.json` | `Taskbar/TaskbarNative.cs` (`Snapshot`, `LeasePath`) | État d'origine de la barre (`State` : 1 = masquage auto, 2 = toujours visible) et barres visibles. Écrit dans `Store.Root` **avant** tout masquage (`ShellTaskbar.Enable`). | +| Gardien natif | `TaskbarRescue.cpp` -> `RETABLIR_BARRE_WINDOWS.exe --watch-parent ` | Processus séparé lancé par `Enable`. Attend la fin du parent (handle `SYNCHRONIZE`) ou l'événement `release`, puis restaure. | +| Gardien de repli | `Panneaux.Glass.exe --taskbar-guard ` (`TaskbarNative.Guard`) | Même rôle si `RETABLIR_BARRE_WINDOWS.exe` est absent. | +| Restauration manuelle | `Panneaux.Glass.exe --restore-taskbar`, ou `RETABLIR_BARRE_WINDOWS.exe` lancé seul (raccourci du menu Démarrer, désinstallation) | Rétablit sans shell ni pipe. | + +## Invariants + +1. **Bail avant masquage.** `Enable` écrit le bail, démarre le gardien, attend son événement `ready` (4 s) et seulement ensuite appelle `HideExplorer`. Si le gardien ne démarre pas, la barre n'est jamais masquée (`Disable` + message). +2. **Arrêt brutal.** Le gardien attend le *handle du processus* parent : `TerminateProcess`, plantage ou arrêt par le gestionnaire des tâches le réveillent sans qu'aucun `finally` du parent ne s'exécute. Il relit `taskbar-session.json` (dossier de son exe, identique à `Store.Root`), applique `ABM_SETSTATE` puis `ShowWindow(SW_SHOWNA)` sur `Shell_TrayWnd` et `Shell_SecondaryTrayWnd`. +3. **Pas de dépendance au pipe ni au shell.** Ni le gardien ni `--restore-taskbar` n'utilisent le canal `PanneauxCaelestia` ni Qt. `--restore-taskbar` est traité dans `Program.Main` avant le mutex d'instance unique et avant tout démarrage de shell : il signale l'événement `Local\PanneauxGlassTaskbarRestore` (si une instance vit, elle bascule `SetTaskbar(false)`), puis applique `RestoreSaved` directement. +4. **Repli sans bail.** Bail absent ou illisible : le gardien natif et `--restore-taskbar` appliquent tous deux « toujours visible » (état 2) et réaffichent les barres. (Corrigé : `RestoreSaved` ne faisait rien dans ce cas, le gardien natif si.) +5. **Arrêt propre.** `Disable` signale `release`, attend le gardien (2,2 s) puis restaure lui-même ; l'arrêt du frontend seul (`SuspendTaskbar`) restaure sans modifier la préférence `ReplaceTaskbar`. +6. **Restauration idempotente.** Restaurer une barre déjà visible est sans effet ; le gardien peut donc s'exécuter après une restauration manuelle. + +## Ctrl+Alt+Retour arrière + +Ce raccourci global (`GlobalShortcut`, id 2) appelle `Coordinator.Rescue()` qui lance `SECOURS_BUREAU.exe` : il rétablit les **icônes du Bureau**, pas la barre des tâches. Il est enregistré par le processus shell : s'il est mort, le raccourci n'existe plus. Dans ce cas la barre est rétablie par le gardien (invariant 2) ; pour la forcer à la main : `RETABLIR_BARRE_WINDOWS.exe` ou `Panneaux.Glass.exe --restore-taskbar`. + +## Limites connues + +- Si le gardien lui-même est tué avant le parent, personne ne restaure : relancer `RETABLIR_BARRE_WINDOWS.exe`. +- Le gardien de repli (`--taskbar-guard`) retourne 2 sans restaurer si le bail est illisible ; seul le cas où le gardien natif est absent est concerné. +- Non vérifié par exécution : aucun test ne tue le processus réel (règle du dépôt : pas de manipulation du bureau). diff --git a/docs/SHORTCUTS.md b/docs/SHORTCUTS.md index 551ee9c..301776b 100644 --- a/docs/SHORTCUTS.md +++ b/docs/SHORTCUTS.md @@ -1,6 +1,6 @@ # Shortcuts and command line -Everything below was checked against the source (`src/backend/Tools/GlobalShortcut.cs`, `src/backend/Storage/Models.cs`, `src/frontend/qml`, `src/backend/Shell/CliCommands.cs`). Global shortcuts can be reassigned in Settings; the values shown are the defaults. If another application already owns a combination, Windows refuses it and Winlestia reports the conflict. +Everything below was checked against the source (`src/backend/GlobalShortcut.cs`, `src/backend/Models.cs`, `src/frontend/qml`, `src/backend/CliCommands.cs`). Global shortcuts can be reassigned in Settings, on the Keyboard shortcuts page (Modify, then press the new combination); Windows-reserved combinations and ones already used by another action are refused; the values shown are the defaults. If another application already owns a combination, Windows refuses it and Winlestia reports the conflict. ## Global shortcuts @@ -8,29 +8,30 @@ Everything below was checked against the source (`src/backend/Tools/GlobalShortc |---|---|---| | Ctrl+F1 | Toggle the dashboard (pressing again closes it) | Yes | | Ctrl+Alt+Space | Also opens the dashboard; always registered as a second way in | No | +| Win+Alt+S | Open Settings | Yes | | Ctrl+F2 | Command palette | Yes | | Ctrl+F3 | Screen capture | Yes | | Ctrl+F8 | Keep the active window on top (pin) | Yes | | Win+Alt+W | Wallpaper switcher. If Windows refuses the combination, Ctrl+Alt+W is the fallback | Yes | | Ctrl+Alt+Backspace | Emergency rescue (see [TROUBLESHOOTING.md](TROUBLESHOOTING.md)) | No, reserved | -| Hold AltGr | Push-to-talk dictation. Off by default, enable it in Settings; the key can be changed | Yes | +| Hold AltGr | Push-to-talk dictation. Off by default, enable it in Settings; the key is chosen in Settings, Media and audio, not on the Keyboard shortcuts page | Set elsewhere | ## The dock The Win key opens the normal Windows Start menu; it does not reveal the dock. The dock auto-hides: it slides away about 0.45 s after the pointer leaves it, and appears when the pointer reaches the bottom edge of the screen. -Reassignable shortcuts must combine Ctrl and/or Alt (the wallpaper switcher also accepts Win) with a letter, digit, Space or a function key (F12 excluded). +Reassignable shortcuts must combine Ctrl and/or Alt (the wallpaper switcher and Settings also accept Win) with a letter, digit, Space or a function key (F12 excluded). ## Automatic tiling -Tiling is **off by default**. These keys are registered only while it is turned on (Settings, or the dock button). +Tiling is **off by default**. These keys are registered only while it is turned on (Settings, or the dock button). They are fixed for now and listed, read-only, on the Keyboard shortcuts page. | Shortcut | Action | |---|---| | Ctrl+Alt+Arrows | Move to the neighbouring tile | | Ctrl+Alt+Shift+Arrows | Swap with the neighbouring tile, across displays too | | Ctrl+Alt+Shift+F | Take the window out of tiling, or put it back | -| Ctrl+Alt+Shift++ / - | Grow or shrink the active tile (20 to 80 %) | +| Ctrl+Alt+Shift++ / - | Grow or shrink the active tile (20 to 80 %); the numeric keypad + and - also work | | Ctrl+Alt+Shift+X | Exclude the active window's application, or include it again | Dropping a window onto another tile with the mouse swaps them. Switching virtual desktops uses the standard Windows Win+Ctrl+Arrows. diff --git a/docs/STEP6-PROGRESS.md b/docs/STEP6-PROGRESS.md deleted file mode 100644 index 30b7c51..0000000 --- a/docs/STEP6-PROGRESS.md +++ /dev/null @@ -1,16 +0,0 @@ -# Step 6 (folder handler, guard, toggles, live palette) - DONE, not deployed -- Owner: src/backend/Shared/FilesHandler.cs + FilesHandlerGuard.cs (pure .NET), linked into src/files, backend and tests/FilesHandler. CLI lives in Winlestia.Files.exe (`--register ""`, `--restore`; exit 0/1, log %LOCALAPPDATA%\Winlestia\Files\handler.log). -- Registry root is configurable (HandlerOptions). Test override: WINLESTIA_FILES_HANDLER_ROOT=Software\WinlestiaTest\ (refused elsewhere; no SHChangeNotify; own backup file, WINLESTIA_FILES_HANDLER_BACKUP). -- Backend: FilesDefaultHandler.cs (toggle, startup guard, FileSystemWatcher on handler-tripped.flag), Preferences.FilesDefaultHandler, bridge keys filesDefaultHandler / filesPreload (+ settings fields), FilesPreload.NotifyPalette (called by Theme.Use, 600 ms debounce, never launches). -- Files: startup crash counter (startup-crashes.txt; 3 in 60 s -> restore + handler-tripped.flag), "palette" pipe cmd -> PaletteLoader.Refresh, selftest `palette`. -- Tests: tests/FilesHandler (50/50), harness `-Mode handler` and `-Mode palette`. -- STEP 7 (installer): [UninstallRun] MUST call `{app}\Glass-v5\Winlestia.Files.exe --restore` before the files are removed. - -## Review fixes (applied) -- Mutex `Local\WinlestiaFilesHandler` around Register/Restore (cross-process); Capture never records our own Default/verb as original; Register rolls back (Restore) on any failure after its first write; Restore resets (Default) first, then deletes the verb key, and keeps a foreign Default; a failed Restore keeps the backup and returns Ok=false. -- FilesDefaultHandler.Set returns (pref, notice): pref stays true when Restore fails; registry work under a lock; Store.State/Save only on the UI dispatcher (bridge continuation, Disable via Dispatcher). -- Crash guard counts only real crashes (FilesHandlerGuard.CountsAsCrash); UnhandledException in the first 15 s counts and hands the requested folder to explorer.exe. -- PathRouter.Clean: `C:"` -> `C:\`, `\srv\share"` -> `\srv\share\`; also used by LaunchExplorer. -- WINLESTIA_FILES_HANDLER_BACKUP honoured only under %TEMP%. -- installer/Winlestia.iss [UninstallRun] calls `Glass-v5\Winlestia.Files.exe --restore` (before the Taskbar rescue entry; skipifdoesntexist). -- Known limit: if Winlestia.Files.exe is deleted/moved while the backend is NOT running, nothing repairs the HKCU keys (folders would fail to open). The backend repairs it at its next start (exe missing -> restore + pref off); the uninstaller restores explicitly. diff --git a/docs/STEP6B-PROGRESS.md b/docs/STEP6B-PROGRESS.md deleted file mode 100644 index 5c6715d..0000000 --- a/docs/STEP6B-PROGRESS.md +++ /dev/null @@ -1,9 +0,0 @@ -# STEP 6B progress - -A icon: tools/Make-FilesIcon.ps1 -> src/files/Assets/winlestia-files.ico (16..256), ApplicationIcon set. Dock icon cache key = SHA256(entry.Id+IconPath+Target), new entry => no stale icon. -B pin: FilesDefaultHandler.Migrate adds dock entry "dock:\Winlestia.Files.exe" ("Winlestia Fichiers") to TaskbarPins once (FilesDefaultsMigrated). -C defaults: Models.cs FilesDefaultHandler/FilesPreload default true + FilesDefaultsMigrated; FilesHandlerGuard.DecideDefaults (pure); migration runs after crash-guard Check, off UI thread via Set(true). -D theme: Files-only tokens (UI.Panel/Recess/Tint/Accent in BackendShim), primary-tinted hover, PrimaryContainer for active sidebar/tab, accent crumb/nav/tab icons. - Root cause: Files read the wallpaper via SPI, but the frontend palette comes from a Desktop Hut live wallpaper override. frontend.py now writes Glass-v5/palette-shell.json (auto mode); Files reads it (FileSystemWatcher, debounced) and falls back to its own computation. -Tests: FilesHandler 64/64 (incl. 4 defaults_*), FilesRouter 27/27, FilesPreloadPolicy 12/12, Explorer077 no false, harness palette 0 fails. -Needs redeploy (frontend.py + backend + Files). Test envs: WINLESTIA_FILES_PALETTE_FILE, WINLESTIA_FILES_PALETTE_SHELL; harness modes themeshot. diff --git a/docs/TESTS.md b/docs/TESTS.md new file mode 100644 index 0000000..5462008 --- /dev/null +++ b/docs/TESTS.md @@ -0,0 +1,60 @@ +# Tests + +## .NET (xUnit) + +Un seul projet : `tests/Winlestia.Tests`. Les sources du backend y sont compilees directement, donc les types `internal` sont testables. + +```powershell +# Tests sans bureau (ceux de la CI) +dotnet test tests/Winlestia.Tests --filter "Category!=Desktop" + +# Un seul test +dotnet test tests/Winlestia.Tests --filter "FullyQualifiedName~CliCommandsTests" +``` + +Les tests de categorie `Desktop` (`[Trait("Category","Desktop")]` : demarrage du coordinateur en preview, audio, explorateur, icones de dossiers, tiling, bureaux virtuels, interop native, plein ecran) ouvrent de vraies fenetres ou touchent au shell. Ne les lancez pas pendant que le shell Winlestia tourne : `dotnet test tests/Winlestia.Tests --filter "Category=Desktop"`, a la main uniquement. + +`SettingsMigrationAndShortcutsTests` genere un fichier de preferences pre-refonte ; `WINLESTIA_LEGACY_PREFS` peut pointer vers une vraie sauvegarde. + +### Couverture + +La CI collecte la couverture avec coverlet (`coverlet.collector`) et verifie un plancher (etape « Resume de couverture » de `.github/workflows/ci.yml`). Valeur mesuree en Release (comme la CI) le 2026-11-09 : 17,61 % des lignes (backend seul, application Fichiers exclue), plancher 17 %. Une mesure en Debug donne environ un point de plus ; seule la valeur Release fait foi. Le backend etant compile dans l'assembly de test, `tests/coverage.runsettings` active `IncludeTestAssembly` et exclut les fichiers de tests. + +```powershell +dotnet test tests/Winlestia.Tests -c Release --filter "Category!=Desktop" --collect:"XPlat Code Coverage" --settings tests/coverage.runsettings --results-directory TestResults +``` + +Le rapport est `TestResults//coverage.cobertura.xml`. Le code WPF/shell non testable sans bureau pese sur le chiffre. + +Regle du cliquet : le plancher vaut toujours `floor(couverture mesuree)` (partie entiere, variable `$floor` de l'etape CI). Il ne baisse jamais ; une PR qui fait monter la couverture d'un point entier releve le plancher dans la meme PR, avec la valeur mesuree datee ici et dans le commentaire de `ci.yml`. Une baisse sous le plancher exige des tests, pas un plancher plus bas. + +### Style et fumee de l'installateur + +Le job `lint` de la CI echoue sur toute ligne de plus de 140 colonnes (`python tools/check_line_length.py`, perimetre : tous les `*.cs`, `*.py`, `*.qml`, `*.ps1` suivis par git, hors `src/terminal/clink`, `VsCodeTheme*` et fichiers generes) et sur `dotnet format whitespace --verify-no-changes` pour les trois projets. Le job `installer-smoke` execute `installer/build.ps1 -CI` (composants natifs MSVC remplaces par des fichiers vides) et publie le setup en artefact `winlestia-setup-smoke`. + +### Winlestia Fichiers (harnais) + +`tests/FilesHarness/Run-Files.ps1 -Mode ` est le seul lanceur autorise des auto-tests de Winlestia Fichiers (processus plafonne par un Job Object : 800 Mo, 4 processus, delai <= 120 s, 4 Go de RAM libre requis). Les auto-tests (`ops`, `dnd`, `refresh`, `states`, `palette`, `handler`, `tabs`, `idle`...) sont absents des builds Release : il faut d'abord compiler avec `dotnet build src/files/Winlestia.Files.csproj -p:SelfTest=true` (constante `WINLESTIA_SELFTEST`), puis passer `-Exe` si l'executable n'est pas au chemin par defaut. Hors CI, a lancer a la main uniquement. + +## Python (pytest) + +```powershell +pip install -r src/frontend/requirements.txt pytest +pytest -q +``` + +`pytest.ini` collecte `tests/`, `src/frontend/tests/` et `src/terminal/tests/` (fichiers `test_*.py`). `conftest.py` force `QT_QPA_PLATFORM=offscreen`. Les scripts `check_*.py` / `render_*.py` restent des outils manuels ; `tools/prefs_shots.py` prend des captures et n'est pas un test. + +Les deux suites tournent en CI (`.github/workflows/ci.yml`). `Winlestia.sln` regroupe les projets `src/` et les tests. + +## Avant une release + +Etape manuelle, a faire par un humain sur un poste de bureau Windows 11 interactif, avant de tagger une version : + +```powershell +dotnet test tests/Winlestia.Tests --filter "Category=Desktop" +``` + +Ces tests (demarrage du coordinateur en preview, audio, explorateur, icones de dossiers, tiling, bureaux virtuels, interop native, plein ecran) exigent une session de bureau reelle : fenetres visibles, shell Explorer, peripherique audio, plusieurs bureaux virtuels. Les runners GitHub Actions n'ont pas de session interactive, donc la CI les exclut (`Category!=Desktop`) et ne peut pas les remplacer. Fermer le shell Winlestia avant de lancer, puis consigner le resultat dans la PR de release. + +Dernier passage : 2026-11-08, Windows 11 26200, shell Winlestia lance, barre des taches en masquage automatique : 7/7 reussis. diff --git a/docs/THREADING.md b/docs/THREADING.md new file mode 100644 index 0000000..7744dce --- /dev/null +++ b/docs/THREADING.md @@ -0,0 +1,28 @@ +# Règle de threading du backend + +Dérivée du code (chemins relatifs à `src/backend/`). Trois familles de threads. + +## 1. Thread UI (STA, Dispatcher WPF) + +- `Core/Coordinator.cs` : `Program.Main` est `[STAThread]`. Toute fenêtre WPF, tout état partagé du shell (`Coordinator`, `QtShellBridge`) et toute mutation de collections vivent sur ce thread. +- `Bridge/QtShellBridge.cs` capture `Dispatcher.CurrentDispatcher` à la construction (champ `dispatcher`) et s'en sert pour revenir sur l'UI : `dispatcher.BeginInvoke(...)` (fire and forget) ou `dispatcher.InvokeAsync(...)` (quand on attend le résultat). +- Hors du pont, le même principe s'applique : `Application.Current.Dispatcher.BeginInvoke` dans `Core/Coordinator.cs` (fin des tâches de fond du catalogue) et `Invoke` dans `Bridge/QtShellBridge.cs` (`Fail`). +- Les minuteries d'interface sont des `DispatcherTimer` (ex. `timer` de `QtShellBridge`), jamais des `System.Threading.Timer`. + +## 2. Tâches de fond (thread pool) + +- Tout ce qui bloque (disque, COM, PowerShell, WinRT, réseau) part en `Task.Run`, jamais sur l'UI : `VirtualDesktops.*`, `RecycleActions.Zip`, `explorer.Sync/Restore`, `FilesDefaultHandler.Set`, la liste des fenêtres de la palette, etc. (`Bridge/QtShellBridge.cs`), ou `CatalogueTick` dans `Core/Coordinator.cs`. +- Le résultat revient à l'UI par le Dispatcher (point 1). Une tâche de fond ne touche jamais un objet WPF directement. +- Les commandes CLI (`Bridge/CliServer.cs`) sont traitées par `RunCli` ; la boucle `Serve` tourne en `Task.Run`, la réponse est bornée à 5 s (`WaitAsync`). + +## 3. Lecture du tube du pont Qt + +- `Bridge/QtShellBridge.cs`, méthode `Serve` (tube `PanneauxCaelestia`, `PipeOptions.Asynchronous`) : boucle `async` démarrée depuis le constructeur (`_ = Serve()`), sans thread dédié. `ReadLineAsync` attend sans bloquer. +- Règle : le lecteur du tube ne traite jamais une requête lui-même. Il parse le JSON, clone l'élément (`Clone()`), puis passe par `await dispatcher.InvokeAsync(() => Handle(element))` : `Handle` s'exécute donc toujours sur l'UI, et la réponse est écrite ensuite. +- Sens inverse (`Send`, appelé depuis l'UI) : file `events` bornée à 32 entrées + `EventWaitHandle` `Local\PanneauxCaelestiaWake`, que le frontend Qt attend de son côté. +- En fin de connexion, le nettoyage (`finally`) repasse aussi par `dispatcher.BeginInvoke`. + +## Threads dédiés (exception) + +- `Taskbar/FullscreenWatcher.cs` : un thread `IsBackground` nommé `FullscreenWatcher` porte les hooks `SetWinEventHook` (boucle de messages obligatoire) ; le recalcul tourne sur le thread pool. +- Processus frontend : sa sortie d'erreur est lue en événementiel (`BeginErrorReadLine`), et `Exited` revient sur l'UI via le Dispatcher. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 42812c9..e04a27c 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -40,7 +40,7 @@ Press Ctrl+Alt+Backspace (see step 2 above). It re-shows the Desktop icons and s ## SmartScreen blocks the installer -The installer and the programs it installs are not code-signed, so SmartScreen warns on a file it does not know. Check the SHA-256 against the one in the [release notes](https://github.com/lilidoxdev/winlestia/releases/tag/v0.9.1) (`Get-FileHash .\Winlestia-Setup-0.9.1.exe`), then choose **More info**, **Run anyway**. Details in [INSTALL.md](INSTALL.md#3-smartscreen-will-probably-warn-you). +The installer and the programs it installs are not code-signed, so SmartScreen warns on a file it does not know. Check the SHA-256 against the one in the [release notes](https://github.com/lilidoxdev/winlestia/releases/tag/v0.9.0) (`Get-FileHash .\Winlestia-Setup-0.9.0.exe`), then choose **More info**, **Run anyway**. Details in [INSTALL.md](INSTALL.md#3-smartscreen-will-probably-warn-you). ## Where the logs are diff --git a/docs/WALLPAPER-SOURCES-PROGRESS.md b/docs/WALLPAPER-SOURCES-PROGRESS.md deleted file mode 100644 index 216429f..0000000 --- a/docs/WALLPAPER-SOURCES-PROGRESS.md +++ /dev/null @@ -1,19 +0,0 @@ -# Wallpaper sources progress -- [started] reading code; plan: wallpaper_sources.py resolver, wallpaper_theme.py wiring, C# events + capture, tests. -- WallpaperX: ProgramData\LiveWallpaperConfig\ServiceConfig.json (Screens:[], GlobalFilePath null = none set). Package LocalState empty. Reader best-effort -- Finding: venv PySide6 6.10.3 has NO QtMultimedia (only .pyi stubs) -> frame extraction = backend exe CLI mode (WinRT Windows.Media.Editing MediaComposition.GetThumbnailAsync), launched as killable subprocess (5 s timeout) from Python. Fallback = thumbnail. -- Finding: bridge is request/response (state replies); capture = command "wallpaperCapture" -> reply field wallpaperCapture. Revision pushed in state as wallpaperRevision. -- Plan order: 1 wallpaper_sources.py, 2 wallpaper_theme.py + frontend.py ~436, 3 C# WindowsWallpaper.cs events, 4 C# FrameExtractor/WallpaperCapture + Coordinator CLI + QtShellBridge, 5 sync_theme, 6 tests, 7 build -- [done] wallpaper_sources.py written (resolve, representative_images, extract_frames->backend exe --extract-frames) -- [done] wallpaper_theme.py rewritten; frontend.py lines 435-437 (update call + wallpaperCapture reply hook) -- [done] WindowsWallpaper.cs rewritten (reg notify, FSW, WinEvent hook, SystemEvents; Revision). Next: WallpaperMedia.cs (frames+capture), Coordinator CLI, QtShellBridge -- [done] WallpaperMedia.cs, Coordinator CLI, QtShellBridge (cmd wallpaperCapture, state wallpaperRevision); dotnet build OK 0 err (-o scratchpad build dir). Next: test frames, unit tests, sync_theme -- FINDING: all DesktopHut videos are HEVC (hvc1); Media Foundation has no HEVC decoder here (0xC00D5212 CODEC_NOT_FOUND); DesktopHut mpv.exe is access-denied outside its package. => on this machine frames fall back to thumbnail. MF path to be proven on an H.264 file. mpv tier only for env WINLESTIA_MPV / PATH. -- MF frame extraction proven on H.264 wispysky.mp4: 8 frames in ~2.1 s (parallel clips, NearestKeyFrame) -- [done] palette wiring: palette_from_images for >1 frame, generate_palette for single image. Remaining: unit tests, real test, sync_theme, final build -- [done] tests (10 pass), sync_theme wait_for_change, real test, build OK. Only final report left. -- [done] WgcCapture.cs (--capture-frames) + python tier 2 (MF -> WGC -> mpv(env/PATH) -> thumbnail). Next: real test on DesktopHut -- [started] reviewer fixes 1-9 -- [done] fix5,6,7 python (publish off-thread+custom, theme_inputs_changed replaces explorer timer, slot hysteresis bounded, LRU) -- [done] C# fixes 1,2,3,4,8 patched -- [done] all reviewer fixes, tests 46 OK, build 0 err, real run done diff --git a/docs/WORKSPACES-REDESIGN-PROGRESS.md b/docs/WORKSPACES-REDESIGN-PROGRESS.md deleted file mode 100644 index 4563861..0000000 --- a/docs/WORKSPACES-REDESIGN-PROGRESS.md +++ /dev/null @@ -1,3 +0,0 @@ -# Workspaces tab redesign -- Rewrote src/frontend/qml/qs/modules/dashboard/Workspaces.qml (header, 4 category cards, favoris + recents cards, quick-action chips). Palette tokens only; handlers/Bridge calls unchanged (navigate workspace:*, clipboard, palette, settings; action launch/icon/websites). -- Render check: src/frontend/tests/render_workspaces.py (+ WorkspacesHarness.qml), offscreen, stub Bridge. diff --git a/docs/WORKSPACES-V2-PROGRESS.md b/docs/WORKSPACES-V2-PROGRESS.md deleted file mode 100644 index f0f8d54..0000000 --- a/docs/WORKSPACES-V2-PROGRESS.md +++ /dev/null @@ -1,5 +0,0 @@ -# Workspaces tab v2 (progress) -- Regression analysis done: QML data source (Bridge.favorites + Bridge.iconUrl(id, iconRevision)) is IDENTICAL to pre-redesign (backup step6b). Cause is data: preferences-glass.json Favorites = ['dock:...\Discord\app-1.0.9265\Discord.exe'] only; that folder no longer exists (Discord is app-1.0.9266) so icon extraction fails -> letter "D". Claude (uwp:Claude_...) / ChatGPT (uwp:OpenAI.Codex_...) / named:discord are not in State.Favorites any more. -- Plan: rewrite Workspaces.qml (blob shapes, Entrance stagger gated by tabActive&&visible, spring hover), Content.qml passes tabActive, harness stub gets icons. -- DONE: Workspaces.qml rewritten; Content.qml passes tabActive (currentTab===3); render_workspaces.py extended (icons stub, tertiary/error palette, anim frame). Renders ws2-{red,teal,red-light,anim}.png OK, only stub Bridge-null warnings. -- NOT verified: hover/press spring, reduced-effects look, real dashboard gating, live data. diff --git a/docs/bridge-schema.json b/docs/bridge-schema.json new file mode 100644 index 0000000..cc34261 --- /dev/null +++ b/docs/bridge-schema.json @@ -0,0 +1,823 @@ +{ + "protocol": 1, + "description": "Contrat du pont Winlestia (voir docs/BRIDGE.md). Un message client->serveur = {\"command\":nom,\"args\":{...}}. Tous les champs d'args absents prennent la valeur par défaut du serveur (\"\"/0/false) ; \"required\" liste ceux sans lesquels la commande est sans effet.", + "envelope": { + "request": { + "required": [ + "command", + "args" + ], + "commonArgs": { + "pid": "number", + "epoch": "number", + "protocol": "number", + "visible": "boolean", + "page": "number", + "dock": "boolean", + "controls": "boolean", + "mixer": "boolean", + "wifi": "boolean", + "bars": "boolean" + } + }, + "reply": { + "required": [] + } + }, + "commands": { + "activate": { + "args": { + "hwnd": "number", + "id": "string" + }, + "required": [ + "hwnd" + ] + }, + "addAccess": { + "args": { + "board": "string" + }, + "required": [] + }, + "addFolder": { + "args": { + "board": "string" + }, + "required": [] + }, + "audio": { + "args": {}, + "required": [] + }, + "audioBars": { + "args": { + "seq": "number" + }, + "required": [] + }, + "bookmarksBrowser": { + "args": { + "id": "string" + }, + "required": [] + }, + "bookmarksDeleteInBrowser": { + "args": { + "confirmSync": "boolean", + "id": "string" + }, + "required": [] + }, + "bookmarksHide": { + "args": { + "id": "string" + }, + "required": [] + }, + "bookmarksItems": { + "args": {}, + "required": [] + }, + "bookmarksOpen": { + "args": { + "id": "string" + }, + "required": [] + }, + "bookmarksRefresh": { + "args": { + "ifDirty": "boolean" + }, + "required": [] + }, + "bookmarksUnhideAll": { + "args": {}, + "required": [] + }, + "brightness": { + "args": { + "id": "string", + "value": "number" + }, + "required": [] + }, + "capture": { + "args": {}, + "required": [] + }, + "captureExport": { + "args": { + "path": "string" + }, + "required": [ + "path" + ] + }, + "captureShortcutMode": { + "args": { + "enabled": "boolean" + }, + "required": [] + }, + "clipboard": { + "args": {}, + "required": [] + }, + "clipboardCopy": { + "args": { + "id": "string" + }, + "required": [] + }, + "clipboardFiles": { + "args": { + "paths": "array" + }, + "required": [] + }, + "clipboardPin": { + "args": { + "id": "string" + }, + "required": [] + }, + "copyText": { + "args": { + "text": "string" + }, + "required": [ + "text" + ] + }, + "customTheme": { + "args": { + "colors": "object", + "glass": "boolean", + "opacity": "number" + }, + "required": [] + }, + "desktopClose": { + "args": { + "index": "number" + }, + "required": [] + }, + "desktopNew": { + "args": {}, + "required": [] + }, + "desktopSwitch": { + "args": { + "index": "number", + "relative": "number" + }, + "required": [] + }, + "deviceMute": { + "args": { + "id": "string", + "muted": "boolean" + }, + "required": [] + }, + "deviceSelect": { + "args": { + "id": "string" + }, + "required": [] + }, + "deviceVolume": { + "args": { + "id": "string", + "value": "number" + }, + "required": [] + }, + "dictation": { + "args": {}, + "required": [] + }, + "dictationCapture": { + "args": { + "enabled": "boolean" + }, + "required": [] + }, + "dockApp": { + "args": { + "action": "string", + "confirmed": "boolean", + "hwnd": "number", + "id": "string", + "index": "number" + }, + "required": [] + }, + "dockPreview": { + "args": { + "cx": "number", + "cy": "number", + "dpr": "number", + "id": "string", + "x": "number", + "y": "number" + }, + "required": [] + }, + "dockPreviewEnd": { + "args": { + "now": "boolean" + }, + "required": [] + }, + "edit": { + "args": { + "id": "string" + }, + "required": [] + }, + "editEntry": { + "args": { + "category": "string", + "id": "string", + "name": "string" + }, + "required": [] + }, + "emptyRecycleBin": { + "args": {}, + "required": [] + }, + "entryIcon": { + "args": { + "id": "string", + "path": "string" + }, + "required": [] + }, + "explorerFolder": { + "args": {}, + "required": [] + }, + "explorerOpen": { + "args": {}, + "required": [] + }, + "explorerPalette": { + "args": { + "accent": "string", + "background": "string", + "text": "string" + }, + "required": [] + }, + "explorerReport": { + "args": {}, + "required": [] + }, + "explorerRestore": { + "args": {}, + "required": [] + }, + "favorite": { + "args": { + "id": "string" + }, + "required": [] + }, + "fileCancel": { + "args": {}, + "required": [] + }, + "fileOpen": { + "args": { + "gen": "number", + "index": "number" + }, + "required": [ + "index" + ] + }, + "fileResults": { + "args": {}, + "required": [] + }, + "fileSearch": { + "args": { + "gen": "number", + "term": "string" + }, + "required": [ + "term" + ] + }, + "icon": { + "args": { + "id": "string" + }, + "required": [] + }, + "launch": { + "args": { + "id": "string" + }, + "required": [] + }, + "masterMute": { + "args": { + "muted": "boolean" + }, + "required": [] + }, + "media": { + "args": { + "action": "string", + "value": "number" + }, + "required": [] + }, + "mediaVolume": { + "args": { + "value": "number" + }, + "required": [] + }, + "microphone": { + "args": {}, + "required": [] + }, + "move": { + "args": { + "board": "string", + "id": "string" + }, + "required": [] + }, + "openFiles": { + "args": {}, + "required": [] + }, + "palette": { + "args": {}, + "required": [] + }, + "paletteRun": { + "args": { + "id": "string", + "kind": "string", + "query": "string" + }, + "required": [] + }, + "preference": { + "args": { + "enabled": "boolean", + "key": "string", + "value": "string" + }, + "required": [ + "key" + ] + }, + "profiles": { + "args": {}, + "required": [] + }, + "quit": { + "args": {}, + "required": [] + }, + "radio": { + "args": { + "kind": "string" + }, + "required": [] + }, + "raiseMedia": { + "args": {}, + "required": [] + }, + "recycleFiles": { + "args": { + "paths": "array" + }, + "required": [] + }, + "refreshCatalogue": { + "args": {}, + "required": [] + }, + "restoreTaskbar": { + "args": {}, + "required": [] + }, + "reveal": { + "args": { + "id": "string" + }, + "required": [] + }, + "runningRefresh": { + "args": {}, + "required": [] + }, + "sessionMute": { + "args": { + "id": "string", + "muted": "boolean" + }, + "required": [] + }, + "sessionVolume": { + "args": { + "id": "string", + "value": "number" + }, + "required": [] + }, + "settings": { + "args": { + "section": "string" + }, + "required": [] + }, + "shortcut": { + "args": { + "combo": "string", + "id": "any", + "key": "number", + "kind": "string", + "modifiers": "number" + }, + "required": [] + }, + "shortcut-reset": { + "args": { + "id": "string" + }, + "required": [] + }, + "socialEnable": { + "args": { + "enabled": "boolean", + "id": "string" + }, + "required": [] + }, + "socialGlobalMute": { + "args": { + "muted": "boolean" + }, + "required": [] + }, + "socialHistoryClear": { + "args": {}, + "required": [] + }, + "socialLogin": { + "args": { + "id": "string" + }, + "required": [] + }, + "socialLogout": { + "args": { + "id": "string" + }, + "required": [] + }, + "socialMute": { + "args": { + "id": "string", + "muted": "boolean" + }, + "required": [] + }, + "socialOpen": { + "args": { + "id": "string" + }, + "required": [] + }, + "socialSound": { + "args": { + "enabled": "boolean" + }, + "required": [] + }, + "socialTestSound": { + "args": {}, + "required": [] + }, + "socialVolume": { + "args": { + "value": "number" + }, + "required": [] + }, + "state": { + "args": {}, + "required": [] + }, + "systemAction": { + "args": { + "confirmed": "boolean", + "id": "string" + }, + "required": [] + }, + "theme": { + "args": { + "dark": "boolean", + "mode": "string" + }, + "required": [] + }, + "toolRun": { + "args": { + "id": "string" + }, + "required": [] + }, + "tools": { + "args": {}, + "required": [] + }, + "visualizer": { + "args": {}, + "required": [] + }, + "volume": { + "args": { + "value": "number" + }, + "required": [] + }, + "wallpaperApply": { + "args": { + "display": "number", + "path": "string" + }, + "required": [ + "path" + ] + }, + "wallpaperCapture": { + "args": { + "h": "number", + "token": "string", + "w": "number", + "x": "number", + "y": "number" + }, + "required": [] + }, + "wallpaperChanged": { + "args": {}, + "required": [] + }, + "wallpaperPicker": { + "args": {}, + "required": [] + }, + "website": { + "args": { + "id": "string" + }, + "required": [] + }, + "websites": { + "args": {}, + "required": [] + }, + "wifiConnect": { + "args": { + "password": "string", + "ssid": "string" + }, + "required": [ + "ssid" + ] + }, + "wifiForget": { + "args": { + "ssid": "string" + }, + "required": [ + "ssid" + ] + }, + "wifiPortal": { + "args": {}, + "required": [] + }, + "wifiSettings": { + "args": { + "page": "string" + }, + "required": [] + }, + "windowList": { + "args": {}, + "required": [] + }, + "workspace": { + "args": { + "id": "string" + }, + "required": [] + }, + "zipFiles": { + "args": { + "paths": "array" + }, + "required": [] + } + }, + "preferenceKeys": { + "accentBorders": { + "enabled": "boolean" + }, + "activeBorderOnly": { + "enabled": "boolean" + }, + "autoTile": { + "enabled": "boolean" + }, + "autostart": { + "enabled": "boolean" + }, + "bookmarksRailVisible": { + "enabled": "boolean" + }, + "clipboardHistory": { + "enabled": "boolean" + }, + "clipboardImages": { + "enabled": "boolean" + }, + "clipboardLimit": { + "value": "number" + }, + "customGlassOpacity": { + "value": "number" + }, + "desktopIndicator": { + "enabled": "boolean" + }, + "desktopLogo": { + "enabled": "boolean" + }, + "dictationEnabled": { + "enabled": "boolean" + }, + "dictationKey": { + "value": "number" + }, + "dockDrawerOpen": { + "enabled": "boolean" + }, + "dockVisible": { + "enabled": "boolean" + }, + "dockWidth": { + "value": "number" + }, + "explorerTheme": { + "enabled": "boolean" + }, + "filesDefaultHandler": { + "enabled": "boolean" + }, + "filesPreload": { + "enabled": "boolean" + }, + "frameThickness": { + "value": "number" + }, + "glassOpacity": { + "value": "number" + }, + "iconSize": { + "value": "number" + }, + "motion": { + "value": "number" + }, + "osd": { + "enabled": "boolean" + }, + "quietFullscreen": { + "enabled": "boolean" + }, + "reducedEffects": { + "enabled": "boolean" + }, + "replaceTaskbar": { + "enabled": "boolean" + }, + "screenFrame": { + "enabled": "boolean" + }, + "socialBackground": { + "enabled": "boolean" + }, + "socialRailVisible": { + "enabled": "boolean" + }, + "taskbar": {}, + "tileExclusionRemove": { + "value": "string" + }, + "topActivationMargin": { + "value": "number" + } + }, + "replyKeys": { + "state": "object", + "events": "array", + "notice": "string|null", + "wallpaperCapture": "object|null", + "bookmarksItems": "array|null", + "windows": "array|null", + "fileResults": "object|null", + "wallpaperPicker": "object|null", + "dictation": "object", + "audioBars": "object", + "spectrum": "array" + }, + "stateKeys": [ + "systemRecent", + "wallpapers", + "wallpaperRevision", + "audioReadMs", + "audioMixer", + "quickTabs", + "social", + "bookmarks", + "settings", + "clipboard", + "diagnostics", + "dark", + "taskbarEnabled", + "networkKind", + "virtualDesktops", + "fullscreenMonitors", + "quiet", + "quietForced", + "performance", + "sysInfo", + "media", + "controls", + "apps", + "favorites", + "recent", + "websites", + "profiles", + "taskbarApps", + "runningApps", + "icons", + "iconRevision" + ], + "events": [ + "toggle", + "access", + "media", + "performance", + "controls", + "palette", + "settings", + "settings:social", + "settings:bookmarks", + "clipboard", + "audio", + "microphone", + "tools", + "capture", + "capture-ocr", + "capture-select", + "wallpaper-switcher", + "close", + "bookmarks", + "social", + "files", + "dock", + "dictation:start", + "dictation:stop", + "dictation:keycancel" + ], + "clientOnlyEvents": [ + "mixer" + ], + "eventPrefixes": [ + "workspace:", + "osd:volume:", + "osd:brightness:", + "shortcut-key:", + "shortcut-saved:", + "osd:mic:", + "dictation:key:" + ] +} \ No newline at end of file diff --git a/docs/history/caelestia-windows.md b/docs/history/caelestia-windows.md new file mode 100644 index 0000000..203d6dd --- /dev/null +++ b/docs/history/caelestia-windows.md @@ -0,0 +1,57 @@ +# Historique : portage Caelestia pour Windows + +Synthèse des notes de portage 8.1 à 9.3 (anciennement `docs/history/caelestia-windows/`, supprimées ; elles restent +consultables dans l'historique git). Le détail courant vit dans `docs/ARCHITECTURE.md` et `docs/BRIDGE.md`. + +## Origine et licence + +- Amont : [Caelestia Shell](https://github.com/caelestia-dots/shell) (caelestia-dots et contributeurs), tag **v1.4.0**, + commit `04bfc87f65d6e13b287358e1024d9bbc055b0c58`. Ce tag correspond aux captures de référence du thème. +- Licence : GPLv3. Le portage est distribué avec son code source ; voir `LICENSE`, `THIRD_PARTY_NOTICES.md` et + `src/backend/LICENSE-CAELESTIA.txt`. +- Le dépôt ne contient pas de copie de l'amont (pas de dossier `upstream/`). La correspondance des fichiers et les + empreintes d'origine sont dans `src/frontend/UPSTREAM.json` ; les empreintes du portage dans + `src/frontend/PORT_PATCHES.json`. `src/frontend/prepare_port.py` documente l'extraction initiale, pas la + reconstruction : ne pas remplacer les QML livrés par une extraction brute de l'amont. + +## Ce qui vient de Caelestia + +Dashboard, calendrier, horloge, lecteur, visualiseur circulaire, bongocat, jauges, onglets, transitions, courbes +d'animation, boutons, ondulations au clic, menus, sliders, typographie et espacements. La bibliothèque et le lanceur +(8.1) ajoutent leurs propres sources QML. + +## Décisions structurantes + +- **Rendu** : Qt Quick 6.10 avec fenêtres natives transparentes (Direct3D 11), sans navigateur embarqué. +- **Remplacement des services Linux** : `frontend.py` remplace les services Quickshell par une connexion locale réservée + au compte Windows courant (pont décrit dans `docs/BRIDGE.md`). Les adaptateurs sont sous `qml/Quickshell`, + `qml/Caelestia` et `qml/qs/services`. +- **Backend** : .NET 8 (`Glass-v5`, projet `Panneaux.Glass`) : contrôle des médias via GSMTC, spectre par WASAPI + loopback et FFT (aucun enregistrement), télémétrie PC, volume, luminosité, connexions. +- **Sobriété** : capteurs arrêtés hors des pages Dashboard/Performance, spectre arrêté hors de Média, écritures des + sliders regroupées. +- **Dock** (8.x à 9.x) : bord physique inférieur, masquage automatique, zone de déclenchement de sept pixels, touche + Windows seule pour l'afficher (les combinaisons Win+lettre sont conservées). Réveil par événement Windows plutôt que + par attente périodique de 500 ms. Applications empaquetées et catalogue actualisés pendant l'affichage. +- **Menus du dock** (9.3) : clic droit sur une application (fenêtres, nouvelle fenêtre, administrateur, épingles, + favoris, Explorateur, fermeture) et sur le logo Windows (applications, alimentation, système, réseau, disques, + Terminal). Les fenêtres se ferment par `WM_CLOSE` ; arrêter un processus est une action distincte avec confirmation. +- **Filets de sécurité** : un gardien indépendant rétablit Explorer si le backend s'arrête ; un arrêt du frontend + rétablit la barre Windows ; `RETABLIR_BARRE_WINDOWS.exe` et `SECOURS_BUREAU.exe` restent disponibles (voir + `docs/RESTORE.md`). +- **Préférences** : fichier `preferences-glass.json` à côté du backend ; caches Qt sous `%LOCALAPPDATA%`. Favoris, + récents et profils ne sont jamais remplacés par des données de test. +- **Météo** : facultative, données [Open-Meteo](https://open-meteo.com/) (CC BY 4.0) ; configurer l'offre du fournisseur + adaptée avant une distribution commerciale. + +## Limites connues + +- Températures dépendantes des sondes et pilotes disponibles. +- Superposition soumise aux restrictions Windows pour les jeux en plein écran exclusif. +- Au portage initial, aucun installateur autonome ; il est venu ensuite avec `installer/` (Inno Setup). + +## Validation d'époque + +Chaque version 8.1 à 9.3 a été validée par captures et rapports JSON (pages, ouverture et fermeture des menus, arrêt des +capteurs, absence d'avertissement QML), puis par tests isolés des favoris, épingles et fermetures de fenêtres. Ces +artefacts n'ont pas été conservés ; les tests actuels sont décrits dans `docs/TESTS.md`. diff --git a/docs/history/caelestia-windows/AMELIORATIONS-8.1.md b/docs/history/caelestia-windows/AMELIORATIONS-8.1.md deleted file mode 100644 index 72a6483..0000000 --- a/docs/history/caelestia-windows/AMELIORATIONS-8.1.md +++ /dev/null @@ -1,44 +0,0 @@ -# Panneaux 8.1 — bibliothèque et lanceur Caelestia - -Le dépôt officiel [caelestia-dots/shell](https://github.com/caelestia-dots/shell) a été revérifié. Le portage conserve le tag v1.4.0 pour rester cohérent avec le thème choisi. Le lanceur ajoute ses sources `Content.qml`, `AppList.qml`, `AppItem.qml` et `LauncherConfig.qml` ; leurs origines et modifications figurent dans les manifestes. - -## Navigation et dock - -- Une application maximisée avec une barre de titre ne bloque plus la détection du bord inférieur. -- Une zone de sept pixels déclenche le dock. Atteindre volontairement le bord l’affiche même devant une application plein écran ; il reste masqué pendant l’usage normal de cette application. -- Les commandes ouvertes depuis le dock restent présentes jusqu’à Échap ou un clic extérieur. -- Un numéro de navigation empêche une ancienne fermeture de replier une vue qui vient d’être ouverte. -- Un événement Windows réveille le canal local à la demande : les raccourcis ne dépendent plus de l’attente périodique de 500 ms. - -## Bibliothèque - -Applications, Jeux, Projets et Documents ouvrent désormais une bibliothèque Qt : espaces latéraux, catégories, recherche, favoris, récents, tri et modes grille/liste. Le rendu est virtualisé ; seules les icônes nécessaires sont chargées. Le clic droit permet de renommer, catégoriser, choisir une icône et ranger un accès dans un autre espace. Les fichiers restent à leur emplacement. - -L’onglet Workspaces du tableau de bord présente quatre cartes d’accès, les favoris et les récents. L’interface précédente reste disponible avec le mode de secours ; elle n’est plus utilisée pour ces vues. - -## Ctrl + F2 et fonctions ajoutées - -Le lanceur utilise les composants visuels et les transitions Caelestia avec les services Windows : applications, onglets rapides conservés, sites, commandes préfixées par `>` et calcul direct. Les flèches sélectionnent un résultat et Entrée l’ouvre ; un calcul est copié à la demande. - -Les paramètres Qt regroupent les deux thèmes, les animations, la taille des icônes, le dock, le démarrage Windows, les raccourcis et l’historique. Le presse-papiers utilise également les surfaces et listes Caelestia, avec recherche, copie et épinglage. Les outils spécialisés Windows existants restent accessibles. - -## Correspondance avec Caelestia - -| Module examiné | Adaptation Windows | -| --- | --- | -| Dashboard / Media / Performance | Pages originales et services Windows conservés | -| Launcher | Composants importés, recherche locale et commandes Windows | -| Control center | Contrôles Caelestia raccordés au son, DDC/CI et radios Windows | -| Workspaces / Hyprland | Bibliothèque d’accès et profils Panneaux ; le compositeur Linux n’est pas utilisé | -| Sidebar / notifications | Le système de notifications Windows existant reste accessible via les outils | -| Schemes / wallpaper / power services | Thèmes clair/sombre repris ; services propres à Linux non chargés sous Windows | - -## Validation - -`tests/test_interaction_search.py` couvre le dock, les fenêtres maximisées, le plein écran, les calculs et la recherche. `tests/check_bridge.py` vérifie les changements de métadonnées dans le backend isolé uniquement, puis les rétablit. Les quinze vues, les captures, les erreurs QML et la veille des capteurs sont vérifiées dans `validation-8.1/report.json`. - -Les préférences de test ne sont jamais recopiées sur l’installation. Une sauvegarde de la version 8.0 se trouve dans `../Glass-v5/checkpoint-20261025-before-8.1` ; le secours du Bureau reste indépendant et inchangé. - -## Correctif du 26 octobre : dock indépendant - -Le dock est explicitement séparé de la fenêtre supérieure (`transientParent: null`). Le test natif `tests/check_dock_lifecycle.py` reproduit la panne précédente et vérifie cinq phases, dont une apparition avant toute ouverture du haut et une réapparition après fermeture. Les rapports avant/après sont dans `validation-8.1/dock-lifecycle-before.json` et `dock-lifecycle.json`. diff --git a/docs/history/caelestia-windows/AMELIORATIONS-8.2.md b/docs/history/caelestia-windows/AMELIORATIONS-8.2.md deleted file mode 100644 index ccda6b0..0000000 --- a/docs/history/caelestia-windows/AMELIORATIONS-8.2.md +++ /dev/null @@ -1,15 +0,0 @@ -# Panneaux 8.2 — Atelier et audio natifs Caelestia - -L’atelier, les sorties audio, les microphones et la capture utilisent maintenant les composants Qt de Caelestia : couleurs du thème, icônes Material, courbes d’animation, boutons et curseurs d’origine. Les accès à l’historique ouvrent la vue Qt existante. Ces vues restent dans la fenêtre native superposée ; aucun navigateur n’est nécessaire. - -Dans **Commandes rapides**, la flèche à droite du volume déploie le **mixeur d’applications** sur le côté. Le groupe reste centré pendant son agrandissement. Chaque application ayant une session audio Windows dispose de son volume et de son bouton muet. Les sessions d’une même application sont regroupées, y compris sur plusieurs sorties. Windows conserve ses réglages audio. Une application sans session apparaît dès qu’elle crée une lecture audio ; aucune liste artificielle d’applications silencieuses n’est affichée. - -Les vues **Sortie audio** et **Microphones** permettent de choisir le périphérique par défaut et de régler son niveau ou son bouton muet. Le microphone n’est pas enregistré. Les lectures des sessions s’arrêtent lorsque ces vues et le mixeur sont fermés. Les changements de valeur mettent à jour les délégués existants afin de garder un curseur stable sous la souris. - -La capture propose sélection de zone, crayon, surligneur, annulation, copie, enregistrement PNG, reconnaissance de texte Windows et épinglage. **Ctrl F3** ouvre la sélection native. L’image reste en mémoire ; un fichier temporaire n’est créé que pour transmettre une capture à l’OCR ou à l’épinglage. L’OCR rejoint le presse-papiers. La capture concerne l’écran où se trouve la souris ; l’enregistrement permet de choisir un fichier. - -Les outils spécialisés déjà existants (pipette, règle, papiers épinglés, gros fichiers et profils) restent accessibles depuis le nouvel atelier. Leur moteur historique est conservé. La version de secours du Bureau n’a pas été modifiée. - -Validation : `validation-8.2/tools-report.json`, `validation-8.2/live-report.json` et `validation-8.2/audio-mixer-verification.json`. Le test audio crée sa propre session silencieuse, vérifie gain, muet, réactivation et bornes, puis restaure ses valeurs. Il vérifie également que les volumes des autres applications restent inchangés. Les tests de cycle de vie du dock et les tests de recherche/raccourcis sont conservés. - -Sauvegardes avant modification : `Glass-v5/checkpoint-20261026-before-8.2` et `Caelestia-Windows/checkpoint-8.1-tools/frontend.zip`. diff --git a/docs/history/caelestia-windows/AMELIORATIONS-8.3.md b/docs/history/caelestia-windows/AMELIORATIONS-8.3.md deleted file mode 100644 index 2d96601..0000000 --- a/docs/history/caelestia-windows/AMELIORATIONS-8.3.md +++ /dev/null @@ -1,20 +0,0 @@ -# Panneaux 8.3 — Cadre Caelestia et thème du fond d’écran - -Un contour permanent de 12 pixels logiques entoure chaque écran. Il utilise le masque arrondi du composant `modules/drawers/Border.qml` de Caelestia. Le dock reprend le tracé inférieur de `modules/launcher/Background.qml`. Les fenêtres de ces contours sont natives, transparentes au clic et incapables de prendre le focus. Le panneau supérieur et le dock sortent maintenant du contour. Le contour se masque sur l’écran d’une application en plein écran et pendant une capture. - -Windows réserve une marge de 20 pixels logiques sur chaque bord, ajustée au DPI. Les fenêtres maximisées suivent cette zone de travail ; les fenêtres ordinaires ouvertes ou déplacées sont ramenées à l’intérieur si elles débordent. Le plein écran reste exclu de ce placement. La réservation utilise les appbars Windows et leurs fenêtres de contrôle : elle disparaît avec le processus. À la fermeture normale, Panneaux retire les réservations et restitue les positions qu’il a ajustées, tant qu’elles n’ont pas été modifiées entre-temps. Le réglage **Cadre de l’écran** permet également de le désactiver. - -Dans **Personnaliser → Apparence**, trois modes sont disponibles : **Porcelaine**, **Nuit** et **Automatique**. Les deux palettes fixes sont conservées. Le mode automatique lit le fond déclaré par Windows pour chaque écran, puis produit une palette Material You : surfaces, textes, pictogrammes et contrôles. Les familles de teintes sont regroupées pour identifier la couleur dominante ; les fonds monochromes restent neutres. La luminosité moyenne du fond décide de la variante claire ou sombre. - -Le fond d’écran n’est jamais capturé à l’écran et n’est envoyé à aucun service. Windows est interrogé toutes les cinq secondes ; une nouvelle analyse n’est faite que si le fichier ou sa date de modification change. Le calcul travaille sur une miniature de 128 pixels, sur un fil distinct. Pour les logiciels de fond animé qui ne publient pas leur image via Windows, la référence utilisée est l’image déclarée par Windows. - -Le mixeur audio dispose maintenant de son propre fil COM : détection des périphériques, recherche des sessions et réglages des applications ne s’exécutent plus sur le fil de l’interface. L’ouverture d’une vue active ce service sans attendre la détection. Les valeurs se mettent à jour ensuite et le suivi s’arrête à la fermeture. Les transitions des menus audio et des commandes rapides passent de 350 à 210 ms à l’échelle normale, en conservant les courbes de Caelestia. - -Validation locale : - -- `validation-8.3/theme-report.json` : palettes bleue, jaune, monochrome, contraste, transparence du contour, passage des clics, plein écran et capture. -- `validation-8.3/frame-verification.json` : réservation réelle des quatre bords Windows et restitution de la zone de travail. -- `validation-8.3/audio-mixer-verification.json` : réglages réels d’une session silencieuse dédiée et activation asynchrone du service audio. -- `validation-8.3/live-report.json` : parcours des menus avec les périphériques et le fond d’écran de ce PC, premières images rendues et mesure au repos. - -Sources originales : [Caelestia Shell](https://github.com/caelestia-dots/shell), commit `04bfc87f65d6e13b287358e1024d9bbc055b0c58`. Adaptation Windows et correspondance dans `PORT_PATCHES.json`. Sauvegardes avant cette version : `Glass-v5/checkpoint-20261026-before-8.3` et `Caelestia-Windows/checkpoint-8.2-adaptive/frontend.zip`. diff --git a/docs/history/caelestia-windows/AMELIORATIONS-8.4.md b/docs/history/caelestia-windows/AMELIORATIONS-8.4.md deleted file mode 100644 index 80ca7ed..0000000 --- a/docs/history/caelestia-windows/AMELIORATIONS-8.4.md +++ /dev/null @@ -1,22 +0,0 @@ -# Panneaux 8.4 — Verre unifié et accents du fond réel - -Le contour passe de 12 à 16 pixels logiques. Le dashboard, le dock et le contour utilisent exactement la même couleur et la même opacité ; le trait extérieur des panneaux et le recouvrement d’un pixel à leur jonction sont supprimés. Porcelaine et Nuit restent opaques. - -En Automatique, les trois surfaces reçoivent un hôte Acrylic Windows séparé, découpé selon leur silhouette. Il floute le contenu réellement derrière les fenêtres, y compris un fond animé. Le centre du cadre reste absent de la région native. La matière utilise une base presque noire teintée selon le fond ; elle ne passe plus en blanc sur un fond lumineux. Les cartes et surfaces secondaires sont également translucides en Automatique et suivent ce même réglage. Le réglage **Personnaliser → Apparence → Transparence du verre**, de 5 à 80 %, agit immédiatement et est enregistré. - -La palette utilise la présence chromatique : pixels saturés et visibles regroupés en familles de teintes. Les ombres bleues très sombres ne prennent plus le dessus sur les fleurs rouges. Les pictogrammes de navigation et les contrôles reprennent cet accent ; les logos des applications conservent leur identité. Les fonds monochromes gardent des accents neutres. Le contraste des accents et du texte est contrôlé sans décaler la teinte. - -Le logiciel lit toujours le fond Windows par écran. Quand DesktopHut est actif, il lit aussi sa configuration, y compris les chemins virtualisés MSIX, et analyse la miniature du fond réellement sélectionné. Il ne dépend donc plus du vieux fond statique Windows pendant la lecture de la vidéo. Le fond actuel dark-knight-in-red-flowers produit l’accent #fa3c5b. Le changement de source est recherché toutes les quatre secondes ; Windows actualise sa liste toutes les cinq secondes. Les configurations DesktopHut sont lues uniquement. Les autres moteurs non reconnus utilisent la source publiée par Windows. - -Le suivi du verre est déclenché par les mouvements, dimensions, visibilité et changements de thème, avec une surveillance légère toutes les 250 ms. La fermeture différée du contenu du dashboard ne dépend plus de sa propre hauteur animée, ce qui supprime une boucle de liaison QML à la réouverture. - -## Vérification - -- 27 vérifications Python : comportement du dock, recherche et accents rouge/bleu/vert/jaune/neutre, ombres sombres et contraste. -- validation-8.4/theme-report.json : comparaison des pixels des trois surfaces, alpha réglable, modes opaques, régions natives, passage des clics, plein écran, capture et absence d’avertissements QML. -- validation-8.4/native-blur-report.json : bandes de 8 pixels derrière une fenêtre transparente ; l’écart type passe d’environ 79 à moins de 1 après activation du verre. -- validation-8.4/live-material-report.json : fond réel DesktopHut, accent rouge, bascule des modes, retour du réglage par le backend et persistance sur disque. - -Sauvegardes : checkpoint-8.3-before-8.4 et ../Glass-v5/checkpoint-20261026-before-8.4. Les sources C++ et le script build-qt-glass.cmd sont inclus ; aucun nouveau paquet Python n’est requis. - -Références : [DWM et régions de flou](https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ns-dwmapi-dwm_blurbehind), [composition des fenêtres](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowpos). La couche Acrylic réutilise le mécanisme Windows déjà employé par le backend ; sa fenêtre de rendu est maintenant placée immédiatement sous le contenu Qt. diff --git a/docs/history/caelestia-windows/AMELIORATIONS-8.5.md b/docs/history/caelestia-windows/AMELIORATIONS-8.5.md deleted file mode 100644 index 9c5cadc..0000000 --- a/docs/history/caelestia-windows/AMELIORATIONS-8.5.md +++ /dev/null @@ -1,19 +0,0 @@ -# Panneaux 8.5 — Correction du voile noir et épaisseur réglable - -La couche Acrylic du cadre ne possède plus de fenêtre de la taille de l’écran avec un trou central. Elle utilise huit fenêtres natives bornées : quatre bandes et quatre coins. Même si le compositeur applique un fond opaque ou ignore un découpage, ces fenêtres ne peuvent pas peindre le centre du bureau. - -Les auxiliaires restent cachés jusqu’à la validation complète de leur géométrie. Les dimensions invalides, un découpage refusé ou une mise à jour incohérente masquent le verre. L’API destinée aux panneaux refuse les fenêtres presque aussi larges et hautes qu’un écran. Les auxiliaires ne changent plus le propriétaire des fenêtres Qt, ne reçoivent pas les clics et disparaissent à la destruction ou au masquage du contenu. La réapparition après masquage natif est également vérifiée. - -Le réglage **Personnaliser → Apparence → Épaisseur des contours** va de **4 à 40 pixels logiques**, dans les trois thèmes, avec 16 px par défaut. Le cadre, le raccord du dashboard et le dock suivent immédiatement cette valeur. Windows réserve l’épaisseur choisie plus quatre pixels pour les applications. La valeur est enregistrée dans les préférences. Le curseur de transparence reste disponible dans le mode Automatique. - -Validation de la version : - -- `validation-8.5/desktop-regression-report.json` : véritable capture du bureau de test sous le compositeur, pixels centraux inchangés à 4/16/40 px, auxiliaires bornés, refus d’un voile plein écran, fermeture en cas d’erreur, masquage/réaffichage et destruction. -- `validation-8.5/theme-report.json` : trois surfaces identiques, transparence, thèmes fixes opaques, géométrie native et QML, changement d’épaisseur, plein écran et capture. -- `validation-8.5/native-blur-report.json` : flou réel conservé derrière une fenêtre de test. -- `validation-8.5/live-thickness-report.json` : curseur, frontend, backend et disque synchronisés ; marges Windows réellement mesurées à 8, 20 et 44 px pour des épaisseurs de 4, 16 et 40 px. -- Les 27 vérifications Python de recherche, comportement du dock et palette passent. - -Sauvegardes : `checkpoint-8.4-before-8.5` et `../Glass-v5/checkpoint-20261026-before-8.5`. Préférences précédentes conservées. L’application reste fermée après l’installation pour le test final de dj. - -Le masquage des icônes du bureau est rétabli au démarrage normal. Les icônes sont réaffichées à la fermeture ou si le moteur Qt s’arrête. Le cycle démarrage/fermeture a été vérifié sur Windows. diff --git a/docs/history/caelestia-windows/AMELIORATIONS-8.6.md b/docs/history/caelestia-windows/AMELIORATIONS-8.6.md deleted file mode 100644 index e33d8ea..0000000 --- a/docs/history/caelestia-windows/AMELIORATIONS-8.6.md +++ /dev/null @@ -1,13 +0,0 @@ -# Panneaux 8.6 — Suppression des rectangles noirs - -Les fonds Acrylic sont remplacés par des surfaces de composition transparentes. Le flou du contenu derrière chaque fenêtre est découpé par un masque géométrique au niveau du compositeur. Les zones extérieures à la forme restent transparentes. - -Le cadre conserve huit auxiliaires bornés aux bords et aux coins. Le dashboard et le dock utilisent le même masque. Transparence, épaisseur, thèmes et masquage des icônes du bureau sont conservés. - -Le test reproduit le défaut avec la DLL 8.5 : 1 936 pixels sombres dans les quatre zones de coin testées. La DLL 8.6 en produit zéro aux épaisseurs 4, 16 et 40 px. Les pixels du centre restent inchangés. Les tests de fermeture, masquage et destruction passent. - -Le test de flou conserve une luminance moyenne de 84/255 tout en supprimant les bandes fines : il vérifie un flou effectif et exclut un fond noir. Les contrôles QML des trois surfaces et des réglages passent sans avertissement. - -Rapports : validation-8.6/. baseline-corner-report.json utilise volontairement l’ancienne DLL ; son échec reproduit le défaut. Les autres rapports concernent la version corrigée. - -Sauvegardes : checkpoint-8.5-before-8.6 et ../Glass-v5/checkpoint-20261026-before-8.6. diff --git a/docs/history/caelestia-windows/AMELIORATIONS-9.1.md b/docs/history/caelestia-windows/AMELIORATIONS-9.1.md deleted file mode 100644 index 2e76fee..0000000 --- a/docs/history/caelestia-windows/AMELIORATIONS-9.1.md +++ /dev/null @@ -1,17 +0,0 @@ -# WinLestia / Panneaux 9.1 — Une matière continue - -Le cadre, le dashboard et le dock partagent désormais un seul fond par écran. `surface_geometry.py` calcule l’union de leurs silhouettes et fournit les mêmes courbes au rendu QML et au compositeur Windows. Main.qml et Dock.qml affichent leurs contenus, sans ajouter un autre fond. Les anciens ScreenBorder.qml et DockBackground.qml ne dessinent plus la matière courante. - -Le rendu natif utilise des courbes Direct2D, découpées en auxiliaires bornés. Le masque n’est plus une collection de rectangles issus des régions Win32. La silhouette commune évite les superpositions de transparence et les raccords divergents. - -Le curseur contrôle à la fois la teinte et la contribution du flou. Auparavant, le flou natif restait opaque même lorsque la teinte devenait transparente. Le produit des transmissions des deux couches correspond maintenant à la transparence demandée. Les surfaces des cartes internes sont plus légères. - -Plage de transparence : 5 à 98 %. Réglage choisi pour la relance : 88 %. Les changements faits ensuite dans l’interface restent enregistrés. Les thèmes Porcelaine et Nuit restent opaques ; les favoris, catégories, raccourcis et le masquage des icônes du bureau sont conservés. - -## Vérification - -`validation-9.1/surface-report.json` mesure une capture réelle sous Windows : mêmes pixels RGB sur les quatre échantillons cadre/dashboard/dock/cadre, aucune séparation inattendue dans les coins, transmission de contraste identique pour les trois surfaces à 88 %, 98 % et 50 %. L’écart aux frontières des auxiliaires natifs reste de 1 niveau RGB à forte transparence, au plus 2 niveaux dans les cas testés. Aucun avertissement QML ni erreur native. - -Les 32 tests Python passent. `live-startup-report.json` confirme le démarrage réel, le fond DesktopHut, les réglages et les auxiliaires bornés. `baseline-opaque-blur-report.json` décrit l’essai antérieur ayant montré qu’une simple réduction du rayon de flou ne suffisait pas : il ne concerne pas le matériau final. - -Sauvegardes : checkpoint-before-9.1 et ../Glass-v5/checkpoint-before-9.1. diff --git a/docs/history/caelestia-windows/AMELIORATIONS-9.2.1.md b/docs/history/caelestia-windows/AMELIORATIONS-9.2.1.md deleted file mode 100644 index 0bc47b6..0000000 --- a/docs/history/caelestia-windows/AMELIORATIONS-9.2.1.md +++ /dev/null @@ -1,9 +0,0 @@ -# WinLestia / Panneaux 9.2.1 — Correctif des clics à travers le verre - -Les auxiliaires natifs du flou étaient transparents visuellement mais leurs rectangles interceptaient les clics lorsqu’ils se trouvaient au-dessus d’une autre application. WS_EX_TRANSPARENT seul ne garantissait pas le passage entre applications ; les auxiliaires utilisent désormais WS_EX_LAYERED avec WS_EX_TRANSPARENT et une alpha globale de 255 pour conserver le rendu du compositeur. Un échec de configuration est contrôlé. - -Le test 9.2 avait placé la fenêtre cible devant les auxiliaires, masquant le défaut. Le test 9.2.1 replace explicitement les auxiliaires au premier plan avant les clics physiques. Le défaut est reproduit avec l’ancienne DLL : aucun clic transmis au bureau, à la marge ou à l’angle. Avec la DLL corrigée, les quatre clics (bureau, marge, angle et cadre) atteignent la fenêtre cible indépendante, et le bouton Applications ouvre son espace sans transmettre son clic. - -Les 14 contrôles du rendu passent toujours : matière identique sur cadre/dashboard/dock, angles propres et transparence conforme. L’application a été relancée ; les auxiliaires visibles possèdent effectivement les deux styles requis. Les réglages et les fichiers du backend 9.2.0 sont conservés. Les mesures de performances précédentes n’ont pas été répétées pour ce correctif natif. - -Rapports : validation-9.3/pointer-after.json, surface-report.json, live-input-report.json. Sauvegarde native : checkpoint-before-9.2.1. diff --git a/docs/history/caelestia-windows/AMELIORATIONS-9.2.md b/docs/history/caelestia-windows/AMELIORATIONS-9.2.md deleted file mode 100644 index 99274d3..0000000 --- a/docs/history/caelestia-windows/AMELIORATIONS-9.2.md +++ /dev/null @@ -1,17 +0,0 @@ -# WinLestia / Panneaux 9.2 — Clics, contours et fluidité - -Les fenêtres de contenu possèdent désormais un masque d’entrée natif correspondant à leur silhouette visible. Les marges et les angles transparents laissent passer les clics vers les applications situées derrière ; les commandes du dashboard restent interactives. Le masque suit aussi le déplacement du dock. - -En mode automatique, un liseré continu de 1 pixel à 23 % d’alpha permet de distinguer les formes, même lorsque le verre est très transparent. Il suit l’union cadre/dashboard/dock et ne dessine pas de raccords internes. La matière et le curseur de transparence restent communs aux trois surfaces. - -Le compositeur conserve ses auxiliaires natifs et ne recalcule que les cellules affectées par une animation. La fenêtre du dashboard conserve son tampon pendant l’ouverture : seule la silhouette change. Les onglets déjà visités restent chargés ; les animations médias sont suspendues lorsqu’elles ne sont pas affichées. Les animations imbriquées de taille ont été retirées et l’ouverture/fermeture utilise une courbe de décélération sur 260 ms, adaptée au réglage de mouvement. - -## Validation - -- 32 tests Python passent. -- 14 contrôles du rendu Windows passent : matière identique, coins propres, absence de raccords entre cellules, transmission identique à 50 %, 88 % et 98 %, aucun verre résiduel à la fermeture. -- Des clics physiques envoyés vers une fenêtre de test indépendante traversent le bureau, la marge et l’angle transparent. Le bouton Applications ouvre son espace sans transmettre le clic à cette fenêtre. -- Sur 12 transitions à 165 Hz, le temps natif médian vaut 0,26 ms et son 95e percentile 0,44 ms, contre 8,85 ms et 41,31 ms avant modification. Les intervalles d’images mesurés pendant les transitions ont une médiane de 6,71 ms, un 95e percentile de 16,95 ms et un maximum de 27,76 ms. Cela mesure ce scénario ; le maintien permanent de 165 images/s n’est pas garanti. -- Démarrage réel confirmé, masque natif présent, aucun nouveau message QML. Réglages conservés, y compris opacité 0,55 et épaisseur 31 au moment de la relance. Les icônes du bureau sont masquées normalement. - -Rapports dans validation-9.2/. Sauvegardes : checkpoint-before-9.2 et ../Glass-v5/checkpoint-before-9.2. Aucun favori, raccourci ou réglage personnel n’a été réinitialisé. diff --git a/docs/history/caelestia-windows/AMELIORATIONS-9.3.md b/docs/history/caelestia-windows/AMELIORATIONS-9.3.md deleted file mode 100644 index e5931de..0000000 --- a/docs/history/caelestia-windows/AMELIORATIONS-9.3.md +++ /dev/null @@ -1,11 +0,0 @@ -# Winlestia 9.3 — Menus du dock - -Clic droit sur une application : nom, chemin, nombre de fenêtres, activation d’une fenêtre précise, nouvelle fenêtre, lancement administrateur, épingler/détacher, favoris, Explorateur, réduire/agrandir, fermer une fenêtre ou toutes ses fenêtres. Ajouter aux favoris épingle aussi l’accès au dock ; retirer un favori conserve son épingle. Les accès externes sont enregistrés sans duplication. - -Clic droit sur le logo Windows : menu Winlestia des applications installées, alimentation, événements, système, périphériques, réseau, disques, gestion de l’ordinateur, Terminal normal/administrateur, gestionnaire des tâches, paramètres, panneau de configuration, Explorateur, recherche/exécution et bureau. Les outils Windows s’ouvrent depuis ce menu personnalisé ; leurs interfaces internes ne sont pas remplacées. - -Le bouton Applications près du son ouvre les applications en cours. Les fenêtres et les applications du catalogue reconnues en arrière-plan sont actualisées chaque seconde pendant que le dock est affiché. Ce panneau ne constitue pas une reproduction de la zone de notification native ni de ses menus spécifiques à chaque éditeur. - -Les fenêtres se ferment normalement avec WM_CLOSE, permettant aux applications de demander l’enregistrement. Arrêter l’application est une action distincte, soumise à une confirmation Winlestia ; les exécutables du dossier Windows et le processus du shell sont exclus de cet arrêt forcé. Les menus occupent une petite fenêtre indépendante, ferment à Échap ou après perte de focus et maintiennent le dock visible. - -Validation : les trois clics ouvrent leurs menus ; aucun avertissement QML ni erreur native dans ce scénario. Tests isolés des favoris, épingles, absence de doublons et fermeture de deux fenêtres d’un exécutable de test unique. Les 32 tests Python passent. Les réglages personnels ont été comparés avant/après relance et sont conservés. Sauvegarde : checkpoint-before-dock10. Rapports : validation-dock10/. diff --git a/docs/history/caelestia-windows/AMELIORATIONS-9.md b/docs/history/caelestia-windows/AMELIORATIONS-9.md deleted file mode 100644 index 23b1efb..0000000 --- a/docs/history/caelestia-windows/AMELIORATIONS-9.md +++ /dev/null @@ -1,118 +0,0 @@ -# Panneaux 9.0 — Une seule matière, une seule transparence - -Le cadre, le dashboard et le dock lisent maintenant la même valeur. Le réglage -« Transparence du verre » pilote les trois surfaces, et rien d'autre. - -## La ligne au raccord - -`ScreenBorder.qml` peignait la teinte du cadre dans une couche Qt (`layer.enabled`) -puis la découpait avec un `MultiEffect` masqué. La couleur était déjà semi-transparente -avant d'entrer dans la texture, puis composée une seconde fois à la sortie : l'alpha -réel du cadre tombait autour de 0,04 au lieu de 0,20. - -Mesure sur le bureau réel, même colonne, avant correction : - -| zone | couleur | -|---|---| -| cadre (y = 0…19) | 43, 72, 93 | -| dashboard (y = 22…) | 33, 42, 49 | - -Le cadre laissait donc passer le fond presque brut, sans teinte, alors que le -dashboard appliquait la sienne. D'où la ligne nette au raccord. - -Le cadre est désormais un `Shape` en anneau, rempli en `OddEvenFill` : un seul -tracé, aucune texture intermédiaire, aucun effet. L'alpha arrive à l'écran tel quel. - -## Une seule matière - -`Colours.qml` expose `surfaceBase` (couleur opaque de la palette) et `surfaceColor` -(`Qt.alpha(surfaceBase, surfaceOpacity)`). Le cadre, le dashboard et le dock -consomment tous les trois `surfaceColor`. Le cadre n'utilise plus sa propre -`paletteForScreen` pour son fond : plus de teinte parallèle, donc plus d'écart. - -## Plus de transparence - -- Curseur : 5 → 95 % (au lieu de 5 → 80 %). -- Bornes backend et frontend : alpha 0,05 → 0,95. -- Valeur en place : `GlassOpacity = 0.05`, soit 95 % de transparence — c'est la position - que dj a laissée sur le curseur. Le verre laisse franchement voir le bureau et le - texte des panneaux reste lisible. -- Épaisseur du cadre inchangée : 20 px. - -Porcelaine et Nuit restent opaques : hors mode Automatique, `surfaceOpacity` vaut 1 -et `surfaceColor` renvoie la couleur pleine, exactement comme avant. - -## Coins bas - -Deux défauts se cumulaient aux angles : - -1. les huit auxiliaires natifs avaient la taille exacte de leur bande (20 px). Un - flou gaussien de rayon 22 dans une fenêtre de 20 px est entièrement mangé par son - bord : le liseré dur du noyau tombait pile sur la limite entre deux pièces ; -2. la couche Qt du cadre ne peignait rien (voir plus haut), donc ces liserés - restaient seuls visibles. - -Chaque fenêtre native est maintenant agrandie vers l'intérieur de `max(56, 2 × épaisseur + rayon)` -pixels. Le noyau de flou se développe entièrement à l'intérieur de la fenêtre et son -bord dur tombe hors de la zone découpée. La zone découpée, elle, reste strictement -la même : huit pièces disjointes, jamais le centre du bureau. - -## Mesures - -`validation-9/seam-report.json` — colonne centrale, dashboard ouvert, même fond -visible des deux côtés : - -- cadre : 184,0 / 200,0 / 215,0 -- dashboard : 184,0 / 200,0 / 215,0 -- écart au raccord : **0,0** - -Un premier passage sur un fond bleu sombre donnait 48,0 / 62,0 / 80,0 de part et -d'autre, également à 0,0 d'écart. - -`validation-9/desktop-regression-report.json` — DLL de production, épaisseurs 4, 16 et 40 px : - -- 0 pixel noir dans les quatre coins pour chacune des trois épaisseurs ; -- 8 auxiliaires par cadre, aucun ne couvre le centre du bureau ; -- chaque auxiliaire reste une bande bornée (< 40 % de la surface de l'écran) ; -- pixels du centre du bureau inchangés ; -- géométrie invalide : tout est masqué, puis tout revient au rétablissement. - -Tests Python : 27/27 (palette chromatique et interaction/recherche). - -## La barre des tâches disparue - -Deux verrous se cumulaient. - -1. `QtShellBridge` appelait `owner.SetTaskbar(false)` quand le moteur Qt s'arrêtait. - Cette méthode écrivait `ReplaceTaskbar = false` dans les préférences : un simple - arrêt du frontend décidait donc, durablement, que la barre n'était plus voulue. - Au démarrage suivant, le secours n'était plus activé, et comme le dock Qt est - conditionné par `TaskbarEnabled` (lui-même `taskbar.Enabled`), le dock restait - caché pour toujours. Le 26/10 à 02:33:23, un arrêt du moteur Qt a suffi. - `SuspendTaskbar()` restaure maintenant la barre Windows **sans** réécrire la - préférence. Vérifié : frontend tué à 02:53:09, `ReplaceTaskbar` toujours à `true`. -2. En plein écran, `EdgePolicy` renvoyait `False` dès que la souris quittait les sept - derniers pixels de l'écran, sans regarder si elle était dans le dock. Le dock - s'ouvrait puis se refermait avant qu'on puisse cliquer une icône. Le test - `inside` passe désormais avant le test `fullscreen` : une fois révélé, le dock - reste tant que la souris est dedans. - -Le journal affiche maintenant `Démarrage Panneaux 9.0`. - -## Fichiers touchés - -Frontend : `qml/ScreenBorder.qml`, `qml/ScreenFrame.qml`, -`qml/qs/services/Colours.qml`, `qml/ShellPreferences.qml`, `frontend.py`, -`interaction.py`, `tests/test_interaction_search.py`, `QtGlassBackdrop.cpp`, -`QtGlassBackdrop.dll`. -Backend : `Coordinator.cs`, `Models.cs`, `QtShellBridge.cs`, -`preferences-glass.json`. - -## Sauvegardes - -`checkpoint-20261026-021942-avant-reprise` (frontend) et -`../Glass-v5/checkpoint-20261026-before-8.6` (backend) restent la référence 8.6. - -## En attente - -Le retour visuel de dj sur cette 9.0. Rien n'est déclaré validé avant. diff --git a/docs/history/caelestia-windows/PORTAGE.md b/docs/history/caelestia-windows/PORTAGE.md deleted file mode 100644 index 51ddaa5..0000000 --- a/docs/history/caelestia-windows/PORTAGE.md +++ /dev/null @@ -1,47 +0,0 @@ -# Panneaux 8.1 — portage Caelestia pour Windows - -L’interface utilise les sources QML de [Caelestia Shell](https://github.com/caelestia-dots/shell), tag **v1.4.0**, commit `04bfc87f65d6e13b287358e1024d9bbc055b0c58`. Cette version correspond aux captures de référence. Ce portage est distribué avec la GPLv3 et son code source. - -Voir aussi [les améliorations 8.1](AMELIORATIONS-8.1.md) pour la bibliothèque complète, Ctrl + F2, les paramètres et le dock. - -## Ce qui vient directement de Caelestia - -Dashboard, calendrier, horloge, lecteur, visualiseur circulaire, bongocat, jauges doubles, onglets, transitions de taille et de déplacement, courbes d’animation, boutons, ondulations au clic, menus, sliders, typographie et espacements. `UPSTREAM.json` conserve les chemins et empreintes d’origine ; `PORT_PATCHES.json` indique les empreintes du portage. `upstream/` reste la copie originale. - -## Adaptations Windows - -Qt Quick 6.10.3 rend des fenêtres natives transparentes avec Direct3D 11. Aucun navigateur n’est utilisé. `frontend.py` remplace les services Quickshell/Linux par une connexion locale réservée au compte Windows courant. Les adapters sont sous `qml/Quickshell`, `qml/Caelestia` et `qml/qs/services`. - -Le backend .NET 8 est dans `../Glass-v5/` : `QtShellBridge.cs`, `WindowsSpectrum.cs`, `WindowsDockKey.cs`, `MediaController.cs`, `PcTelemetry.cs`, `SystemAudio.cs`, `MonitorBrightness.cs`, `WindowsConnections.cs` et les outils Panneaux existants. Le quatrième onglet est adapté aux applications et fichiers Windows. - -La lecture audio utilise GSMTC ; le spectre utilise WASAPI loopback et une FFT, sans enregistrement audio. Les capteurs s’arrêtent hors des pages Dashboard/Performance. Le spectre s’arrête hors de Media. Les écritures des sliders sont regroupées pour éviter les commandes en attente. - -Le dock est au bord physique inférieur et se masque automatiquement. La touche Windows seule l’affiche ; les combinaisons Win+lettre sont conservées. Un gardien indépendant rétablit Explorer si le backend s’arrête. Un arrêt du frontend rétablit également la barre Windows. - -## Lancer et revenir en arrière - -`LANCER_CAELESTIA.cmd` lance le logiciel installé. Survoler le haut au centre affiche les quatre onglets ; Ctrl+F1 ouvre/replie le panneau. Le bas de l’écran fait apparaître le dock. Ses boutons son/Wi-Fi ouvrent les commandes Windows. Le clic droit sur un accès propose favoris, Explorateur et personnalisation. - -`REVENIR_A_PANNEAUX.ps1` désactive le nouveau frontend et relance l’interface précédente. `../Glass-v5/RETABLIR_BARRE_WINDOWS.exe` rétablit la barre Windows indépendamment. Le secours du Bureau dans Documents reste inchangé. - -Les préférences restent dans `../Glass-v5/preferences-glass.json`. Favoris, récents, sites et profils ne sont pas remplacés par les données de test. Les caches Qt sont dans `%LOCALAPPDATA%/Panneaux/Caelestia`. - -## Reconstruire - -Installer Python 3.12 et .NET SDK 8, puis : - -```powershell -python -m venv .venv -.\.venv\Scripts\python.exe -m pip install -r requirements.txt -dotnet publish ..\Glass-v5\Panneaux.Glass.csproj -c Release -o .\artifacts\backend -``` - -Les sources QML livrées contiennent déjà les adaptations : ne pas les remplacer par une extraction brute de l’upstream. `prepare_port.py` documente l’extraction initiale, pas la reconstruction finale. - -## Validation et limites - -`validation/report.json` et les PNG montrent les quatre pages avec les données du PC, la fermeture et l’arrêt des capteurs. `verification-qt-keys.json` vérifie les combinaisons Windows sans injecter de frappes. `validation/summary.json` contient les mesures de la dernière exécution. - -Les températures dépendent des sondes/pilotes disponibles. La superposition reste soumise aux restrictions Windows des jeux en plein écran exclusif. Le backend et les anciens outils Windows restent conservés ; ce premier portage n’est pas encore un installateur commercial autonome pour d’autres PC. - -La météo est facultative : cliquer sur la tuile pour choisir une ville. Données [Open-Meteo](https://open-meteo.com/), attribution CC BY 4.0 ; configurer l’offre du fournisseur adaptée avant une distribution commerciale. diff --git a/docs/history/caelestia-windows/PORT_PATCHES.json b/docs/history/caelestia-windows/PORT_PATCHES.json deleted file mode 100644 index db4c596..0000000 --- a/docs/history/caelestia-windows/PORT_PATCHES.json +++ /dev/null @@ -1,544 +0,0 @@ -{ - "upstreamCommit": "04bfc87f65d6e13b287358e1024d9bbc055b0c58", - "files": [ - { - "source": "components/Anim.qml", - "destination": "qml/qs/components/Anim.qml", - "sha256": "daf84b005a64e78d8f50c02b42edb9ca502b01dd0eafc8685bce3dc98d87164f", - "portedSha256": "daf84b005a64e78d8f50c02b42edb9ca502b01dd0eafc8685bce3dc98d87164f", - "modified": false - }, - { - "source": "components/CAnim.qml", - "destination": "qml/qs/components/CAnim.qml", - "sha256": "1634c7fd92a6bc0190d96fca86803e20ac528f114255d83adbf90cae71715a1f", - "portedSha256": "1634c7fd92a6bc0190d96fca86803e20ac528f114255d83adbf90cae71715a1f", - "modified": false - }, - { - "source": "components/ConnectionHeader.qml", - "destination": "qml/qs/components/ConnectionHeader.qml", - "sha256": "0e03f121d9406b6732fb5ac7439175a01c8d536cd7086706fea57c5ca27939c2", - "portedSha256": "0e03f121d9406b6732fb5ac7439175a01c8d536cd7086706fea57c5ca27939c2", - "modified": false - }, - { - "source": "components/ConnectionInfoSection.qml", - "destination": "qml/qs/components/ConnectionInfoSection.qml", - "sha256": "63eac37f457195fae4c2263da2f0d02f862474cf502ecadd187ce7f35dc926ec", - "portedSha256": "63eac37f457195fae4c2263da2f0d02f862474cf502ecadd187ce7f35dc926ec", - "modified": false - }, - { - "source": "components/MaterialIcon.qml", - "destination": "qml/qs/components/MaterialIcon.qml", - "sha256": "d870983245e4fcf9886a2b0ac6fd31fc3ed480b8f7bffb6511543db462b16a37", - "portedSha256": "7a152099c3187deab38b59451e690ec7049dab9111ec3e8daf586e80d3265de1", - "modified": true - }, - { - "source": "components/PropertyRow.qml", - "destination": "qml/qs/components/PropertyRow.qml", - "sha256": "cdcd744a0b3edff9e66182cd0bf4f00a7ee81d985b663bd6043bca24640a8dec", - "portedSha256": "cdcd744a0b3edff9e66182cd0bf4f00a7ee81d985b663bd6043bca24640a8dec", - "modified": false - }, - { - "source": "components/SectionContainer.qml", - "destination": "qml/qs/components/SectionContainer.qml", - "sha256": "9b5fe860b1a1ec67983fcf5fc1723202f323206ca6526e9f14d7ae88079bd623", - "portedSha256": "9b5fe860b1a1ec67983fcf5fc1723202f323206ca6526e9f14d7ae88079bd623", - "modified": false - }, - { - "source": "components/SectionHeader.qml", - "destination": "qml/qs/components/SectionHeader.qml", - "sha256": "1493475da6aeba138d558e6d6997103b687d4c06606f4ae27aeeec9fbffe6507", - "portedSha256": "1493475da6aeba138d558e6d6997103b687d4c06606f4ae27aeeec9fbffe6507", - "modified": false - }, - { - "source": "components/StateLayer.qml", - "destination": "qml/qs/components/StateLayer.qml", - "sha256": "811a93ca953af089eed2409e5c8fa41712698161a67bed8637e2d05c3465c99c", - "portedSha256": "811a93ca953af089eed2409e5c8fa41712698161a67bed8637e2d05c3465c99c", - "modified": false - }, - { - "source": "components/StyledClippingRect.qml", - "destination": "qml/qs/components/StyledClippingRect.qml", - "sha256": "9c7509b93a19c136e81e7c5b3e9b157736468b8a64961d1d2237b2dc61141745", - "portedSha256": "9c7509b93a19c136e81e7c5b3e9b157736468b8a64961d1d2237b2dc61141745", - "modified": false - }, - { - "source": "components/StyledRect.qml", - "destination": "qml/qs/components/StyledRect.qml", - "sha256": "2555d0dc4a18ec897238cc1594fbc4ab32522ad1ac09679337e2b5de25500f2d", - "portedSha256": "2555d0dc4a18ec897238cc1594fbc4ab32522ad1ac09679337e2b5de25500f2d", - "modified": false - }, - { - "source": "components/StyledText.qml", - "destination": "qml/qs/components/StyledText.qml", - "sha256": "5ecb84234195763bb52da29392a1c55ce047bd3b8be989be723f2e43a9274ad8", - "portedSha256": "5ecb84234195763bb52da29392a1c55ce047bd3b8be989be723f2e43a9274ad8", - "modified": false - }, - { - "source": "components/containers/StyledFlickable.qml", - "destination": "qml/qs/components/containers/StyledFlickable.qml", - "sha256": "35653286ea5ea0c1cb1e387c827a2097b61d04e9e31b2524e95933e8dc872258", - "portedSha256": "35653286ea5ea0c1cb1e387c827a2097b61d04e9e31b2524e95933e8dc872258", - "modified": false - }, - { - "source": "components/containers/StyledListView.qml", - "destination": "qml/qs/components/containers/StyledListView.qml", - "sha256": "15d8994170f60f3d012245947600dbb3f51bc4fb29394455dc578a753d820f27", - "portedSha256": "15d8994170f60f3d012245947600dbb3f51bc4fb29394455dc578a753d820f27", - "modified": false - }, - { - "source": "components/containers/StyledWindow.qml", - "destination": "qml/qs/components/containers/StyledWindow.qml", - "sha256": "e6d8bc5cf20a4e5e27ce7354c83902711ee66a40ac89c23347d1fb87653a95d4", - "portedSha256": "e6d8bc5cf20a4e5e27ce7354c83902711ee66a40ac89c23347d1fb87653a95d4", - "modified": false - }, - { - "source": "components/controls/CircularIndicator.qml", - "destination": "qml/qs/components/controls/CircularIndicator.qml", - "sha256": "b55d36c9d1697d40ba3d4d114eafaab5b563f4f7bb620c49753324abdcd271b2", - "portedSha256": "b55d36c9d1697d40ba3d4d114eafaab5b563f4f7bb620c49753324abdcd271b2", - "modified": false - }, - { - "source": "components/controls/CircularProgress.qml", - "destination": "qml/qs/components/controls/CircularProgress.qml", - "sha256": "498fcab5ec9a6c3c76bcaae10adf90c9469da01a87255dd9a6f2fe1040355436", - "portedSha256": "498fcab5ec9a6c3c76bcaae10adf90c9469da01a87255dd9a6f2fe1040355436", - "modified": false - }, - { - "source": "components/controls/CollapsibleSection.qml", - "destination": "qml/qs/components/controls/CollapsibleSection.qml", - "sha256": "d20d96f869fd85f03c4cfad8450474d00706c7bb48d1f54f1666da6f27581382", - "portedSha256": "d20d96f869fd85f03c4cfad8450474d00706c7bb48d1f54f1666da6f27581382", - "modified": false - }, - { - "source": "components/controls/CustomMouseArea.qml", - "destination": "qml/qs/components/controls/CustomMouseArea.qml", - "sha256": "d558f65cd6614bdb299c35309aef6fea9fb86a910149a7f1add37298b8caad71", - "portedSha256": "d558f65cd6614bdb299c35309aef6fea9fb86a910149a7f1add37298b8caad71", - "modified": false - }, - { - "source": "components/controls/CustomSpinBox.qml", - "destination": "qml/qs/components/controls/CustomSpinBox.qml", - "sha256": "acb1a8dc210104b2d68bfc908e7b745fa58a49dd244e69a63a0a4f1e4b189d9e", - "portedSha256": "acb1a8dc210104b2d68bfc908e7b745fa58a49dd244e69a63a0a4f1e4b189d9e", - "modified": false - }, - { - "source": "components/controls/FilledSlider.qml", - "destination": "qml/qs/components/controls/FilledSlider.qml", - "sha256": "58c7ca603dda875fd175e6d101b295f5688ae584866625d290950e76620ef695", - "portedSha256": "58c7ca603dda875fd175e6d101b295f5688ae584866625d290950e76620ef695", - "modified": false - }, - { - "source": "components/controls/IconButton.qml", - "destination": "qml/qs/components/controls/IconButton.qml", - "sha256": "464dd333c6ca7a995483e5bf525c1c50ca68674272df1d54326fae22dcd75358", - "portedSha256": "f2b2b99c8c36444731f7cc1c8c66790c7b5e00f49339b310c6bb34929d5623d3", - "modified": true - }, - { - "source": "components/controls/IconTextButton.qml", - "destination": "qml/qs/components/controls/IconTextButton.qml", - "sha256": "1b1256141e5c3d05b2c4b89a3f76ed1ebac16a1d4c66ee0064d50ee45921b9bd", - "portedSha256": "1b1256141e5c3d05b2c4b89a3f76ed1ebac16a1d4c66ee0064d50ee45921b9bd", - "modified": false - }, - { - "source": "components/controls/Menu.qml", - "destination": "qml/qs/components/controls/Menu.qml", - "sha256": "a3f7ad510270a0f7672ec806f8a2a9c32be74811e7f66280178a3d296cb9428d", - "portedSha256": "a3f7ad510270a0f7672ec806f8a2a9c32be74811e7f66280178a3d296cb9428d", - "modified": false - }, - { - "source": "components/controls/MenuItem.qml", - "destination": "qml/qs/components/controls/MenuItem.qml", - "sha256": "c6c1d766d2c017e050c734ba0ca50052eee183b9b56d74a915fa38c64362931d", - "portedSha256": "c6c1d766d2c017e050c734ba0ca50052eee183b9b56d74a915fa38c64362931d", - "modified": false - }, - { - "source": "components/controls/SpinBoxRow.qml", - "destination": "qml/qs/components/controls/SpinBoxRow.qml", - "sha256": "07c3a74e0ae89aefa817a7dc9f32641757c112fd318269a1e7a30bfa7270be9a", - "portedSha256": "07c3a74e0ae89aefa817a7dc9f32641757c112fd318269a1e7a30bfa7270be9a", - "modified": false - }, - { - "source": "components/controls/SplitButton.qml", - "destination": "qml/qs/components/controls/SplitButton.qml", - "sha256": "b12e1d459b0781f260ea49054357726a769326ed38caf806e9fe3b7f6443b9e3", - "portedSha256": "b12e1d459b0781f260ea49054357726a769326ed38caf806e9fe3b7f6443b9e3", - "modified": false - }, - { - "source": "components/controls/StyledInputField.qml", - "destination": "qml/qs/components/controls/StyledInputField.qml", - "sha256": "ee17c58b75890e6750c4f7257fa98ed30fad4022a5fde5436d4f938da1a01b58", - "portedSha256": "ee17c58b75890e6750c4f7257fa98ed30fad4022a5fde5436d4f938da1a01b58", - "modified": false - }, - { - "source": "components/controls/StyledRadioButton.qml", - "destination": "qml/qs/components/controls/StyledRadioButton.qml", - "sha256": "a9205bf8b1ddbd779106620c2cc2223a7304297026be4fca7782083b6e5ab615", - "portedSha256": "a9205bf8b1ddbd779106620c2cc2223a7304297026be4fca7782083b6e5ab615", - "modified": false - }, - { - "source": "components/controls/StyledScrollBar.qml", - "destination": "qml/qs/components/controls/StyledScrollBar.qml", - "sha256": "b5a63a5ceedf25a67db03d2d0a6a59d85a7ed3eca025fbd0a49d1f75e6926481", - "portedSha256": "b5a63a5ceedf25a67db03d2d0a6a59d85a7ed3eca025fbd0a49d1f75e6926481", - "modified": false - }, - { - "source": "components/controls/StyledSlider.qml", - "destination": "qml/qs/components/controls/StyledSlider.qml", - "sha256": "c1efa217e500861de9b06df7e2712ce931a551eeededf1e513ff5c0ec0f40cf1", - "portedSha256": "c1efa217e500861de9b06df7e2712ce931a551eeededf1e513ff5c0ec0f40cf1", - "modified": false - }, - { - "source": "components/controls/StyledSwitch.qml", - "destination": "qml/qs/components/controls/StyledSwitch.qml", - "sha256": "f83abd29b83da7ff59991ba6c77db90fbffc61cfc659e6794aaf8a1fa6ee7b2d", - "portedSha256": "f83abd29b83da7ff59991ba6c77db90fbffc61cfc659e6794aaf8a1fa6ee7b2d", - "modified": false - }, - { - "source": "components/controls/StyledTextField.qml", - "destination": "qml/qs/components/controls/StyledTextField.qml", - "sha256": "57dc4adf28817b4d347d83da764f9da8d0dde2f6c1fbac57586e3025d1f0e6ef", - "portedSha256": "57dc4adf28817b4d347d83da764f9da8d0dde2f6c1fbac57586e3025d1f0e6ef", - "modified": false - }, - { - "source": "components/controls/SwitchRow.qml", - "destination": "qml/qs/components/controls/SwitchRow.qml", - "sha256": "7a79f65393b7dfdbe1a9c887711fadf857a4461da5145a7ae87e0fe2de3dae95", - "portedSha256": "7a79f65393b7dfdbe1a9c887711fadf857a4461da5145a7ae87e0fe2de3dae95", - "modified": false - }, - { - "source": "components/controls/TextButton.qml", - "destination": "qml/qs/components/controls/TextButton.qml", - "sha256": "706ca5d766de4b4aa0a2b4431a47c8f2b02bfb3679db88930399977e26bf5568", - "portedSha256": "706ca5d766de4b4aa0a2b4431a47c8f2b02bfb3679db88930399977e26bf5568", - "modified": false - }, - { - "source": "components/controls/ToggleButton.qml", - "destination": "qml/qs/components/controls/ToggleButton.qml", - "sha256": "bf6ace2309b827c39852cd8a0eeedb5ddc736377e0933ca0ecd0345fe7523a64", - "portedSha256": "bf6ace2309b827c39852cd8a0eeedb5ddc736377e0933ca0ecd0345fe7523a64", - "modified": false - }, - { - "source": "components/controls/ToggleRow.qml", - "destination": "qml/qs/components/controls/ToggleRow.qml", - "sha256": "4e27e73651daa0cf87501aeb1216d49106ed81bfa0b52aa81a0fa0d2f37cdce0", - "portedSha256": "4e27e73651daa0cf87501aeb1216d49106ed81bfa0b52aa81a0fa0d2f37cdce0", - "modified": false - }, - { - "source": "components/controls/Tooltip.qml", - "destination": "qml/qs/components/controls/Tooltip.qml", - "sha256": "fc442b46f9d04e89863c931945b23cbc6e7ea26175f691b2c6051b34991a0c99", - "portedSha256": "fc442b46f9d04e89863c931945b23cbc6e7ea26175f691b2c6051b34991a0c99", - "modified": false - }, - { - "source": "components/effects/ColouredIcon.qml", - "destination": "qml/qs/components/effects/ColouredIcon.qml", - "sha256": "b5a1bbe0a21447b1e878be8bf738d13636ff8e8a103f1ff2aca99bfcf8bbf9f2", - "portedSha256": "2851b3bfb364c01d04c3b99e8946f40ed77eec349b74ccf7e635b43df1cef623", - "modified": true - }, - { - "source": "components/effects/Colouriser.qml", - "destination": "qml/qs/components/effects/Colouriser.qml", - "sha256": "033d2ecd96b51a47e4b857293ecac67a6edefa32651d2ed67f19ae498abef0a5", - "portedSha256": "033d2ecd96b51a47e4b857293ecac67a6edefa32651d2ed67f19ae498abef0a5", - "modified": false - }, - { - "source": "components/effects/Elevation.qml", - "destination": "qml/qs/components/effects/Elevation.qml", - "sha256": "60c626e0612d2bd6292d7a1bd5367df6b36ec844178e77e2d88d747fc52872d3", - "portedSha256": "60c626e0612d2bd6292d7a1bd5367df6b36ec844178e77e2d88d747fc52872d3", - "modified": false - }, - { - "source": "components/effects/InnerBorder.qml", - "destination": "qml/qs/components/effects/InnerBorder.qml", - "sha256": "6aeaba2fc7beb5164894447a63c32a7300e311fe3d06da5fddc3649560df6dc9", - "portedSha256": "6aeaba2fc7beb5164894447a63c32a7300e311fe3d06da5fddc3649560df6dc9", - "modified": false - }, - { - "source": "components/effects/OpacityMask.qml", - "destination": "qml/qs/components/effects/OpacityMask.qml", - "sha256": "e8426f9af1efc42d4e57d4b5e10d4350991169fe6326097f0d83a1e15faf4558", - "portedSha256": "5f8d1754dcc53fcd9b189b62b47042c5782d1964a2e2baf5444bf1de2ef07923", - "modified": true - }, - { - "source": "components/filedialog/CurrentItem.qml", - "destination": "qml/qs/components/filedialog/CurrentItem.qml", - "sha256": "5b76c88c4bd1bdc6af0346f90c833e3d19dee9da54f1f48917ce44fb74585cfe", - "portedSha256": "5b76c88c4bd1bdc6af0346f90c833e3d19dee9da54f1f48917ce44fb74585cfe", - "modified": false - }, - { - "source": "components/filedialog/DialogButtons.qml", - "destination": "qml/qs/components/filedialog/DialogButtons.qml", - "sha256": "daac554da65b774f3b90ff3d387db2c5511e0e544db8691cfe9dba4ce56d5115", - "portedSha256": "daac554da65b774f3b90ff3d387db2c5511e0e544db8691cfe9dba4ce56d5115", - "modified": false - }, - { - "source": "components/filedialog/FileDialog.qml", - "destination": "qml/qs/components/filedialog/FileDialog.qml", - "sha256": "3603dcd37b18a7eb0e6336bbd57c158a7a960a83a6df1aa2e4529c5cc1ea0028", - "portedSha256": "40a2d49dffaf73965da9454d3d17dc5c64ed12b451dc65352859e78579e3aac6", - "modified": true - }, - { - "source": "components/filedialog/FolderContents.qml", - "destination": "qml/qs/components/filedialog/FolderContents.qml", - "sha256": "886016d444ae09fcc9d763878da96899cdcc3a58dcba769ff3071d449bb227ca", - "portedSha256": "886016d444ae09fcc9d763878da96899cdcc3a58dcba769ff3071d449bb227ca", - "modified": false - }, - { - "source": "components/filedialog/HeaderBar.qml", - "destination": "qml/qs/components/filedialog/HeaderBar.qml", - "sha256": "4dbb3a19647beda53dd1dd5dede1a636022bf5182fc9561278b8da1ac29054b3", - "portedSha256": "4dbb3a19647beda53dd1dd5dede1a636022bf5182fc9561278b8da1ac29054b3", - "modified": false - }, - { - "source": "components/filedialog/Sidebar.qml", - "destination": "qml/qs/components/filedialog/Sidebar.qml", - "sha256": "b34a8d7520ae9ffc6c1f025d40eb84dc9113a18b5c09dd6aad20108f822dcb91", - "portedSha256": "b34a8d7520ae9ffc6c1f025d40eb84dc9113a18b5c09dd6aad20108f822dcb91", - "modified": false - }, - { - "source": "components/filedialog/Sizes.qml", - "destination": "qml/qs/components/filedialog/Sizes.qml", - "sha256": "e97931ccca06782a298da3b364ab5d76f78ce51688f1b8238b93e0147828c61b", - "portedSha256": "e97931ccca06782a298da3b364ab5d76f78ce51688f1b8238b93e0147828c61b", - "modified": false - }, - { - "source": "components/images/CachingIconImage.qml", - "destination": "qml/qs/components/images/CachingIconImage.qml", - "sha256": "e8ea65276888b78b9e495bb7426e8baeb8a7206ddbbd144d47c229cfe3feefa0", - "portedSha256": "e8ea65276888b78b9e495bb7426e8baeb8a7206ddbbd144d47c229cfe3feefa0", - "modified": false - }, - { - "source": "components/images/CachingImage.qml", - "destination": "qml/qs/components/images/CachingImage.qml", - "sha256": "50cf3a006df64eff545218726e6ef1244485396b9940821793e854f6c60c6509", - "portedSha256": "0e12bf92a5d8e34c366661f74737bd4e3465a703588656b35b35641e9f2458d5", - "modified": true - }, - { - "source": "components/misc/CustomShortcut.qml", - "destination": "qml/qs/components/misc/CustomShortcut.qml", - "sha256": "209c4587ce9f2d15fddf1fccb90b714794c75d83d9b38780f90b367e6ed58716", - "portedSha256": "209c4587ce9f2d15fddf1fccb90b714794c75d83d9b38780f90b367e6ed58716", - "modified": false - }, - { - "source": "components/misc/Ref.qml", - "destination": "qml/qs/components/misc/Ref.qml", - "sha256": "6d1ecab07d42502a9e960ee8c42b4c65c34af3d0fabaddab05ea97ff8b663d23", - "portedSha256": "6d1ecab07d42502a9e960ee8c42b4c65c34af3d0fabaddab05ea97ff8b663d23", - "modified": false - }, - { - "source": "components/widgets/ExtraIndicator.qml", - "destination": "qml/qs/components/widgets/ExtraIndicator.qml", - "sha256": "c0f629a88b1f4aa1232e13a7bbb9fa40e98b283cb4d88dd449c9c79765d5b4f7", - "portedSha256": "c0f629a88b1f4aa1232e13a7bbb9fa40e98b283cb4d88dd449c9c79765d5b4f7", - "modified": false - }, - { - "source": "modules/dashboard/Background.qml", - "destination": "qml/qs/modules/dashboard/Background.qml", - "sha256": "a3b3b517917ed6b977f020ad17f9cc8f45a491111fa1d3b12773a786e478474c", - "portedSha256": "a3b3b517917ed6b977f020ad17f9cc8f45a491111fa1d3b12773a786e478474c", - "modified": false - }, - { - "source": "modules/dashboard/Content.qml", - "destination": "qml/qs/modules/dashboard/Content.qml", - "sha256": "ea54bd484dcdaa90decbb09e57247f97e82483f8d6bfa87f93e1d8c93af7fdb9", - "portedSha256": "b7e62080e97d13f058cbc48aeec4f1a05eb2fcb5648c07f0fb7bd81f905329fd", - "modified": true - }, - { - "source": "modules/dashboard/Dash.qml", - "destination": "qml/qs/modules/dashboard/Dash.qml", - "sha256": "00de0d966cc2884dc205398a0e34b1f5e6e25e9b6cce46243e31568c6b04825f", - "portedSha256": "00de0d966cc2884dc205398a0e34b1f5e6e25e9b6cce46243e31568c6b04825f", - "modified": false - }, - { - "source": "modules/dashboard/Media.qml", - "destination": "qml/qs/modules/dashboard/Media.qml", - "sha256": "fa269b03bf3bc57a160d76f8433bede03df38a2e675930463268bac2bef8ec77", - "portedSha256": "81269e42571ea33bc736d601eede3284d74cc038a44dbf4486de88cf0b134ec4", - "modified": true - }, - { - "source": "modules/dashboard/Performance.qml", - "destination": "qml/qs/modules/dashboard/Performance.qml", - "sha256": "286780cb4f3710c4aaefe78b3191c09f388ac80336c4f7c46ec6aba145360df4", - "portedSha256": "f744683901d853c2f9471d19862e444aad20f7241dfe280b818422200ad8aa13", - "modified": true - }, - { - "source": "modules/dashboard/Tabs.qml", - "destination": "qml/qs/modules/dashboard/Tabs.qml", - "sha256": "130f34e7f732e0bd7f44036cccbd786d55e2cb0d9507c9601919ef9fcb440b16", - "portedSha256": "c455042a2f6c147661d5f831abe9972ed28eade713c9c1af984a59938464a0cf", - "modified": true - }, - { - "source": "modules/dashboard/Weather.qml", - "destination": "qml/qs/modules/dashboard/Weather.qml", - "sha256": "199d46e5e8c73a26f7a93963a68db84a983dd3b86fa5d35650480a5301127839", - "portedSha256": "199d46e5e8c73a26f7a93963a68db84a983dd3b86fa5d35650480a5301127839", - "modified": false - }, - { - "source": "modules/dashboard/Wrapper.qml", - "destination": "qml/qs/modules/dashboard/Wrapper.qml", - "sha256": "d27391367eaa7a7c619f7c5a09c3da5dd17c4642dd0b85f0dfee63c48cc6c1c6", - "portedSha256": "2510692404a495aa70283085ea4d861008717446c40c4ee53af85a784fe9b3c4", - "modified": true - }, - { - "source": "modules/dashboard/dash/Calendar.qml", - "destination": "qml/qs/modules/dashboard/dash/Calendar.qml", - "sha256": "530630415e2dcd2e9ebb287fe63378983089191f16a9348903267f00be4a43ea", - "portedSha256": "530630415e2dcd2e9ebb287fe63378983089191f16a9348903267f00be4a43ea", - "modified": false - }, - { - "source": "modules/dashboard/dash/DateTime.qml", - "destination": "qml/qs/modules/dashboard/dash/DateTime.qml", - "sha256": "71f9922430dd9093b007c59e8914bd08657397c2956748a3715e69e46d883fcf", - "portedSha256": "71f9922430dd9093b007c59e8914bd08657397c2956748a3715e69e46d883fcf", - "modified": false - }, - { - "source": "modules/dashboard/dash/Media.qml", - "destination": "qml/qs/modules/dashboard/dash/Media.qml", - "sha256": "51c254d5f1b15081a9ab6262448ea2d60e2ee98fbccfad6ebbb4bc94fe9238b1", - "portedSha256": "51c254d5f1b15081a9ab6262448ea2d60e2ee98fbccfad6ebbb4bc94fe9238b1", - "modified": false - }, - { - "source": "modules/dashboard/dash/Resources.qml", - "destination": "qml/qs/modules/dashboard/dash/Resources.qml", - "sha256": "60259f1f737926aa8dc553c48d831b63b7ae074eb8c46b9d3f4576c2dd0d19c2", - "portedSha256": "60259f1f737926aa8dc553c48d831b63b7ae074eb8c46b9d3f4576c2dd0d19c2", - "modified": false - }, - { - "source": "modules/dashboard/dash/User.qml", - "destination": "qml/qs/modules/dashboard/dash/User.qml", - "sha256": "9b3c0bb5c8bcd4504e469d995cf633fd6aacdd7e3e6c41e2f1367ee3e6d5066b", - "portedSha256": "9b3c0bb5c8bcd4504e469d995cf633fd6aacdd7e3e6c41e2f1367ee3e6d5066b", - "modified": false - }, - { - "source": "modules/dashboard/dash/Weather.qml", - "destination": "qml/qs/modules/dashboard/dash/Weather.qml", - "sha256": "78c9d738cdeeece52f4d9dae3cdf11d06c22fc364306c4fa6e589fc6ee296dda", - "portedSha256": "36a792c8cb1c73c8a23ca6d2d84955d929987ee3811243bd0bfe1196fcb80bdc", - "modified": true - }, - { - "source": "config/AppearanceConfig.qml", - "destination": "qml/qs/config/AppearanceConfig.qml", - "sha256": "83c680d2ded938f3b806b76583a1e2fe6aa6860da06b8db3e10e55badfe2e449", - "portedSha256": "83c680d2ded938f3b806b76583a1e2fe6aa6860da06b8db3e10e55badfe2e449", - "modified": false - }, - { - "source": "config/DashboardConfig.qml", - "destination": "qml/qs/config/DashboardConfig.qml", - "sha256": "6952b707bea10e3f5d446b56da906a64f82256c77067a86740afe1d058174007", - "portedSha256": "6952b707bea10e3f5d446b56da906a64f82256c77067a86740afe1d058174007", - "modified": false - }, - { - "source": "modules/launcher/Content.qml", - "destination": "qml/qs/modules/launcher/Content.qml", - "sha256": "2985b439005b63207e70fe619c2324cd77fd9c4d105e050fa688c7837235e3af", - "portedSha256": "10cf945889a2cc6962ed4b3169443b08f2e38bbe60f5e3895e644b4240feae4f", - "modified": true - }, - { - "source": "modules/launcher/AppList.qml", - "destination": "qml/qs/modules/launcher/AppList.qml", - "sha256": "66ff5d423364ffe8d2704cdd4a6c57fd476827ca2706b5f09682cbff443d3822", - "portedSha256": "abab7263bcc5c395a32be38d5f0521d46d194b9135d447a631271b70e99acab3", - "modified": true - }, - { - "source": "modules/launcher/items/AppItem.qml", - "destination": "qml/qs/modules/launcher/items/AppItem.qml", - "sha256": "10480d489d74c66b50adc5106439094af98fc621f3aa9910c7bc7bfc5214a83d", - "portedSha256": "0a10b8c87ae802a6cb739cd1c6296bdfe6e458cc2f497c88eb5061aa8fde8587", - "modified": true - }, - { - "source": "config/LauncherConfig.qml", - "destination": "qml/qs/config/LauncherConfig.qml", - "sha256": "1c1176d0fc2b907413a83326f178ecc5893ce24fae2f244c2bd3c362a4f0735e", - "portedSha256": "1c1176d0fc2b907413a83326f178ecc5893ce24fae2f244c2bd3c362a4f0735e", - "modified": false - }, - { - "source": "modules/drawers/Border.qml", - "destination": "qml/ScreenBorder.qml", - "sha256": "d1f1ba5123b7d96e5f84e3c4febf4a18c060512fbd8f0aeec569537142517a4e", - "portedSha256": "9d52192d4210543b66074d1c38c6f7908d469317e26780c155007c766fd84472", - "modified": true - }, - { - "source": "modules/launcher/Background.qml", - "destination": "qml/DockBackground.qml", - "sha256": "e4e131915cff6e1b469ae1a6a05417627a7af99df241fe6718b8b22c86df4bd4", - "portedSha256": "a20e827a809c6c0d6052ad019857c23229a0a4bcb31008c557bca952a17ed64c", - "modified": true - } - ] -} \ No newline at end of file diff --git a/docs/history/caelestia-windows/RELEASE-8.1.json b/docs/history/caelestia-windows/RELEASE-8.1.json deleted file mode 100644 index 9ec081f..0000000 --- a/docs/history/caelestia-windows/RELEASE-8.1.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "version": "8.1.0", - "upstreamCommit": "04bfc87f65d6e13b287358e1024d9bbc055b0c58", - "originalFiles": 75, - "unmodifiedOriginalFiles": 63, - "favoritesPreserved": true, - "recentPreserved": true, - "websitesPreserved": true, - "quickTabsPreserved": true, - "validation": { - "checks": { - "all15ViewsVisible": true, - "noQmlIssues": true, - "controlsStayOpen": true, - "shortcutRecorderArmed": true, - "shortcutRecorderReleased": true, - "sensorsStopped": true, - "panelClosed": true - }, - "allPassed": true, - "pythonTests": 20, - "bridgeTests": 13, - "cpuPercentWholeMachineIdle": 0.195, - "idleSeconds": 6.0, - "logicalProcessors": 12 - }, - "backup": "checkpoint-20261025-before-8.1", - "desktopRescueSha256": "fd1015b569c9292a7fa07afd23bf3b4ce760ffce26e1b1daad320a1a4b619779", - "dockIndependentWindowFix": { - "date": "2026-10-26", - "nativeLifecycleChecks": 5, - "allPassed": true - } -} \ No newline at end of file diff --git a/docs/history/caelestia-windows/RELEASE-8.2.json b/docs/history/caelestia-windows/RELEASE-8.2.json deleted file mode 100644 index 0e8a64f..0000000 --- a/docs/history/caelestia-windows/RELEASE-8.2.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "version": "8.2.0", - "upstreamCommit": "04bfc87f65d6e13b287358e1024d9bbc055b0c58", - "originalFiles": 75, - "unmodifiedOriginalFiles": 63, - "preferencesPreserved": { - "Favorites": true, - "Websites": true, - "QuickTabs": true, - "Shortcut": true, - "PaletteShortcut": true, - "BoardOverrides": true, - "Names": true, - "Categories": true, - "Icons": true, - "ExistingRecentEntries": true - }, - "desktopRescueSha256": "fd1015b569c9292a7fa07afd23bf3b4ce760ffce26e1b1daad320a1a4b619779", - "validation": { - "tools-report.json": 14, - "live-report.json": 14, - "audio-mixer-verification.json": 11, - "dock-lifecycle.json": 5 - }, - "allPassed": true, - "pythonTests": 20, - "bridgeTests": 13, - "backup": "checkpoint-20261026-before-8.2", - "idle": { - "seconds": 5.498555399997713, - "qtPercentOneCore": 0.568331092926935, - "backendPercentOneCore": 1.4208277323173373 - } -} \ No newline at end of file diff --git a/docs/history/caelestia-windows/RELEASE-8.3.json b/docs/history/caelestia-windows/RELEASE-8.3.json deleted file mode 100644 index 5b19597..0000000 --- a/docs/history/caelestia-windows/RELEASE-8.3.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "version": "8.3.0", - "upstreamCommit": "04bfc87f65d6e13b287358e1024d9bbc055b0c58", - "originalFiles": 77, - "unmodifiedOriginalFiles": 63, - "preferencesPreserved": { - "Favorites": true, - "Websites": true, - "QuickTabs": true, - "Shortcut": true, - "PaletteShortcut": true, - "BoardOverrides": true, - "Names": true, - "Categories": true, - "Icons": true, - "Custom": true, - "Workspaces": true, - "ExistingRecentEntries": true - }, - "autoThemeEnabled": true, - "screenFrameEnabled": true, - "framePixelsLogical": 12, - "windowInsetLogical": 20, - "validation": { - "theme-report.json": 21, - "live-report.json": 15, - "tools-report.json": 14, - "frame-verification.json": 3, - "audio-mixer-verification.json": 14, - "dock-lifecycle.json": 5 - }, - "allPassed": true, - "pythonTests": 20, - "bridgeTests": 13, - "menuFirstFrameMs": { - "tools": 170.3, - "audio": 33.7, - "microphone": 30.4, - "capture": 25.8, - "clipboard": 4.1, - "controls": 38.4, - "mixer": 10.7 - }, - "idleCpuPercentWholeMachine": 0.26022210569536575, - "logicalProcessors": 12, - "desktopRescueSha256": "fd1015b569c9292a7fa07afd23bf3b4ce760ffce26e1b1daad320a1a4b619779", - "backup": "checkpoint-20261026-before-8.3" -} \ No newline at end of file diff --git a/docs/history/caelestia-windows/RELEASE-8.4.json b/docs/history/caelestia-windows/RELEASE-8.4.json deleted file mode 100644 index 4616f74..0000000 --- a/docs/history/caelestia-windows/RELEASE-8.4.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "version": "8.4.0", - "upstreamCommit": "04bfc87f65d6e13b287358e1024d9bbc055b0c58", - "framePixelsLogical": 16, - "windowInsetLogical": 20, - "autoThemeEnabled": true, - "glassOpacityDefault": 0.6, - "transparencyRangePercent": [ - 5, - 80 - ], - "animatedWallpaperProviders": [ - "DesktopHut" - ], - "preferencesPreserved": { - "Favorites": true, - "Websites": true, - "QuickTabs": true, - "Shortcut": true, - "PaletteShortcut": true, - "BoardOverrides": true, - "Names": true, - "Categories": true, - "Icons": true, - "Custom": true, - "Workspaces": true, - "TaskbarPins": true - }, - "validation": { - "theme-report.json": 41, - "native-blur-report.json": 0, - "live-material-report.json": 11 - }, - "nativeBlurVerified": true, - "pythonTests": 27, - "allPassed": true, - "backupFrontend": "checkpoint-8.3-before-8.4", - "backupBackend": "checkpoint-20261026-before-8.4", - "artifacts": { - "Panneaux.Glass.exe": "e81d55aff2b937d71610c160f48c32919eba62d5eabb30b5f8df19672b9ae97b", - "Panneaux.Glass.dll": "36e6ba1a58768f51639bb7698a600b35537a1ee7eda26e6a67ebfacced99cd13" - } -} \ No newline at end of file diff --git a/docs/history/caelestia-windows/RELEASE-8.5.json b/docs/history/caelestia-windows/RELEASE-8.5.json deleted file mode 100644 index b38da61..0000000 --- a/docs/history/caelestia-windows/RELEASE-8.5.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "version": "8.5.0", - "fix": "Bounded Acrylic frame helpers replace screen-sized backdrop", - "nativeFramePieces": 8, - "nativeFullscreenFrame": false, - "frameThicknessDefault": 16, - "frameThicknessRange": [ - 4, - 40 - ], - "windowInset": "frameThickness + 4", - "preferencesPreserved": true, - "allPassed": true, - "pythonTests": 27, - "validation": { - "theme-report.json": 48, - "desktop-regression-report.json": 47, - "native-blur-report.json": 0, - "live-thickness-report.json": 17 - }, - "backupFrontend": "checkpoint-8.4-before-8.5", - "backupBackend": "checkpoint-20261026-before-8.5", - "artifacts": { - "Panneaux.Glass.exe": "a0c0256871524e46efda939580ca6805757cd2b146e988b657dfef306635def3", - "Panneaux.Glass.dll": "5d0504be6644912ab9fc9deb812eaeef0e2ac6e0b607b8140ed136cf1890bf49" - }, - "nativeDllSha256": "777bdad44446ba149dcb87e32e760ba73ba6b7b9d15c6e95fdbbbe09fe0f6bb8", - "desktopIconsLifecycleVerified": true -} \ No newline at end of file diff --git a/docs/history/caelestia-windows/RELEASE-8.6.json b/docs/history/caelestia-windows/RELEASE-8.6.json deleted file mode 100644 index 5dc5101..0000000 --- a/docs/history/caelestia-windows/RELEASE-8.6.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "version": "8.6.0", - "fix": "Per-pixel composition masks replace black Acrylic backing", - "nativeFramePieces": 8, - "nativeFullscreenFrame": false, - "frameThicknessDefault": 16, - "frameThicknessRange": [ - 4, - 40 - ], - "windowInset": "frameThickness + 4", - "preferencesPreserved": true, - "allPassed": true, - "pythonTests": 27, - "validation": { - "theme-report.json": 48, - "desktop-regression-report.json": 50, - "native-blur-report.json": 0 - }, - "backupFrontend": "checkpoint-8.5-before-8.6", - "backupBackend": "checkpoint-20261026-before-8.6", - "artifacts": { - "Panneaux.Glass.exe": "a8da922a18d2bedef99181ff81845515f4d43dbf0015e48d77395dc9b7a0ba33", - "Panneaux.Glass.dll": "1a502ff772831ab59acf6be6d444d4dde66ed55b4abf0364c5b264227a04f478" - }, - "nativeDllSha256": "339b0e48734e45be02c46987b02b96b66d493a19fb69f8c578fc2f41e4f9246f", - "desktopIconsLifecycleVerified": true, - "blackCornerPixels": { - "4px": 0, - "16px": 0, - "40px": 0 - }, - "baselineBugReproduced": true -} \ No newline at end of file diff --git a/docs/history/caelestia-windows/RELEASE-9.0.json b/docs/history/caelestia-windows/RELEASE-9.0.json deleted file mode 100644 index 6176638..0000000 --- a/docs/history/caelestia-windows/RELEASE-9.0.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "version": "9.0.0", - "fix": "One shared surface alpha for frame, dashboard and dock; native ring hosts grown inwards; dock survives engine shutdown and stays open under the pointer", - "nativeFramePieces": 8, - "nativeFullscreenFrame": false, - "nativeHostPadding": "max(56, 2*thickness + rounding)", - "frameThickness": 20, - "frameThicknessRange": [4, 40], - "glassOpacity": 0.05, - "glassOpacityRange": [0.05, 0.95], - "transparencySliderRange": [5, 95], - "windowInset": "frameThickness + 4", - "preferencesPreserved": true, - "pythonTests": 28, - "seamDelta": 0.0, - "seamSamples": { - "frame": [184.0, 200.0, 215.0], - "dashboard": [184.0, 200.0, 215.0] - }, - "blackCornerPixels": { - "4px": 0, - "16px": 0, - "40px": 0 - }, - "centrePixelsUnchanged": true, - "helpersNeverCoverCentre": true, - "favouritesPreserved": true, - "desktopIconsLifecycleVerified": true, - "artifacts": { - "Panneaux.Glass.exe": "a8da922a18d2bedef99181ff81845515f4d43dbf0015e48d77395dc9b7a0ba33", - "Panneaux.Glass.dll": "7703127a2d7ad193378a14cdbd528bd2e015d251b919c6ba1dbf64b3a5c91384" - }, - "nativeDllSha256": "03f69103f5044b06ff2a3fb08b25ae0fc7d7daded27626ed4c2cc1eb95f4d989", - "nativeStagingCopy": "../research/qt90-glass/QtGlassBackdrop.dll", - "validation": { - "seam-report.json": "1/1", - "desktop-regression-report.json": "74/74" - }, - "backupFrontend": "checkpoint-20261026-021942-avant-reprise", - "backupBackend": "../Glass-v5/checkpoint-20261026-before-8.6", - "dockRevealedOnEdge": true, - "dockStaysOpenUnderPointer": true, - "taskbarPreferenceSurvivesEngineShutdown": true, - "awaitingUserVisualReview": true -} diff --git a/docs/history/caelestia-windows/RELEASE-9.1.json b/docs/history/caelestia-windows/RELEASE-9.1.json deleted file mode 100644 index 6258be1..0000000 --- a/docs/history/caelestia-windows/RELEASE-9.1.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "version": "9.1.0", - "surfaceRenderer": "One shared vector union per monitor", - "nativeGeometry": "Analytic Direct2D curves in bounded composition tiles", - "launchTransparencyPercent": 88, - "transparencyRangePercent": [ - 5, - 98 - ], - "frameThicknessAtLaunch": 33, - "preferencesPreserved": { - "Favorites": true, - "TaskbarPins": true, - "Websites": true, - "QuickTabs": true, - "Shortcut": true, - "PaletteShortcut": true, - "Categories": true, - "Names": true, - "BoardOverrides": true, - "Custom": true, - "Workspaces": true - }, - "pythonTests": 32, - "surfaceChecks": 13, - "surfaceReport": { - "checks": [ - { - "name": "Frame dashboard dock have identical composited material", - "pass": true - }, - { - "name": "Smooth corners have no internal segment lines", - "pass": true - }, - { - "name": "No native geometry errors", - "pass": true - }, - { - "name": "0.12 no native tile seam", - "pass": true - }, - { - "name": "0.12 same transparency for every surface", - "pass": true - }, - { - "name": "0.12 transparency matches requested detail transmission", - "pass": true - }, - { - "name": "0.02 no native tile seam", - "pass": true - }, - { - "name": "0.02 same transparency for every surface", - "pass": true - }, - { - "name": "0.02 transparency matches requested detail transmission", - "pass": true - }, - { - "name": "0.5 no native tile seam", - "pass": true - }, - { - "name": "0.5 same transparency for every surface", - "pass": true - }, - { - "name": "0.5 transparency matches requested detail transmission", - "pass": true - }, - { - "name": "No QML warnings", - "pass": true - } - ], - "flatSamples": { - "frameTop": [ - 146, - 176, - 205 - ], - "panelTop": [ - 146, - 176, - 205 - ], - "dockBottom": [ - 146, - 176, - 205 - ], - "frameBottom": [ - 146, - 176, - 205 - ] - }, - "cornerMismatches": 0, - "0.12Detail": { - "frame": 0.875, - "panel": 0.875, - "dock": 0.875 - }, - "0.12TileSeamDelta": 1, - "0.02Detail": { - "frame": 0.9791666666666666, - "panel": 0.9791666666666666, - "dock": 0.9791666666666666 - }, - "0.02TileSeamDelta": 1, - "0.5Detail": { - "frame": 0.5, - "panel": 0.5, - "dock": 0.5 - }, - "0.5TileSeamDelta": 2, - "allPassed": true, - "qmlIssues": [], - "nativeErrors": [] - }, - "liveStartupPassed": true, - "allPassed": true, - "artifacts": { - "Panneaux.Glass.exe": "891cbf9b85a687788a05cb88eeb48f4254f028b45872911b17b744034b07d399", - "Panneaux.Glass.dll": "f96d17405979689302f0d034206e26dd9f851a903f56a4df6033bd2601b99d55" - }, - "nativeDllSha256": "fd4d76a490ee156e92d8d4270fc5e673f1defae876840360aae8881ed6c04c7b", - "backupFrontend": "checkpoint-before-9.1", - "backupBackend": "../Glass-v5/checkpoint-before-9.1", - "awaitingUserVisualReview": true -} \ No newline at end of file diff --git a/docs/history/caelestia-windows/RELEASE-9.2.1.json b/docs/history/caelestia-windows/RELEASE-9.2.1.json deleted file mode 100644 index ba2852e..0000000 --- a/docs/history/caelestia-windows/RELEASE-9.2.1.json +++ /dev/null @@ -1,177 +0,0 @@ -{ - "version": "9.2.1", - "input": "Native input regions follow the visible dashboard and dock silhouette; the frame remains click-through.", - "rim": "One 1px continuous outline at 23% alpha in automatic mode, independent of glass transparency.", - "renderer": "Persistent native tiles; dirty bounds update only changed cells; fixed backing window during panel animation.", - "preferencesPreserved": true, - "pythonTests": 32, - "surfaceChecks": 14, - "surfaceReport": { - "checks": [ - { - "name": "Frame dashboard dock have identical composited material", - "pass": true - }, - { - "name": "Smooth corners have no internal segment lines", - "pass": true - }, - { - "name": "No native geometry errors", - "pass": true - }, - { - "name": "0.12 no native tile seam", - "pass": true - }, - { - "name": "0.12 same transparency for every surface", - "pass": true - }, - { - "name": "0.12 transparency matches requested detail transmission", - "pass": true - }, - { - "name": "0.02 no native tile seam", - "pass": true - }, - { - "name": "0.02 same transparency for every surface", - "pass": true - }, - { - "name": "0.02 transparency matches requested detail transmission", - "pass": true - }, - { - "name": "0.5 no native tile seam", - "pass": true - }, - { - "name": "0.5 same transparency for every surface", - "pass": true - }, - { - "name": "0.5 transparency matches requested detail transmission", - "pass": true - }, - { - "name": "Closing panel leaves no stale glass tile", - "pass": true - }, - { - "name": "No QML warnings", - "pass": true - } - ], - "flatSamples": { - "frameTop": [ - 146, - 176, - 205 - ], - "panelTop": [ - 146, - 176, - 205 - ], - "dockBottom": [ - 146, - 176, - 205 - ], - "frameBottom": [ - 146, - 176, - 205 - ] - }, - "cornerMismatches": 0, - "0.12Detail": { - "frame": 0.875, - "panel": 0.875, - "dock": 0.875 - }, - "0.12TileSeamDelta": 1, - "0.02Detail": { - "frame": 0.9791666666666666, - "panel": 0.9791666666666666, - "dock": 0.9791666666666666 - }, - "0.02TileSeamDelta": 1, - "0.5Detail": { - "frame": 0.5, - "panel": 0.5, - "dock": 0.5 - }, - "0.5TileSeamDelta": 2, - "allPassed": true, - "qmlIssues": [], - "nativeErrors": [] - }, - "pointerReport": { - "before": 0, - "afterDesktopClick": 1, - "afterShoulderClick": 2, - "afterCornerClick": 3, - "tileFound": true, - "buttonPoint": [ - 458.0, - 219.0 - ], - "beforeFrameClick": 3, - "afterButtonClick": 3, - "afterFrameClick": 4, - "framePass": true, - "mode": "workspace", - "qmlIssues": [], - "cornerPass": true, - "buttonPass": true, - "desktopPass": true, - "shoulderPass": true, - "nativeErrors": [], - "surfaceStyles": [ - 134742184 - ], - "helperStyles": [ - 136839336 - ] - }, - "performanceReport": { - "spikes": [], - "activeFrameMs": { - "count": 404, - "median": 6.714999999985594, - "p95": 16.94919999999911, - "max": 27.759399999922607 - }, - "nativeGeometryMs": { - "count": 388, - "median": 0.26109999998880085, - "p95": 0.4338999997344217, - "max": 33.82469999996829 - }, - "over33ms": 0, - "nativeErrors": [], - "qmlIssues": [], - "refreshRate": 165.0 - }, - "liveStartupPassed": true, - "artifacts": { - "Panneaux.Glass.exe": "5e034a4b6b5372f010866698b998bccc25dd80cf73d038677f6b85137ecb0fea", - "Panneaux.Glass.dll": "54779f823f6aaef28aae90de8df1fa5abfb9c956f5a1903f1b298f0ce671e4c2" - }, - "nativeDllSha256": "ab1c68aa99005f4f60fc459532a37b4b8243ce702340a1f5063368a5a5d34d54", - "backupFrontend": "checkpoint-before-9.2", - "backupBackend": "../Glass-v5/checkpoint-before-9.2", - "allPassed": true, - "awaitingUserVisualReview": true, - "backendVersion": "9.2.0", - "liveInputReport": { - "visibleHelpers": 19, - "allHelpersLayeredAndTransparent": true - }, - "backupNative": "checkpoint-before-9.2.1", - "performanceMeasuredVersion": "9.2.0" -} \ No newline at end of file diff --git a/docs/history/caelestia-windows/RELEASE-9.2.json b/docs/history/caelestia-windows/RELEASE-9.2.json deleted file mode 100644 index 55aa43f..0000000 --- a/docs/history/caelestia-windows/RELEASE-9.2.json +++ /dev/null @@ -1,167 +0,0 @@ -{ - "version": "9.2.0", - "input": "Native input regions follow the visible dashboard and dock silhouette; the frame remains click-through.", - "rim": "One 1px continuous outline at 23% alpha in automatic mode, independent of glass transparency.", - "renderer": "Persistent native tiles; dirty bounds update only changed cells; fixed backing window during panel animation.", - "preferencesPreserved": true, - "pythonTests": 32, - "surfaceChecks": 14, - "surfaceReport": { - "checks": [ - { - "name": "Frame dashboard dock have identical composited material", - "pass": true - }, - { - "name": "Smooth corners have no internal segment lines", - "pass": true - }, - { - "name": "No native geometry errors", - "pass": true - }, - { - "name": "0.12 no native tile seam", - "pass": true - }, - { - "name": "0.12 same transparency for every surface", - "pass": true - }, - { - "name": "0.12 transparency matches requested detail transmission", - "pass": true - }, - { - "name": "0.02 no native tile seam", - "pass": true - }, - { - "name": "0.02 same transparency for every surface", - "pass": true - }, - { - "name": "0.02 transparency matches requested detail transmission", - "pass": true - }, - { - "name": "0.5 no native tile seam", - "pass": true - }, - { - "name": "0.5 same transparency for every surface", - "pass": true - }, - { - "name": "0.5 transparency matches requested detail transmission", - "pass": true - }, - { - "name": "Closing panel leaves no stale glass tile", - "pass": true - }, - { - "name": "No QML warnings", - "pass": true - } - ], - "flatSamples": { - "frameTop": [ - 146, - 176, - 205 - ], - "panelTop": [ - 146, - 176, - 205 - ], - "dockBottom": [ - 146, - 176, - 205 - ], - "frameBottom": [ - 146, - 176, - 205 - ] - }, - "cornerMismatches": 0, - "0.12Detail": { - "frame": 0.875, - "panel": 0.875, - "dock": 0.875 - }, - "0.12TileSeamDelta": 1, - "0.02Detail": { - "frame": 0.9791666666666666, - "panel": 0.9791666666666666, - "dock": 0.9791666666666666 - }, - "0.02TileSeamDelta": 1, - "0.5Detail": { - "frame": 0.5, - "panel": 0.5, - "dock": 0.5 - }, - "0.5TileSeamDelta": 2, - "allPassed": true, - "qmlIssues": [], - "nativeErrors": [] - }, - "pointerReport": { - "before": 0, - "afterDesktopClick": 1, - "afterShoulderClick": 2, - "afterCornerClick": 3, - "tileFound": true, - "buttonPoint": [ - 458.0, - 219.0 - ], - "afterButtonClick": 3, - "mode": "workspace", - "qmlIssues": [], - "cornerPass": true, - "buttonPass": true, - "desktopPass": true, - "shoulderPass": true, - "nativeErrors": [], - "surfaceStyles": [ - 134742184 - ], - "helperStyles": [ - 136315048 - ] - }, - "performanceReport": { - "spikes": [], - "activeFrameMs": { - "count": 404, - "median": 6.714999999985594, - "p95": 16.94919999999911, - "max": 27.759399999922607 - }, - "nativeGeometryMs": { - "count": 388, - "median": 0.26109999998880085, - "p95": 0.4338999997344217, - "max": 33.82469999996829 - }, - "over33ms": 0, - "nativeErrors": [], - "qmlIssues": [], - "refreshRate": 165.0 - }, - "liveStartupPassed": true, - "artifacts": { - "Panneaux.Glass.exe": "5e034a4b6b5372f010866698b998bccc25dd80cf73d038677f6b85137ecb0fea", - "Panneaux.Glass.dll": "54779f823f6aaef28aae90de8df1fa5abfb9c956f5a1903f1b298f0ce671e4c2" - }, - "nativeDllSha256": "17b29663669e9c54738c33bd6f3952a97e453edf6c2fdea030eb005efc9cc38c", - "backupFrontend": "checkpoint-before-9.2", - "backupBackend": "../Glass-v5/checkpoint-before-9.2", - "allPassed": true, - "awaitingUserVisualReview": true -} \ No newline at end of file diff --git a/docs/history/caelestia-windows/RELEASE-9.3.json b/docs/history/caelestia-windows/RELEASE-9.3.json deleted file mode 100644 index 82fa0c2..0000000 --- a/docs/history/caelestia-windows/RELEASE-9.3.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "version": "9.3.0", - "dockMenus": true, - "nativeClickThroughDllUnchanged": true, - "report": { - "version": "9.3.0", - "preferencesPreserved": true, - "qml": { - "states": [ - { - "kind": "app", - "height": 589, - "visible": true - }, - { - "kind": "system", - "height": 620, - "visible": true - }, - { - "kind": "running", - "height": 119, - "visible": true - } - ], - "clicks": [ - { - "target": "dockStart", - "kind": "system", - "visible": true, - "point": [ - 54.0, - 34.0 - ], - "itemSize": [ - 40.0, - 40.0 - ], - "pass": true - }, - { - "target": "dock-app-fixture", - "kind": "app", - "visible": true, - "point": [ - 152.0, - 33.0 - ], - "itemSize": [ - 42.0, - 42.0 - ], - "pass": true - }, - { - "target": "dockRunning", - "kind": "running", - "visible": true, - "point": [ - 552.5, - 34.5 - ], - "itemSize": [ - 29.0, - 29.0 - ], - "pass": true - } - ], - "qmlIssues": [], - "nativeErrors": [], - "allPassed": true - }, - "backend": { - "favoritePersistsAndPins": true, - "unfavoriteKeepsPin": true, - "unpin": true, - "repinWithoutDuplicate": true, - "closeAllOnlyFixtureWindows": true - }, - "pythonTestsPassed": 32, - "allPassed": true - }, - "nativeDllSha256": "ab1c68aa99005f4f60fc459532a37b4b8243ce702340a1f5063368a5a5d34d54", - "artifacts": { - "Panneaux.Glass.exe": "d45a4c75933173390477da9437704abc85799186f0df80bead2b02b1afccb042", - "Panneaux.Glass.dll": "2f049933fa1b757f0978f7fd3e8aeaa7d38a84c168f7744b715b1c1fa0098d22" - }, - "backup": "checkpoint-before-dock10", - "awaitingUserVisualReview": true -} \ No newline at end of file diff --git a/docs/history/workspace-v4/VALIDATION_2026-10-20.md b/docs/history/workspace-v4/VALIDATION_2026-10-20.md deleted file mode 100644 index b942336..0000000 --- a/docs/history/workspace-v4/VALIDATION_2026-10-20.md +++ /dev/null @@ -1,33 +0,0 @@ -# Panneaux v4 — Vérification du 20 octobre 2026 - -## Version livrée - -Application native .NET Framework compilée en exécutable Windows x64. Sources conservées dans ce dossier. Ancienne application PowerShell conservée dans le dossier parent. - -## Contrôles réussis - -- Compilation C# terminée sans erreur ni avertissement sur la version finale. -- Tous les scripts PowerShell passent l’analyse syntaxique. -- Inventaire généré et régénéré : **355 accès**, aucun diagnostic de collecte. -- **254 applications et outils**, **76 dossiers**, **24 jeux référencés** et **1 accès Rocket League pour terminer son installation dans Epic**. -- Aucun identifiant dupliqué et aucun doublon de nom parmi les logiciels/jeux. -- Aucune cible locale absente dans le catalogue. -- Canada pointe sur `Documents\04_Documents_Administratifs\Canada`. -- Les raccourcis Ubisoft Anno 1800, The Crew Motorfest et The Division 2 dont les installations sont absentes sont écartés. -- Les jeux et applications encore présents sur E: sont pris en compte, indépendamment des suppressions décrites dans le rapport précédent. -- Recherche « canada » vérifiée dans l’interface : Canada et ses deux sous-dossiers apparaissent. -- Favori Canada ajouté dans l’interface et retrouvé après arrêt puis relancement. -- Repli par Échap vérifié à l’écran. -- Déclenchement de la poignée par le pointeur au bord gauche vérifié à l’écran. -- Clic sur la poignée : réouverture du panneau vérifiée à l’écran. -- Ouverture de Canada : fenêtre Explorateur et chemin réel vérifiés. -- Arrêt du processus par son canal d’instance unique vérifié après correction de la libération des ressources. -- Registre de démarrage : entrée Panneaux vers `Workspace-v4\Panneaux.Workspace.exe`. -- Réglage Windows `HideIcons=1`, appliqué par le Bureau Windows. -- Ancien réglage du Bureau, ancien démarrage et anciens raccourcis sauvegardés avant intégration. - -## Limites de vérification - -La session Windows n’a pas été redémarrée. Le démarrage automatique a été contrôlé par sa configuration et par le lancement du même exécutable en arrière-plan. Les jeux et logiciels n’ont pas tous été lancés ; les cibles locales et les manifestes ont été contrôlés. Le comportement multi-écrans et plein écran est implémenté, mais n’a pas été essayé sur plusieurs écrans ni pendant une partie. - -Rocket League dispose de données sur disque, mais son manifeste Epic est dans `Pending`, avec `bIsIncompleteInstall=true` et un exécutable de lancement manquant. Sa tuile ouvre Epic et indique « installation à terminer ». diff --git a/docs/media/src/render.ps1 b/docs/media/src/render.ps1 index ea1ee66..3a4449d 100644 --- a/docs/media/src/render.ps1 +++ b/docs/media/src/render.ps1 @@ -12,7 +12,8 @@ try { (Get-Process -Id $PID).PriorityClass = 'BelowNormal' } catch {} $src = $PSScriptRoot $out = [IO.Path]::GetFullPath((Join-Path $src '..\..\illustrations')) $media = [IO.Path]::GetFullPath((Join-Path $src '..')) -$edge = @("${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe", "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe") | Where-Object { Test-Path $_ } | Select-Object -First 1 +$edge = @("${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe", "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe") | + Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $edge) { throw 'Microsoft Edge not found.' } $utf8 = New-Object System.Text.UTF8Encoding($false) $tmp = Join-Path ([IO.Path]::GetTempPath()) 'winlestia-render' @@ -34,7 +35,8 @@ $scenes = @( @{ n='v-workspaces'; frag=@('workspaces'); amb=''; wp='wallpaper-aurora.svg'; title='Workspaces'; shot=$true }, @{ n='v-workspaces-warm'; frag=@('workspaces'); amb='tokens-warm.css'; wp='wallpaper-warm.svg'; title='Workspaces'; shot=$true }, @{ n='v-workspaces-teal'; frag=@('workspaces'); amb='tokens-teal.css'; wp='wallpaper-teal.svg'; title='Workspaces'; shot=$true }, - @{ n='v-dock-palette'; frag=@('dock'); amb=''; wp='wallpaper-aurora.svg'; title='Dock and quick command'; shot=$true }, + @{ n='v-dock-palette'; frag=@('dock'); amb=''; wp='wallpaper-aurora.svg'; title='Dock and quick command'; + shot=$true }, @{ n='v-tools'; frag=@('outils'); amb=''; wp='wallpaper-aurora.svg'; title='Tools'; shot=$true }, @{ n='v-settings'; frag=@('reglages'); amb=''; wp='wallpaper-aurora.svg'; title='Settings'; shot=$true }, @{ n='banner'; page='banner.html'; w=1280; h=640; scale=2; dest=$media } @@ -53,7 +55,8 @@ foreach ($s in $scenes) { if ($s.frag) { # compose page = template + fragments $body = ($s.frag | ForEach-Object { [IO.File]::ReadAllText((Join-Path $src "fragments\$_.html"), $utf8) }) -join "`n" $amb = if ($s.amb) { "" } else { '' } - $html = $template.Replace('{{TITLE}}', $s.title).Replace('{{AMBIANCE}}', $amb).Replace('{{WALLPAPER}}', $s.wp).Replace('{{ICONS}}', $icons).Replace('{{BODY}}', $body) + $html = $template.Replace('{{TITLE}}', $s.title).Replace('{{AMBIANCE}}', $amb).Replace('{{WALLPAPER}}', $s.wp).Replace('{{ICONS}}', + $icons).Replace('{{BODY}}', $body) $page = "_$($s.n).html" [IO.File]::WriteAllText((Join-Path $src $page), $html, $utf8) } else { $page = $s.page } @@ -69,14 +72,16 @@ foreach ($s in $scenes) { $img = [Drawing.Image]::FromFile($big) $dst = New-Object Drawing.Bitmap $ow, $oh $g = [Drawing.Graphics]::FromImage($dst) - $g.InterpolationMode = 'HighQualityBicubic'; $g.SmoothingMode = 'HighQuality'; $g.PixelOffsetMode = 'HighQuality'; $g.CompositingQuality = 'HighQuality' + $g.InterpolationMode = 'HighQualityBicubic'; $g.SmoothingMode = 'HighQuality'; $g.PixelOffsetMode = 'HighQuality'; + $g.CompositingQuality = 'HighQuality' $g.DrawImage($img, 0, 0, $ow, $oh) $g.Dispose(); $img.Dispose() if ($Shots) { $dst.Save((Join-Path $dest "$($s.n).png"), [Drawing.Imaging.ImageFormat]::Png); $dst.Dispose() } else { $mid = Join-Path $tmp "$($s.n)-2x.png" $dst.Save($mid, [Drawing.Imaging.ImageFormat]::Png); $dst.Dispose() - & ffmpeg -v error -y -i $mid -vf scale=2000:-1:flags=lanczos -c:v libwebp -quality 90 -compression_level 6 (Join-Path $dest "$($s.n).webp") + $webp = Join-Path $dest "$($s.n).webp" + & ffmpeg -v error -y -i $mid -vf scale=2000:-1:flags=lanczos -c:v libwebp -quality 90 -compression_level 6 $webp if ($LASTEXITCODE -ne 0) { throw "ffmpeg failed for $($s.n)" } } Write-Host "ok $($s.n)" diff --git a/docs/media/src/video/build_scene.py b/docs/media/src/video/build_scene.py index 680ec1b..f7d8deb 100644 --- a/docs/media/src/video/build_scene.py +++ b/docs/media/src/video/build_scene.py @@ -37,14 +37,17 @@ def layers(tag): background:rgba(10,6,19,.45);border:1px solid rgba(255,255,255,.16)}} #title,#end{{position:absolute;inset:0;opacity:0}} #title .bgp,#end .bgp{{position:absolute;inset:-40px;background:url("../wallpaper-aurora.svg") center/cover;filter:saturate(1.15)}} -#title .shade,#end .shade{{position:absolute;inset:0;background:radial-gradient(ellipse 60% 50% at 50% 50%,rgba(10,6,19,.62),rgba(10,6,19,.18) 70%,rgba(10,6,19,0))}} +#title .shade,#end .shade{{position:absolute;inset:0; + background:radial-gradient(ellipse 60% 50% at 50% 50%,rgba(10,6,19,.62),rgba(10,6,19,.18) 70%,rgba(10,6,19,0))}} .cen{{position:absolute;left:0;right:0;display:flex;flex-direction:column;align-items:center;text-align:center}} -.mark{{width:250px;height:178px;background:url("../../../../src/frontend/assets/winlestia-mark.png") no-repeat;background-size:350px 350px;background-position:-53px -76px; +.mark{{width:250px;height:178px;background:url("../../../../src/frontend/assets/winlestia-mark.png") no-repeat; + background-size:350px 350px;background-position:-53px -76px; filter:drop-shadow(0 0 22px rgba(214,150,255,.75)) drop-shadow(0 6px 22px rgba(60,20,120,.6))}} .nm{{font-weight:700;letter-spacing:-2px;line-height:1;display:flex;filter:drop-shadow(0 4px 24px rgba(120,40,200,.55))}} .nm span{{display:inline-block;background:linear-gradient(180deg,#fff 30%,#e6c9ff 100%);-webkit-background-clip:text;color:transparent}} .tg{{font-weight:500;color:rgba(255,255,255,.92);text-shadow:0 2px 14px rgba(20,8,40,.6)}} -.url{{padding:12px 28px;border-radius:99px;font-weight:500;color:#f4e9ff;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.26); +.url{{padding:12px 28px;border-radius:99px;font-weight:500;color:#f4e9ff;background:rgba(255,255,255,.12); + border:1px solid rgba(255,255,255,.26); box-shadow:inset 0 1px 0 rgba(255,255,255,.18),0 6px 20px rgba(10,6,19,.35)}}
@@ -53,9 +56,11 @@ def layers(tag):
{layers("c")}
-
A port of Caelestia for Windows 11
+
+
A port of Caelestia for Windows 11
-
Desktop shell for Windows 11
{REPO_URL}
+
+
Desktop shell for Windows 11
{REPO_URL}
diff --git a/docs/media/src/video/capture.py b/docs/media/src/video/capture.py index 9afdb1d..435dd3b 100644 --- a/docs/media/src/video/capture.py +++ b/docs/media/src/video/capture.py @@ -117,14 +117,16 @@ def main(): shot(float(t), frames / f"t{float(t):05.2f}.png") finally: edge.terminate() - out = Path(os.environ.get("WL_DEBUG_DIR", Path(tempfile.gettempdir()) / "winlestia-frames-debug")); out.mkdir(parents=True, exist_ok=True) + out = Path(os.environ.get("WL_DEBUG_DIR", Path(tempfile.gettempdir()) / "winlestia-frames-debug")) + out.mkdir(parents=True, exist_ok=True) for f in frames.glob("t*.png"): shutil.copy(f, out / f.name) print("dumped to", out); return n = round(a.seconds * a.fps) if not a.frames_dir: t0 = time.time() - ps = [multiprocessing.Process(target=worker, args=(k, a.workers, n, a.fps, w, h, str(tmp), str(frames))) for k in range(a.workers)] + ps = [multiprocessing.Process(target=worker, args=(k, a.workers, n, a.fps, w, h, str(tmp), str(frames))) + for k in range(a.workers)] [p.start() for p in ps]; [p.join() for p in ps] got = len(list(frames.glob("f*.png"))) print(f"captured {got}/{n} frames in {time.time() - t0:.1f}s") diff --git a/docs/quality/MATRIX.md b/docs/quality/MATRIX.md deleted file mode 100644 index 21c098f..0000000 --- a/docs/quality/MATRIX.md +++ /dev/null @@ -1,89 +0,0 @@ -# Verification matrix - -Branch `quality/community-fixes`, commits `6b1ba4a..29dd13a`. Date: 2026-11-04. - -## Request matrix - -| Item | Cause | Fix | Files / commit | Proof | Limits | -|---|---|---|---|---|---| -| #1 media widget | Firefox AUMID hash shown as name; dead session object kept (not compared by AUMID); metadata not re-read; position not ticking (frontend `positionChanged` was a no-op, duration may be 0); audio session matching by exe stem fails for Firefox child processes; visualizer target PID 0 for Firefox | Identity compared by AUMID; readable display name; metadata re-read; position extrapolated only inside the existing visible-dashboard timer; audio sessions matched through the process tree; visualizer uses WASAPI process loopback (`PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE`, `src/backend/ProcessAudio.cpp:21`) on the root PID of the matched tree, 2048-pt Hann FFT into 48 log bands; paused: capture destroyed, bars decay to 0; Discord not captured unless a descendant. Dashboard bar (AudioBars) = default output loopback, unchanged | ebac43b | `tests/MediaIdentity` 61/61 | Not yet verified live with Firefox/YouTube. Registry DisplayName may not exist (process heuristic used). Ambiguous browsers shown as "Navigateur". Per-tab volume impossible (Windows audio sessions are per process) | -| #2 social rail overlays tabs | Rail drawn over tab content | Dashboard slides by the real overlap and scales panes (min 0.7). Below ~1000 logical px the rail overlays (documented fallback) | ef8a438 | qmllint 0 errors; arithmetic checked for 1920 and 1280 widths | Not visually checked at 100/125/150/200 %, FR/EN/ES, multi-DPI. Text scaled to >= 0.7 | -| Reopen on last tab (top-bar reveal) | Dashboard always opened on the default tab | Last tab kept in session memory only (explicit decision: not persisted). Drag-drop menu priority unchanged | ef8a438 | qmllint 0 errors | Multi-monitor, fullscreen and quiet mode not manually tested | -| Installer English + auto detect | Installer was French only | English default, language auto-detected (EN fallback, FR, ES); UTF-8 BOM added; licence files shipped | b27b22c, 17cbda7 | Installer compiled | Not executed: fresh install, upgrade with preferences kept, paths with spaces and accents, launch without SDK, uninstall (needs test account or VM) | -| Security | Audit requested | SQL injection reported as false positive, with evidence. Hardening: L1 bookmarks http/https only (user-visible change); L2 private folder for task XML; L3 `install.py` typographic quotes | 966b58e, see `SECURITY-AUDIT.md` | `tests/SecurityHardening` 20/20, `tests/FileSearch` 22/22 | No confirmed vulnerability; no external pen-test | -| Readability | Wide files, ad hoc name logic, tracked artefacts | 5f2c24a name normaliser; b7220cb docs, `.editorconfig`, 31 artefacts untracked; 659b862 QtShellBridge split; 8857ab3 Coordinator/Models/Visuals split | 5f2c24a, b7220cb, 659b862, 8857ab3 | Normaliser 43/43 + PowerShell parity; splits checked by token equivalence; Preferences JSON order kept | Behaviour-preserving only; no runtime comparison beyond tests | -| .NET 10 | Requested | 31 csproj net8.0 to net10.0 (suffixes kept); Microsoft.Data.Sqlite 10.0.14, other packages unchanged and build. Self-contained publish verified: coreclr/hostfxr present, runtimeconfig 10.0.14, `runtimes/` tree identical to the net8 payload. Native95 test made baseline-aware: on net10 the maximized titled fixture already has a window region before RoundMaximized; product restores the original region correctly; test compares with the captured baseline (type + EqualRgn) | fe48ea8 | Full build; all test projects pass | Installer not yet run on a PC without SDK (no test session available; owner chose to publish and handle bug reports) | -| Hygiene | Not requested explicitly | Duplicate ProcessAudio.dll item removed, CS9191 fixed, analyzers enabled as suggestions (`Directory.Build.props`, `.editorconfig`), local CI workflow `.github/workflows/build.yml` (not pushed) | 44d3449 | Build | Backend warning WFO0003 (app.manifest high-DPI, ApplicationHighDpiMode) left on purpose: changing it may alter DPI behaviour | -| Atomic writes | Robustness | `AtomicFile.cs` / `atomicfile.py` (unique tmp, flush, replace) on preferences, clipboard history, social counts, recents, taskbar lease, tiler exclusions, handler backup, Explorer appearance files; Python language.json, hidden wallpapers, weather city | 125dd1c | `tests/AtomicFile` 13/13 | No power-loss test | -| Preferences recovery | Robustness | Corrupt JSON: keep `.corrupt.bak`, restore from `.bak`, defaults only if both fail. Transient IO errors retried once, never treated as corruption | 89f76b2 | `tests/StoreRecovery` 10/10 | None | - -## Readability metrics (base to now) - -| Metric | Base | Now | -|---|---|---| -| Lines over 1000 characters | 29 | 0 | -| Lines over 500 characters | 207 | 0 | -| One-line loops | 246 | 54 (collection initialisers) | -| C# namespaces | one dominant namespace (81% of files) | 12 area namespaces (largest 20%) | -| French literals in code | about 535 lines | data keys and patterns only (about 140 sites: category ids, regexes, preference ids; kept on purpose) | -| App-name normaliser | chained `Regex.Replace` | ordered rule table plus a dictionary of known display names | - -UI text is in `Views/Strings.cs` (fr, en, es); log and exception messages are in English (commits 888c4c4, 29dd13a). The taskbar window title stays French because `TaskbarRescue.cpp` matches it verbatim. - -## Formatting and checks - -- CSharpier (printWidth 120) and ruff are enforced (`dotnet-tools.json`, `.csharpierrc.json`, `ruff.toml`); formatting commit f8bc3e2 changes no behaviour. -- `.editorconfig` and analyzers are in place (analyzers as suggestions). -- CI runs the `StringsTable` test. - -## Platform - -.NET 8 to .NET 10 (see the .NET 10 row above). Version 0.9.1. - -## SQL injection claim - -Investigated: no exploitable injection. `BookmarksCore.cs` passes values through the parameterised `$u`; the Windows Search query escapes literals with `EscapeLiteral`. Evidence in `SECURITY-AUDIT.md` (commit 966b58e). - -## Live validation (2026-11-04) - -The 0.9.0 build was installed on the maintainer's PC and the UI was tested by the user: OK. The `FullscreenWatcher` test needs a real foreground desktop; in headless runs it ends in an environmental timeout. - -## Automated tests - -All 30 projects pass (tests-final run). New: AtomicFile 13, StoreRecovery 10. - -With counts: AppCatalogueWatch 32, AppNameNormalizer 43, AudioBars 22, FileSearch 22, AutostartPolicy 10, FramePolicy 10, BrightnessPolicy 21, CliCommands 106, DesktopReassert 15, FilesHandler 64, FilesPinMigration 8, FilesPreloadPolicy 12, FilesRouter 27, MediaIdentity 61, SecurityHardening 20, SocialPolicy 11, WifiPolicy 41, FolderIcons 18. - -PASS without counts: DataDirMigration, StaleDockTarget, TileLogicTests, FullscreenWatcher (finished within 90 s this run), NetworkKind, VirtualDesktops. - -JSON flags all true: Explorer077, Native95, Tiler075. Audio06: variation=True, silence=True. - -## Manual checks done - -- qmllint on the changed QML: 0 errors. -- Installer compiled: build 1 min 13 s with warm caches. -- Payload inspected: self-contained runtime, embedded Python, restore tools present, no Unsplash key. - -Installer artefact (0.9.0, historical): build/out/Winlestia-Setup-0.9.0.exe (rebuilt on .NET 10; 196,046,165 bytes, SHA256 64c53b17f2c49b08d96f7201b111caea6c7b58857b3c7d77bba516fa02ad4ce2) - -0.9.1 artefact: `Winlestia-Setup-0.9.1.exe`, 196,088,088 bytes, SHA-256 `93293e815af688fd6135e6f1cd271b5481475bd3d4ba2b90fdaebd294c208c51`, built from 506cee3 in a neutral path (no personal path in the payload). - -## Not executed - -- Live UI checks (media widget with Firefox/YouTube, social rail at several DPI and languages, top-bar reveal). -- Installer scenarios (fresh, upgrade, special paths, no-SDK launch, uninstall). -- CPU, memory and latency measurements (visualizer capture, position timer). -- Installer run on a PC without SDK (.NET 10 payload). -- Power-loss test of atomic writes. - -## Known issues found, not fixed - -- Payload contains the build user path in `.pyc` and numpy config. Build from a neutral path such as `C:\wl-build`, as recommended in the `build.ps1` header. -- `build.ps1` fails if stderr is redirected inside PowerShell (a pip warning becomes fatal). -- `build.ps1` hard-coded the version in its final message (now 0.9.1, still hard-coded). -- `src/terminal/clink-profile` (3 files) is tracked since v0.7.6 and shipped by `build.ps1`. Decided: keep tracked; contains no personal data, it is the shipped terminal theme. -- Autostart task name is `WinlestiaShell`. -- Swallowed errors left as findings: Store legacy Workspace-v4 migration `catch{}`, DataDir `CreateDirectory` `catch{}`, FilesHandler `LoadBackup` returns null, icon writes `catch{}`, SocialService `QueueSave` `catch{}`. -- Timers: Coordinator.cs 120 ms timer runs for the process lifetime (cheap; follow-up). Frontend pointer timer 500 ms when idle. -- async void audit: no crash path (global Dispatcher/AppDomain handlers exist; Files app handlers wrapped). -- Backend warning WFO0003 left on purpose (see Hygiene row). diff --git a/docs/quality/NAME-NORMALISATION.md b/docs/quality/NAME-NORMALISATION.md deleted file mode 100644 index de815b0..0000000 --- a/docs/quality/NAME-NORMALISATION.md +++ /dev/null @@ -1,27 +0,0 @@ -# App display-name normalisation - -Single C# source of truth: `src/backend/Platform/AppNameNormalizer.cs` (`Normalize`). `CatalogueWatchRules.CleanName` is a thin wrapper. -PowerShell twin: `Clean-Name` in `src/backend/Inventory.ps1` (keep in sync; `tests/AppNameNormalizer/parity.ps1` compares both). - -Rules apply in this order (all case-insensitive, culture-invariant), then the result is trimmed. Null gives "". - -| # | Rule | Regex | Example | -|---|------|-------|---------| -| 1 | ExeShortcutSuffix | `\.exe\s*-\s*Raccourci$` | `Foo.exe - Raccourci` -> `Foo` | -| 2 | ShortcutSuffix | `\s*-\s*Raccourci$` | `Foo - Raccourci` -> `Foo` | -| 3 | LaunchPrefix | `^Lancer\s+` | `Lancer Foo` -> `Foo` | -| 4 | VlcExe | `^VLC Media Player\.exe$` | `VLC Media Player.exe` -> `VLC Media Player` | -| 5 | TrademarkSigns | `Â?®\|™` | `Foo™` -> `Foo` | -| 6 | Windows10Suffix | `\s*-\s*Windows10$` | `Foo - Windows10` -> `Foo` | -| 7 | CrimsonDesert | `^CrimsonDesert$` | `CrimsonDesert` -> `Crimson Desert` | -| 8 | HogwartsLegacy | `^HogwartsLegacy$` | `HogwartsLegacy` -> `Hogwarts Legacy` | - -## Where used -- `AppCatalogueWatcher.cs` (via `CatalogueWatchRules.CleanName`): shortcut events and catalogue scans. -- `Inventory.ps1` `Clean-Name`: inventory of shortcuts and Epic pending installs. - -## Adding a rule -1. Add a named `static readonly Regex` with an XML doc example in `AppNameNormalizer.cs` and apply it in `Normalize` (order matters). -2. Add the same `-replace` to `Clean-Name` in `Inventory.ps1` at the same position. -3. Add inputs to `Inputs` and recorded outputs to `Expected` in `tests/AppNameNormalizer/Program.cs` (same index). -4. Run `tests/AppNameNormalizer` (expects `TOTAL n/n`; parity prints `SKIP` if PowerShell is unavailable) and `tests/AppCatalogueWatch`. diff --git a/docs/quality/PROGRESS.md b/docs/quality/PROGRESS.md deleted file mode 100644 index fc17d0c..0000000 --- a/docs/quality/PROGRESS.md +++ /dev/null @@ -1,64 +0,0 @@ -# Quality pass — progress log - -Branch `quality/community-fixes`, -based on `6b1ba4a` (= tag `v0.9.0` + README-only commits; no `src/` or `installer/` difference). -The installed build reports `0.9.0+a41b13b` (tag `v0.9.0`). `winlestia-0.9.0-clean` is an older -ancestor (`27e07f6`) and is not used. Nothing is pushed. - -## Baseline (2026-11-04) - -| Language | Files | Lines | Lines > 120 | > 300 | > 1000 | -|---|---|---|---|---|---| -| C# | 183 | 18,234 | 4,150 | 645 | 32 | -| QML | 142 | 11,764 | 900 | 69 | 0 | -| Python | 72 | 8,339 | 1,131 | 74 | 0 | -| PowerShell | 16 | 1,071 | 144 | 5 | 0 | - -- C# namespaces: `PanneauxGlass` 129 files, `WinlestiaFiles` 25, `Winlestia.Shared` 3, `WinlestiaCli` 1, none 25. -- 226 empty `catch` blocks, 65 direct `File.WriteAll*` calls, 67 timers, ~14 `async void` candidates. -- No `.editorconfig`, no analyzer configuration, no CI workflow. -- Worst files: `QtShellBridge.cs` (line of 4,775 chars, 105 command cases), `Coordinator.cs` (3,324), - `Visuals.cs`, `Models.cs`, `frontend.py`. -- 30 generated artefacts (`performance-*.json`, `verification-*.json`, …) are tracked in `src/backend`. -- Tests (25 console projects, 90 s cap each, script `run-tests.ps1` pattern): 24 report all checks - passing; `FullscreenWatcher` does not finish within 90 s (pre-existing, to investigate). - Backend Release build: 8.7 s, 0 errors. -- Toolchain: SDK 8.0.407 only. Backend/Files published self-contained (`build.ps1`), so target PCs need no runtime. - -## Plan (separate commits, in this order) - -1. **Fix — media widget (#1)**: session identity, AUMID display name, browser audio-session matching, - visualizer target, live position. Pure functions + tests. -2. **Fix — social rail reserves space (#2)** and **dashboard reopens on last tab** (session memory). -3. **Fix — installer in English** with automatic language detection (EN fallback, FR, ES). -4. **Security hardening** (no confirmed vulnerability; see `SECURITY-AUDIT.md`). -5. **Structure (behaviour-preserving)**: `.editorconfig`, analyzers, shortcut-name normalisation module, - splitting the widest files, removing tracked artefacts, contributor docs. -6. **Migration .NET 8 → .NET 10** (separate commit; needs the .NET 10 SDK). -7. Build + local installer, verification matrix. - -## User-visible changes - -- Bookmarks with file: URIs no longer open (only http/https). - -## Log - -- 2026-11-04: audits done (architecture, security, media, social rail/top bar, .NET/installer). -- 2026-11-04: committed ef8a438 social rail + last tab, b27b22c installer EN/auto-detect, ebac43b media #1 (61 tests) -- 2026-11-04: lot 4 security hardening (see SECURITY-AUDIT.md) -- 2026-11-04: 5f2c24a shortcut-name normaliser (43 tests, PowerShell parity) -- 2026-11-04: b7220cb docs, .editorconfig, 31 generated artefacts untracked -- 2026-11-04: 659b862 QtShellBridge split into partial files (token equivalence) -- 2026-11-04: 8857ab3 Coordinator/Models/Visuals split (token equivalence, Preferences JSON order kept) -- 2026-11-04: 17cbda7 installer ships Caelestia licence and notices -- 2026-11-04: build + 27 test projects pass; installer Winlestia-Setup-0.9.0.exe built; see MATRIX.md (.NET 10 not done) -- 2026-11-04: fe48ea8 .NET 10 migration (31 csproj, Sqlite 10.0.14, Native95 baseline-aware, self-contained publish verified) -- 2026-11-04: 44d3449 hygiene (duplicate item, CS9191, analyzers as suggestions, local CI) -- 2026-11-04: 125dd1c atomic writes for durable state (AtomicFile 13/13) -- 2026-11-04: 89f76b2 preferences recovery from .bak (StoreRecovery 10/10); 30 test projects pass; installer to be rebuilt -- 2026-11-04: f8bc3e2 CSharpier (printWidth 120) and ruff; no line over 500 characters (was 207 over 500, 29 over 1000); one-line loops 246 to 54 -- 2026-11-04: 0a53929 backend split into 12 area namespaces (largest 20% of files, was 81%); AppNameNormalizer as ordered rule table plus KnownDisplayNames -- 2026-11-04: 888c4c4, 29dd13a French literals out of code (about 535 lines to about 140 data keys and patterns); UI text in Views/Strings.cs (fr/en/es), English logs; StringsTable test in CI -- 2026-11-04: SQL injection claim investigated: no exploitable injection (parameterised `$u`, EscapeLiteral in the Windows Search query); see SECURITY-AUDIT.md -- 2026-11-04: live validation: 0.9.0 build installed on the maintainer's PC, UI tested OK. FullscreenWatcher test needs a real foreground desktop (environmental timeout in headless runs) -- 2026-11-04: version bumped to 0.9.1; notes in docs/RELEASE-0.9.1.md diff --git a/docs/quality/SECURITY-AUDIT.md b/docs/quality/SECURITY-AUDIT.md deleted file mode 100644 index 2b62b0c..0000000 --- a/docs/quality/SECURITY-AUDIT.md +++ /dev/null @@ -1,37 +0,0 @@ -# Security audit - lot 4 (2026-11-04) - -## Scope -C# backend (`src/backend`), file manager IPC (`src/files/SingleInstance.cs`, `src/backend/Shared/FilesPipe.cs`), Python frontend IPC client (`src/frontend/frontend.py`), terminal installer (`src/terminal/install.py`), backend installer script (`src/backend/Install.ps1`), and the community claim of "SQL injection". - -## Method -Manual reading of the cited code (line numbers re-located with `rg`), targeted pure-function tests (`tests/SecurityHardening`), Release build of the backend. No dynamic testing, no fuzzing, no scheduled task or schtasks call was run. This is a point-in-time review; it does not prove the absence of other issues. - -## SQL injection claim: false positive -| Claim | Evidence read | -|---|---| -| Bookmark favicon lookup | `BookmarksCore.cs` `Query()`: the only `string.Format` argument is a constant clause (`=$u` or ` LIKE $u`); values go through `Parameters.AddWithValue("$u", ...)`. No user text reaches the SQL text. | -| Windows Search query | `FileSearch.cs` `TryBuild`: term is normalised, `NeutraliseContains` replaces `" * % _ [ ]` with blanks, `EscapeLiteral` doubles `'`; the profile path is fully-qualified-checked, NUL-checked and `EscapeLiteral`-ed. Characterised by tests (see S5). | -| WMI/WQL | `CpuTempChain.cs`, `OsdWatcher.cs`: query strings are constants. | -Result: not confirmed; no injectable sink found in the code read. - -## Findings -| ID | File:line | Scenario | Impact | Status | Fix | Test | -|---|---|---|---|---|---|---| -| S1 | `BookmarksService.cs` Open (~149) | Imported/edited bookmark with `file://server/share/...` or `file:///...` passed to the browser | file: was accepted; a UNC host makes Windows authenticate to a remote SMB server, local files open in browser. Requires a malicious bookmark already in the user's browser profile. Low | Hardening | New `BookmarkUri.IsOpenable` (http/https only). file: was not documented as a feature; the only caller is `QtShellBridge.cs:205` (`bookmarksOpen`). User-visible behaviour change: `file:` bookmarks previously opened and are now refused. about:/edge: were never accepted, left refused | `tests/SecurityHardening` (javascript:, file:, UNC, ms-settings:, data:, malformed, http, https) | -| S2 | `AutostartTask.cs` Create (~29) | Task XML written to shared `%TEMP%` then read by `schtasks /Create /XML` | Predictable-window swap by another local process of the same user (a different user cannot write into this user's `%TEMP%` by default ACL, but the pattern is fragile). Low | Hardening | XML now written to `%LOCALAPPDATA%\Panneaux\tmp\winlestia-task-.xml` (created if missing), deleted in `finally`. Task name and XML content unchanged | `AutostartPolicy.PrivateTaskFile` tests (location, unique, not under `%TEMP%`). `schtasks` itself NOT run | -| S3 | `src/terminal/install.py:58-65` | Script path taken from the Run registry value is embedded in a single-quoted PowerShell literal for `-EncodedCommand`; only `'` was doubled, but PowerShell also treats U+2018/2019/201A/201B as quote delimiters | A path containing a typographic quote would break out of the literal (also a functional bug for user names like O'Brien with a curly quote). The value is in HKCU, so exploiting it already needs same-user write access. Low | Hardening | All four typographic quotes are now doubled too | Not covered by an automated test (no Python test harness for install.py); regex checked by hand (`'` and U+2019 are doubled) | -| S4 | `src/backend/Install.ps1` | Search for `Invoke-Expression`, `-EncodedCommand`, user-controlled strings | Only fixed paths and `Start-Process` with constant arguments found in lines matched | Not confirmed | none | n/a | -| S5 | `FileSearch.cs:39-58` | Inputs `a' OR 1=1 --`, `"x" OR "y"`, `100%_[a]*`, profile path with `'` | Stay literals inside the CONTAINS string / SCOPE string | False positive | none (behaviour unchanged; helpers already `internal`) | `tests/SecurityHardening` characterisation (8 checks) | -| S6 | `CliServer.cs:23-26`, `CliCommands.cs:44-86` | Other local users or processes driving the CLI pipe | DACL holds one entry (current user SID); bounded line read; command whitelist | Not confirmed | none | existing `tests/CliCommands` | -| S7 | `SingleInstance.cs:27-29,75-100`, `FilesPipe.cs` | Foreign client on the Files pipe | `CurrentUserOnly`, 64 KiB buffer, 2 s limit, 16 handlers, JsonException swallowed; request only opens folders/args via the app's own handler | Not confirmed | none | n/a | -| S8 | `QtShellBridge.cs:124` | Qt bridge pipe `PanneauxCaelestia` | `CurrentUserOnly`; `JsonDocument.Parse` in try/catch; `switch(command)` dispatch. Observation: the 131072-char limit is applied after `ReadLineAsync` returns, so a same-user peer could send one huge line and consume memory. Same-user only, so no privilege boundary crossed | Hardening | none made | n/a | -| S9 | `src/frontend/frontend.py:78-90` | Python client of the bridge | Client reads with `readline(8 MiB)` and `json.loads` inside `except (OSError, ValueError)`. The client does not verify the server's identity (pipe name squatting by a same-user process) | Not confirmed | none | n/a | -| S10 | repo-wide | `BinaryFormatter`, `shell=True` | none found by search | Not confirmed | none | n/a | - -## Not reviewed -- `Install.ps1` was only searched for dangerous constructs (`Invoke-Expression`, `-EncodedCommand`, `$env:`, `Start-Process`), not read in full; `installer/*.ps1`, `Winlestia.iss` likewise not audited. -- The body of `QtShellBridge.Handle` (each command's argument handling), `Social*`, `WebIcons`, WebView2 pages (`assets/web`), the Whisper/voice code, the native DLLs, `src/files` beyond the pipe, `src/cli`. -- Python frontend beyond the IPC client; QML. -- Third-party dependencies and the Clink download path (hash pinned in `install.py`; not re-verified). -- Browser profiles and cookies were deliberately not read; no secrets were opened. -- No dynamic, fuzz or penetration testing. diff --git a/docs/release-notes-0.9.2.md b/docs/release-notes-0.9.2.md new file mode 100644 index 0000000..cd44227 --- /dev/null +++ b/docs/release-notes-0.9.2.md @@ -0,0 +1,35 @@ +# Winlestia 0.9.2 + +Winlestia est un shell de bureau libre pour Windows 11, inspiré de Caelestia. Cette version refond les réglages et le gestionnaire de fichiers, et corrige la dictée vocale. + +## Nouveautés + +- Réglages redessinés en huit pages, avec recherche et navigation latérale ; éditeur de palette ; le terminal suit le mode clair ou sombre du thème. +- Page « Raccourcis clavier » : liste complète, réaffectation des raccourcis configurables, refus expliqué des combinaisons réservées ou en conflit. Win+Alt+S ouvre les Réglages. +- Gestionnaire de fichiers redessiné. +- Icônes dans le dock pour les applications empaquetées ; catalogue d'applications mis à jour en direct. +- Dock, signets et panneau social masquables séparément. +- « Ajouter un site web » est retiré. + +## Corrections + +- Dictée vocale : Whisper.net revient sur .NET 8. +- Flèches affichées comme des cases vides dans la liste des raccourcis de mosaïque. +- Raccourci non enregistré au démarrage signalé à tort « inchangé » ; raccourci Réglages en doublon remplacé par une combinaison libre. +- Dock, signets et panneau social non masqués avant le premier instantané des réglages. + +## Installation + +Télécharger `Winlestia-Setup-0.9.2.exe` ci-dessous, comparer son SHA-256 (`Get-FileHash`) à la valeur indiquée dans ces notes, puis lancer l'installateur. Il n'est pas signé : SmartScreen peut avertir (Informations complémentaires, puis Exécuter quand même). Guide : `docs/INSTALL.md`. + +SHA-256 de `Winlestia-Setup-0.9.2.exe` : `A7E525FB7FF2AB3A918F5185414D7B563EEB19E39454E354D996446A2AD4142B` + +## Restauration + +Si la barre des tâches Windows ne revient pas : Ctrl+Alt+Retour arrière, ou `--restore-taskbar`. + +## Interne + +Version unique dans `Directory.Build.props` ; CI (build, xUnit, pytest) ; tests regroupés ; blocs `catch` vides classés ; auto-tests du gestionnaire de fichiers exclus des builds Release ; code reformaté, backend et `frontend.py` découpés ; dépendances épinglées ; l'installateur vérifie l'empreinte du Python téléchargé. + +Détail : `CHANGELOG.md`. diff --git a/dotnet-tools.json b/dotnet-tools.json deleted file mode 100644 index 6e2e244..0000000 --- a/dotnet-tools.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "csharpier": { - "version": "1.3.0", - "commands": [ - "csharpier" - ], - "rollForward": false - } - } -} \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..066a8f7 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.407", + "rollForward": "latestPatch" + } +} diff --git a/installer/Close-Winlestia.ps1 b/installer/Close-Winlestia.ps1 index e5ddd4d..dea54d1 100644 --- a/installer/Close-Winlestia.ps1 +++ b/installer/Close-Winlestia.ps1 @@ -5,14 +5,21 @@ $installRoot=[IO.Path]::GetFullPath($AppRoot).TrimEnd('\')+'\' if($installRoot.Length -lt 12 -or $installRoot -eq [IO.Path]::GetPathRoot($installRoot)){throw 'Unexpected installation root'} $targets=@(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($installRoot,[StringComparison]::OrdinalIgnoreCase) -and - (($_.Name -eq 'Panneaux.Glass.exe') -or ($_.Name -in @('python.exe','pythonw.exe') -and $_.CommandLine -match '(frontend\.py|sync_theme\.py)')) + (($_.Name -eq 'Panneaux.Glass.exe') -or ($_.Name -in @('python.exe','pythonw.exe') -and + $_.CommandLine -match '(frontend\.py|sync_theme\.py)')) }) if($ListOnly){$targets | Select-Object ProcessId,Name;return} $backend=Join-Path $installRoot 'Glass-v5\Panneaux.Glass.exe' -if(Test-Path -LiteralPath $backend){$quit=Start-Process -FilePath $backend -ArgumentList '--quit' -WindowStyle Hidden -PassThru;$quit.WaitForExit(1000) | Out-Null} +if(Test-Path -LiteralPath $backend){$quit=Start-Process -FilePath $backend -ArgumentList '--quit' -WindowStyle Hidden -PassThru; +$quit.WaitForExit(1000) | Out-Null} $until=[DateTime]::UtcNow.AddSeconds(4) -do{ $remaining=@($targets | Where-Object {Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue});if(!$remaining){break};Start-Sleep -Milliseconds 100 }while([DateTime]::UtcNow -lt $until) -foreach($target in $remaining){$live=Get-CimInstance Win32_Process -Filter "ProcessId=$($target.ProcessId)";if($live -and $live.ExecutablePath -eq $target.ExecutablePath){Stop-Process -Id $target.ProcessId -Force -ErrorAction SilentlyContinue}} -# Balayage final : tout processus restant dont l'exécutable est sous la racine d'installation (hors désinstalleur) garde des fichiers verrouillés. +do{ $remaining=@($targets | Where-Object {Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue});if(!$remaining){break}; +Start-Sleep -Milliseconds 100 }while([DateTime]::UtcNow -lt $until) +foreach($target in $remaining){$live=Get-CimInstance Win32_Process -Filter "ProcessId=$($target.ProcessId)"; +if($live -and $live.ExecutablePath -eq $target.ExecutablePath){Stop-Process -Id $target.ProcessId -Force -ErrorAction SilentlyContinue}} +# Balayage final : tout processus restant dont l'exécutable est sous la racine d'installation (hors désinstalleur) garde des fichiers +# verrouillés. Start-Sleep -Milliseconds 300 -foreach($left in @(Get-CimInstance Win32_Process | Where-Object {$_.ExecutablePath -and $_.ExecutablePath.StartsWith($installRoot,[StringComparison]::OrdinalIgnoreCase) -and $_.Name -notlike 'unins*.exe' -and $_.ProcessId -ne $PID})){Stop-Process -Id $left.ProcessId -Force -ErrorAction SilentlyContinue} +foreach($left in @(Get-CimInstance Win32_Process | Where-Object {$_.ExecutablePath -and $_.ExecutablePath.StartsWith($installRoot, + [StringComparison]::OrdinalIgnoreCase) -and $_.Name -notlike 'unins*.exe' -and $_.ProcessId -ne $PID})){ + Stop-Process -Id $left.ProcessId -Force -ErrorAction SilentlyContinue} diff --git a/installer/Deploy-Local.ps1 b/installer/Deploy-Local.ps1 index 0b7a685..1a1b265 100644 --- a/installer/Deploy-Local.ps1 +++ b/installer/Deploy-Local.ps1 @@ -10,9 +10,33 @@ $root = Split-Path $PSScriptRoot -Parent $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $backup = Join-Path $root "build\backups\$BackupTag-$stamp" $stage = Join-Path $root 'build\deploy-staging\Glass-v5' +$filesStage = Join-Path $root 'build\deploy-staging\Files' $glass = Join-Path $InstallDir 'Glass-v5' $front = Join-Path $InstallDir 'Caelestia-Windows' +# Fusionne un publish secondaire dans le dossier principal : ajoute les fichiers absents et les assemblies +# gérées dont l'AssemblyVersion est strictement supérieure ; Winlestia.Files.* est toujours copié. +function Merge-Publish([string]$Source, [string]$Dest) { + $srcRoot = (Resolve-Path $Source).Path.TrimEnd('\') + foreach ($f in Get-ChildItem $srcRoot -Recurse -File) { + $rel = $f.FullName.Substring($srcRoot.Length).TrimStart('\') + $target = Join-Path $Dest $rel + $copy = $false + if ($f.Name -like 'Winlestia.Files.*' -or -not (Test-Path $target)) { $copy = $true } + else { + try { + $new = [Reflection.AssemblyName]::GetAssemblyName($f.FullName).Version + $old = [Reflection.AssemblyName]::GetAssemblyName($target).Version + if ($new -gt $old) { $copy = $true } + } catch { } # natif ou illisible : on garde la copie du backend + } + if ($copy) { + New-Item -ItemType Directory -Force -Path (Split-Path $target -Parent) | Out-Null + Copy-Item $f.FullName $target -Force + } + } +} + Write-Host '-> Sauvegarde' -ForegroundColor Cyan New-Item -ItemType Directory -Force -Path $backup | Out-Null robocopy $glass (Join-Path $backup 'Glass-v5') /E /NFL /NDL /NJH /NJS /NP | Out-Null @@ -33,8 +57,10 @@ if ($LASTEXITCODE -ne 0) { throw 'Compilation du backend echouee.' } Write-Host '-> Publish Winlestia Fichiers' -ForegroundColor Cyan dotnet publish (Join-Path $root 'src\files\Winlestia.Files.csproj') ` -c Release -r win-x64 --self-contained true -p:PublishSingleFile=false ` - -o $stage --nologo + -o $filesStage --nologo if ($LASTEXITCODE -ne 0) { throw 'Compilation de Winlestia Fichiers echouee.' } +Merge-Publish $filesStage $stage +Remove-Item -Recurse -Force $filesStage -ErrorAction SilentlyContinue Write-Host '-> Publish winlestia.exe' -ForegroundColor Cyan dotnet publish (Join-Path $root 'src\cli\winlestia.csproj') ` -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true ` @@ -48,15 +74,34 @@ Copy-Item (Join-Path $root 'src\backend\native\*.exe') $stage -Force Write-Host '-> Copie backend' -ForegroundColor Cyan robocopy $stage $glass /E /NFL /NDL /NJH /NJS /NP | Out-Null if ($LASTEXITCODE -ge 8) { throw "robocopy backend: $LASTEXITCODE" } +# robocopy saute un fichier de même taille et même date même si son contenu diffère (Whisper.net net8 vs net10 : la DLL net10 restait +# en place et cassait la dictée). Passe de rattrapage par empreinte. +Get-ChildItem $stage -File -Recurse | ForEach-Object { + $target = Join-Path $glass $_.FullName.Substring($stage.Length).TrimStart('\') + if (-not (Test-Path $target) -or (Get-FileHash $_.FullName).Hash -ne (Get-FileHash $target).Hash) { + Copy-Item $_.FullName $target -Force + Write-Host " recopie (contenu différent) : $($_.Name)" + } +} + +Write-Host '-> Verre natif Qt' -ForegroundColor Cyan +$qtGlass = Join-Path $root 'build\QtGlassBackdrop.dll' +if (-not (Test-Path $qtGlass)) { + cmd /c (Join-Path $root 'src\native\qtglass\build-qt-glass.cmd') + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $qtGlass)) { + throw 'QtGlassBackdrop.dll manquant : compilation (src\native\qtglass\build-qt-glass.cmd) echouee.' } +} Write-Host '-> Copie frontend' -ForegroundColor Cyan robocopy (Join-Path $root 'src\frontend') $front /E /NFL /NDL /NJH /NJS /NP ` /XD '.venv' '__pycache__' 'tests' 'validation-*' 'checkpoint-*' /XF '*.zip' '*.pyc' | Out-Null if ($LASTEXITCODE -ge 8) { throw "robocopy frontend: $LASTEXITCODE" } $global:LASTEXITCODE = 0 +# La DLL est produite dans build\ : elle doit finir a cote des modules du frontend. +Copy-Item -LiteralPath $qtGlass -Destination $front -Force if (-not $NoStart) { Write-Host '-> Demarrage' -ForegroundColor Cyan - Start-Process (Join-Path $glass 'Panneaux.Glass.exe') -WorkingDirectory $glass + schtasks /run /tn WinlestiaShell | Out-Null # same entry point as login autostart (--qt-shell) } Write-Host "Termine. Sauvegarde: $backup" diff --git a/installer/Winlestia.iss b/installer/Winlestia.iss index b67cf0f..1378af9 100644 --- a/installer/Winlestia.iss +++ b/installer/Winlestia.iss @@ -7,7 +7,9 @@ ; - déjà installé → une page propose « Mettre à jour » ou « Désinstaller ». #define AppName "Winlestia" -#define AppVersion "0.9.1" +#ifndef AppVersion + #error AppVersion non defini : compiler via installer/build.ps1 (/DAppVersion lu dans Directory.Build.props). +#endif #define AppPublisher "dj" #define AppExe "Panneaux.Glass.exe" #define AppId "{{8E2B7C41-5F1A-4F7B-9C3D-6A2E0D5B7C18}" diff --git a/installer/build.ps1 b/installer/build.ps1 index 262da0c..74a8c35 100644 --- a/installer/build.ps1 +++ b/installer/build.ps1 @@ -8,39 +8,77 @@ # d'utilisateur, jamais depuis le dossier personnel : # git clone --no-hardlinks C:\wl-build\src (ou git worktree add --detach, au commit final) # cmd /c src\backend\build-backdrop.cmd, build-process-audio.cmd, build-rescue.cmd, build-taskbar-rescue.cmd +# cmd /c src\native\qtglass\build-qt-glass.cmd (verre natif Qt : build\QtGlassBackdrop.dll, ignore par git) # $env:TEMP = $env:TMP = 'C:\wl-build\tmp'; $env:NUGET_PACKAGES = 'C:\wl-build\nuget'; $env:PIP_CACHE_DIR = 'C:\wl-build\pip' # pwsh C:\wl-build\src\installer\build.ps1 # Aucun .pdb ni lanceur pip n'est livre. Verifier ensuite que build\payload ne contient ni le nom # d'utilisateur ni C:\Users\ (UTF-8 et UTF-16LE). +# -CI : fabrication de verification sur runner CI. Les composants natifs ignores par git (MSVC requis) sont +# remplaces par des fichiers vides : le setup produit sert uniquement de test de fumee, jamais de livraison. +param([switch]$CI) + $ErrorActionPreference = 'Stop' $root = Split-Path $PSScriptRoot -Parent $payload = Join-Path $root 'build\payload' +$filesStage = Join-Path $root 'build\files-staging' $pythonVersion = '3.12.10' +# SHA256 de python-3.12.10-embed-amd64.zip (MD5 fe8ef205f2e9c3ba44d0cf9954e1abd3 confirmé sur la page de release python.org) +$pythonSha256 = '4acbed6dd1c744b0376e3b1cf57ce906f9dc9e95e68824584c8099a63025a3c3' +# Version unique lue dans Directory.Build.props +$version = ([xml](Get-Content (Join-Path $root 'Directory.Build.props') -Raw)).Project.PropertyGroup.Version +if (-not $version) { throw 'Version introuvable dans Directory.Build.props.' } $pythonUrl = "https://www.python.org/ftp/python/$pythonVersion/python-$pythonVersion-embed-amd64.zip" +# Fusionne un publish secondaire dans le dossier principal : ajoute les fichiers absents et les assemblies +# gérées dont l'AssemblyVersion est strictement supérieure ; Winlestia.Files.* est toujours copié. +function Merge-Publish([string]$Source, [string]$Dest) { + $srcRoot = (Resolve-Path $Source).Path.TrimEnd('\') + foreach ($f in Get-ChildItem $srcRoot -Recurse -File) { + $rel = $f.FullName.Substring($srcRoot.Length).TrimStart('\') + $target = Join-Path $Dest $rel + $copy = $false + if ($f.Name -like 'Winlestia.Files.*' -or -not (Test-Path $target)) { $copy = $true } + else { + try { + $new = [Reflection.AssemblyName]::GetAssemblyName($f.FullName).Version + $old = [Reflection.AssemblyName]::GetAssemblyName($target).Version + if ($new -gt $old) { $copy = $true } + } catch { } # natif ou illisible : on garde la copie du backend + } + if ($copy) { + New-Item -ItemType Directory -Force -Path (Split-Path $target -Parent) | Out-Null + Copy-Item $f.FullName $target -Force + } + } +} + Write-Host '→ Nettoyage' -ForegroundColor Cyan Remove-Item -Recurse -Force $payload -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $payload | Out-Null # Composants natifs (ignorés par git) : compilés au besoin AVANT le publish, qui copie ProcessAudio.dll. +# Source unique partagée avec la CI (tools/build-native.ps1). En mode -CI, les absents sont remplacés par des fichiers vides plus bas. $nativeDir = Join-Path $root 'src\backend\native' -$missingNative = @('RETABLIR_BARRE_WINDOWS.exe','SECOURS_BUREAU.exe','GlassBackdrop.dll','ProcessAudio.dll') | Where-Object { -not (Test-Path (Join-Path $nativeDir $_)) } -if ($missingNative) { & (Join-Path $root 'tools\build-native.ps1') } # source unique, partagée avec la CI +$missingNative = @('RETABLIR_BARRE_WINDOWS.exe','SECOURS_BUREAU.exe','GlassBackdrop.dll','ProcessAudio.dll') | + Where-Object { -not (Test-Path (Join-Path $nativeDir $_)) } +if ($missingNative -and -not $CI) { & (Join-Path $root 'tools\build-native.ps1') } -Write-Host '→ Backend .NET 10 (auto-suffisant)' -ForegroundColor Cyan +Write-Host '→ Backend .NET 8 (auto-suffisant)' -ForegroundColor Cyan dotnet publish (Join-Path $root 'src\backend\Panneaux.Glass.csproj') ` -c Release -r win-x64 --self-contained true -p:PublishSingleFile=false -p:DebugType=none -p:DebugSymbols=false ` -o (Join-Path $payload 'Glass-v5') --nologo if ($LASTEXITCODE -ne 0) { throw 'Compilation du backend échouée.' } -Write-Host '→ Winlestia Fichiers (.NET 10, auto-suffisant)' -ForegroundColor Cyan +Write-Host '→ Winlestia Fichiers (.NET 8, auto-suffisant)' -ForegroundColor Cyan dotnet publish (Join-Path $root 'src\files\Winlestia.Files.csproj') ` -c Release -r win-x64 --self-contained true -p:PublishSingleFile=false -p:DebugType=none -p:DebugSymbols=false ` - -o (Join-Path $payload 'Glass-v5') --nologo + -o $filesStage --nologo if ($LASTEXITCODE -ne 0) { throw 'Compilation de Winlestia Fichiers échouée.' } +Merge-Publish $filesStage (Join-Path $payload 'Glass-v5') +Remove-Item -Recurse -Force $filesStage -ErrorAction SilentlyContinue -Write-Host '→ Commande winlestia.exe (.NET 10, auto-suffisant)' -ForegroundColor Cyan +Write-Host '→ Commande winlestia.exe (.NET 8, auto-suffisant)' -ForegroundColor Cyan dotnet publish (Join-Path $root 'src\cli\winlestia.csproj') ` -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true ` -p:EnableCompressionInSingleFile=true -p:DebugType=none ` @@ -50,7 +88,14 @@ if ($LASTEXITCODE -ne 0) { throw 'Compilation de winlestia.exe échouée.' } # Composants natifs précompilés (ignorés par git) : sans eux le setup serait livré sans outil de restauration. $nativeDir = Join-Path $root 'src\backend\native' foreach ($required in 'RETABLIR_BARRE_WINDOWS.exe','SECOURS_BUREAU.exe','GlassBackdrop.dll') { - if (-not (Test-Path (Join-Path $nativeDir $required))) { throw "Composant natif manquant : $required dans $nativeDir. Copie-le depuis une installation de Winlestia (Glass-v5) ou recompile-le (voir native\README.md)." } + if ($CI -and -not (Test-Path (Join-Path $nativeDir $required))) { + Write-Warning "CI : $required absent, remplace par un fichier vide (setup de fumee)." + New-Item -ItemType File -Force -Path (Join-Path $nativeDir $required) | Out-Null + } + if (-not (Test-Path (Join-Path $nativeDir $required))) { + throw ("Composant natif manquant : $required dans $nativeDir. " + + "Copie-le depuis une installation de Winlestia (Glass-v5) ou recompile-le (voir native\README.md).") + } } # Fichiers attendus à côté de l’exécutable mais hors du publish @@ -68,15 +113,33 @@ foreach ($leak in 'catalog.json', 'preferences-glass.json', 'taskbar-session.jso Get-ChildItem (Join-Path $payload 'Glass-v5') -Filter '*.log' -ErrorAction SilentlyContinue | Remove-Item -Force Write-Host '→ Frontend QML' -ForegroundColor Cyan +# Verre natif Qt (ignoré par git) : sans lui native_glass.py retombe sans flou natif. Copié avec le frontend. +$qtGlass = Join-Path $root 'build\QtGlassBackdrop.dll' +if ($CI -and -not (Test-Path $qtGlass)) { + Write-Warning 'CI : QtGlassBackdrop.dll absent, remplace par un fichier vide (setup de fumee).' + New-Item -ItemType File -Force -Path $qtGlass | Out-Null +} +if (-not (Test-Path $qtGlass)) { + cmd /c (Join-Path $root 'src\native\qtglass\build-qt-glass.cmd') + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $qtGlass)) { + throw "QtGlassBackdrop.dll manquant dans build : compilation (src\native\qtglass\build-qt-glass.cmd) echouee." } +} $frontDest = Join-Path $payload 'Caelestia-Windows' robocopy (Join-Path $root 'src\frontend') $frontDest /E /NFL /NDL /NJH /NJS /NP ` /XD '.venv' 'validation-*' 'checkpoint-*' '__pycache__' '/XF' '*.zip' '*.pyc' | Out-Null +# La DLL est produite dans build\ : elle doit finir a cote des modules du frontend (native_glass.default_library). +Copy-Item -LiteralPath $qtGlass -Destination $frontDest -Force Write-Host '→ Python embarqué' -ForegroundColor Cyan $pyDir = Join-Path $frontDest '.venv\Scripts' New-Item -ItemType Directory -Force -Path $pyDir | Out-Null $zip = Join-Path $env:TEMP "python-$pythonVersion-embed-amd64.zip" if (-not (Test-Path $zip)) { Invoke-WebRequest $pythonUrl -OutFile $zip } +$actualSha = (Get-FileHash -Algorithm SHA256 -Path $zip).Hash +if ($actualSha -ne $pythonSha256) { + Remove-Item -Force $zip -ErrorAction SilentlyContinue + throw "SHA256 de l'archive Python invalide : attendu $pythonSha256, obtenu $actualSha." +} Expand-Archive -Path $zip -DestinationPath $pyDir -Force # Le backend lance `.venv\Scripts\pythonw.exe` : la distribution embarquée vit donc dans Scripts. # `..\..` est indispensable : sans lui, un `._pth` retire le dossier du script de sys.path @@ -86,8 +149,8 @@ Expand-Archive -Path $zip -DestinationPath $pyDir -Force Write-Host '→ Dépendances Python' -ForegroundColor Cyan python -m pip install --target (Join-Path $pyDir 'Lib\site-packages') ` - --no-warn-script-location --disable-pip-version-check --only-binary=:all: ` - -r (Join-Path $root 'src\frontend\requirements.txt') + --no-warn-script-location --disable-pip-version-check --only-binary=:all: --no-deps --require-hashes ` + -r (Join-Path $root 'src\frontend\requirements.lock.txt') if ($LASTEXITCODE -ne 0) { throw 'Installation des dépendances Python échouée.' } # Allègement : modules Qt que le frontend n’importe jamais @@ -104,7 +167,8 @@ Get-ChildItem $site -Filter 'Qt63D*.dll' | Remove-Item -Force Get-ChildItem $site -Filter 'Qt6Quick3D*.dll' | Remove-Item -Force Get-ChildItem $site -Filter 'Qt6Charts*.dll' | Remove-Item -Force Get-ChildItem $site -Filter 'Qt6Multimedia*.dll' | Remove-Item -Force -Get-ChildItem $site -Filter '*.pyd' | Where-Object { $_.Name -match 'WebEngine|Pdf|3D|Quick3D|Charts|Multimedia|DataVisualization|Graphs' } | Remove-Item -Force +Get-ChildItem $site -Filter '*.pyd' | Where-Object { $_.Name -match 'WebEngine|Pdf|3D|Quick3D|Charts|Multimedia|DataVisualization|Graphs' +} | Remove-Item -Force # Lanceurs pip (pyside6-*.exe, f2py.exe...) : outils de developpement, jamais lances par l'application, # et chacun embarque le chemin de l'interpreteur de construction. @@ -122,6 +186,7 @@ Write-Host '→ Vérification du frontend dans le payload' -ForegroundColor Cyan $probe = Join-Path $frontDest '_probe.py' Set-Content -LiteralPath $probe -Encoding ASCII -Value @' import interaction, search, live_model, capture_native, wallpaper_theme, native_glass, surface_geometry +import win_native, bridge_client, windows_player from PySide6.QtQuick import QQuickWindow print('frontend ok') '@ @@ -133,19 +198,23 @@ if ($probeCode -ne 0) { throw "Le frontend ne s'importe pas depuis le payload : Write-Host 'Terminal Winlestia' -ForegroundColor Cyan $terminalDest = Join-Path $payload 'Winlestia-Terminal' New-Item -ItemType Directory -Force -Path $terminalDest | Out-Null -foreach ($file in 'Profile.ps1','Banner.ps1','Colors.ps1','ConfigurePackagedCmd.ps1','Configure-Policy.ps1','sync_theme.py','install.py','theme-template.json','winlestia.ico') { +foreach ($file in 'Profile.ps1','Banner.ps1','Colors.ps1','ConfigurePackagedCmd.ps1','Configure-Policy.ps1','sync_theme.py','install.py', + 'theme-template.json','winlestia.ico') { Copy-Item (Join-Path $root "src\terminal\$file") $terminalDest -Force } foreach ($dir in 'clink','clink-profile') { - robocopy (Join-Path $root "src\terminal\$dir") (Join-Path $terminalDest $dir) /E /NFL /NDL /NJH /NJS /NP /XF '*.log' '*history*' '*.tmp' 'clink_errorlevel*' | Out-Null + $terminalSrc = Join-Path $root "src\terminal\$dir" + $terminalDst = Join-Path $terminalDest $dir + robocopy $terminalSrc $terminalDst /E /NFL /NDL /NJH /NJS /NP /XF '*.log' '*history*' '*.tmp' 'clink_errorlevel*' | Out-Null } Write-Host '→ Compilation du setup' -ForegroundColor Cyan $iscc = @( "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "${env:ProgramFiles}\Inno Setup 6\ISCC.exe", "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe" ) | Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $iscc) { throw 'Inno Setup 6 introuvable : winget install JRSoftware.InnoSetup' } -& $iscc /Qp (Join-Path $PSScriptRoot 'Winlestia.iss') +& $iscc /Qp "/DAppVersion=$version" (Join-Path $PSScriptRoot 'Winlestia.iss') if ($LASTEXITCODE -ne 0) { throw 'Compilation Inno Setup échouée.' } -Write-Host "`nTerminé : build\out\Winlestia-Setup-0.9.1.exe" -ForegroundColor Green +Write-Host "`nTerminé : build\out\Winlestia-Setup-$version.exe" -ForegroundColor Green diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5cae631 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests src/frontend/tests src/terminal/tests +python_files = test_*.py diff --git a/ruff.toml b/ruff.toml deleted file mode 100644 index ddacf16..0000000 --- a/ruff.toml +++ /dev/null @@ -1,2 +0,0 @@ -line-length = 120 -exclude = ["build", "src/terminal/clink-profile"] diff --git a/src/backend/Media/AudioBars.cs b/src/backend/Audio/AudioBars.cs similarity index 62% rename from src/backend/Media/AudioBars.cs rename to src/backend/Audio/AudioBars.cs index c103d5c..6bee98d 100644 --- a/src/backend/Media/AudioBars.cs +++ b/src/backend/Audio/AudioBars.cs @@ -1,26 +1,18 @@ -// C# 12 / AudioBars.cs / Pure DSP for the dashboard visualizer: samples in, bar levels 0-1 out. No I/O, no audio API, nothing kept but the levels. +// C# 12 / AudioBars.cs / DSP pur pour le visualiseur du tableau de bord : échantillons en entrée, niveaux de barres 0-1 en sortie. +// Aucune +// E/S, aucune API audio, seuls les niveaux sont conservés. using System; -namespace PanneauxGlass.Media; +namespace PanneauxGlass; internal sealed class AudioBars { - internal const int Bars = 28, - FftSize = 1024; - const double LowHz = 50, - HighHz = 16000, - FloorDb = -60, - Rise = 0.6, - Fall = 0.12, - Epsilon = 0.004; + internal const int Bars = 28, FftSize = 1024; + const double LowHz = 50, HighHz = 16000, FloorDb = -60, Rise = 0.6, Fall = 0.12, Epsilon = 0.004; - readonly double[] re = new double[FftSize], - im = new double[FftSize], - window = new double[FftSize], - raw = new double[Bars], + readonly double[] re = new double[FftSize], im = new double[FftSize], window = new double[FftSize], raw = new double[Bars], levels = new double[Bars]; - readonly double[] cos = new double[FftSize / 2], - sin = new double[FftSize / 2]; + readonly double[] cos = new double[FftSize / 2], sin = new double[FftSize / 2]; internal AudioBars() { @@ -45,20 +37,21 @@ internal bool Silent } } - // Takes the last FftSize mono samples (zero padded when fewer) and updates the smoothed levels: fast rise, slow fall. + // Prend les FftSize derniers échantillons mono (complétés par des zéros s'il y en a moins) et met à jour les niveaux lissés : + // montée + // rapide, descente lente. internal void Update(ReadOnlySpan mono, int sampleRate) { Analyze(mono, sampleRate); for (int b = 0; b < Bars; b++) { - double target = raw[b], - v = levels[b]; + double target = raw[b], v = levels[b]; v += (target - v) * (target > v ? Rise : Fall); levels[b] = v < Epsilon && target == 0 ? 0 : Math.Clamp(v, 0, 1); } } - // No input: levels fall toward zero and reach exactly zero. + // Sans entrée : les niveaux retombent vers zéro et l'atteignent exactement. internal void Decay() { for (int b = 0; b < Bars; b++) @@ -70,14 +63,14 @@ internal void Decay() internal void Reset() => Array.Clear(levels); - // Band edges are logarithmic between LowHz and min(HighHz, Nyquist). Returns the unsmoothed levels (valid until the next call). + // Les bornes des bandes sont logarithmiques entre LowHz et min(HighHz, Nyquist). Renvoie les niveaux non lissés (valides jusqu'au + // prochain appel). internal ReadOnlySpan Analyze(ReadOnlySpan mono, int sampleRate) { Array.Clear(raw); if (sampleRate < 8000 || sampleRate > 384000) return raw; - int n = Math.Min(mono.Length, FftSize), - skip = mono.Length - n; + int n = Math.Min(mono.Length, FftSize), skip = mono.Length - n; bool any = false; for (int i = 0; i < FftSize; i++) { @@ -92,15 +85,12 @@ internal ReadOnlySpan Analyze(ReadOnlySpan mono, int sampleRate) if (!any) return raw; Fft(); - double binHz = (double)sampleRate / FftSize, - high = Math.Min(HighHz, sampleRate / 2.0 * 0.98), - ratio = high / LowHz; + double binHz = (double)sampleRate / FftSize, high = Math.Min(HighHz, sampleRate / 2.0 * 0.98), ratio = high / LowHz; for (int b = 0; b < Bars; b++) { - double f0 = LowHz * Math.Pow(ratio, (double)b / Bars), - f1 = LowHz * Math.Pow(ratio, (double)(b + 1) / Bars); - int lo = Math.Clamp((int)Math.Floor(f0 / binHz), 1, FftSize / 2 - 1), - hi = Math.Clamp((int)Math.Ceiling(f1 / binHz), lo + 1, FftSize / 2); + double f0 = LowHz * Math.Pow(ratio, (double)b / Bars), f1 = LowHz * Math.Pow(ratio, (double)(b + 1) / Bars); + int lo = Math.Clamp((int)Math.Floor(f0 / binHz), 1, FftSize / 2 - 1), hi = Math.Clamp((int)Math.Ceiling(f1 / binHz), lo + 1, + FftSize / 2); double peak = 0; for (int k = lo; k < hi; k++) peak = Math.Max(peak, Math.Sqrt(re[k] * re[k] + im[k] * im[k])); @@ -135,22 +125,18 @@ void Fft() } for (int len = 2; len <= FftSize; len <<= 1) { - int half = len / 2, - step = FftSize / len; + int half = len / 2, step = FftSize / len; for (int i = 0; i < FftSize; i += len) - for (int k = 0; k < half; k++) - { - double wr = cos[k * step], - wi = sin[k * step]; - int a = i + k, - c = a + half; - double tr = re[c] * wr - im[c] * wi, - ti = re[c] * wi + im[c] * wr; - re[c] = re[a] - tr; - im[c] = im[a] - ti; - re[a] += tr; - im[a] += ti; - } + for (int k = 0; k < half; k++) + { + double wr = cos[k * step], wi = sin[k * step]; + int a = i + k, c = a + half; + double tr = re[c] * wr - im[c] * wi, ti = re[c] * wi + im[c] * wr; + re[c] = re[a] - tr; + im[c] = im[a] - ti; + re[a] += tr; + im[a] += ti; + } } } } diff --git a/src/backend/Media/AudioBarsHost.cs b/src/backend/Audio/AudioBarsHost.cs similarity index 74% rename from src/backend/Media/AudioBarsHost.cs rename to src/backend/Audio/AudioBarsHost.cs index b6fa6e7..ae175c4 100644 --- a/src/backend/Media/AudioBarsHost.cs +++ b/src/backend/Audio/AudioBarsHost.cs @@ -1,6 +1,8 @@ -// C# 12 / AudioBarsHost.cs / WASAPI loopback of the default output device, only while the dashboard Media panel is visible. -// The sound is never stored or sent: samples go through a 2048-float ring, the worker turns them into 28 levels, the ring dies with the thread. -// Silence: loopback delivers no packet, the bars decay to zero, one idle frame is published, then the worker blocks (no timer, no wake-up). +// C# 12 / AudioBarsHost.cs / Boucle WASAPI du périphérique de sortie par défaut, uniquement tant que le panneau Média du tableau de +// bord est visible. Le son n'est jamais stocké ni envoyé : les échantillons passent par un anneau de 2048 flottants, le worker en tire +// 28 niveaux, l'anneau disparaît avec le thread. +// Silence : la boucle ne livre aucun paquet, les barres retombent à zéro, une image au repos est publiée, puis le worker se bloque (ni +// minuteur ni réveil). using System; using System.Diagnostics; using System.Threading; @@ -8,12 +10,11 @@ using NAudio.CoreAudioApi.Interfaces; using NAudio.Wave; -namespace PanneauxGlass.Media; +namespace PanneauxGlass; internal sealed class AudioBarsHost : IDisposable { - internal const int FrameMs = 33, - StopWaitMs = 500; + internal const int FrameMs = 33, StopWaitMs = 500; internal readonly record struct Frame(bool Running, long Frames, int Seq, bool Idle, int[] Bars); @@ -43,17 +44,12 @@ internal bool Running internal Frame Snapshot(bool withBars) { lock (gate) - return new( - run != null && !run.Stop.WaitOne(0), - frames, - seq, - idle, - withBars ? (int[])published.Clone() : Array.Empty() - ); + return new(run != null && !run.Stop.WaitOne(0), frames, seq, idle, withBars ? (int[])published.Clone() : Array.Empty()); } - // Called from the UI thread, never blocks: a stop only raises the flag. A start while the previous worker is still leaving - // hands it to the new worker, which waits for it (at most StopWaitMs) before opening its own capture, so two captures never overlap. + // Appelé depuis le thread UI, ne bloque jamais : un arrêt se contente de lever le drapeau. Un démarrage alors que le worker + // précédent finit encore le confie au nouveau worker, qui l'attend (au plus StopWaitMs) avant d'ouvrir sa propre capture : deux + // captures ne se chevauchent jamais. internal void SetActive(bool on) { lock (gate) @@ -74,11 +70,7 @@ internal void SetActive(bool on) { next.Thread.Start(); } - catch (Exception ex) - { - run = null; - Store.Log("Audio bars: " + ex.Message); - } + catch (Exception ex) { run = null; Store.Log("Audio bars: " + ex.Message); } } else if (run != null) { @@ -99,9 +91,7 @@ void Work(Run self, Run? prev) var fresh = new AutoResetEvent(false); var changed = new AutoResetEvent(false); object ringGate = new(); - int write = 0, - filled = 0, - rate = 0; + int write = 0, filled = 0, rate = 0; bool hasData = false; MMDeviceEnumerator? enumerator = null; MMDevice? device = null; @@ -116,22 +106,10 @@ void Close() device = null; if (c != null) { - try - { - c.StopRecording(); - } - catch { } - try - { - c.Dispose(); - } - catch { } - } - try - { - d?.Dispose(); + Safe.Try(() => c.StopRecording()); + Safe.Dispose(c); } - catch { } + Safe.Dispose(d); if (c == null) return; lock (ringGate) @@ -149,9 +127,8 @@ void Open() device = enumerator!.GetDefaultAudioEndpoint(DataFlow.Render, Role.Console); var c = new WasapiLoopbackCapture(device); var wf = c.WaveFormat; - bool isFloat = - wf.Encoding == WaveFormatEncoding.IeeeFloat - || (wf.Encoding == WaveFormatEncoding.Extensible && wf.BitsPerSample == 32); + bool isFloat = wf.Encoding == WaveFormatEncoding.IeeeFloat || (wf.Encoding == WaveFormatEncoding.Extensible + && wf.BitsPerSample == 32); if (wf.Channels < 1 || wf.Channels > 16 || !(isFloat || wf.BitsPerSample == 16)) { c.Dispose(); @@ -164,8 +141,7 @@ void Open() { try { - int step = channels * (isFloat ? 4 : 2), - count = e.BytesRecorded / step; + int step = channels * (isFloat ? 4 : 2), count = e.BytesRecorded / step; if (count <= 0) return; lock (ringGate) @@ -179,9 +155,7 @@ void Open() for (int ch = 0; ch < channels; ch++) { int at = i * step + ch * (isFloat ? 4 : 2); - sum += isFloat - ? BitConverter.ToSingle(e.Buffer, at) - : BitConverter.ToInt16(e.Buffer, at) / 32768.0; + sum += isFloat ? BitConverter.ToSingle(e.Buffer, at) : BitConverter.ToInt16(e.Buffer, at) / 32768.0; } ring[write] = (float)(sum / channels); write = (write + 1) % ring.Length; @@ -191,22 +165,15 @@ void Open() } fresh.Set(); } - catch { } + catch (Exception) + { + // Callback audio à haute fréquence : une trame illisible est simplement sautée. + } }; c.StartRecording(); capture = c; } - catch (Exception ex) - { - capture = null; - try - { - device?.Dispose(); - } - catch { } - device = null; - Store.Log("Audio bars: " + ex.Message); - } + catch (Exception ex) { capture = null; Safe.Dispose(device); device = null; Store.Log("Audio bars: " + ex.Message); } } try @@ -248,14 +215,12 @@ void Open() if (now < next && self.Stop.WaitOne((int)(next - now))) break; bool got = false; - int count = 0, - frameRate = 0; + int count = 0, frameRate = 0; lock (ringGate) { if (hasData && filled > 0) { - int n = Math.Min(filled, frame.Length), - start = (write - n + ring.Length) % ring.Length; + int n = Math.Min(filled, frame.Length), start = (write - n + ring.Length) % ring.Length; for (int i = 0; i < n; i++) frame[i] = ring[(start + i) % ring.Length]; got = true; @@ -282,22 +247,14 @@ void Open() } finally { - try - { - Close(); - } - catch { } - try + Safe.Try(() => Close()); + Safe.Try(() => { if (notifier != null) enumerator?.UnregisterEndpointNotificationCallback(notifier); - } - catch { } - try - { - enumerator?.Dispose(); - } - catch { } + + }); + Safe.Dispose(enumerator); fresh.Dispose(); changed.Dispose(); self.Exited = true; @@ -324,12 +281,10 @@ void Publish(Run self, AudioBars bars, ReadOnlySpan samples, int rate, bo sealed class Notifier : IMMNotificationClient { readonly AutoResetEvent changed; - internal Notifier(AutoResetEvent changed) { this.changed = changed; } - public void OnDefaultDeviceChanged(DataFlow flow, Role role, string defaultDeviceId) { if (flow == DataFlow.Render && role == Role.Console) @@ -338,17 +293,24 @@ public void OnDefaultDeviceChanged(DataFlow flow, Role role, string defaultDevic { changed.Set(); } - catch { } + catch (ObjectDisposedException) + { + // Signal déjà libéré à l'arrêt : plus personne à réveiller. + } } } - - public void OnDeviceStateChanged(string deviceId, DeviceState newState) { } - - public void OnDeviceAdded(string pwstrDeviceId) { } - - public void OnDeviceRemoved(string deviceId) { } - - public void OnPropertyValueChanged(string pwstrDeviceId, PropertyKey key) { } + public void OnDeviceStateChanged(string deviceId, DeviceState newState) + { + } + public void OnDeviceAdded(string pwstrDeviceId) + { + } + public void OnDeviceRemoved(string deviceId) + { + } + public void OnPropertyValueChanged(string pwstrDeviceId, PropertyKey key) + { + } } public void Dispose() => SetActive(false); diff --git a/src/backend/Media/AudioMixer.cs b/src/backend/Audio/AudioMixer.cs similarity index 72% rename from src/backend/Media/AudioMixer.cs rename to src/backend/Audio/AudioMixer.cs index 4e7ce7c..a491458 100644 --- a/src/backend/Media/AudioMixer.cs +++ b/src/backend/Audio/AudioMixer.cs @@ -7,53 +7,34 @@ using System.Text; using NAudio.CoreAudioApi; using NAudio.CoreAudioApi.Interfaces; +namespace PanneauxGlass; -namespace PanneauxGlass.Media; - -internal sealed record MixerApplication( - string id, - string name, - string path, - int pid, - double volume, - bool muted, - bool playing -); - +internal sealed record MixerApplication(string id, string name, string path, int pid, double volume, bool muted, bool playing); internal sealed record MixerDevice(string id, string name, bool selected, double volume, bool muted, int flow); - internal sealed class AudioMixer : IDisposable { - sealed record Session( - string Id, - string Name, - string Path, - int Pid, - AudioSessionControl Control, - AudioSessionInfo Info - ); - - readonly List sessions = new(); - readonly List endpoints = new(); - DateTime nextScan, - nextDeviceScan; - ProcessSnapshot processes = ProcessSnapshot.Capture(); - bool mediaPidActive; - - // Executable stem of the selected media source (for example "firefox") and its raw AUMID; either may be empty. - internal string MediaApp = ""; - internal string MediaAumid = ""; - internal double MediaPeak { get; private set; } - - // Root process of the matched audio sessions: process-loopback capture of this PID includes its child processes. - internal int MediaPid { get; private set; } - - // Highest volume (0-100) among the matched sessions, or -1 when no session matches. + sealed record Session(string Id, string Name, string Path, int Pid, AudioSessionControl Control, AudioSessionInfo Info); + readonly List sessions = new(); readonly List endpoints = new(); DateTime nextScan, nextDeviceScan; + ProcessSnapshot processes = ProcessSnapshot.Capture(); bool mediaPidActive; + // Nom d'exécutable (sans extension) de la source média choisie, par exemple "firefox", et son AUMID brut ; l'un ou l'autre peut être + // vide. + internal string MediaApp = ""; internal string MediaAumid = ""; internal double MediaPeak + { + get; private set; + } + // Processus racine des sessions audio correspondantes : la capture loopback de ce PID inclut ses processus enfants. + internal int MediaPid + { + get; private set; + } + // Volume le plus haut (0-100) parmi les sessions correspondantes, ou -1 si aucune ne correspond. internal double MediaVolume { get; private set; } = -1; - internal bool Running { get; private set; } + internal bool Running + { + get; private set; + } internal MixerApplication[] Applications { get; private set; } = Array.Empty(); internal MixerDevice[] Devices { get; private set; } = Array.Empty(); - internal void Refresh() { Running = true; @@ -70,8 +51,7 @@ internal void Refresh() foreach (var group in sessions.GroupBy(s => s.Id)) { double volume = 0; - bool muted = true, - playing = false; + bool muted = true, playing = false; int count = 0; foreach (var s in group) try @@ -92,7 +72,10 @@ internal void Refresh() playing |= state == AudioSessionState.AudioSessionStateActive; count++; } - catch (System.Runtime.InteropServices.COMException) { } + catch (System.Runtime.InteropServices.COMException) + { + // Session audio disparue pendant l'énumération : on passe à la suivante. + } var first = group.First(); if (count > 0) result.Add(new(first.Id, first.Name, first.Path, first.Pid, Math.Round(volume), muted, playing)); @@ -104,7 +87,6 @@ internal void Refresh() nextDeviceScan = DateTime.UtcNow.AddSeconds(1); } } - void Scan() { ClearSessions(); @@ -125,40 +107,33 @@ void Scan() if (control.State == AudioSessionState.AudioSessionStateExpired) continue; int pid = (int)control.GetProcessID; - string name = Strings.SystemSounds, - path = ""; + string name = "Sons système", path = ""; if (pid != 0) - { (name, path) = DescribeProcess(pid); - } - string key = - pid == 0 ? "system" - : !string.IsNullOrEmpty(path) ? path.ToLowerInvariant() - : "pid:" + pid; + string key = pid == 0 ? "system" : !string.IsNullOrEmpty(path) ? path.ToLowerInvariant() : "pid:" + pid; string id = "audio:" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key)))[..20]; string stem = System.IO.Path.GetFileNameWithoutExtension(path); if (stem.Length == 0) - { stem = processes.StemOf(pid); - } var ancestors = MediaSessionMatch.AncestorStems(pid, processes.ParentOf, processes.StemOf); var info = new AudioSessionInfo(pid, stem, ancestors, control.GetSessionIdentifier ?? ""); sessions.Add(new(id, name, path, pid, control, info)); keep = true; } - catch (System.Runtime.InteropServices.COMException) { } - finally + catch (System.Runtime.InteropServices.COMException) { - if (!keep) - control.Dispose(); + // Session audio disparue pendant l'énumération : on passe à la suivante. } + finally { if (!keep) control.Dispose(); } } } - catch (System.Runtime.InteropServices.COMException) { } + catch (System.Runtime.InteropServices.COMException) + { + // Session audio disparue pendant l'énumération : on passe à la suivante. + } } } - - // Process.MainModule fails for protected or already-exiting processes; QueryFullProcessImageName is a fallback. + // Process.MainModule échoue pour les processus protégés ou en cours de sortie ; QueryFullProcessImageName sert de repli. static (string Name, string Path) DescribeProcess(int pid) { string processName = ""; @@ -169,21 +144,15 @@ void Scan() processName = process.ProcessName; path = process.MainModule?.FileName ?? ""; } - catch (Exception ex) - when (ex - is InvalidOperationException - or ArgumentException - or NotSupportedException - or System.ComponentModel.Win32Exception - ) { } - if (path.Length == 0) + catch (Exception ex) when (ex is InvalidOperationException or ArgumentException or NotSupportedException + or System.ComponentModel.Win32Exception) { - path = TaskbarNative.ProcessImagePath((uint)pid); + // Processus inaccessible : on retombe sur le chemin d'image ci-dessous. } if (path.Length == 0) - { + path = TaskbarNative.ProcessImagePath((uint)pid); + if (path.Length == 0) return (processName.Length > 0 ? processName : "Application " + pid, ""); - } string fallbackName = processName.Length > 0 ? processName : System.IO.Path.GetFileNameWithoutExtension(path); try { @@ -194,7 +163,6 @@ or System.ComponentModel.Win32Exception return (fallbackName, path); } } - static MixerDevice[] ReadDevices() { using var enumerator = new MMDeviceEnumerator(); @@ -207,59 +175,50 @@ static MixerDevice[] ReadDevices() using var device = enumerator.GetDefaultAudioEndpoint((DataFlow)flow, Role.Multimedia); current = device.ID; } - catch { } + catch (System.Runtime.InteropServices.COMException) + { + // Aucun périphérique par défaut (pas de sortie audio) : la valeur reste vide. + } foreach (var device in enumerator.EnumerateAudioEndPoints((DataFlow)flow, DeviceState.Active)) using (device) try { var gain = device.AudioEndpointVolume; - result.Add( - new( - device.ID, - device.FriendlyName, - current == device.ID, - Math.Round(gain.MasterVolumeLevelScalar * 100), - gain.Mute, - flow - ) - ); + result.Add(new(device.ID, device.FriendlyName, current == device.ID, + Math.Round(gain.MasterVolumeLevelScalar * 100), + gain.Mute, flow)); + } + catch (System.Runtime.InteropServices.COMException) + { + // Périphérique retiré pendant l'énumération : ignoré. } - catch (System.Runtime.InteropServices.COMException) { } } return result.OrderByDescending(d => d.selected).ThenBy(d => d.name).ToArray(); } - bool MatchesMedia(Session session) { if (MediaApp.Length == 0 && MediaAumid.Length == 0) - { return false; - } return MediaSessionMatch.Matches(session.Info, new MediaTarget(MediaApp, MediaAumid)); } - - // Prefers a session that is actually producing audio; otherwise the first matched session. + // Préfère une session qui produit réellement du son ; sinon la première session correspondante. void TrackMediaPid(Session session, AudioSessionState state) { bool playing = state == AudioSessionState.AudioSessionStateActive; if (session.Pid == 0 || (MediaPid != 0 && (mediaPidActive || !playing))) - { return; - } MediaPid = MediaSessionMatch.RootPid(session.Pid, MediaApp, processes.ParentOf, processes.StemOf); mediaPidActive = playing; } - internal void SetMediaVolume(double value) { Refresh(); var ids = sessions.Where(MatchesMedia).Select(s => s.Id).Distinct().ToArray(); if (ids.Length == 0) - throw new InvalidOperationException(Strings.NoAudioSessionForPlayer); + throw new InvalidOperationException("Aucune session audio pour ce lecteur."); foreach (var id in ids) SetApplication(id, value, null); } - internal void SetApplication(string id, double? level, bool? muted) { int updated = 0; @@ -273,16 +232,18 @@ internal void SetApplication(string id, double? level, bool? muted) gain.Mute = muted.Value; updated++; } - catch (System.Runtime.InteropServices.COMException) { } + catch (System.Runtime.InteropServices.COMException) + { + // Session fermée entre l'énumération et le réglage : rien à régler. + } if (updated == 0) - throw new InvalidOperationException(Strings.AppNoLongerHasAudioSession); + throw new InvalidOperationException("Cette application n’a plus de session audio. Relance sa lecture."); Refresh(); } - internal void SetDevice(string id, double? level, bool? muted, bool select) { if (!Devices.Any(d => d.id == id)) - throw new InvalidOperationException(Strings.DeviceNoLongerConnected); + throw new InvalidOperationException("Ce périphérique n’est plus connecté."); if (select) AudioSwitch.SetDefault(id); else @@ -298,7 +259,6 @@ internal void SetDevice(string id, double? level, bool? muted, bool select) nextDeviceScan = DateTime.MinValue; Refresh(); } - void ClearSessions() { foreach (var session in sessions) @@ -308,7 +268,6 @@ void ClearSessions() device.Dispose(); endpoints.Clear(); } - internal void Stop() { if (!Running) @@ -319,6 +278,5 @@ internal void Stop() Running = false; nextScan = nextDeviceScan = DateTime.MinValue; } - public void Dispose() => Stop(); } diff --git a/src/backend/Media/AudioMixerHost.cs b/src/backend/Audio/AudioMixerHost.cs similarity index 73% rename from src/backend/Media/AudioMixerHost.cs rename to src/backend/Audio/AudioMixerHost.cs index 5d642e3..7bda49f 100644 --- a/src/backend/Media/AudioMixerHost.cs +++ b/src/backend/Audio/AudioMixerHost.cs @@ -1,40 +1,33 @@ -// C# 12 / AudioMixerHost.cs / Windows .NET 8. All mixer COM calls live on one background STA. +// C# 12 / AudioMixerHost.cs / Windows .NET 8. Tous les appels COM du mixeur vivent sur un unique STA d'arrière-plan. using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; - -namespace PanneauxGlass.Media; +namespace PanneauxGlass; internal sealed class AudioMixerHost : IDisposable { - readonly AutoResetEvent wake = new(false); - readonly ConcurrentQueue<(Action action, TaskCompletionSource done)> commands = new(); - readonly Thread thread; - volatile bool enabled, - disposed; + readonly AutoResetEvent wake = new(false); readonly ConcurrentQueue<(Action action, + TaskCompletionSource done)> commands = new(); readonly Thread thread; + volatile bool enabled, disposed; internal volatile MixerApplication[] Applications = Array.Empty(); internal volatile MixerDevice[] Devices = Array.Empty(); - internal volatile string MediaApp = "", - MediaAumid = ""; - internal double MediaPeak; - internal volatile int MediaPid; - internal double MediaVolume = -1; - + internal volatile string MediaApp = "", MediaAumid = ""; + internal double MediaPeak; internal volatile int MediaPid; internal double MediaVolume = -1; internal Task SetMediaVolume(double value) => Enqueue(m => m.SetMediaVolume(value)); - internal bool Running => enabled; - internal volatile bool NativeRunning, - Ready; - internal double LastReadMilliseconds { get; private set; } - + internal volatile bool NativeRunning, Ready; + internal double LastReadMilliseconds + { + get; private set; + } internal AudioMixerHost() { + // Nom historique conservé pour compatibilité (nom de thread vu dans les journaux). thread = new Thread(Loop) { IsBackground = true, Name = "Panneaux Audio" }; thread.SetApartmentState(ApartmentState.STA); thread.Start(); } - internal void SetActive(bool value) { if (enabled == value) @@ -48,13 +41,8 @@ internal void SetActive(bool value) } wake.Set(); } - - internal Task SetApplication(string id, double? level, bool? muted) => - Enqueue(m => m.SetApplication(id, level, muted)); - - internal Task SetDevice(string id, double? level, bool? muted, bool select) => - Enqueue(m => m.SetDevice(id, level, muted, select)); - + internal Task SetApplication(string id, double? level, bool? muted) => Enqueue(m => m.SetApplication(id, level, muted)); + internal Task SetDevice(string id, double? level, bool? muted, bool select) => Enqueue(m => m.SetDevice(id, level, muted, select)); Task Enqueue(Action action) { var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -67,7 +55,6 @@ Task Enqueue(Action action) wake.Set(); return done.Task; } - void Publish(AudioMixer mixer) { if (!enabled) @@ -79,7 +66,6 @@ void Publish(AudioMixer mixer) Devices = mixer.Devices; Ready = true; } - void Loop() { using var mixer = new AudioMixer(); @@ -91,17 +77,14 @@ void Loop() try { if (!enabled) - throw new InvalidOperationException(Strings.OpenMixerToAdjustApp); + throw new InvalidOperationException("Ouvre le mixeur pour régler une application."); if (!mixer.Running) mixer.Refresh(); command.action(mixer); Publish(mixer); command.done.SetResult(); } - catch (Exception ex) - { - command.done.SetException(ex); - } + catch (Exception ex) { command.done.SetException(ex); } if (enabled) { var watch = System.Diagnostics.Stopwatch.StartNew(); @@ -118,17 +101,11 @@ void Loop() NativeRunning = false; } } - catch (Exception ex) - { - Store.Log("Audio mixer: " + ex.Message); - mixer.Stop(); - } + catch (Exception ex) { Store.Log("Mixeur audio: " + ex.Message); mixer.Stop(); } wake.WaitOne(enabled ? 250 : Timeout.Infinite); - } - while (commands.TryDequeue(out var command)) + } while (commands.TryDequeue(out var command)) command.done.TrySetCanceled(); } - public void Dispose() { disposed = true; diff --git a/src/backend/Audio/AudioTools.cs b/src/backend/Audio/AudioTools.cs new file mode 100644 index 0000000..e16a069 --- /dev/null +++ b/src/backend/Audio/AudioTools.cs @@ -0,0 +1,251 @@ +// C# 12, fichier AudioTools.cs, cible Windows 11 / .NET 8 WPF, points de terminaison audio +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace PanneauxGlass; + +internal sealed record AudioDevice(string Id, string Name, bool Default, bool Muted); +[ComImport, Guid("f8679f50-850a-41cf-9c72-430f290290c8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IPolicyConfig +{ + [PreserveSig] int GetMixFormat([MarshalAs(UnmanagedType.LPWStr)] string id, IntPtr format); + [PreserveSig] int GetDeviceFormat([MarshalAs(UnmanagedType.LPWStr)] string id, bool isDefault, IntPtr format); + [PreserveSig] int ResetDeviceFormat([MarshalAs(UnmanagedType.LPWStr)] string id); + [PreserveSig] int SetDeviceFormat([MarshalAs(UnmanagedType.LPWStr)] string id, IntPtr endpoint, IntPtr mix); + [PreserveSig] int GetProcessingPeriod([MarshalAs(UnmanagedType.LPWStr)] string id, bool isDefault, IntPtr a, IntPtr b); + [PreserveSig] int SetProcessingPeriod([MarshalAs(UnmanagedType.LPWStr)] string id, IntPtr period); + [PreserveSig] int GetShareMode([MarshalAs(UnmanagedType.LPWStr)] string id, IntPtr mode); + [PreserveSig] int SetShareMode([MarshalAs(UnmanagedType.LPWStr)] string id, IntPtr mode); + [PreserveSig] int GetPropertyValue([MarshalAs(UnmanagedType.LPWStr)] string id, bool store, IntPtr key, IntPtr value); + [PreserveSig] int SetPropertyValue([MarshalAs(UnmanagedType.LPWStr)] string id, bool store, IntPtr key, IntPtr value); + [PreserveSig] int SetDefaultEndpoint([MarshalAs(UnmanagedType.LPWStr)] string id, int role); + [PreserveSig] int SetEndpointVisibility([MarshalAs(UnmanagedType.LPWStr)] string id, int visible); +} +internal static class AudioSwitch +{ + internal static List Devices(int flow) + { + using var devices = new NAudio.CoreAudioApi.MMDeviceEnumerator(); + var result = new List(); + string current = ""; + try + { + using var device = devices.GetDefaultAudioEndpoint((NAudio.CoreAudioApi.DataFlow)flow, NAudio.CoreAudioApi.Role.Multimedia); + current = device.ID; + } + catch (System.Runtime.InteropServices.COMException) + { + // Aucun périphérique par défaut (pas de sortie audio) : la valeur reste vide. + } + foreach (var device in devices.EnumerateAudioEndPoints((NAudio.CoreAudioApi.DataFlow)flow, NAudio.CoreAudioApi.DeviceState.Active)) + using (device) + { + result.Add(new(device.ID, device.FriendlyName, device.ID == current, flow == 1 && device.AudioEndpointVolume.Mute)); + } + return result.OrderByDescending(d => d.Default).ThenBy(d => d.Name, StringComparer.CurrentCultureIgnoreCase).ToList(); + } + internal static void SetDefault(string id) + { + var config = (IPolicyConfig)Activator.CreateInstance(Type.GetTypeFromCLSID(new Guid("870af99c-171d-4f9e-af0d-e63df40c2bc9")))!; + try + { + for (int role = 0; role <= 2; role++) + { + int hr = config.SetDefaultEndpoint(id, role); + if (hr != 0 && role == 0) + throw new COMException("Windows a refusé le changement de sortie.", hr); + } + } + finally { Marshal.ReleaseComObject(config); } + } + internal static void SetMicMute(bool mute) + { + using var devices = new NAudio.CoreAudioApi.MMDeviceEnumerator(); + using var device = devices.GetDefaultAudioEndpoint(NAudio.CoreAudioApi.DataFlow.Capture, NAudio.CoreAudioApi.Role.Multimedia); + device.AudioEndpointVolume.Mute = mute; + } + internal static bool MicMuted() + { + try + { + using var devices = new NAudio.CoreAudioApi.MMDeviceEnumerator(); + using var device = devices.GetDefaultAudioEndpoint(NAudio.CoreAudioApi.DataFlow.Capture, NAudio.CoreAudioApi.Role.Multimedia); + return device.AudioEndpointVolume.Mute; + } + catch { return false; } + } +} + +internal sealed class AudioWindow : GlassWindow +{ + readonly Coordinator owner; readonly StackPanel body = new(); readonly TextBlock status; + internal AudioWindow(Coordinator coordinator) : base("Winlestia — son et micro") + { + owner = coordinator; + Width = 560; + Height = Math.Min(640, SystemParameters.WorkArea.Height - 80); + WindowStartupLocation = WindowStartupLocation.CenterOwner; + Radius = 32; + ShowActivated = true; + ContentHost.Margin = new Thickness(26, 22, 26, 20); + ContentHost.RowDefinitions.Add(new() + { + Height = GridLength.Auto + }); + ContentHost.RowDefinitions.Add(new() + { + Height = new GridLength(1, GridUnitType.Star) + }); + ContentHost.RowDefinitions.Add(new() + { + Height = GridLength.Auto + }); + var header = new DockPanel(); + var close = UI.IconButton("\uE711", "Fermer", Close); + DockPanel.SetDock(close, Dock.Right); + header.Children.Add(close); + var titles = new StackPanel(); + titles.Children.Add(UI.Text("Le bon son, tout de suite.", 25, null, true)); + var sub = UI.Text("Change de casque, de micro ou coupe le micro sans passer par les réglages", 11, UI.Dim); + sub.TextWrapping = TextWrapping.Wrap; + sub.Margin = new Thickness(0, 6, 0, 0); + titles.Children.Add(sub); + header.Children.Add(titles); + ContentHost.Children.Add(header); + var scroll = new ScrollViewer + { + Content = body, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + Margin = new Thickness(0, 16, 0, 10), + }; + Grid.SetRow(scroll, 1); + ContentHost.Children.Add(scroll); + status = UI.Text("", 11, UI.Dim); + status.TextWrapping = TextWrapping.Wrap; + Grid.SetRow(status, 2); + ContentHost.Children.Add(status); + Loaded += (_, _) => { ApplyGlass(); Rebuild(); }; + KeyDown += (_, e) => { if (e.Key == Key.Escape) Close(); }; + } + void Rebuild() + { + body.Children.Clear(); + try + { + var outputs = AudioSwitch.Devices(0); + var inputs = AudioSwitch.Devices(1); + body.Children.Add(UI.Text("SORTIE", 10, UI.Brush("#ABD2E4F1"), true)); + if (outputs.Count == 0) + body.Children.Add(Note("Aucune sortie active détectée.")); + foreach (var device in outputs) + body.Children.Add(Row(device, "\uE7F5", () => Apply(device, false))); + body.Children.Add(UI.Text("MICRO", 10, UI.Brush("#ABD2E4F1"), true)); + if (inputs.Count == 0) + body.Children.Add(Note("Aucun microphone actif détecté.")); + foreach (var device in inputs) + body.Children.Add(Row(device, "\uE720", () => Apply(device, true))); + if (inputs.Count > 0) + { + var muted = inputs.FirstOrDefault(d => d.Default)?.Muted == true; + var toggle = UI.Button(muted ? "Réactiver le micro" : "Couper le micro", () => + { + try + { + AudioSwitch.SetMicMute(!muted); + Rebuild(); + owner.ShowToast(!muted ? "Micro coupé" : "Micro réactivé"); + } + catch (Exception ex) + { + status.Text = ex.Message; + } + }); + toggle.HorizontalAlignment = HorizontalAlignment.Left; + toggle.Margin = new Thickness(0, 6, 0, 4); + body.Children.Add(toggle); + } + var settings = UI.Button("Ouvrir les réglages Windows", () => owner.LaunchWindowsShortcut(new WindowsShortcut + { + Name = "Son", + Target = "ms-settings:sound" + })); + settings.HorizontalAlignment = HorizontalAlignment.Left; + settings.Margin = new Thickness(0, 14, 0, 0); + body.Children.Add(settings); + status.Text = "Le changement est immédiat pour toutes les applications."; + } + catch (Exception ex) + { + status.Text = "Réglage audio indisponible : " + ex.Message; + body.Children.Add(Note("Windows a refusé l’accès. Ouvre les réglages Windows pour changer de sortie.")); + } + } + FrameworkElement Row(AudioDevice device, string glyph, Action pick) + { + var button = UI.Button("", pick); + button.HorizontalContentAlignment = HorizontalAlignment.Stretch; + button.Padding = new Thickness(12, 10, 12, 10); + button.Margin = new Thickness(0, 4, 0, 0); + var grid = new Grid(); + grid.ColumnDefinitions.Add(new() + { + Width = new GridLength(30) + }); + grid.ColumnDefinitions.Add(new() + { + Width = new GridLength(1, GridUnitType.Star) + }); + grid.ColumnDefinitions.Add(new() + { + Width = GridLength.Auto + }); + var icon = UI.Glyph(glyph, 16); + icon.HorizontalAlignment = HorizontalAlignment.Left; + grid.Children.Add(icon); + var name = UI.Text(device.Name, 13, device.Default ? UI.White : UI.Dim); + name.TextTrimming = TextTrimming.CharacterEllipsis; + Grid.SetColumn(name, 1); + grid.Children.Add(name); + if (device.Default) + { + var check = UI.Glyph("\uE73E", 12); + check.Foreground = UI.Accent; + Grid.SetColumn(check, 2); + grid.Children.Add(check); + } + else if (device.Muted) + { + var muted = UI.Text("muet", 10, UI.Dim); + Grid.SetColumn(muted, 2); + grid.Children.Add(muted); + } + button.Content = grid; + button.ToolTip = device.Default ? "Sortie actuelle" : "Choisir cette sortie"; + System.Windows.Automation.AutomationProperties.SetName(button, (device.Default ? "Actuel : " : "Choisir ") + device.Name); + return button; + } + TextBlock Note(string text) + { + var note = UI.Text(text, 12, UI.Dim); + note.TextWrapping = TextWrapping.Wrap; + note.Margin = new Thickness(2, 4, 2, 8); + return note; + } + void Apply(AudioDevice device, bool input) + { + try + { + AudioSwitch.SetDefault(device.Id); + Rebuild(); + owner.ShowToast((input ? "Micro : " : "Sortie : ") + device.Name); + } + catch (Exception ex) { status.Text = ex.Message; } + } +} diff --git a/src/backend/Media/MediaController.cs b/src/backend/Audio/MediaController.cs similarity index 98% rename from src/backend/Media/MediaController.cs rename to src/backend/Audio/MediaController.cs index 92bd16a..69153c5 100644 --- a/src/backend/Media/MediaController.cs +++ b/src/backend/Audio/MediaController.cs @@ -9,7 +9,7 @@ using Windows.Media.Control; using Windows.Storage.Streams; -namespace PanneauxGlass.Media; +namespace PanneauxGlass; internal sealed record MediaSnapshot( bool Available, @@ -267,8 +267,8 @@ void SetSession(GlobalSystemMediaTransportControlsSession? next) return new( false, "", - Strings.NothingPlaying, - Strings.StartSpotifyOrAnotherAudioApp, + "Aucune lecture", + "Lance Spotify ou une autre application audio", false, 0, 0, @@ -343,7 +343,7 @@ void SetSession(GlobalSystemMediaTransportControlsSession? next) return new( true, source.DisplayName, - string.IsNullOrWhiteSpace(title) ? Strings.NowPlaying : title, + string.IsNullOrWhiteSpace(title) ? "Lecture en cours" : title, artist, playing, position, diff --git a/src/backend/Media/MediaSessionMatch.cs b/src/backend/Audio/MediaSessionMatch.cs similarity index 99% rename from src/backend/Media/MediaSessionMatch.cs rename to src/backend/Audio/MediaSessionMatch.cs index 77aebc7..7011ad2 100644 --- a/src/backend/Media/MediaSessionMatch.cs +++ b/src/backend/Audio/MediaSessionMatch.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using System.Linq; -namespace PanneauxGlass.Media; +namespace PanneauxGlass; /// An audio session as seen by the mixer. ExeStem may be empty when the image path is unreadable. internal sealed record AudioSessionInfo( diff --git a/src/backend/Media/MediaSourceIdentity.cs b/src/backend/Audio/MediaSourceIdentity.cs similarity index 99% rename from src/backend/Media/MediaSourceIdentity.cs rename to src/backend/Audio/MediaSourceIdentity.cs index 2de09aa..5a65192 100644 --- a/src/backend/Media/MediaSourceIdentity.cs +++ b/src/backend/Audio/MediaSourceIdentity.cs @@ -6,7 +6,7 @@ using System.Linq; using System.Text; -namespace PanneauxGlass.Media; +namespace PanneauxGlass; /// A resolved media source. is the raw, stable identifier. internal sealed record MediaSource(string Aumid, string DisplayName, string ExeStem); diff --git a/src/backend/Media/MediaTimeline.cs b/src/backend/Audio/MediaTimeline.cs similarity index 98% rename from src/backend/Media/MediaTimeline.cs rename to src/backend/Audio/MediaTimeline.cs index 80fc779..4879b55 100644 --- a/src/backend/Media/MediaTimeline.cs +++ b/src/backend/Audio/MediaTimeline.cs @@ -2,7 +2,7 @@ // Pure position/duration maths for media sessions, kept free of WinRT types so it can be unit tested. using System; -namespace PanneauxGlass.Media; +namespace PanneauxGlass; internal static class MediaTimeline { diff --git a/src/backend/Platform/OsdWatcher.cs b/src/backend/Audio/OsdWatcher.cs similarity index 52% rename from src/backend/Platform/OsdWatcher.cs rename to src/backend/Audio/OsdWatcher.cs index 65d1b5a..bac3115 100644 --- a/src/backend/Platform/OsdWatcher.cs +++ b/src/backend/Audio/OsdWatcher.cs @@ -1,28 +1,17 @@ -// C# 12 / OsdWatcher.cs / Windows .NET 8. Event-driven (no polling) default-output volume/mute change notifications for the Qt OSD. +// C# 12 / OsdWatcher.cs / Windows .NET 8. Notifications de volume/sourdine de la sortie par défaut pilotées par événements (sans +// sondage) pour l'OSD Qt. using System; using System.Management; using System.Windows.Threading; using NAudio.CoreAudioApi; using NAudio.CoreAudioApi.Interfaces; - -namespace PanneauxGlass.Platform; +namespace PanneauxGlass; internal sealed class OsdWatcher : IDisposable, IMMNotificationClient { - readonly Dispatcher dispatcher; - readonly Action changed; - readonly Action? extra; - MMDeviceEnumerator? devices; - MMDevice? endpoint; - MMDevice? mic; - ManagementEventWatcher? brightnessWatcher; - int lastBrightness = -1, - lastMic = -1; - bool lastMicMuted; - bool disposed; - int lastVolume = -1; - bool lastMuted; - + readonly Dispatcher dispatcher; readonly Action changed; readonly Action? extra; + MMDeviceEnumerator? devices; MMDevice? endpoint; MMDevice? mic; ManagementEventWatcher? brightnessWatcher; int lastBrightness = -1, + lastMic = -1; bool lastMicMuted; bool disposed; int lastVolume = -1; bool lastMuted; internal OsdWatcher(Dispatcher dispatcher, Action changed, Action? extra = null) { this.dispatcher = dispatcher; @@ -35,13 +24,9 @@ internal OsdWatcher(Dispatcher dispatcher, Action changed, Action endpoint.AudioEndpointVolume.OnVolumeNotification -= OnVolume); + Safe.Dispose(endpoint); endpoint = null; } - void AttachMic() { try @@ -92,49 +63,31 @@ void AttachMic() lastMicMuted = volume.Mute; volume.OnVolumeNotification += OnMic; } - catch (Exception ex) - { - DetachMic(); - Store.Log("OSD: microphone unavailable: " + ex.Message); - } + catch (Exception ex) { DetachMic(); Store.Log("OSD: micro indisponible: " + ex.Message); } } - void DetachMic() { if (mic == null) return; - try - { - mic.AudioEndpointVolume.OnVolumeNotification -= OnMic; - } - catch { } - try - { - mic.Dispose(); - } - catch { } + Safe.Try(() => mic.AudioEndpointVolume.OnVolumeNotification -= OnMic); + Safe.Dispose(mic); mic = null; } - void OnMic(AudioVolumeNotificationData data) { int volume = (int)Math.Round(Math.Clamp(data.MasterVolume, 0, 1) * 100); bool muted = data.Muted; - _ = dispatcher.BeginInvoke( - (Action)( - () => - { - if (disposed || (volume == lastMic && muted == lastMicMuted)) - return; - lastMic = volume; - lastMicMuted = muted; - extra?.Invoke("osd:mic:" + volume + ":" + (muted ? 1 : 0)); - } - ) - ); + _ = dispatcher.BeginInvoke((Action)(() => + { + if (disposed || (volume == lastMic && muted == lastMicMuted)) + return; + lastMic = volume; + lastMicMuted = muted; + extra?.Invoke("osd:mic:" + volume + ":" + (muted ? 1 : 0)); + })); } - - // Brightness: WMI event only (internal panels); external DDC-only screens raise nothing and nothing is shown. + // Luminosité : événement WMI uniquement (écrans internes) ; les écrans externes DDC seuls ne déclenchent rien et rien n'est + // affiché. void StartBrightness() { try @@ -146,66 +99,46 @@ void StartBrightness() try { int value = Math.Clamp(Convert.ToInt32(e.NewEvent["Brightness"]), 0, 100); - _ = dispatcher.BeginInvoke( - (Action)( - () => - { - if (disposed || value == lastBrightness) - return; - lastBrightness = value; - extra?.Invoke("osd:brightness:" + value); - } - ) - ); + _ = dispatcher.BeginInvoke((Action)(() => + { + if (disposed || value == lastBrightness) + return; + lastBrightness = value; + extra?.Invoke("osd:brightness:" + value); + })); + } + catch (Exception) + { + /* Événement WMI fréquent : une valeur illisible est ignorée. */ } - catch { } }; watcher.Start(); brightnessWatcher = watcher; } - catch (Exception ex) - { - brightnessWatcher = null; - Store.Log("OSD: WMI brightness unavailable: " + ex.Message); - } + catch (Exception ex) { brightnessWatcher = null; Store.Log("OSD: luminosité WMI indisponible: " + ex.Message); } } - void StopBrightness() { var watcher = brightnessWatcher; brightnessWatcher = null; if (watcher == null) return; - try - { - watcher.Stop(); - } - catch { } - try - { - watcher.Dispose(); - } - catch { } + Safe.Try(() => watcher.Stop()); + Safe.Dispose(watcher); } - void OnVolume(AudioVolumeNotificationData data) { int volume = (int)Math.Round(Math.Clamp(data.MasterVolume, 0, 1) * 100); bool muted = data.Muted; - _ = dispatcher.BeginInvoke( - (Action)( - () => - { - if (disposed || (volume == lastVolume && muted == lastMuted)) - return; - lastVolume = volume; - lastMuted = muted; - changed(volume, muted); - } - ) - ); + _ = dispatcher.BeginInvoke((Action)(() => + { + if (disposed || (volume == lastVolume && muted == lastMuted)) + return; + lastVolume = volume; + lastMuted = muted; + changed(volume, muted); + })); } - public void OnDefaultDeviceChanged(DataFlow flow, Role role, string defaultDeviceId) { if (role != Role.Multimedia) @@ -215,25 +148,24 @@ public void OnDefaultDeviceChanged(DataFlow flow, Role role, string defaultDevic else if (flow == DataFlow.Capture) _ = dispatcher.BeginInvoke((Action)AttachMic); } - - public void OnDeviceStateChanged(string deviceId, DeviceState newState) { } - - public void OnDeviceAdded(string pwstrDeviceId) { } - - public void OnDeviceRemoved(string deviceId) { } - - public void OnPropertyValueChanged(string pwstrDeviceId, PropertyKey key) { } - + public void OnDeviceStateChanged(string deviceId, DeviceState newState) + { + } + public void OnDeviceAdded(string pwstrDeviceId) + { + } + public void OnDeviceRemoved(string deviceId) + { + } + public void OnPropertyValueChanged(string pwstrDeviceId, PropertyKey key) + { + } public void Dispose() { if (disposed) return; disposed = true; - try - { - devices?.UnregisterEndpointNotificationCallback(this); - } - catch { } + Safe.Try(() => devices?.UnregisterEndpointNotificationCallback(this)); Detach(); DetachMic(); StopBrightness(); diff --git a/src/backend/Media/SystemAudio.cs b/src/backend/Audio/SystemAudio.cs similarity index 65% rename from src/backend/Media/SystemAudio.cs rename to src/backend/Audio/SystemAudio.cs index bad19bf..9effe31 100644 --- a/src/backend/Media/SystemAudio.cs +++ b/src/backend/Audio/SystemAudio.cs @@ -1,17 +1,12 @@ -// C# 12 / SystemAudio.cs / Windows .NET 8. Same Core Audio factory as mixer and spectrum. +// C# 12 / SystemAudio.cs / Windows .NET 8. Même fabrique Core Audio que le mixeur et le spectre. using System; using NAudio.CoreAudioApi; - -namespace PanneauxGlass.Media; +namespace PanneauxGlass; internal sealed record AudioSnapshot(bool Available, double Volume, bool Muted, string Device, string Error = ""); - internal sealed class SystemAudio : IDisposable { - MMDeviceEnumerator? devices; - MMDevice? endpoint; - string endpointId = ""; - + MMDeviceEnumerator? devices; MMDevice? endpoint; string endpointId = ""; internal AudioSnapshot Read() { try @@ -27,45 +22,32 @@ internal AudioSnapshot Read() else current.Dispose(); var volume = endpoint.AudioEndpointVolume; - return new( - true, - Math.Clamp(volume.MasterVolumeLevelScalar * 100, 0, 100), - volume.Mute, - endpoint.FriendlyName - ); - } - catch (Exception ex) - { - ReleaseEndpoint(); - return new(false, 0, false, Strings.NoAudioOutput, ex.Message); + return new(true, Math.Clamp(volume.MasterVolumeLevelScalar * 100, 0, 100), volume.Mute, endpoint.FriendlyName); } + catch (Exception ex) { ReleaseEndpoint(); return new(false, 0, false, "Aucune sortie audio", ex.Message); } } - internal void SetVolume(double percent) { if (endpoint == null) Read(); if (endpoint == null) - throw new InvalidOperationException("No audio output available."); + throw new InvalidOperationException("Aucune sortie audio disponible."); endpoint.AudioEndpointVolume.MasterVolumeLevelScalar = (float)Math.Clamp(percent / 100, 0, 1); } - internal void SetMute(bool value) { if (endpoint == null) Read(); if (endpoint == null) - throw new InvalidOperationException("No audio output available."); + throw new InvalidOperationException("Aucune sortie audio disponible."); endpoint.AudioEndpointVolume.Mute = value; } - void ReleaseEndpoint() { endpoint?.Dispose(); endpoint = null; endpointId = ""; } - public void Dispose() { ReleaseEndpoint(); diff --git a/src/backend/Media/WindowsSpectrum.cs b/src/backend/Audio/WindowsSpectrum.cs similarity index 71% rename from src/backend/Media/WindowsSpectrum.cs rename to src/backend/Audio/WindowsSpectrum.cs index 48f1b01..d35fa27 100644 --- a/src/backend/Media/WindowsSpectrum.cs +++ b/src/backend/Audio/WindowsSpectrum.cs @@ -1,35 +1,24 @@ -// C# 12 / WindowsSpectrum.cs / Windows 11. FFT of the selected process tree, never global output. +// C# 12 / WindowsSpectrum.cs / Windows 11. FFT de l'arbre de processus choisi, jamais de la sortie globale. using System; -using System.Runtime.InteropServices; using System.Threading; +using System.Runtime.InteropServices; using NAudio.Dsp; - -namespace PanneauxGlass.Media; +namespace PanneauxGlass; internal sealed class WindowsSpectrum : IDisposable { + [DllImport("ProcessAudio.dll", CallingConvention = CallingConvention.Cdecl)] static extern IntPtr AudioCreate(uint pid); + [DllImport("ProcessAudio.dll", CallingConvention = CallingConvention.Cdecl)] static extern void AudioDestroy(IntPtr handle); [DllImport("ProcessAudio.dll", CallingConvention = CallingConvention.Cdecl)] - static extern IntPtr AudioCreate(uint pid); - - [DllImport("ProcessAudio.dll", CallingConvention = CallingConvention.Cdecl)] - static extern void AudioDestroy(IntPtr handle); - - [DllImport("ProcessAudio.dll", CallingConvention = CallingConvention.Cdecl)] - static extern int AudioRead(IntPtr handle, [Out] float[] data, int count, out uint serial); - - readonly object gate = new(); - readonly AutoResetEvent wake = new(false); - readonly Thread thread; - volatile int requested; - volatile bool disposed; - double[] values = new double[48]; - + static extern int AudioRead(IntPtr handle, + [Out] float[] data, int count, out uint serial); + readonly object gate = new(); readonly AutoResetEvent wake = new(false); readonly Thread thread; volatile int requested; + volatile bool disposed; double[] values = new double[48]; internal WindowsSpectrum() { thread = new Thread(Loop) { IsBackground = true, Name = "Winlestia process FFT" }; thread.Start(); } - internal double[] Values { get @@ -38,9 +27,7 @@ internal double[] Values return (double[])values.Clone(); } } - internal void Start() => SetTarget(Environment.ProcessId); - internal void SetTarget(int pid) { if (requested == pid) @@ -48,14 +35,12 @@ internal void SetTarget(int pid) requested = pid; wake.Set(); } - internal void Stop() { SetTarget(0); lock (gate) values = new double[48]; } - void Loop() { IntPtr capture = IntPtr.Zero; @@ -76,11 +61,7 @@ void Loop() previous = 0; } var next = new double[48]; - if ( - capture != IntPtr.Zero - && AudioRead(capture, pcm, pcm.Length, out uint serial) == pcm.Length - && serial != previous - ) + if (capture != IntPtr.Zero && AudioRead(capture, pcm, pcm.Length, out uint serial) == pcm.Length && serial != previous) { previous = serial; for (int i = 0; i < 2048; i++) @@ -91,8 +72,8 @@ void Loop() FastFourierTransform.FFT(true, 11, fft); for (int bar = 0; bar < 48; bar++) { - int lo = Math.Max(1, (int)(40 * Math.Pow(400, bar / 48d) * 2048 / 44100)), - hi = Math.Min(1024, (int)(40 * Math.Pow(400, (bar + 1) / 48d) * 2048 / 44100) + 1); + int lo = Math.Max(1, (int)(40 * Math.Pow(400, bar / 48d) * 2048 / 44100)), hi = Math.Min(1024, + (int)(40 * Math.Pow(400, (bar + 1) / 48d) * 2048 / 44100) + 1); double peak = 0; for (int bin = lo; bin < hi; bin++) peak = Math.Max(peak, Math.Sqrt(fft[bin].X * fft[bin].X + fft[bin].Y * fft[bin].Y)); @@ -105,17 +86,9 @@ void Loop() wake.WaitOne(current > 0 ? 25 : Timeout.Infinite); } } - catch (Exception ex) - { - Store.Log("Process FFT: " + ex.Message); - } - finally - { - if (capture != IntPtr.Zero) - AudioDestroy(capture); - } + catch (Exception ex) { Store.Log("Process FFT: " + ex.Message); } + finally { if (capture != IntPtr.Zero) AudioDestroy(capture); } } - public void Dispose() { disposed = true; diff --git a/src/backend/Bridge/BridgeActionTable.cs b/src/backend/Bridge/BridgeActionTable.cs new file mode 100644 index 0000000..d3e9dd1 --- /dev/null +++ b/src/backend/Bridge/BridgeActionTable.cs @@ -0,0 +1,82 @@ +// language: C# 12, file: BridgeActionTable.cs, target: Windows 11 / .NET 8, noyau testable du dispatch des actions du pont +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +namespace PanneauxGlass; + +// Table nom -> gestionnaire, construite par réflexion sur les méthodes [BridgeAction]. Générique et sans dépendance au bureau : +// le pont réel l'instancie avec QtShellBridge, les tests avec un propriétaire factice. +internal sealed class BridgeActionTable where TOwner : class +{ + readonly IReadOnlyDictionary> handlers; + + BridgeActionTable(IReadOnlyDictionary> handlers) => this.handlers = handlers; + + internal IReadOnlyCollection Names => handlers.Keys.ToArray(); + + // On balaie tous les modificateurs (statique, publique comprises) : une méthode marquée mais hors du contrat serait sinon + // ignorée sans bruit et l'action disparaîtrait. Toute anomalie lève ici, donc dès la construction du pont. + internal static BridgeActionTable Build(Type type) + { + const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly; + var table = new Dictionary>(StringComparer.Ordinal); + foreach (var method in type.GetMethods(all)) + { + var names = method.GetCustomAttributes().Select(a => a.Name).ToArray(); + if (names.Length == 0) + continue; + string where = type.Name + "." + method.Name; + var parameters = method.GetParameters(); + if (method.IsStatic || method.IsGenericMethodDefinition || parameters.Length != 1 + || parameters[0].ParameterType != typeof(BridgeCall) || parameters[0].IsOut || parameters[0].ParameterType.IsByRef) + throw new InvalidOperationException("Signature de [BridgeAction] non prise en charge : " + where + + " (attendu : méthode d'instance (BridgeCall))."); + Func handler; + if (method.ReturnType == typeof(Task)) + handler = method.CreateDelegate>(); + else if (method.ReturnType == typeof(void)) + { + // Un async void échapperait à l'await du dispatch : l'exception partirait sur le thread du pool au lieu du notice. + if (method.GetCustomAttribute() != null) + throw new InvalidOperationException("[BridgeAction] async void interdit (renvoyer Task) : " + where); + var sync = method.CreateDelegate>(); + handler = (owner, call) => + { + sync(owner, call); + return Task.CompletedTask; + }; + } + else + throw new InvalidOperationException("[BridgeAction] doit renvoyer void ou Task : " + where); + foreach (string name in names) + if (!table.TryAdd(name, handler)) + throw new InvalidOperationException("Action de pont en double : « " + name + " » (" + where + ")."); + } + return new BridgeActionTable(table); + } + + // Une commande inconnue est ignorée : la réponse porte simplement l'état courant. + internal Task Dispatch(TOwner owner, string command, BridgeCall call) + => handlers.TryGetValue(command, out var handler) ? handler(owner, call) : Task.CompletedTask; + + // Exécute l'action et renvoie la réponse brute éventuelle (sondes rapides) ; null = il faut assembler la réponse d'état. + // Une exception du gestionnaire devient le notice, et les sorties déjà posées dans `call` survivent. La réponse brute + // posée avant l'exception est écartée : l'appelant doit alors voir l'état et le notice. + internal async Task Run(TOwner owner, string command, BridgeCall call, Action log) + { + try + { + await Dispatch(owner, command, call); + return call.RawReply; + } + catch (Exception ex) + { + call.Notice = ex.Message; + log("Commande Qt: " + ex.Message); + return null; + } + } +} diff --git a/src/backend/Bridge/BridgePorts.cs b/src/backend/Bridge/BridgePorts.cs new file mode 100644 index 0000000..9b845a1 --- /dev/null +++ b/src/backend/Bridge/BridgePorts.cs @@ -0,0 +1,79 @@ +// language: C# 12, file: BridgePorts.cs, target: Windows 11 / .NET 8, appels système que les actions du pont déclenchent +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Windows.Devices.Radios; +namespace PanneauxGlass; + +// Second côté de la couture du pont : tout ce qui touche l'OS, découpé en ports cohérents pour que chaque test ne doive doubler +// que ce qu'il exerce. L'implémentation de production (QtShellBridge.System.cs) délègue aux services réels ; les tests fournissent +// un journal. Les noms en minuscules des vues ci-dessous sont ceux du JSON envoyé au frontend : ne pas les renommer. +internal sealed record PaletteWindow(long hwnd, string title, string exe); +internal sealed record WallpaperDisplayView(int id, string name, int width, int height, bool primary, string path); +internal sealed record WallpaperPickerView(WallpaperDisplayView[] displays, string[] animated); + +// Volume, mixeur, lecture multimédia, spectre et dictée (capture du micro). +internal interface IAudioPort +{ + AudioSnapshot ReadAudio(); + void SetVolume(double percent); + void SetMute(bool muted); + Task SetMediaVolume(double value); + Task SetApplication(string id, double? level, bool? muted); + Task SetDevice(string id, double? level, bool? muted, bool select); + Task MediaCommand(string action, double position); + double[] SpectrumValues(); + + DictationSnapshot DictationState(); + void BeginDictationCapture(); + void EndDictationCapture(); +} + +internal interface IWifiPort +{ + Task WifiConnect(string ssid, string? password); + Task WifiForget(string ssid); + void OpenWifiPortal(); + void OpenWifiSettings(bool location); + Task ToggleRadio(RadioKind kind); + Task ReadConnections(); +} + +// Bureaux virtuels et fenêtres ouvertes. +internal interface IDesktopPort +{ + void CloseDesktop(int index); + void NewDesktop(); + void SwitchDesktop(int target, int relative); + bool ActivateApplication(long hwnd); + IEnumerable PaletteWindows(); +} + +internal interface IWallpaperPort +{ + void NotifyWallpaper(); + (int code, string text, string[] animated) ApplyWallpaper(string path, int display, bool light); + WallpaperPickerView WallpaperPicker(); + string? CaptureWallpaper(Native.Rect? target); +} + +// Luminosité des écrans (DDC/CI). +internal interface IDisplayPort +{ + Task SetBrightnessAtCursor(double level); + Task SetDisplay(int id, double level); + IReadOnlyList Displays(); +} + +// Le reste du shell : corbeille, boîtes de dialogue, presse-papiers, arrêt, Explorateur, lancement de processus. +internal interface IShellPort +{ + void EmptyRecycleBin(); + void OpenFilesHandler(); + void SetClipboardText(string text); + string? PickFolder(); + void Shutdown(); + void SyncExplorerPalette(string accent, string background, string text); + string RestoreExplorer(); + void StartProcess(ProcessStartInfo info); +} diff --git a/src/backend/Bridge/CliCommands.cs b/src/backend/Bridge/CliCommands.cs new file mode 100644 index 0000000..62b9388 --- /dev/null +++ b/src/backend/Bridge/CliCommands.cs @@ -0,0 +1,224 @@ +// C# 12 / CliCommands.cs / Analyse pure et liste blanche de la ligne de commande `winlestia`. Aucune E/S hors vérification de fichier +// injectée. +using System; +using System.IO; +using System.Linq; + +namespace PanneauxGlass; + +internal enum CliKind +{ + Help, Status, Panel, DesktopNext, DesktopPrev, DesktopNew, DesktopGo, Wallpaper, WallpaperList, Palette, FindWindows, FindFiles, + Brightness, BrightnessSet, Visualizer, Quiet, Wifi +} + +internal sealed record CliCommand(CliKind Kind, string Arg = "", int Number = 0, int Value = 0); + +internal static class CliCommands +{ + // Explicit allow-list. Nothing here shuts down, restarts, signs out or deletes anything. + internal static readonly string[] Panels = { "settings", "clipboard", "tools", "audio", "mixer", "capture", "palette", "dashboard" }; + internal static readonly string[] ImageExtensions = { ".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp" }; + internal const int MaxArgs = 4; + + internal const string HelpText = + "Usage: winlestia \n" + + " status print the shell state as JSON\n" + + " panel open a panel: settings, clipboard, tools, audio, mixer, capture, palette, dashboard\n" + + " desktop next|prev|new| switch virtual desktop (n starts at 1) or create one\n" + + " wallpaper set the wallpaper to an existing image file\n" + + " wallpaper --display set it on one display only (ids from 'wallpaper list')\n" + + " wallpaper list print {\"displays\":[{\"id\":n,\"name\":s,\"path\":s}],\"animated\":[s]}\n" + + " (animated wallpaper software running)\n" + + " palette open the quick command palette\n" + + " find windows print {\"count\":n} for the open windows (no titles)\n" + + " find files print {\"count\":n,\"indexAvailable\":b} for the Windows index (no paths)\n" + + " brightness print the displays as JSON [{\"id\":n,\"name\":s,\"value\":0-100,\"available\":b}]\n" + + " brightness <0-100> set the brightness of one display (DDC/CI)\n" + + " visualizer print {\"running\":b,\"frames\":n,\"panelOpen\":b,\"wanted\":b} for the dashboard audio bars\n" + + " (frames since the last start)\n" + + " quiet print {\"quiet\":b,\"forced\":\"auto|on|off\",\"detected\":b} for the fullscreen quiet mode\n" + + " quiet on|off|auto force the quiet mode without a fullscreen window\n" + + " (not saved: back to auto when the shell restarts)\n" + + " wifi print {\"available\":b,\"connected\":b,\"networks\":n,\"reason\":s} for the Wi-Fi\n" + + " (read-only, no network name)\n" + + " help show this text\n" + + "Exit codes: 0 success, 1 refused by the shell, 2 unknown command or bad argument, 3 shell not running."; + + internal static bool TryParse(string[]? args, out CliCommand? command, out string error) + { + command = null; + error = ""; + if (args == null || args.Length == 0) + { + command = new(CliKind.Help); + return true; + } + if (args.Length > MaxArgs) + { + error = "Too many arguments."; + return false; + } + string name = (args[0] ?? "").ToLowerInvariant(); + switch (name) + { + case "help" or "--help" or "-h" or "/?" when args.Length == 1: + command = new(CliKind.Help); + return true; + case "status" when args.Length == 1: + command = new(CliKind.Status); + return true; + case "visualizer" when args.Length == 1: + command = new(CliKind.Visualizer); + return true; + case "palette" when args.Length == 1: + command = new(CliKind.Palette); + return true; + case "wifi" when args.Length == 1: + command = new(CliKind.Wifi); + return true; + case "quiet" when args.Length == 1: + command = new(CliKind.Quiet); + return true; + case "quiet" when args.Length == 2: + { + if (!QuietPolicy.TryParse(args[1], out var forced)) + { + error = "Quiet mode must be on, off or auto."; + return false; + } + command = new(CliKind.Quiet, QuietPolicy.Label(forced)); + return true; + } + case "panel" when args.Length == 2: + { + string panel = (args[1] ?? "").ToLowerInvariant(); + if (Array.IndexOf(Panels, panel) < 0) + { + error = "Unknown panel '" + Clip(args[1]) + "'. Known: " + string.Join(", ", Panels) + "."; + return false; + } + command = new(CliKind.Panel, panel); + return true; + } + case "desktop" when args.Length == 2: + { + string target = (args[1] ?? "").ToLowerInvariant(); + switch (target) + { + case "next": + command = new(CliKind.DesktopNext); + return true; + case "prev": + case "previous": + command = new(CliKind.DesktopPrev); + return true; + case "new": + command = new(CliKind.DesktopNew); + return true; + } + if (target.Length is >= 1 and <= 2 && target.All(char.IsAsciiDigit) && int.Parse(target) is >= 1 and <= 20) + { + command = new(CliKind.DesktopGo, "", int.Parse(target)); + return true; + } + error = "Desktop must be next, prev, new or a number from 1 to 20."; + return false; + } + case "find" when args.Length == 2 && (args[1] ?? "").ToLowerInvariant() == "windows": + command = new(CliKind.FindWindows); + return true; + case "find" when args.Length >= 3 && (args[1] ?? "").ToLowerInvariant() == "files": + { + string term = string.Join(" ", args.Skip(2)).Trim(); + if (term.Length is < 2 or > 64 || term.Any(char.IsControl)) + { + error = "Search term must have 2 to 64 characters."; + return false; + } + command = new(CliKind.FindFiles, term); + return true; + } + case "brightness" when args.Length == 1: + command = new(CliKind.Brightness); + return true; + case "brightness" when args.Length == 3: + { + string id = args[1] ?? "", value = args[2] ?? ""; + if (id.Length is < 1 or > 2 || !id.All(char.IsAsciiDigit) || int.Parse(id) < 1) + { + error = "Display id must be a number from 1 to 99 (see 'winlestia brightness')."; + return false; + } + if (value.Length is < 1 or > 3 || !value.All(char.IsAsciiDigit) || int.Parse(value) > 100) + { + error = "Brightness must be a whole number from 0 to 100."; + return false; + } + command = new(CliKind.BrightnessSet, "", int.Parse(id), int.Parse(value)); + return true; + } + case "wallpaper" when args.Length == 2 && (args[1] ?? "").ToLowerInvariant() == "list": + command = new(CliKind.WallpaperList); + return true; + case "wallpaper" when args.Length >= 2 && (args[1] ?? "").ToLowerInvariant() == "--display": + error = args.Length == 2 ? "--display needs an image path first, then a display id: wallpaper --display ." + : "--display must come after the image path: wallpaper --display ."; + return false; + case "wallpaper" when args.Length == 3 && (args[2] ?? "").ToLowerInvariant() == "--display": + error = "--display needs a display id (see 'winlestia wallpaper list')."; + return false; + case "wallpaper" when args.Length == 2: + command = new(CliKind.Wallpaper, args[1] ?? ""); + return true; + case "wallpaper" when args.Length == 4 && (args[2] ?? "").ToLowerInvariant() == "--display": + { + string id = args[3] ?? ""; + if (id.Length is < 1 or > 2 || !id.All(char.IsAsciiDigit) || int.Parse(id) < 1) + { + error = "Display id must be a number from 1 to 99 (see 'winlestia wallpaper list')."; + return false; + } + command = new(CliKind.Wallpaper, args[1] ?? "", int.Parse(id)); + return true; + } + } + error = "Unknown command or wrong arguments: '" + Clip(name) + "'. Try 'winlestia help'."; + return false; + } + + // Chemin local absolu, extension d'image, fichier existant. Les chemins UNC et périphériques sont refusés + // (un script ne doit pas déclencher d'authentification réseau). + internal static bool ValidWallpaper(string? path, Func fileExists, out string error) + { + error = ""; + if (string.IsNullOrWhiteSpace(path) || path.Length > 259) + { + error = "Wallpaper path is empty or too long."; + return false; + } + if (path.StartsWith(@"\\") || path.StartsWith("//") || path.IndexOf('\0') >= 0 || path.IndexOf(':', 2) >= 0 + || !Path.IsPathFullyQualified(path)) + { + error = "Wallpaper path must be a local absolute path."; + return false; + } + if (Array.IndexOf(ImageExtensions, Path.GetExtension(path).ToLowerInvariant()) < 0) + { + error = "Wallpaper must be a .jpg, .jpeg, .png, .bmp, .gif or .webp file."; + return false; + } + if (!fileExists(path)) + { + error = "Wallpaper file not found."; + return false; + } + return true; + } + + static string Clip(string? text) + { + text ??= ""; + return text.Length > 40 ? text[..40] : text; + } +} diff --git a/src/backend/Shell/CliServer.cs b/src/backend/Bridge/CliServer.cs similarity index 59% rename from src/backend/Shell/CliServer.cs rename to src/backend/Bridge/CliServer.cs index ba60b34..ac475e1 100644 --- a/src/backend/Shell/CliServer.cs +++ b/src/backend/Bridge/CliServer.cs @@ -1,4 +1,4 @@ -// C# 12 / CliServer.cs / Local named pipe for the `winlestia` command line. Async wait for a connection only: no timer, no polling. +// C# 12 / CliServer.cs / Pipe nommé local de la ligne de commande `winlestia`. Attente asynchrone de connexion : ni minuteur ni sondage. using System; using System.IO; using System.IO.Pipes; @@ -9,12 +9,15 @@ using System.Threading; using System.Threading.Tasks; -namespace PanneauxGlass.Shell; +namespace PanneauxGlass; internal sealed class CliServer : IDisposable { internal const string PipeName = "WinlestiaCli"; const int MaxRequestChars = 4096; + static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5); + const double BackoffBaseMs = 500, BackoffMaxMs = 30000; + const int BackoffMaxDoublings = 10; readonly CancellationTokenSource cancellation = new(); readonly Func> run; @@ -24,30 +27,18 @@ internal CliServer(Func> run) _ = Task.Run(Serve); } - // Only the current user: the DACL holds a single entry for the user's SID. (CurrentUserOnly cannot be combined with an explicit PipeSecurity; the client uses it to check the server's owner.) + // Utilisateur courant seulement : la DACL ne contient que son SID. CurrentUserOnly est incompatible avec un + // PipeSecurity explicite ; le client s'en sert pour vérifier le propriétaire du serveur. static NamedPipeServerStream Create(bool first) { var security = new PipeSecurity(); - security.AddAccessRule( - new PipeAccessRule( - WindowsIdentity.GetCurrent().User!, - PipeAccessRights.ReadWrite | PipeAccessRights.CreateNewInstance, - AccessControlType.Allow - ) - ); - return NamedPipeServerStreamAcl.Create( - PipeName, - PipeDirection.InOut, - 1, - PipeTransmissionMode.Byte, - PipeOptions.Asynchronous | (first ? PipeOptions.FirstPipeInstance : PipeOptions.None), - 0, - 0, - security - ); + security.AddAccessRule(new PipeAccessRule(WindowsIdentity.GetCurrent().User!, + PipeAccessRights.ReadWrite | PipeAccessRights.CreateNewInstance, AccessControlType.Allow)); + return NamedPipeServerStreamAcl.Create(PipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | ( + first ? PipeOptions.FirstPipeInstance : PipeOptions.None), 0, 0, security); } - // Reads one line of at most MaxRequestChars characters, in bounded blocks. Returns null when the line is missing or too long. + // Lit une ligne d'au plus MaxRequestChars caractères, par blocs bornés. Renvoie null si la ligne manque ou est trop longue. static async Task ReadBounded(StreamReader reader, CancellationToken token) { var sb = new StringBuilder(); @@ -83,7 +74,7 @@ async Task Serve() lastMessage = ""; await server.WaitForConnectionAsync(cancellation.Token); using var limit = CancellationTokenSource.CreateLinkedTokenSource(cancellation.Token); - limit.CancelAfter(TimeSpan.FromSeconds(5)); // a stalled client must not hold the single instance + limit.CancelAfter(RequestTimeout); // un client bloqué ne doit pas retenir l'unique instance using var reader = new StreamReader(server, new UTF8Encoding(false), false, 256, true); string? line = await ReadBounded(reader, limit.Token); string reply; @@ -92,31 +83,26 @@ async Task Serve() else reply = await Handle(line); using var writer = new StreamWriter(server, new UTF8Encoding(false), 4096, true) { AutoFlush = true }; - await writer.WriteLineAsync(reply.AsMemory(), limit.Token); // no WaitForPipeDrain: a client that never reads must not block the loop - } - catch (OperationCanceledException) - { - if (cancellation.IsCancellationRequested) - break; + // Pas de WaitForPipeDrain : un client qui ne lit jamais ne doit pas bloquer la boucle. + await writer.WriteLineAsync(reply.AsMemory(), limit.Token); } + catch (OperationCanceledException) { if (cancellation.IsCancellationRequested) break; } catch (Exception ex) { - // Progressive back-off up to 30 s (e.g. pipe name already taken); logged only when the message changes. + // Attente croissante jusqu'à 30 s (ex. nom de pipe déjà pris) ; journalisé seulement quand le message change. failures++; if (ex.Message != lastMessage) { lastMessage = ex.Message; Store.Log("CLI: " + ex.Message); } - var wait = TimeSpan.FromMilliseconds(Math.Min(30000, 500.0 * Math.Pow(2, Math.Min(failures - 1, 10)))); + var wait = TimeSpan.FromMilliseconds(Math.Min(BackoffMaxMs, BackoffBaseMs * Math.Pow(2, Math.Min(failures - 1, + BackoffMaxDoublings)))); try { await Task.Delay(wait, cancellation.Token); } - catch - { - break; - } + catch { break; } } } } @@ -128,30 +114,17 @@ async Task Handle(string line) { args = JsonSerializer.Deserialize(line); } - catch (JsonException) - { - return Reply(2, "Invalid request."); - } + catch (JsonException) { return Reply(2, "Invalid request."); } if (!CliCommands.TryParse(args, out var command, out var error)) return Reply(2, error); try { - var (code, text) = await run(command!).WaitAsync(TimeSpan.FromSeconds(5), cancellation.Token); + var (code, text) = await run(command!).WaitAsync(RequestTimeout, cancellation.Token); return Reply(code, text); } - catch (TimeoutException) - { - return Reply(1, "The shell did not answer in time."); - } - catch (OperationCanceledException) - { - return Reply(1, "The shell is stopping."); - } - catch (Exception ex) - { - Store.Log("CLI: " + ex); - return Reply(1, "Command failed: " + ex.Message); - } + catch (TimeoutException) { return Reply(1, "The shell did not answer in time."); } + catch (OperationCanceledException) { return Reply(1, "The shell is stopping."); } + catch (Exception ex) { Store.Log("CLI: " + ex); return Reply(1, "Command failed: " + ex.Message); } } static string Reply(int code, string text) => JsonSerializer.Serialize(new { code, text }); diff --git a/src/backend/Bridge/IBridgeOwner.cs b/src/backend/Bridge/IBridgeOwner.cs new file mode 100644 index 0000000..22b82a0 --- /dev/null +++ b/src/backend/Bridge/IBridgeOwner.cs @@ -0,0 +1,104 @@ +// language: C# 12, file: IBridgeOwner.cs, target: Windows 11 / .NET 8, sous-ensemble du Coordinator réellement appelé par le pont +using System; +using System.Collections.Generic; +namespace PanneauxGlass; + +// Couture étroite : le pont ne dépend plus du Coordinator concret (fenêtres WPF, bureau), seulement de ces appels. Le Coordinator +// l'implémente par délégation (Core/Coordinator.BridgeOwner.cs) ; les tests fournissent un propriétaire factice. +internal interface IBridgeOwner +{ + IShortcutBindings Keys + { + get; + } + IShortcutEditor Shortcuts + { + get; + } + IClipboardService QtClipboard + { + get; + } + IIconSource Icons + { + get; + } + bool TaskbarEnabled + { + get; + } + + void SetTheme(string id); + void SetTaskbar(bool enabled); + void SuspendTaskbar(); + void RestoreDesktopIcons(); + void ApplyClipboardSettings(); + void RefreshCatalogue(); + void RefreshCatalogueIfStale(); + + void Launch(Entry entry); + void LaunchTarget(string target); + void Reveal(Entry entry); + void Edit(Entry entry); + void RunQuickTab(WindowsShortcut tab); + void WebSearch(string value); + + void AddAccess(string board); + void AddFolder(string board); + void AddWebsite(); + + void OpenWorkspaces(); + void OpenTools(); + void OpenRuler(); + void OpenNotes(); + void OpenMemo(); + void OpenLargeFiles(); + void OpenHelp(); + void OpenFocus(); + void OpenColorPicker(); + void OpenCapture(); + void OpenAudio(); + void EditQuickTabs(); + void ToggleNotifications(); + void ToggleAlwaysOnTop(); + void TaskManager(); + void PinImage(System.Windows.Media.Imaging.BitmapImage image); +} + +// Vues étroites des services concrets (presse-papiers, icônes, raccourcis) : le pont ne voit plus les classes WPF/Win32. +internal interface IClipboardService +{ + IReadOnlyList Items + { + get; + } + void TogglePin(ClipboardItem item); + void CopyBack(ClipboardItem item); +} + +internal interface IIconSource +{ + void Load(Entry entry, Action callback, string scope = ""); + bool IsPlaceholder(System.Windows.Media.ImageSource image); + int FailureCount(Entry entry); + void ResetFailures(); +} + +internal interface IShortcutEditor +{ + object[] List(); + object Status(); + ShortcutResult Reset(string id); + ShortcutResult Apply(string id, string? combo); +} + +// Adaptateur du ShortcutManager réel (ses membres restent internes et inchangés). +internal sealed class ShortcutEditor : IShortcutEditor +{ + readonly ShortcutManager manager; + internal ShortcutEditor(ShortcutManager manager) => this.manager = manager; + public object[] List() => manager.List(); + public object Status() => manager.Status(); + public ShortcutResult Reset(string id) => manager.Reset(id); + public ShortcutResult Apply(string id, string? combo) => manager.Apply(id, combo); +} diff --git a/src/backend/Bridge/QtShellBridge.Actions.Devices.cs b/src/backend/Bridge/QtShellBridge.Actions.Devices.cs new file mode 100644 index 0000000..34a8859 --- /dev/null +++ b/src/backend/Bridge/QtShellBridge.Actions.Devices.cs @@ -0,0 +1,167 @@ +// language: C# 12, file: QtShellBridge.Actions.Devices.cs, target: Windows 11 / .NET 8, actions du pont : média, volume, luminosité, +// Wi-Fi et radios +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using Windows.Devices.Radios; +namespace PanneauxGlass; + +internal sealed partial class QtShellBridge +{ + [BridgeAction("dictationCapture")] + void OnDictationCapture(BridgeCall c) + { + if (Flag(c.Args, "enabled")) + audioPort.BeginDictationCapture(); + else + audioPort.EndDictationCapture(); + } + + [BridgeAction("media")] + async Task OnMedia(BridgeCall c) + { + await audioPort.MediaCommand(Text(c.Args, "action"), Number(c.Args, "value")); + } + + [BridgeAction("mediaVolume")] + async Task OnMediaVolume(BridgeCall c) + { + await audioPort.SetMediaVolume(Math.Clamp(Number(c.Args, "value"), 0, 100)); + } + + [BridgeAction("volume")] + void OnVolume(BridgeCall c) + { + audioPort.SetVolume(Math.Clamp(Number(c.Args, "value"), 0, 100)); + sound = audioPort.ReadAudio(); + } + + [BridgeAction("brightness")] + async Task OnBrightness(BridgeCall c) + { + double brightLevel = Math.Clamp(Number(c.Args, "value"), 0, 100); + if (c.Args.TryGetProperty("id", out _)) + _ = SetDisplay((int)Number(c.Args, "id"), brightLevel, false); + else + { + await displayPort.SetBrightnessAtCursor(brightLevel); + nextBrightness = DateTime.MinValue; + } + } + + [BridgeAction("wifiConnect")] + void OnWifiConnect(BridgeCall c) + { + _ = wifiPort.WifiConnect(Text(c.Args, "ssid"), Text(c.Args, "password")); + } + + [BridgeAction("wifiForget")] + void OnWifiForget(BridgeCall c) + { + _ = wifiPort.WifiForget(Text(c.Args, "ssid")); + } + + [BridgeAction("wifiPortal")] + void OnWifiPortal(BridgeCall c) + { + wifiPort.OpenWifiPortal(); + } + + [BridgeAction("wifiSettings")] + void OnWifiSettings(BridgeCall c) + { + wifiPort.OpenWifiSettings(Text(c.Args, "page") == "location"); + } + + [BridgeAction("radio")] + async Task OnRadio(BridgeCall c) + { + { + var radioWifi = Text(c.Args, "kind") == "wifi"; + try + { + await wifiPort.ToggleRadio(radioWifi ? RadioKind.WiFi : RadioKind.Bluetooth); + } + catch (RadioAccessDeniedException) + { + if (radioWifi + ? !wifiSettingsOpened + : !bluetoothSettingsOpened) + { + if (radioWifi) + wifiSettingsOpened = true; + else + bluetoothSettingsOpened = true; + try + { + Process.Start(new ProcessStartInfo(radioWifi + ? "ms-settings:network-wifi" + : "ms-settings:bluetooth") + { + UseShellExecute = true + })?.Dispose(); + } + catch (Exception ex) { Store.Log("Réglages connexion: " + ex.Message); } + } + throw; + } + } + connection = await wifiPort.ReadConnections(); + } + + [BridgeAction("audio")] + void OnAudio(BridgeCall c) + { + Send("audio"); + } + + [BridgeAction("microphone")] + void OnMicrophone(BridgeCall c) + { + Send("microphone"); + } + + [BridgeAction("sessionVolume")] + async Task OnSessionVolume(BridgeCall c) + { + await audioPort.SetApplication(c.Id, Number(c.Args, "value"), null); + } + + [BridgeAction("sessionMute")] + async Task OnSessionMute(BridgeCall c) + { + await audioPort.SetApplication(c.Id, null, Flag(c.Args, "muted")); + } + + [BridgeAction("deviceVolume")] + async Task OnDeviceVolume(BridgeCall c) + { + await audioPort.SetDevice(c.Id, Number(c.Args, "value"), null, false); + } + + [BridgeAction("deviceMute")] + async Task OnDeviceMute(BridgeCall c) + { + await audioPort.SetDevice(c.Id, null, Flag(c.Args, "muted"), false); + } + + [BridgeAction("deviceSelect")] + async Task OnDeviceSelect(BridgeCall c) + { + await audioPort.SetDevice(c.Id, null, null, true); + sound = audioPort.ReadAudio(); + } + + [BridgeAction("masterMute")] + void OnMasterMute(BridgeCall c) + { + audioPort.SetMute(Flag(c.Args, "muted")); + sound = audioPort.ReadAudio(); + } +} diff --git a/src/backend/Bridge/QtShellBridge.Actions.Entries.cs b/src/backend/Bridge/QtShellBridge.Actions.Entries.cs new file mode 100644 index 0000000..52ba771 --- /dev/null +++ b/src/backend/Bridge/QtShellBridge.Actions.Entries.cs @@ -0,0 +1,264 @@ +// language: C# 12, file: QtShellBridge.Actions.Entries.cs, target: Windows 11 / .NET 8, actions du pont : applications, catalogue, +// presse-papiers et outils +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using Windows.Devices.Radios; +namespace PanneauxGlass; + +internal sealed partial class QtShellBridge +{ + [BridgeAction("systemAction")] + void OnSystemAction(BridgeCall c) + { + DockSystem(Text(c.Args, "id"), Flag(c.Args, "confirmed")); + } + + [BridgeAction("icon")] + void OnIcon(BridgeCall c) + { + var iconEntry = Store.Entries.FirstOrDefault(e => e.Id == c.Id) ?? dockEntries.GetValueOrDefault(c.Id); + if (iconEntry == null) + { + lock (DesktopRecents.Entries) + DesktopRecents.Entries.TryGetValue(c.Id, out iconEntry); + } + if (iconEntry != null) + LoadIcon(iconEntry); + } + + [BridgeAction("launch")] + void OnLaunch(BridgeCall c) + { + var entry = Store.Visible().FirstOrDefault(e => e.Id == c.Id); + if (entry != null) + { + Send("close"); + owner.Launch(entry); + } + } + + [BridgeAction("activate")] + void OnActivate(BridgeCall c) + { + long hwnd = (long)Number(c.Args, "hwnd"); + if (desktopPort.ActivateApplication(hwnd)) + return; + if (dockEntries.TryGetValue(c.Id, out var pin)) + owner.Launch(pin); + } + + [BridgeAction("palette")] + void OnPalette(BridgeCall c) + { + Send("palette"); + } + + [BridgeAction("copyText")] + void OnCopyText(BridgeCall c) + { + string copied = Text(c.Args, "text"); + if (copied.Length <= 512) + shellPort.SetClipboardText(copied); + } + + [BridgeAction("paletteRun")] + void OnPaletteRun(BridgeCall c) + { + RunPalette(Text(c.Args, "kind"), c.Id, Text(c.Args, "query")); + } + + [BridgeAction("clipboardCopy")] + void OnClipboardCopy(BridgeCall c) + { + var clip = owner.QtClipboard.Items.FirstOrDefault(e => e.Id == c.Id); + if (clip != null) + owner.QtClipboard.CopyBack(clip); + } + + [BridgeAction("clipboardPin")] + void OnClipboardPin(BridgeCall c) + { + var pinnedClip = owner.QtClipboard.Items.FirstOrDefault(e => e.Id == c.Id); + if (pinnedClip != null) + owner.QtClipboard.TogglePin(pinnedClip); + } + + [BridgeAction("refreshCatalogue")] + void OnRefreshCatalogue(BridgeCall c) + { + owner.RefreshCatalogue(); + } + + [BridgeAction("move")] + void OnMove(BridgeCall c) + { + string board = Text(c.Args, "board"); + if (board is "apps" or "games" or "projects" or "documents" && Store.Entries.Any(e => e.Id == c.Id)) + { + Store.State.BoardOverrides[c.Id] = board; + Store.Save(); + } + } + + [BridgeAction("editEntry")] + void OnEditEntry(BridgeCall c) + { + var edited = Store.Entries.FirstOrDefault(e => e.Id == c.Id); + if (edited != null) + { + string name = Text(c.Args, "name").Trim(), category = Text(c.Args, "category").Trim(); + if (name.Length is > 0 and <= 120) + { + edited.Name = name; + Store.State.Names[c.Id] = name; + } + if (category.Length <= 100) + { + edited.Category = category; + Store.State.Categories[c.Id] = category; + } + Store.Save(); + } + } + + [BridgeAction("entryIcon")] + void OnEntryIcon(BridgeCall c) + { + var changedIcon = Store.Entries.FirstOrDefault(e => e.Id == c.Id); + string path = Text(c.Args, "path"); + if (changedIcon != null + && File.Exists(path) + && new[] { ".png", ".ico", ".jpg", ".jpeg", ".webp" }.Contains(Path.GetExtension(path).ToLowerInvariant())) + { + changedIcon.IconPath = path; + Store.State.Icons[c.Id] = path; + Store.Save(); + requested.Remove(c.Id); + LoadIcon(changedIcon); + } + } + + [BridgeAction("addAccess")] + void OnAddAccess(BridgeCall c) + { + Send("close"); + string addBoard = Text(c.Args, "board"); + _ = dispatcher.BeginInvoke((Action)(() => + WithDialog(() => owner.AddAccess(addBoard is "apps" or "games" or "projects" or "documents" ? addBoard : "apps")))); + } + + [BridgeAction("addFolder")] + void OnAddFolder(BridgeCall c) + { + Send("close"); + string folderBoard = Text(c.Args, "board"); + _ = dispatcher.BeginInvoke((Action)(() => + WithDialog(() => owner.AddFolder(folderBoard is "apps" or "games" or "projects" or "documents" ? folderBoard : "documents")))); + } + + [BridgeAction("tools")] + void OnTools(BridgeCall c) + { + Send("tools"); + } + + [BridgeAction("toolRun")] + void OnToolRun(BridgeCall c) + { + RunTool(c.Id); + } + + [BridgeAction("captureExport")] + async Task OnCaptureExport(BridgeCall c) + { + c.Notice = await CaptureExport(c.Args); + } + + [BridgeAction("clipboard")] + void OnClipboard(BridgeCall c) + { + Send("clipboard"); + } + + [BridgeAction("profiles")] + void OnProfiles(BridgeCall c) + { + Send("close"); + _ = dispatcher.BeginInvoke((Action)(() => WithDialog(owner.OpenWorkspaces))); + } + + [BridgeAction("websites")] + void OnWebsites(BridgeCall c) + { + Send("close"); + _ = dispatcher.BeginInvoke((Action)(() => WithDialog(() => owner.AddWebsite()))); + } + + [BridgeAction("capture")] + void OnCapture(BridgeCall c) + { + Send("capture"); + } + + [BridgeAction("restoreTaskbar")] + void OnRestoreTaskbar(BridgeCall c) + { + owner.SetTaskbar(false); + } + + [BridgeAction("quit")] + void OnQuit(BridgeCall c) + { + _ = dispatcher.BeginInvoke((Action)(() => shellPort.Shutdown())); + } + + [BridgeAction("favorite")] + void OnFavorite(BridgeCall c) + { + if (Store.Entries.Any(e => e.Id == c.Id)) + { + if (!Store.State.Favorites.Remove(c.Id)) + Store.State.Favorites.Add(c.Id); + Store.Save(); + } + } + + [BridgeAction("reveal")] + void OnReveal(BridgeCall c) + { + var reveal = Store.Entries.FirstOrDefault(e => e.Id == c.Id); + if (reveal != null) + owner.Reveal(reveal); + } + + [BridgeAction("edit")] + void OnEdit(BridgeCall c) + { + var edit = Store.Entries.FirstOrDefault(e => e.Id == c.Id); + if (edit != null) + _ = dispatcher.BeginInvoke((Action)(() => WithDialog(() => owner.Edit(edit)))); + } + + [BridgeAction("website")] + void OnWebsite(BridgeCall c) + { + var site = Store.State.Websites.FirstOrDefault(w => w.Id == c.Id); + if (site != null) + owner.LaunchTarget(site.Url); + } + + [BridgeAction("raiseMedia")] + void OnRaiseMedia(BridgeCall c) + { + var target = Store.Entries.FirstOrDefault(e => e.Name.Equals(player?.App, StringComparison.OrdinalIgnoreCase)); + if (target != null) + owner.Launch(target); + } +} diff --git a/src/backend/Bridge/QtShellBridge.Actions.Files.cs b/src/backend/Bridge/QtShellBridge.Actions.Files.cs new file mode 100644 index 0000000..1f11eb3 --- /dev/null +++ b/src/backend/Bridge/QtShellBridge.Actions.Files.cs @@ -0,0 +1,193 @@ +// language: C# 12, file: QtShellBridge.Actions.Files.cs, target: Windows 11 / .NET 8, actions du pont : fichiers, corbeille, bureaux +// virtuels, dock et recherche de fichiers +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using Windows.Devices.Radios; +namespace PanneauxGlass; + +internal sealed partial class QtShellBridge +{ + [BridgeAction("clipboardFiles")] + void OnClipboardFiles(BridgeCall c) + { + if (c.Args.TryGetProperty("paths", out var clipPaths)) + { + RecycleActions.ClipboardFiles(clipPaths.EnumerateArray().Select(p => p.GetString() ?? "").ToArray()); + c.Notice = "Fichiers copiés dans le presse-papiers."; + } + } + + [BridgeAction("zipFiles")] + async Task OnZipFiles(BridgeCall c) + { + if (c.Args.TryGetProperty("paths", out var zipPaths)) + { + var files = zipPaths.EnumerateArray().Select(p => p.GetString() ?? "").ToArray(); + string? destination = RecycleActions.ChooseZip(files); + if (destination != null) + { + await Task.Run(() => RecycleActions.Zip(files, destination)); + c.Notice = "Archive ZIP créée."; + } + } + } + + [BridgeAction("explorerPalette")] + async Task OnExplorerPalette(BridgeCall c) + { + c.Notice = null; + await Task.Run(() => shellPort.SyncExplorerPalette(Text(c.Args, "accent"), Text(c.Args, "background"), Text(c.Args, "text"))); + } + + [BridgeAction("explorerFolder")] + void OnExplorerFolder(BridgeCall c) + { + string? picked = shellPort.PickFolder(); + if (picked != null && !Store.State.ExplorerFolders.Contains(picked)) + { + Store.State.ExplorerFolders.Add(picked); + Store.Save(); + } + } + + [BridgeAction("explorerRestore")] + async Task OnExplorerRestore(BridgeCall c) + { + Store.State.ExplorerTheme = false; + Store.Save(); + c.Notice = await Task.Run(() => shellPort.RestoreExplorer()); + } + + [BridgeAction("explorerReport")] + void OnExplorerReport(BridgeCall c) + { + owner.LaunchTarget(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Winlestia", + "Explorer", + "last-report.txt")); + } + + [BridgeAction("explorerOpen")] + void OnExplorerOpen(BridgeCall c) + { + owner.LaunchTarget(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)); + } + + [BridgeAction("emptyRecycleBin")] + void OnEmptyRecycleBin(BridgeCall c) + { + shellPort.EmptyRecycleBin(); + } + + [BridgeAction("desktopSwitch")] + void OnDesktopSwitch(BridgeCall c) + { + int rel = (int)Math.Clamp(Number(c.Args, "relative"), -1, 1), to = (int)Number(c.Args, "index"); + _ = Task.Run(() => desktopPort.SwitchDesktop(to, rel)); + } + + [BridgeAction("desktopNew")] + void OnDesktopNew(BridgeCall c) + { + _ = Task.Run(() => desktopPort.NewDesktop()); + } + + [BridgeAction("desktopClose")] + void OnDesktopClose(BridgeCall c) + { + int at = (int)Number(c.Args, "index") - 1; + _ = Task.Run(() => desktopPort.CloseDesktop(at)); + } + + [BridgeAction("recycleFiles")] + void OnRecycleFiles(BridgeCall c) + { + if (c.Args.TryGetProperty("paths", out var paths) && paths.ValueKind == JsonValueKind.Array) + RecycleActions.Recycle(paths.EnumerateArray().Select(p => p.GetString() ?? "").ToArray()); + } + + [BridgeAction("dockApp")] + void OnDockApp(BridgeCall c) + { + c.Notice = DockAction(c.Args); + nextTasks = DateTime.MinValue; + } + + [BridgeAction("dockPreview")] + void OnDockPreview(BridgeCall c) + { + DockPreviewOpen(c.Args); + } + + [BridgeAction("dockPreviewEnd")] + void OnDockPreviewEnd(BridgeCall c) + { + DockPreviewEnd(c.Args); + } + + [BridgeAction("runningRefresh")] + void OnRunningRefresh(BridgeCall c) + { + RefreshBackgroundApps(); + nextTasks = DateTime.MinValue; + } + + [BridgeAction("openFiles")] + void OnOpenFiles(BridgeCall c) + { + shellPort.OpenFilesHandler(); + } + + [BridgeAction("windowList")] + async Task OnWindowList(BridgeCall c) + { + c.WindowItems = await Task.Run(() => desktopPort.PaletteWindows().Take(60).ToArray()); + } + + [BridgeAction("fileSearch")] + void OnFileSearch(BridgeCall c) + { + fileSearch.Start(Text(c.Args, "term"), + (long)Number(c.Args, "gen"), + () => _ = dispatcher.BeginInvoke((Action)(() => { if (!disposed) Send("files"); }))); + } + + [BridgeAction("fileResults")] + void OnFileResults(BridgeCall c) + { + c.FileItems = fileSearch.Payload(); + } + + [BridgeAction("fileCancel")] + void OnFileCancel(BridgeCall c) + { + fileSearch.Cancel(); + } + + [BridgeAction("fileOpen")] + void OnFileOpen(BridgeCall c) + { + if (fileSearch.TryResolve((long)Number(c.Args, "gen"), (int)Number(c.Args, "index"), out var filePath)) + { + try + { + if (FileQuery.IsExecutable(filePath)) + { + var selectInfo = new ProcessStartInfo("explorer.exe") { UseShellExecute = false }; + selectInfo.ArgumentList.Add("/select," + filePath.Replace("\"", "")); + shellPort.StartProcess(selectInfo); + } + else + shellPort.StartProcess(new ProcessStartInfo(filePath) { UseShellExecute = true }); + } + catch { c.Notice = "Impossible d’ouvrir ce fichier."; } + } + } +} diff --git a/src/backend/Bridge/QtShellBridge.Actions.Settings.cs b/src/backend/Bridge/QtShellBridge.Actions.Settings.cs new file mode 100644 index 0000000..5204b83 --- /dev/null +++ b/src/backend/Bridge/QtShellBridge.Actions.Settings.cs @@ -0,0 +1,331 @@ +// language: C# 12, file: QtShellBridge.Actions.Settings.cs, target: Windows 11 / .NET 8, actions du pont : thème, fond d'écran, +// préférences et raccourcis +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using Windows.Devices.Radios; +namespace PanneauxGlass; + +internal sealed partial class QtShellBridge +{ + static readonly TimeSpan WallpaperCaptureTimeout = TimeSpan.FromSeconds(3); + + [BridgeAction("wallpaperCapture")] + async Task OnWallpaperCapture(BridgeCall c) + { + Native.Rect? shotTarget = + c.Args.ValueKind == JsonValueKind.Object && Number(c.Args, "w") > 0 + ? new Native.Rect + { + Left = (int)Number(c.Args, "x"), + Top = (int)Number(c.Args, "y"), + Right = (int)(Number(c.Args, "x") + Number(c.Args, "w")), + Bottom = (int)(Number(c.Args, "y") + Number(c.Args, "h")) + } + : null; + string? shot = null; + try + { + shot = await Task.Run(() => + { + try + { + return wallpaperPort.CaptureWallpaper(shotTarget); + } + catch (Exception ex) + { + Store.Log("Capture du fond: " + ex.Message); + return null; + } + }).WaitAsync(WallpaperCaptureTimeout); + } + catch (TimeoutException) { Store.Log("Capture du fond: delai depasse."); } + c.WallpaperCapture = new + { + token = Text(c.Args, "token"), + ok = shot != null, + path = shot ?? "" + }; + } + + [BridgeAction("wallpaperChanged")] + void OnWallpaperChanged(BridgeCall c) + { + wallpaperPort.NotifyWallpaper(); + } + + [BridgeAction("captureShortcutMode")] + void OnCaptureShortcutMode(BridgeCall c) + { + CapturingShortcut = Flag(c.Args, "enabled"); + } + + [BridgeAction("customTheme")] + void OnCustomTheme(BridgeCall c) + { + if (!c.Args.TryGetProperty("colors", out var colors) || colors.ValueKind != JsonValueKind.Object) + throw new InvalidOperationException("Palette invalide."); + var custom = new Dictionary(); + foreach (string role in new[] { "background", "text", "primary", "secondary", "tertiary" }) + { + string color = Text(colors, role); + if (!System.Text.RegularExpressions.Regex.IsMatch(color, "^#[0-9a-fA-F]{6}$")) + throw new InvalidOperationException("Couleur invalide : " + role); + custom[role] = color.ToLowerInvariant(); + } + Store.State.CustomPalette = custom; + Store.State.CustomGlass = Flag(c.Args, "glass"); + Store.State.CustomGlassOpacity = Math.Clamp(Number(c.Args, "opacity"), .02, .95); + Store.State.Theme = "custom"; + Store.State.AutoTheme = false; + Store.Save(); + owner.SetTheme("custom"); + } + + [BridgeAction("theme")] + void OnTheme(BridgeCall c) + { + Store.State.AutoTheme = Text(c.Args, "mode") == "auto"; + if (Text(c.Args, "mode") == "custom") + { + Store.State.Theme = "custom"; + owner.SetTheme("custom"); + } + else if (!Store.State.AutoTheme) + { + var themeId = Flag(c.Args, "dark") ? "nuit" : "shell"; + Store.State.Theme = themeId; + owner.SetTheme(themeId); + } + else + owner.SetTheme(Store.State.Theme); + Store.Save(); + } + + [BridgeAction("workspace")] + void OnWorkspace(BridgeCall c) + { + Send("workspace:" + (c.Id is "apps" or "games" or "projects" or "documents" ? c.Id : "all")); + } + + [BridgeAction("settings")] + void OnSettings(BridgeCall c) + { + Send("settings"); + if (Text(c.Args, "section") == "social") + Send("settings:social"); + else if (Text(c.Args, "section") == "bookmarks") + Send("settings:bookmarks"); + } + + [BridgeAction("wallpaperPicker")] + void OnWallpaperPicker(BridgeCall c) + { + _ = Task.Run(() => + { + var picked = wallpaperPort.WallpaperPicker(); + dispatcher.BeginInvoke((Action)(() => lateWallpaperPicker = picked)); + }); + // Détaché : le pipe sert une requête à la fois et le COM peut prendre 5 s. + } + + [BridgeAction("wallpaperApply")] + void OnWallpaperApply(BridgeCall c) + { + // Détaché : la réponse partira avec le prochain échange (lateNotice, lateWallpaperPicker). + string wp = Text(c.Args, "path"); + int wd = (int)Number(c.Args, "display"); + bool light = LightRendering; + _ = Task.Run(() => + { + var done = wallpaperPort.ApplyWallpaper(wp, wd, light); + var names = done.animated; + dispatcher.BeginInvoke((Action)(() => + { + if (done.code == 0) + wallpaperPort.NotifyWallpaper(); + else + lateNotice = done.text; + lateWallpaperPicker = new + { + animated = names + }; + })); + }); + } + + [BridgeAction("preference")] + async Task OnPreference(BridgeCall c) + { + string preference = Text(c.Args, "key"); + switch (preference) + { + case "autoTile": + Store.State.AutoTile = Flag(c.Args, "enabled"); + tiler.SetEnabled(Store.State.AutoTile); + break; + case "tileExclusionRemove": + tiler.RemoveExclusion(Text(c.Args, "value")); + break; + case "accentBorders": + Store.State.AccentBorders = Flag(c.Args, "enabled"); + break; + case "activeBorderOnly": + Store.State.ActiveBorderOnly = Flag(c.Args, "enabled"); + explorer.RefreshFrames(); + break; + case "explorerTheme": + Store.State.ExplorerTheme = Flag(c.Args, "enabled"); + if (!Store.State.ExplorerTheme) + c.Notice = await Task.Run(() => explorer.Restore()); + break; + case "desktopLogo": + Store.State.DesktopLogo = Flag(c.Args, "enabled"); + break; + case "osd": + Store.State.Osd = Flag(c.Args, "enabled"); + break; + case "quietFullscreen": + Store.State.QuietFullscreen = Flag(c.Args, "enabled"); + ApplyQuiet(); + break; + case "desktopIndicator": + Store.State.DesktopIndicator = Flag(c.Args, "enabled"); + break; + case "dockDrawerOpen": + Store.State.DockDrawerOpen = Flag(c.Args, "enabled"); + break; + case "filesDefaultHandler": + { + bool wantHandler = Flag(c.Args, "enabled"); + var handlerResult = await Task.Run(() => FilesDefaultHandler.Set(wantHandler)); + Store.State.FilesDefaultHandler = handlerResult.pref; + c.Notice = handlerResult.notice; + break; + } + case "filesPreload": + Store.State.FilesPreload = Flag(c.Args, "enabled"); + if (Store.State.FilesPreload) + FilesPreload.Start(); + break; + case "reducedEffects": + Store.State.ReducedEffects = Flag(c.Args, "enabled"); + owner.SetTheme(Store.State.Theme); + break; + case "topActivationMargin": + Store.State.TopActivationMargin = (int)Math.Clamp(Number(c.Args, "value"), 0, 160); + break; + case "dockWidth": + Store.State.DockWidth = Math.Clamp(Number(c.Args, "value"), 560, 4000); + break; + case "frameThickness": + Store.State.FrameThickness = (int)Math.Round(Math.Clamp(Number(c.Args, "value"), 4, 40)); + frame.UpdateThickness(); + break; + case "glassOpacity": + Store.State.GlassOpacity = Math.Clamp(Number(c.Args, "value"), .02, .95); + break; + case "motion": + Store.State.Motion = Math.Clamp(Number(c.Args, "value"), 0, 1.5); + break; + case "iconSize": + Store.State.IconSize = Math.Clamp(Number(c.Args, "value"), 40, 80); + break; + case "taskbar": + case "replaceTaskbar": + owner.SetTaskbar(Flag(c.Args, "enabled")); + break; + case "dockVisible": + Store.State.DockVisible = Flag(c.Args, "enabled"); + if (!Store.State.DockVisible && owner.TaskbarEnabled) + owner.SetTaskbar(false); + break; // dock masqué pendant qu'il remplace la barre Windows : on rend la barre Windows, jamais sans barre + case "bookmarksRailVisible": + Store.State.BookmarksRailVisible = Flag(c.Args, "enabled"); + break; + case "socialRailVisible": + Store.State.SocialRailVisible = Flag(c.Args, "enabled"); + break; + case "clipboardImages": + Store.State.ClipboardImages = Flag(c.Args, "enabled"); + owner.ApplyClipboardSettings(); + break; + case "clipboardLimit": + Store.State.ClipboardLimit = (int)Math.Clamp(Math.Round(Number(c.Args, "value")), 8, 80); + owner.ApplyClipboardSettings(); + break; + case "customGlassOpacity": + Store.State.CustomGlassOpacity = Math.Clamp(Number(c.Args, "value"), .02, .95); + break; + case "autostart": + Native.SetAutostart(Flag(c.Args, "enabled")); + break; + case "screenFrame": + Store.State.ScreenFrame = Flag(c.Args, "enabled"); + if (Store.State.ScreenFrame) + frame.Enable(); + else + frame.Disable(); + break; + case "socialBackground": + Store.State.SocialBackground = Flag(c.Args, "enabled"); + social.BackgroundChanged(); + break; + case "dictationEnabled": + Store.State.DictationEnabled = Flag(c.Args, "enabled"); + dictation.Apply(Store.State.DictationEnabled, Store.State.DictationKey); + break; + case "dictationKey": + { + int vk = (int)Number(c.Args, "value"); + if (vk is > 0 and < 255) + Store.State.DictationKey = vk; + dictation.Apply(Store.State.DictationEnabled, Store.State.DictationKey); + break; + } + case "clipboardHistory": + Store.State.ClipboardHistory = Flag(c.Args, "enabled"); + owner.ApplyClipboardSettings(); + break; + } + Store.Save(); + } + + [BridgeAction("shortcut-reset")] + void OnShortcutReset(BridgeCall c) + { + owner.Shortcuts.Reset(Text(c.Args, "id")); + } + + [BridgeAction("shortcut")] + void OnShortcut(BridgeCall c) + { + if (c.Args.ValueKind == JsonValueKind.Object && c.Args.TryGetProperty("id", out _)) + { + owner.Shortcuts.Apply(Text(c.Args, "id"), Text(c.Args, "combo")); + return; + } + uint modifiers = (uint)Number(c.Args, "modifiers"), key = (uint)Number(c.Args, "key"); + if (modifiers is > 0 and <= 15 && key is >= 0x20 and <= 0xFE) + { + string error; + bool success = Text(c.Args, "kind") switch + { + "palette" => owner.Keys.SetPaletteShortcut(modifiers, key, out error), + "wallpaper" => owner.Keys.SetWallpaperShortcut(modifiers, key, out error), + _ => owner.Keys.SetShortcut(modifiers, key, "home", out error) + }; + c.Notice = success ? "Raccourci enregistré" : error; + if (success) + Send("shortcut-saved:" + Text(c.Args, "kind")); + } + else + c.Notice = "Choisis Ctrl, Alt ou Windows avec une autre touche."; + } +} diff --git a/src/backend/Bridge/QtShellBridge.Actions.Social.cs b/src/backend/Bridge/QtShellBridge.Actions.Social.cs new file mode 100644 index 0000000..bd82837 --- /dev/null +++ b/src/backend/Bridge/QtShellBridge.Actions.Social.cs @@ -0,0 +1,118 @@ +// language: C# 12, file: QtShellBridge.Actions.Social.cs, target: Windows 11 / .NET 8, actions du pont : réseaux sociaux et favoris des +// navigateurs +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using Windows.Devices.Radios; +namespace PanneauxGlass; + +internal sealed partial class QtShellBridge +{ + [BridgeAction("socialOpen")] + void OnSocialOpen(BridgeCall c) + { + _ = social.Open(c.Id, false); + } + + [BridgeAction("socialLogin")] + void OnSocialLogin(BridgeCall c) + { + _ = social.Open(c.Id, true); + } + + [BridgeAction("socialEnable")] + void OnSocialEnable(BridgeCall c) + { + _ = social.Enable(c.Id, Flag(c.Args, "enabled")); + } + + [BridgeAction("socialLogout")] + void OnSocialLogout(BridgeCall c) + { + _ = social.Logout(c.Id); + } + + [BridgeAction("socialMute")] + void OnSocialMute(BridgeCall c) + { + social.MuteNet(c.Id, Flag(c.Args, "muted")); + } + + [BridgeAction("socialGlobalMute")] + void OnSocialGlobalMute(BridgeCall c) + { + social.GlobalMute(Flag(c.Args, "muted")); + } + + [BridgeAction("socialSound")] + void OnSocialSound(BridgeCall c) + { + social.SoundEnabled(Flag(c.Args, "enabled")); + } + + [BridgeAction("socialVolume")] + void OnSocialVolume(BridgeCall c) + { + social.Volume(Number(c.Args, "value")); + } + + [BridgeAction("socialHistoryClear")] + void OnSocialHistoryClear(BridgeCall c) + { + social.HistoryClear(); + } + + [BridgeAction("bookmarksOpen")] + void OnBookmarksOpen(BridgeCall c) + { + bookmarks.Open(c.Id); + } + + [BridgeAction("bookmarksBrowser")] + void OnBookmarksBrowser(BridgeCall c) + { + bookmarks.ChooseBrowser(c.Id); + } + + [BridgeAction("bookmarksHide")] + void OnBookmarksHide(BridgeCall c) + { + bookmarks.Hide(c.Id); + } + + [BridgeAction("bookmarksUnhideAll")] + void OnBookmarksUnhideAll(BridgeCall c) + { + bookmarks.UnhideAll(); + } + + [BridgeAction("bookmarksDeleteInBrowser")] + void OnBookmarksDeleteInBrowser(BridgeCall c) + { + _ = bookmarks.DeleteInBrowser(c.Id, Flag(c.Args, "confirmSync")); + } + + [BridgeAction("bookmarksItems")] + void OnBookmarksItems(BridgeCall c) + { + c.BookmarksItems = bookmarks.Items(); + } + + [BridgeAction("bookmarksRefresh")] + void OnBookmarksRefresh(BridgeCall c) + { + bookmarks.Refresh(Flag(c.Args, "ifDirty")); + } + + [BridgeAction("socialTestSound")] + void OnSocialTestSound(BridgeCall c) + { + social.TestSound(); + } +} diff --git a/src/backend/Bridge/QtShellBridge.Actions.cs b/src/backend/Bridge/QtShellBridge.Actions.cs new file mode 100644 index 0000000..16f3c5d --- /dev/null +++ b/src/backend/Bridge/QtShellBridge.Actions.cs @@ -0,0 +1,129 @@ +// language: C# 12, file: QtShellBridge.Actions.cs, target: Windows 11 / .NET 8, table de dispatch des actions du pont +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +namespace PanneauxGlass; + +// Une méthode marquée [BridgeAction("nom")] traite l'action « nom » de docs/bridge-schema.json. +// Un test de contrat compare ces noms au schéma : une action ne peut plus exister d'un seul côté. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +internal sealed class BridgeActionAttribute : Attribute +{ + internal BridgeActionAttribute(string name) => Name = name; + + internal string Name + { + get; + } +} + +// Entrées et sorties d'une requête. Les sorties (Notice, …) deviennent les clés de la réponse JSON. +internal sealed class BridgeCall +{ + internal BridgeCall(JsonElement args, string id) + { + Args = args; + Id = id; + } + + internal JsonElement Args + { + get; + } + internal string Id + { + get; + } + internal string? Notice + { + get; set; + } + internal object? WallpaperCapture + { + get; set; + } + internal object? BookmarksItems + { + get; set; + } + internal object? WindowItems + { + get; set; + } + internal object? WallpaperPicker + { + get; set; + } + internal object? FileItems + { + get; set; + } + + // Réponse complète qui court-circuite l'état (sondes rapides du frontend : dictée, barres audio, spectre). + internal string? RawReply + { + get; set; + } +} + +internal sealed partial class QtShellBridge +{ + static readonly BridgeActionTable actionTable; + + // Table construite dès le premier accès au type (donc avant toute instance) : un doublon ou une signature invalide + // fait échouer le démarrage au lieu de se révéler à la première action. + static QtShellBridge() => actionTable = BridgeActionTable.Build(typeof(QtShellBridge)); + + // Exposé pour le test de contrat. + internal static IReadOnlyCollection ActionNames => actionTable.Names; + + // La réponse contient déjà l'état : rien d'autre à faire. + [BridgeAction("state")] + void OnState(BridgeCall c) + { + } + + [BridgeAction("dictation")] + void OnDictation(BridgeCall c) + { + var d = audioPort.DictationState(); + c.RawReply = JsonSerializer.Serialize(new + { + dictation = new + { + state = d.State, + level = d.Level, + levels = d.Levels, + lang = d.Lang, + text = d.Text, + error = d.Error + } + }, Json); + } + + [BridgeAction("audioBars")] + void OnAudioBars(BridgeCall c) + { + var head = bars.Snapshot(false); + var v = head.Seq == (int)Number(c.Args, "seq") ? head : bars.Snapshot(true); + c.RawReply = JsonSerializer.Serialize(new + { + audioBars = new + { + seq = v.Seq, + idle = v.Idle, + bars = v.Bars.Length > 0 ? v.Bars : null + } + }, Json); + } + + [BridgeAction("visualizer")] + void OnVisualizer(BridgeCall c) + { + c.RawReply = JsonSerializer.Serialize(new + { + spectrum = audioPort.SpectrumValues() + }, Json); + } +} diff --git a/src/backend/Bridge/QtShellBridge.Cli.cs b/src/backend/Bridge/QtShellBridge.Cli.cs new file mode 100644 index 0000000..dab057a --- /dev/null +++ b/src/backend/Bridge/QtShellBridge.Cli.cs @@ -0,0 +1,308 @@ +// language: C# 12, file: QtShellBridge.Cli.cs, target: Windows 11 / .NET 8, commandes CLI, outils, fenêtres et luminosité +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using Windows.Devices.Radios; +namespace PanneauxGlass; + +internal sealed partial class QtShellBridge +{ + void RefreshMixer() + { + foreach (var app in mixer.Applications.Where(a => !string.IsNullOrEmpty(a.path))) + LoadIcon(new Entry { Id = app.id, Name = app.name, Target = app.path, IconPath = app.path }); + } + [System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode, SetLastError = true)] + static extern bool SystemParametersInfo( + uint action, + uint param, + string value, + uint flags); + // Ligne de commande `winlestia` : seules les commandes acceptées par CliCommands.TryParse arrivent ici. Exécuté sur le dispatcher + // UI. Fond d'écran : la vérification du fichier (parfois un lecteur réseau) et SystemParametersInfo (peut attendre une fenêtre + // figée) restent hors du thread UI. + // Fenêtres ouvertes pour la palette et `find windows` : les fenêtres du shell (titres « Panneaux · … ») sont exclues. Appelé + // hors thread UI. + static IEnumerable PaletteWindows() + => TaskbarNative.Applications() + .Where(w => !w.Title.StartsWith("Panneaux ·", StringComparison.Ordinal)) + .Select(w => new PaletteWindow(w.Handle.ToInt64(), w.Title, Path.GetFileNameWithoutExtension(w.Path))); + static async Task<(int code, string text)> WifiCli() => (0, await WifiHost.SummaryAsync()); + Task<(int code, string text)> RunCli(CliCommand c) => c.Kind == CliKind.Wifi ? WifiCli() : c.Kind == CliKind.FindWindows ? Task.Run(( + ) => + (0, JsonSerializer.Serialize(new + { + count = PaletteWindows().Count() + }, + Json))) : c.Kind == CliKind.FindFiles ? RunFindFiles(c.Arg) + : c.Kind is CliKind.Brightness or CliKind.BrightnessSet ? RunBrightness(c) + : c.Kind == CliKind.WallpaperList ? Task.Run(() => + (0, JsonSerializer.Serialize(new + { + displays = WallpaperApply.List().Select(d => new { d.id, d.name, d.path }).ToArray(), + animated = WallpaperApply.Animated() + }, + Json))) : c.Kind == CliKind.Wallpaper ? Task.Run(() => + { + if (!CliCommands.ValidWallpaper( + c.Arg, + File.Exists, + out var why)) + return (2, why); + var r = WallpaperApply.Apply(c.Arg, c.Number, LightRendering); + if (r.code == 0) + wallpaper.Notify(100, true); + return r; + }) : RunCliUi(c); + // Comptages seuls : ni chemin ni nom ne sort de cette méthode. FileSearch borne la requête à 2 s, sous les 5 s du serveur. + static async Task<(int code, string text)> RunFindFiles(string term) + { + var r = await FileSearch.RunOnceAsync(term, CancellationToken.None); + return (0, JsonSerializer.Serialize(new + { + count = r.Items.Count, + indexAvailable = r.Status != FileSearch.Unavailable + }, Json)); + } + Task<(int code, string text)> RunCliUi(CliCommand c) => dispatcher.InvokeAsync(() => + { + switch (c.Kind) + { + case CliKind.Help: + return (0, CliCommands.HelpText); + case CliKind.Status: + { + var snap = VirtualDesktops.Current; + return (0, JsonSerializer.Serialize(new + { + version = typeof(QtShellBridge).Assembly.GetName().Version?.ToString(3) ?? "", + desktops = snap.Count, + currentDesktop = snap.Current + 1, + theme = Store.State.Theme, + wallpaper = wallpaper.Monitors.FirstOrDefault()?.path ?? "", + reducedEffects = LightRendering, + quiet = Quiet, + autoOrganize = Store.State.AutoTile, + activeBorderOnly = Store.State.ActiveBorderOnly + }, Json)); + } + case CliKind.Panel: + Send(c.Arg == "dashboard" ? "toggle" : c.Arg); + return (0, "ok"); + case CliKind.Visualizer: + { + var v = bars.Snapshot(false); + return (0, JsonSerializer.Serialize(new + { + running = v.Running, + frames = v.Frames, + panelOpen = active, + wanted = barsVisible + }, Json)); + } + case CliKind.Palette: + Send("palette"); + return (0, "ok"); + case CliKind.Quiet: + { + if (c.Arg != "" && QuietPolicy.TryParse(c.Arg, out var forcedQuiet)) + { + quietForce = forcedQuiet; + ApplyQuiet(); + } + return (0, JsonSerializer.Serialize(new + { + quiet = Quiet, + forced = QuietPolicy.Label(quietForce), + detected = QuietDetected + }, Json)); + } + case CliKind.DesktopNext: + _ = Task.Run(() => VirtualDesktops.SwitchTo(0, 1)); + return (0, "ok"); + case CliKind.DesktopPrev: + _ = Task.Run(() => VirtualDesktops.SwitchTo(0, -1)); + return (0, "ok"); + case CliKind.DesktopNew: + _ = Task.Run(() => VirtualDesktops.CreateNew()); + return (0, "ok"); + case CliKind.DesktopGo: + { + int count = VirtualDesktops.Current.Count; + if (c.Number > count) + return (1, "Desktop " + c.Number + " does not exist (" + count + " available)."); + int to = c.Number - 1; + _ = Task.Run(() => VirtualDesktops.SwitchTo(to)); + return (0, "ok"); + } + } + return (2, "Unknown command."); + }).Task; + + void RunTool(string id) + { + switch (id) + { + case "capture": + Send("capture"); + return; + case "ocr": + Send("capture-ocr"); + return; + case "audio": + Send("audio"); + return; + case "clipboard": + Send("clipboard"); + return; + case "notifications": + owner.ToggleNotifications(); + return; + } + Send("close"); + _ = dispatcher.BeginInvoke((Action)(() => WithDialog(() => + { + switch (id) + { + case "pipette": + owner.OpenColorPicker(); + break; + case "ruler": + owner.OpenRuler(); + break; + case "pin": + owner.ToggleAlwaysOnTop(); + break; + case "largefiles": + owner.OpenLargeFiles(); + break; + case "profiles": + owner.OpenWorkspaces(); + break; + case "memo": + owner.OpenMemo(); + break; + case "help": + owner.OpenHelp(); + break; + } + }))); + } + async Task CaptureExport(JsonElement args) + { + string path = Path.GetFullPath(Text(args, "path")), directory = Path.GetFullPath(Path.Combine(cache, "captures")) + + Path.DirectorySeparatorChar; + if (!path.StartsWith(directory, StringComparison.OrdinalIgnoreCase) + || Path.GetExtension(path) != ".png" + || !File.Exists(path) + || new FileInfo(path).Length > 100_000_000) + throw new InvalidOperationException("Capture invalide."); + BitmapImage image = new(); + image.BeginInit(); + image.CacheOption = BitmapCacheOption.OnLoad; + image.UriSource = new Uri(path); + image.EndInit(); + image.Freeze(); + try + { + if (Text(args, "action") == "pin") + { + owner.PinImage(image); + return "Capture épinglée"; + } + string text = await OcrService.RecognizeAsync(image); + if (!string.IsNullOrWhiteSpace(text)) + { + System.Windows.Clipboard.SetText(text); + Send("clipboard"); + return "Texte copié dans le presse-papiers"; + } + Send("capture"); + return "Aucun texte lisible dans cette zone"; + } + finally { File.Delete(path); } + } + + // Les lectures DDC/CI sont lentes : Tick ne les attend jamais. SetDisplay : l'OSD passe par le signal existant + // seulement pour les commandes (CLI), jamais pour le curseur du panneau. + async Task ReadDisplays() + { + readingDisplays = true; + try + { + displays = await MonitorBrightness.ReadDisplaysAsync(); + } + catch (Exception ex) { Store.Log("Luminosité Qt: " + ex.Message); } + finally { readingDisplays = false; } + } + async Task SetDisplay(int id, double level, bool osd) + { + bool ok = await displayPort.SetDisplay(id, level); + _ = dispatcher.BeginInvoke((Action)(() => + { + if (disposed) + return; + if (ok) + { + displays = displayPort.Displays(); + if (osd && Store.State.Osd && !active && !Quiet) + Send("osd:brightness:" + (int)Math.Round(level)); + } + else + nextBrightness = DateTime.MinValue; + })); + return ok; + } + // Cache d'abord ; nouvelle lecture seulement s'il est vide ou périmé, bornée à 4 s (le serveur CLI répond sous 5 s). + static readonly TimeSpan DisplayReadTimeout = TimeSpan.FromSeconds(4); + async Task<(int code, string text)> RunBrightness(CliCommand c) + { + try + { + if (c.Kind == CliKind.BrightnessSet) + return await SetDisplay(c.Number, c.Value, true).WaitAsync(DisplayReadTimeout) + ? (0, "ok") + : (1, "Display " + c.Number + " not found or without DDC/CI."); + var list = MonitorBrightness.Displays(); + if (list.Count == 0 || MonitorBrightness.CacheAgeMs > BrightnessPolicy.RefreshSeconds * 1000L) + { + try + { + list = await MonitorBrightness.ReadDisplaysAsync().WaitAsync(DisplayReadTimeout); + } + catch (TimeoutException) { list = MonitorBrightness.Displays(); } + } + return (0, JsonSerializer.Serialize(list.Select(d => + new + { + id = d.Id, + name = d.Name.Length > 0 ? d.Name : "Display " + d.Id, + value = (int)Math.Round(d.Value), + available = d.Available + }), + Json)); + } + catch (TimeoutException) { return (1, "The display did not answer in time."); } + } + + void WithDialog(Action action) + { + DialogOpen = true; + try + { + action(); + } + finally { DialogOpen = false; } + } +} diff --git a/src/backend/Bridge/QtShellBridge.Frontend.cs b/src/backend/Bridge/QtShellBridge.Frontend.cs new file mode 100644 index 0000000..96750ea --- /dev/null +++ b/src/backend/Bridge/QtShellBridge.Frontend.cs @@ -0,0 +1,136 @@ +// language: C# 12, file: QtShellBridge.Frontend.cs, target: Windows 11 / .NET 8, processus du frontend Qt : Job Object, nom d'utilisateur +// et erreur fatale +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using Windows.Devices.Radios; +namespace PanneauxGlass; + +internal sealed partial class QtShellBridge +{ + static IntPtr frontendJob; + [System.Runtime.InteropServices.DllImport("kernel32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode, + SetLastError = true)] + static extern IntPtr CreateJobObject( + IntPtr attributes, + string? name); + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetInformationJobObject( + IntPtr job, + int infoClass, + IntPtr info, + uint length); + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + static extern bool AssignProcessToJobObject( + IntPtr job, IntPtr process); + static void AttachFrontendJob(Process process) + { + try + { + if (frontendJob == IntPtr.Zero) + { + var job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) + { + Store.Log("Job Object frontend: création impossible (" + System.Runtime.InteropServices.Marshal.GetLastWin32Error() + + ")"); + return; + } + // JOBOBJECT_EXTENDED_LIMIT_INFORMATION (x64 = 144 octets) ; LimitFlags à l'offset 16 de BasicLimitInformation. + int size = IntPtr.Size == 8 ? 144 : 112; + var info = System.Runtime.InteropServices.Marshal.AllocHGlobal(size); + try + { + for (int i = 0; i < size; i++) + System.Runtime.InteropServices.Marshal.WriteByte(info, i, 0); + System.Runtime.InteropServices.Marshal.WriteInt32(info, 16, 0x2000); + if (!SetInformationJobObject(job, 9, info, (uint)size)) + { + Store.Log("Job Object frontend: configuration impossible (" + + System.Runtime.InteropServices.Marshal.GetLastWin32Error() + ")"); + return; + } + } + finally { System.Runtime.InteropServices.Marshal.FreeHGlobal(info); } + frontendJob = job; + } + if (!AssignProcessToJobObject(frontendJob, process.Handle)) + Store.Log("Job Object frontend: assignation impossible (" + System.Runtime.InteropServices.Marshal.GetLastWin32Error() + + ")"); + } + catch (Exception error) { Store.Log("Job Object frontend: " + error.Message); } + } + [System.Runtime.InteropServices.DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + + [System.Runtime.InteropServices.DllImport("user32.dll")] static extern bool AllowSetForegroundWindow(uint pid); + [System.Runtime.InteropServices.DllImport("secur32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + static extern bool GetUserNameEx( + int format, + System.Text.StringBuilder name, + ref uint size); + static readonly Lazy displayUserName = new(() => + { + try + { + string file = Path.Combine(Store.DataDir, "profil.json"); + if (File.Exists(file)) + { + byte[] raw = File.ReadAllBytes(file); + if (raw.Length >= 3 && raw[0] == 0xEF && raw[1] == 0xBB && raw[2] == 0xBF) + raw = raw[3..]; + string text; + try + { + text = new UTF8Encoding(false, true).GetString(raw); + } + catch { text = CodePagesEncodingProvider.Instance.GetEncoding(1252)!.GetString(raw); } + using var doc = System.Text.Json.JsonDocument.Parse(text); + if (doc.RootElement.TryGetProperty("displayName", out var dn) + && dn.ValueKind == System.Text.Json.JsonValueKind.String + && dn.GetString() is { } s + && !string.IsNullOrWhiteSpace(s)) + return s.Trim(); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + Store.Log("QtShellBridge.(initialiseur) : " + ex.GetType().Name + ": " + ex.Message); + } + try + { + var name = new StringBuilder(256); + uint size = 256; + if (GetUserNameEx(3, name, ref size) && name.Length > 0) + return name.ToString(); + } + catch (Exception ex) + { + Store.Log("QtShellBridge.(initialiseur) : " + ex.GetType().Name + ": " + ex.Message); + } + return Environment.UserName; + }); + + static string FrontendLog => Path.Combine(Store.DataDir, "Caelestia", "frontend.log"); + + // Un moteur mort laissait un bureau sans barre et sans explication : on le dit, puis on rend la main. + static void Fail() => System.Windows.Application.Current?.Dispatcher.Invoke(() => + { + System.Windows.MessageBox.Show( + "Winlestia n'a pas pu démarrer son interface.\n\n" + + "Le détail de l'incident est dans :\n" + FrontendLog + "\n\n" + + "La barre des tâches Windows a été rétablie. Réinstalle Winlestia si cela se reproduit.", + "Winlestia", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error); + System.Windows.Application.Current.Shutdown(); + }); +} diff --git a/src/backend/Bridge/QtShellBridge.Icons.cs b/src/backend/Bridge/QtShellBridge.Icons.cs new file mode 100644 index 0000000..512d840 --- /dev/null +++ b/src/backend/Bridge/QtShellBridge.Icons.cs @@ -0,0 +1,215 @@ +// language: C# 12, file: QtShellBridge.Icons.cs, target: Windows 11 / .NET 8, icônes, palette, tâches et entrées +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using Windows.Devices.Radios; +namespace PanneauxGlass; + +internal sealed partial class QtShellBridge +{ + object EntryValue(Entry e) + => new + { + id = e.Id, + name = e.Name, + target = e.Target, + category = e.Category, + board = Store.BoardFor(e), + favorite = Store.State.Favorites.Contains(e.Id), + recent = Store.State.Recent.IndexOf(e.Id), + kind = Store.BoardFor(e) is "projects" or "documents" ? "folder" : "app" + }; + + void RunPalette(string kind, string id, string query) + { + if (kind == "systemRecent") + { + lock (DesktopRecents.Entries) + if (DesktopRecents.Entries.TryGetValue(id, out var recent)) + owner.Launch(recent); + return; + } + if (kind == "app") + { + var entry = Store.Visible().FirstOrDefault(e => e.Id == id); + if (entry != null) + owner.Launch(entry); + return; + } + if (kind == "quickTab") + { + var tab = Store.State.QuickTabs.FirstOrDefault(t => t.Id == id); + if (tab != null) + owner.RunQuickTab(tab); + return; + } + if (kind == "website") + { + var site = Store.State.Websites.FirstOrDefault(w => w.Id == id); + if (site != null) + owner.LaunchTarget(site.Url); + return; + } + if (kind == "web") + { + owner.WebSearch(query); + return; + } + if (kind != "command") + return; + _ = dispatcher.BeginInvoke((Action)(() => WithDialog(() => + { + switch (id) + { + case "capture": + owner.OpenCapture(); + break; + case "notes": + owner.OpenNotes(); + break; + case "focus": + owner.OpenFocus(); + break; + case "audio": + owner.OpenAudio(); + break; + case "profiles": + owner.OpenWorkspaces(); + break; + case "quicktabs": + owner.EditQuickTabs(); + break; + case "tools": + owner.OpenTools(); + break; + case "taskmgr": + owner.TaskManager(); + break; + case "calculator": + owner.LaunchTarget("calc.exe"); + break; + } + }))); + } + + void LoadIcon(Entry entry) + { + if (!requested.Add(entry.Id)) + return; + owner.Icons.Load(entry, source => + { + if (disposed) + return; + BitmapSource bitmap; + if (source is BitmapSource native) + bitmap = native; + else + { + var image = new System.Windows.Media.DrawingVisual(); + using (var dc = image.RenderOpen()) + dc.DrawImage(source, new System.Windows.Rect(0, 0, 64, 64)); + var target = new RenderTargetBitmap(64, 64, 96, 96, System.Windows.Media.PixelFormats.Pbgra32); + target.Render(image); + target.Freeze(); + bitmap = target; + } + bool fallback = owner.Icons.IsPlaceholder(source); + string file = + Path.Combine(cache, + "icons", + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(entry.Id + entry.IconPath + entry.Target)))[..24] + + (fallback ? ".fb.png" : ".png")); + _ = Task.Run(() => + { + try + { + SaveImage(bitmap, file); + dispatcher.BeginInvoke((Action)(() => + { + if (disposed) + return; + string previous = icons.GetValueOrDefault(entry.Id, ""); + icons[entry.Id] = file; + iconRevision++; + if (fallback) + RetryIcon(entry); + else + { + placeholderIcons.Remove(entry.Id); + if (previous.EndsWith(".fb.png", StringComparison.Ordinal)) + { + Safe.Try(() => File.Delete(previous)); + } + } + })); + } + catch (Exception ex) + { + dispatcher.BeginInvoke((Action)(() => requested.Remove(entry.Id))); + Store.Log("Icon Qt: " + ex.Message); + } + }); + }, "qt"); + } + + // Une vignette de remplacement vient d'un fichier .fb.png séparé, remplacé par le vrai PNG dès qu'un nouvel essai borné réussit. + readonly HashSet placeholderIcons = new(); + void RetryIcon(Entry entry) + { + placeholderIcons.Add(entry.Id); + if (CatalogueWatchRules.IconRetryDelay(owner.Icons.FailureCount(entry)) is not TimeSpan delay) + return; + _ = Task.Delay(delay).ContinueWith(_ => dispatcher.BeginInvoke((Action)(() => + { + if (disposed || !placeholderIcons.Contains(entry.Id)) + return; + requested.Remove(entry.Id); + LoadIcon(Store.Entries.FirstOrDefault(e => e.Id == entry.Id) ?? entry); + }))); + } + // Catalogue relu : les icônes en échec retrouvent une série d'essais. + internal void CatalogueChanged() + { + dispatcher.BeginInvoke((Action)(() => + { + if (disposed) + return; + owner.Icons.ResetFailures(); + foreach (string id in placeholderIcons.ToArray()) + { + var entry = Store.Entries.FirstOrDefault(e => e.Id == id); + if (entry == null || owner.Icons.FailureCount(entry) > 0) + continue; + requested.Remove(id); + LoadIcon(entry); + } + })); + } + + static void SaveImage(BitmapSource bitmap, string file) + { + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + using var stream = File.Create(file); + encoder.Save(stream); + } + + void RefreshTasks() + { + if (!owner.TaskbarEnabled || (!active && !dockVisible) || DateTime.UtcNow < nextTasks) + return; + nextTasks = DateTime.UtcNow.AddSeconds(3); + RefreshDockApps(); + } +} diff --git a/src/backend/Bridge/QtShellBridge.Pipe.cs b/src/backend/Bridge/QtShellBridge.Pipe.cs new file mode 100644 index 0000000..b33bf12 --- /dev/null +++ b/src/backend/Bridge/QtShellBridge.Pipe.cs @@ -0,0 +1,197 @@ +// language: C# 12, file: QtShellBridge.Pipe.cs, target: Windows 11 / .NET 8, serveur du pipe, enveloppe des requêtes et réponse +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using Windows.Devices.Radios; +namespace PanneauxGlass; + +internal sealed partial class QtShellBridge +{ + int firstReply; internal void Send(string value) + { + if (value == "palette") + dispatcher.BeginInvoke(DispatcherPriority.Background, (Action)owner.RefreshCatalogueIfStale); + if (frontendWindowPid > 0 + && (value is "toggle" or "access" or "media" or "performance" or "controls" or "palette" or "settings" + or "settings:social" or "settings:bookmarks" or "clipboard" or "audio" or "microphone" or "tools" + or "capture" or "wallpaper-switcher" + || value.StartsWith("workspace:"))) + AllowSetForegroundWindow(frontendWindowPid); + if (events.Count < 32) + events.Enqueue((value, frontendEpoch)); + wake.Set(); + } + + // Avertit si le client parle une autre version du protocole (ou n'en annonce aucune : client ancien). + static void CheckProtocol(JsonElement request) + { + int? announced = request.TryGetProperty("args", out var a) && a.ValueKind == JsonValueKind.Object + && a.TryGetProperty("protocol", out var p) && p.TryGetInt32(out int v) ? v : null; + if (announced != ProtocolVersion) + Store.Log("Qt IPC: version de protocole différente (client " + (announced?.ToString() ?? "non annoncée") + + ", serveur " + ProtocolVersion + ") : des commandes peuvent être ignorées."); + } + + async Task Serve() + { + while (!cancellation.IsCancellationRequested) + { + try + { + using var server = + new NamedPipeServerStream("PanneauxCaelestia", + PipeDirection.InOut, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + pipe = server; + await server.WaitForConnectionAsync(cancellation.Token); + using var reader = new StreamReader(server, new UTF8Encoding(false), false, 65536, true); + using var writer = new StreamWriter(server, new UTF8Encoding(false), 65536, true) { AutoFlush = true }; + bool versionChecked = false; + while (server.IsConnected && !cancellation.IsCancellationRequested) + { + string? line = await reader.ReadLineAsync(cancellation.Token); + if (line == null || line.Length > 131072) + break; + using var request = JsonDocument.Parse(line); + var element = request.RootElement.Clone(); + if (!versionChecked) + { + versionChecked = true; + CheckProtocol(element); + } + var operation = await dispatcher.InvokeAsync(() => Handle(element)); + string reply = await operation; + await writer.WriteLineAsync(reply.AsMemory(), cancellation.Token); + } + } + catch (OperationCanceledException) { break; } + catch (Exception ex) { if (!disposed) Store.Log("Qt IPC: " + ex.Message); } + finally + { + pipe = null; + if (!disposed) + _ = dispatcher.BeginInvoke((Action)(() => + { + dockPreview.Close(); + active = false; + CapturingShortcut = false; + dismissal.SetActive(false); + tiler.SetEnabled(false); + StopSampling(); + })); + } + } + } + + static string Text(JsonElement args, string name) + => args.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : ""; + + static double Number(JsonElement args, string name) + => args.TryGetProperty(name, out var value) && value.TryGetDouble(out double number) && double.IsFinite(number) ? number : 0; + + static bool Flag(JsonElement args, string name) => args.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True; + + string? lateNotice; + object? lateWallpaperPicker; + async Task Handle(JsonElement request) + { + string? notice = null; + object? wallpaperCapture = null; + object? bookmarksItems = null; + object? windowItems = null; + object? wallpaperPicker = null; + object? fileItems = null; + try + { + string command = Text(request, "command"); + var args = request.TryGetProperty("args", out var a) ? a : default; + string id = args.ValueKind == JsonValueKind.Object ? Text(args, "id") : ""; + + if (args.ValueKind == JsonValueKind.Object && args.TryGetProperty("epoch", out _)) + { + long epoch = (long)Number(args, "epoch"); + bool navigationChanged = epoch != frontendEpoch; + if (navigationChanged) + { + DesktopRecents.RefreshSoon(); + dockPreview.Close(); + } + if (Interlocked.Exchange(ref firstReply, 1) == 0) + Store.Log("Premier affichage Qt · t+" + (int)(DateTime.Now - Process.GetCurrentProcess().StartTime).TotalMilliseconds + + " ms"); + frontendEpoch = epoch; + frontendWindowPid = (uint)Number(args, "pid"); + bool shown = Flag(args, "visible"); + tiler.ExcludedProcess = frontendWindowPid; + explorer.ExcludedProcess = frontendWindowPid; + tiler.SetEnabled(Store.State.AutoTile && !options.IsAutomated); + if (Store.State.ScreenFrame) + frame.Enable(); + else + frame.Disable(); + if (!shown) + CapturingShortcut = false; + page = (int)Number(args, "page"); + dockVisible = Flag(args, "dock"); + controls = Flag(args, "controls"); + mixerVisible = Flag(args, "mixer"); + wifiVisible = Flag(args, "wifi"); + barsVisible = Flag(args, "bars"); + if (active != shown || navigationChanged) + { + active = shown; + dismissal.SetActive(active); + } + lastReply = DateTime.UtcNow; + wifi.SetOpen(shown && wifiVisible && !Quiet); + UpdateSampling(); + } + var call = new BridgeCall(args, id); + // Les sorties déjà posées par un handler survivent à une exception, comme avant le découpage. + string? raw = await actionTable.Run(this, command, call, Store.Log); + notice = call.Notice; + wallpaperCapture = call.WallpaperCapture; + bookmarksItems = call.BookmarksItems; + windowItems = call.WindowItems; + wallpaperPicker = call.WallpaperPicker; + fileItems = call.FileItems; + if (raw != null) + return raw; + } + catch (Exception ex) + { + notice = ex.Message; + Store.Log("Commande Qt: " + ex.Message); + } + notice ??= lateNotice; + lateNotice = null; + wallpaperPicker ??= lateWallpaperPicker; + lateWallpaperPicker = null; + var queued = events.Where(e => KeepEvent(e.name, e.epoch, frontendEpoch)).Select(e => e.name).ToArray(); + events.Clear(); + return JsonSerializer.Serialize(new + { + state = State(), + events = queued, + notice, + wallpaperCapture, + bookmarksItems, + windows = windowItems, + fileResults = fileItems, + wallpaperPicker + }, Json); + } +} diff --git a/src/backend/Shell/QtShellBridge.Sampling.cs b/src/backend/Bridge/QtShellBridge.Sampling.cs similarity index 69% rename from src/backend/Shell/QtShellBridge.Sampling.cs rename to src/backend/Bridge/QtShellBridge.Sampling.cs index 8e9f394..694cec6 100644 --- a/src/backend/Shell/QtShellBridge.Sampling.cs +++ b/src/backend/Bridge/QtShellBridge.Sampling.cs @@ -1,5 +1,4 @@ -// Quiet mode, sampling switches and the periodic Tick. - +// language: C# 12, file: QtShellBridge.Sampling.cs, target: Windows 11 / .NET 8, mode silencieux et échantillonnage périodique using System; using System.Collections.Generic; using System.Diagnostics; @@ -15,23 +14,20 @@ using System.Windows.Media.Imaging; using System.Windows.Threading; using Windows.Devices.Radios; - -namespace PanneauxGlass.Shell; +namespace PanneauxGlass; internal sealed partial class QtShellBridge { - // Fullscreen quiet mode: forced state (CLI only, never saved) over detection (preference on and a fullscreen - // monitor). Any monitor counts. + // Mode silencieux plein écran : l'état forcé (CLI seulement, jamais enregistré) prime sur la détection + // (préférence active et un moniteur en plein écran, n'importe lequel). volatile QuietForce quietForce = QuietForce.Auto; bool QuietDetected => QuietPolicy.Detected(Store.State.QuietFullscreen, FullscreenWatcher.Current.Length); bool Quiet => QuietPolicy.Effective(quietForce, QuietDetected); - void FullscreenChanged() { networkChanged?.Invoke(); ApplyQuiet(); } - void ApplyQuiet() { if (disposed) @@ -40,21 +36,13 @@ void ApplyQuiet() { wake.Set(); } - catch { } - dispatcher.BeginInvoke( - (Action)( - () => - { - if (!disposed) - UpdateSampling(); - } - ) - ); + catch (ObjectDisposedException) + { + // Signal déjà libéré à l'arrêt : plus personne à réveiller. + } + dispatcher.BeginInvoke((Action)(() => { if (!disposed) UpdateSampling(); })); } - - static bool LightRendering => - Store.State.ReducedEffects ?? GC.GetGCMemoryInfo().TotalAvailableMemoryBytes <= 8L * 1024 * 1024 * 1024; - + static bool LightRendering => Store.State.ReducedEffects ?? GC.GetGCMemoryInfo().TotalAvailableMemoryBytes <= 8L * 1024 * 1024 * 1024; void UpdateSampling() { bars.SetActive(active && barsVisible && page == 0 && Store.State.ReducedEffects != true && !Quiet); @@ -65,11 +53,8 @@ void UpdateSampling() if (need) { if (!telemetry.Running) - telemetry.Start( - value => dispatcher.BeginInvoke((Action)(() => reading = value)), - LightRendering ? 2000 : 1000, - quick: true - ); + telemetry.Start(value => dispatcher.BeginInvoke((Action)(() => reading = value)), LightRendering ? 2000 : 1000, + quick: true); } else if (telemetry.Running) _ = telemetry.StopAsync(); @@ -92,10 +77,7 @@ async Task StartMedia() { await media.StartAsync(); } - catch (Exception ex) - { - Store.Log("Qt music: " + ex.Message); - } + catch (Exception ex) { Store.Log("Musique Qt: " + ex.Message); } } void StopSampling() @@ -135,20 +117,17 @@ async void Tick(object? sender, EventArgs e) player = snapshot; mixer.MediaApp = snapshot.ExeStem; mixer.MediaAumid = snapshot.Aumid; - // Technique: WASAPI process loopback (ProcessAudio.dll) on the root PID of the selected player, with - // INCLUDE_TARGET_PROCESS_TREE, then a 2048-point Hann-windowed FFT in WindowsSpectrum (48 log bands). - // Not a peak meter and never the default output: another app's audio (a call) is not captured. - // Rest state: a paused/ended player gives target 0, the capture is destroyed and the bars decay to - // zero. + // Technique : capture WASAPI process loopback (ProcessAudio.dll) sur le PID racine du lecteur choisi, avec + // INCLUDE_TARGET_PROCESS_TREE, puis FFT de 2048 points fenêtrée de Hann dans WindowsSpectrum (48 bandes log). + // Ni un vu-mètre, ni la sortie par défaut : le son d'une autre application (un appel) n'est pas capturé. + // Au repos : un lecteur en pause ou terminé donne la cible 0, la capture est détruite et les barres retombent à zéro. spectrum.SetTarget(active && page == 1 && snapshot.Playing ? mixer.MediaPid : 0); string key = snapshot.Title + snapshot.Artist; if (snapshot.Artwork != null && key != artworkKey) { artworkKey = key; - artworkPath = Path.Combine( - cache, - "cover-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key)))[..16] + ".png" - ); + artworkPath = Path.Combine(cache, "cover-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( + key)))[..16] + ".png"); SaveImage(snapshot.Artwork, artworkPath); } } @@ -176,13 +155,7 @@ async void Tick(object? sender, EventArgs e) else displaysOpen = false; } - catch (Exception ex) - { - Store.Log("Services Qt: " + ex.Message); - } - finally - { - busy = false; - } + catch (Exception ex) { Store.Log("Services Qt: " + ex.Message); } + finally { busy = false; } } } diff --git a/src/backend/Bridge/QtShellBridge.State.cs b/src/backend/Bridge/QtShellBridge.State.cs new file mode 100644 index 0000000..e051df8 --- /dev/null +++ b/src/backend/Bridge/QtShellBridge.State.cs @@ -0,0 +1,196 @@ +// language: C# 12, file: QtShellBridge.State.cs, target: Windows 11 / .NET 8, état envoyé au frontend +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using Windows.Devices.Radios; +namespace PanneauxGlass; + +internal sealed partial class QtShellBridge +{ + object State() + { + M3.Wallpaper(wallpaper.Monitors, () => owner.SetTheme(Store.State.Theme)); + RefreshTasks(); + var gpu = reading?.Gpus.FirstOrDefault(); + double mem = reading == null || reading.MemoryTotal == 0 ? 0 : 1 - (double)reading.MemoryAvailable / reading.MemoryTotal; + long total = reading?.Drives.Sum(d => d.Total) ?? 0, free = reading?.Drives.Sum(d => d.Free) ?? 0; + var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64); + + return new + { + systemRecent = DesktopRecents.Values, + wallpapers = wallpaper.Monitors, + wallpaperRevision = wallpaper.Revision, + audioReadMs = mixer.LastReadMilliseconds, + audioMixer = new + { + running = mixer.Running, + ready = mixer.Ready, + applications = mixer.Applications, + devices = mixer.Devices + }, + quickTabs = Store.State.QuickTabs.Select(t => new { id = t.Id, name = t.Name }).ToArray(), + social = social.Snapshot(), + bookmarks = bookmarks.Snapshot(), + settings = new + { + dataFolder = Store.DataDir, + version = (System.Reflection + .Assembly + .GetExecutingAssembly() + .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) + .OfType() + .FirstOrDefault()?.InformationalVersion + ?? "").Split('+')[0], + accentBorders = Store.State.AccentBorders, + explorerStatus = explorer.Status, + explorerTheme = Store.State.ExplorerTheme, + explorerFolders = Store.State.ExplorerFolders.ToArray(), + autoTile = Store.State.AutoTile, + tileExclusions = tiler.Exclusions, + desktopLogo = Store.State.DesktopLogo, + osd = Store.State.Osd, + quietFullscreen = Store.State.QuietFullscreen, + desktopIndicator = Store.State.DesktopIndicator, + dockDrawerOpen = Store.State.DockDrawerOpen, + activeBorderOnly = Store.State.ActiveBorderOnly, + reducedEffects = LightRendering, + customPalette = Store.State.CustomPalette, + customGlass = Store.State.CustomGlass, + customGlassOpacity = Store.State.CustomGlassOpacity, + themeMode = Store.State.AutoTheme ? "auto" : Store.State.Theme == "custom" ? "custom" : Store.State.Theme == "shell" + ? "light" : "dark", + screenFrame = Store.State.ScreenFrame, + topActivationMargin = Store.State.TopActivationMargin, + dockWidth = Store.State.DockWidth, + frameThickness = Store.State.FrameThickness, + glassOpacity = Store.State.GlassOpacity, + motionScale = Store.State.Motion, + iconSize = Store.State.IconSize, + autostart = Native.Autostart, + filesDefaultHandler = Store.State.FilesDefaultHandler, + filesPreload = Store.State.FilesPreload, + socialBackground = Store.State.SocialBackground, + clipboardHistory = Store.State.ClipboardHistory, + dictationEnabled = Store.State.DictationEnabled, + dictationKey = Store.State.DictationKey, + shortcut = GlobalShortcut.Label(Store.State.Shortcut.Modifiers, Store.State.Shortcut.Key), + paletteShortcut = owner.Keys.PaletteLabel, + wallpaperShortcut = owner.Keys.WallpaperLabel, + settingsShortcut = GlobalShortcut.Label(Store.State.SettingsShortcut.Modifiers, Store.State.SettingsShortcut.Key), + dockVisible = Store.State.DockVisible, + replaceTaskbar = Store.State.ReplaceTaskbar, + bookmarksRailVisible = Store.State.BookmarksRailVisible, + socialRailVisible = Store.State.SocialRailVisible, + clipboardImages = Store.State.ClipboardImages, + clipboardLimit = Store.State.ClipboardLimit, + shortcuts = owner.Shortcuts.List(), + shortcutStatus = owner.Shortcuts.Status() + }, + clipboard = active && page == 6 + ? owner.QtClipboard + .Items + .Take(80) + .Select(c => new { id = c.Id, kind = c.Kind, text = c.Text, image = c.ImagePath, pinned = c.Pinned, at = c.At }) + .ToArray() + : Array.Empty(), + diagnostics = new + { + capturingShortcut = CapturingShortcut, + root = Store.Root, + frontendEpoch, + frontendWindowPid, + launcherPid = frontend?.Id ?? 0, + cpuSeconds = Process.GetCurrentProcess().TotalProcessorTime.TotalSeconds, + workingSet = Process.GetCurrentProcess().WorkingSet64 + }, + dark = Store.State.Theme != "shell", + taskbarEnabled = owner.TaskbarEnabled, + networkKind = NetworkKindWatcher.Current, + virtualDesktops = new + { + count = VirtualDesktops.Current.Count, + current = VirtualDesktops.Current.Current, + names = VirtualDesktops.Current.Names + }, + fullscreenMonitors = FullscreenWatcher.Current, + quiet = Quiet, + quietForced = QuietPolicy.Label(quietForce), + performance = new + { + cpu = reading?.Cpu, + cpuTemp = reading?.CpuTemperature, + cpuTempSource = reading?.CpuTempSource, + gpu = gpu?.Load, + gpuTemp = gpu?.Temperature, + memPercent = mem, + memUsedKiB = reading == null ? 0 : (reading.MemoryTotal - reading.MemoryAvailable) / 1024d, + diskPercent = total > 0 ? 1 - (double)free / total : 0, + diskUsedKiB = (total - free) / 1024d, + download = reading?.Download, + upload = reading?.Upload, + samples = telemetry.Samples, + sampling = telemetry.Running + }, + sysInfo = new + { + osName = "Windows " + (Environment.OSVersion.Version.Build >= 22000 ? "11" : "10"), + uptime = $"{(int)uptime.TotalHours} h {uptime.Minutes} min", + userName = displayUserName.Value + }, + media = new + { + volume = mixer.MediaVolume >= 0 ? Math.Round(mixer.MediaVolume) : 70, + durationKnown = player?.DurationKnown ?? true, + rate = player?.Rate ?? 1, + available = player?.Available ?? false, + app = player?.App ?? "", + title = player?.Title ?? "", + artist = player?.Artist ?? "", + album = player?.Album ?? "", + art = player?.Artwork != null ? new Uri(artworkPath).AbsoluteUri : "", + playing = player?.Playing ?? false, + position = player?.Position ?? 0, + duration = player?.Duration ?? 0, + canToggle = player?.CanToggle ?? false, + canNext = player?.CanNext ?? false, + canPrevious = player?.CanPrevious ?? false, + canSeek = player?.CanSeek ?? false + }, + controls = new + { + volume = sound?.Volume ?? 0, + muted = sound?.Muted ?? false, + device = sound?.Device ?? "", + brightnessAvailable = displays.Any(d => d.Available), + brightness = displays.FirstOrDefault(d => d.Available)?.Value ?? 0, + displays = displays.Select(d => new { id = d.Id, name = d.Name, value = d.Value, available = d.Available }).ToArray(), + wifiOn = connection?.Wifi.On ?? false, + wifi = wifiVisible ? wifi.Snapshot() : null, + bluetoothOn = connection?.Bluetooth.On ?? false, + network = connection?.Network ?? "" + }, + apps = Store.Visible().Select(EntryValue).ToArray(), + favorites = Store.HomeFavorites().Select(EntryValue).ToArray(), + recent = Store.RecentEntries().Take(24).Select(EntryValue).ToArray(), + websites = Store.State.Websites.Select(w => new { id = w.Id, name = w.Name, icon = w.IconPath }).ToArray(), + profiles = Store.State.Workspaces.Select(w => new { id = w.Id, name = w.Name }).ToArray(), + taskbarApps = tasks, + runningApps = runningApps, + icons, + iconRevision + }; + + } +} diff --git a/src/backend/Bridge/QtShellBridge.System.cs b/src/backend/Bridge/QtShellBridge.System.cs new file mode 100644 index 0000000..19ebe04 --- /dev/null +++ b/src/backend/Bridge/QtShellBridge.System.cs @@ -0,0 +1,88 @@ +// language: C# 12, file: QtShellBridge.System.cs, target: Windows 11 / .NET 8, implémentation de production des ports du pont +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Windows.Devices.Radios; +namespace PanneauxGlass; + +internal sealed partial class QtShellBridge +{ + // Délègue aux services du pont (champs lus à l'appel : ils sont affectés par le constructeur de production). + sealed class RealSystem : IAudioPort, IWifiPort, IDesktopPort, IWallpaperPort, IDisplayPort, IShellPort + { + readonly QtShellBridge b; + internal RealSystem(QtShellBridge bridge) => b = bridge; + + public AudioSnapshot ReadAudio() => b.audio.Read(); + public void SetVolume(double percent) => b.audio.SetVolume(percent); + public void SetMute(bool muted) => b.audio.SetMute(muted); + public Task SetMediaVolume(double value) => b.mixer.SetMediaVolume(value); + public Task SetApplication(string id, double? level, bool? muted) => b.mixer.SetApplication(id, level, muted); + public Task SetDevice(string id, double? level, bool? muted, bool select) => b.mixer.SetDevice(id, level, muted, select); + public Task MediaCommand(string action, double position) => b.media.CommandAsync(action, position); + public double[] SpectrumValues() => b.spectrum.Values; + + public Task WifiConnect(string ssid, string? password) => b.wifi.ConnectAsync(ssid, password); + public Task WifiForget(string ssid) => b.wifi.ForgetAsync(ssid); + public void OpenWifiPortal() => WifiHost.OpenPortal(); + public void OpenWifiSettings(bool location) => WifiHost.OpenSettings(location); + public Task ToggleRadio(RadioKind kind) => b.connections.ToggleAsync(kind); + public Task ReadConnections() => b.connections.ReadAsync(); + + public void CloseDesktop(int index) => VirtualDesktops.Close(index); + public void NewDesktop() => VirtualDesktops.CreateNew(); + public void SwitchDesktop(int target, int relative) => VirtualDesktops.SwitchTo(target, relative); + + public void EmptyRecycleBin() => RecycleActions.Empty(); + public void OpenFilesHandler() => FilesDefaultHandler.Open(); + public void SetClipboardText(string text) => System.Windows.Clipboard.SetText(text); + public void Shutdown() => System.Windows.Application.Current.Shutdown(); + public IEnumerable PaletteWindows() => QtShellBridge.PaletteWindows(); + + public string? PickFolder() + { + using var dialog = new System.Windows.Forms.FolderBrowserDialog + { + Description = "Choisir le dossier à personnaliser", + UseDescriptionForTitle = true + }; + return dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK ? dialog.SelectedPath : null; + } + + public Task SetDisplay(int id, double level) => MonitorBrightness.SetDisplayAsync(id, level); + public IReadOnlyList Displays() => MonitorBrightness.Displays(); + public void StartProcess(System.Diagnostics.ProcessStartInfo info) => System.Diagnostics.Process.Start(info); + + public async Task SetBrightnessAtCursor(double level) + { + Native.GetCursorPos(out var point); + await MonitorBrightness.SetAsync(point, level); + } + + public bool ActivateApplication(long hwnd) + { + var app = TaskbarNative.Applications().FirstOrDefault(w => w.Handle.ToInt64() == hwnd); + if (app == null) + return false; + TaskbarNative.Activate(app); + return true; + } + + public DictationSnapshot DictationState() => b.dictation.Snapshot(); + public void BeginDictationCapture() => b.dictation.BeginCapture(); + public void EndDictationCapture() => b.dictation.EndCapture(); + + public void NotifyWallpaper() => b.wallpaper.Notify(100, true); + public (int code, string text, string[] animated) ApplyWallpaper(string path, int display, bool light) + { + var done = WallpaperApply.ApplyKnown(path, display, light); + return (done.code, done.text, WallpaperApply.Animated()); + } + public WallpaperPickerView WallpaperPicker() => new( + WallpaperApply.List().Select(d => new WallpaperDisplayView(d.id, d.name, d.width, d.height, d.primary, d.path)).ToArray(), + WallpaperApply.Animated()); + public string? CaptureWallpaper(Native.Rect? target) => WallpaperMedia.CaptureHost(target); + public void SyncExplorerPalette(string accent, string background, string text) => b.explorer.Sync(accent, background, text); + public string RestoreExplorer() => b.explorer.Restore(); + } +} diff --git a/src/backend/Bridge/QtShellBridge.Testing.cs b/src/backend/Bridge/QtShellBridge.Testing.cs new file mode 100644 index 0000000..5fbe571 --- /dev/null +++ b/src/backend/Bridge/QtShellBridge.Testing.cs @@ -0,0 +1,43 @@ +// language: C# 12, file: QtShellBridge.Testing.cs, target: Windows 11 / .NET 8, point d'entrée du dispatch pour les tests +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +namespace PanneauxGlass; + +internal sealed partial class QtShellBridge +{ + // Même chemin que Handle (table de dispatch réelle, sortie dans `call`) sans l'assemblage d'état, qui lit l'audio, le réseau + // et la télémétrie. Renvoie la réponse brute éventuelle (null = réponse d'état à assembler). + internal Task RunAction(string command, BridgeCall call) => actionTable.Run(this, command, call, Store.Log); + + // Messages différés (réponse au prochain échange), lus par les tests. + internal string? LateNotice => lateNotice; + internal object? LateWallpaperPicker => lateWallpaperPicker; + internal AudioSnapshot? LastSound => sound; + internal Dictionary DockEntries => dockEntries; + + // Évènements en attente, filtrés comme dans Handle, puis vidés. + internal string[] DrainEvents() + { + var queued = events.Where(e => KeepEvent(e.name, e.epoch, frontendEpoch)).Select(e => e.name).ToArray(); + events.Clear(); + return queued; + } +} + +// Pièces injectées dans le constructeur d'essai du pont : le banc de test les fournit, la production n'y passe pas. +internal sealed record BridgeParts( + IAudioPort Audio, + IWifiPort Wifi, + IDesktopPort Desktop, + IWallpaperPort Wallpaper, + IDisplayPort Display, + IShellPort Shell, + System.Threading.EventWaitHandle Wake, + WindowTiler Tiler, + ScreenFrameReservations Frame, + FileSearch FileSearch, + AudioBarsHost Bars, + SocialService Social, + BookmarksService Bookmarks, + string Directory); diff --git a/src/backend/Bridge/QtShellBridge.cs b/src/backend/Bridge/QtShellBridge.cs new file mode 100644 index 0000000..2b48a30 --- /dev/null +++ b/src/backend/Bridge/QtShellBridge.cs @@ -0,0 +1,328 @@ +// C# 12 / .NET 8 / Windows 11. GPL-3.0-or-later. Services Windows du frontend QML Caelestia d'origine. +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using Windows.Devices.Radios; +namespace PanneauxGlass; + +internal sealed partial class QtShellBridge : IDisposable +{ + + uint frontendWindowPid; + IBridgeOwner owner; + readonly RunOptions options; + readonly Dispatcher dispatcher = Dispatcher.CurrentDispatcher; + + readonly CancellationTokenSource cancellation = new(); + readonly EventWaitHandle wake; + readonly Queue<(string name, long epoch)> events = new(); + long frontendEpoch; + readonly Action networkChanged; + + // Services système lourds (registre, threads audio, Explorateur) : créés par le constructeur de production seulement ; + // les tests passent par les ports (BridgePorts.cs), jamais par ces champs. + readonly ExplorerAppearance explorer = null!; + readonly WindowTiler tiler; + readonly ScreenFrameReservations frame; + readonly WindowsWallpaper wallpaper = null!; + readonly AudioMixerHost mixer = null!; + bool mixerVisible; + readonly WindowsSpectrum spectrum = null!; + readonly MediaController media = null!; + readonly SystemAudio audio = null!; + readonly WindowsConnections connections = null!; + readonly WifiHost wifi = null!; + bool wifiVisible; + bool wifiSettingsOpened, bluetoothSettingsOpened; + readonly PcTelemetry telemetry = null!; + readonly DictationService dictation; + readonly FileSearch fileSearch; + readonly IAudioPort audioPort; + readonly IWifiPort wifiPort; + readonly IDesktopPort desktopPort; + readonly IWallpaperPort wallpaperPort; + readonly IDisplayPort displayPort; + readonly IShellPort shellPort; + + CliServer? cli; + readonly AudioBarsHost bars; + bool barsVisible; + static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(500); + readonly DispatcherTimer timer = new() { Interval = TickInterval }; + + readonly Dictionary icons = new(); + readonly HashSet requested = new(); + readonly Dictionary dockEntries = new(); + + readonly SocialService social; + readonly BookmarksService bookmarks; + readonly OutsideDismiss dismissal; + readonly string root, cache; + Process? frontend; + NamedPipeServerStream? pipe; + // Version du protocole JSON lignes (docs/BRIDGE.md). Le client l'annonce dans args.protocol de chaque requête ; + // une divergence est journalisée une fois par connexion, sans refuser la connexion. + internal const int ProtocolVersion = 1; + + OsdWatcher? osdWatcher; + PcReading? reading; + MediaSnapshot? player; + AudioSnapshot? sound; + IReadOnlyList displays = Array.Empty(); + ConnectionSnapshot? connection; + + DateTime nextConnections, nextBrightness, nextTasks, lastReply; + object[] tasks = Array.Empty(); + + bool displaysOpen, readingDisplays, dockVisible, active, controls, pageMusic, busy, disposed, startingMedia; + int page, iconRevision; + string artworkKey = "", artworkPath = ""; + + static readonly JsonSerializerOptions Json = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + + internal void DisplaysChanged() => frame.DisplaysChanged(); + internal bool Visible => active; + internal bool DialogOpen; + internal bool CapturingShortcut + { + get; private set; + } + internal void CaptureHotkey(IntPtr data) + { + uint value = unchecked((uint)data.ToInt64()); + Send("shortcut-key:" + (value & 15) + ":" + ((value >> 16) & 255)); + } + + internal static bool KeepEvent(string name, long queuedEpoch, long currentEpoch) => name != "close" || queuedEpoch == currentEpoch; + + // Constructeur d'essai : ni pipe, ni processus Qt, ni hooks ; tout ce qui touche l'OS vient de `parts`. + internal QtShellBridge(IBridgeOwner coordinator, RunOptions options, BridgeParts parts) + { + this.options = options; + owner = coordinator; + audioPort = parts.Audio; + wifiPort = parts.Wifi; + desktopPort = parts.Desktop; + wallpaperPort = parts.Wallpaper; + displayPort = parts.Display; + shellPort = parts.Shell; + wake = parts.Wake; + tiler = parts.Tiler; + frame = parts.Frame; + fileSearch = parts.FileSearch; + bars = parts.Bars; + social = parts.Social; + bookmarks = parts.Bookmarks; + dictation = null!; + dismissal = null!; + networkChanged = null!; + root = cache = parts.Directory; + } + + internal QtShellBridge(IBridgeOwner coordinator, RunOptions options) + { + this.options = options; + var real = new RealSystem(this); + audioPort = real; + wifiPort = real; + desktopPort = real; + wallpaperPort = real; + displayPort = real; + shellPort = real; + wake = new EventWaitHandle(false, EventResetMode.AutoReset, @"Local\PanneauxCaelestiaWake"); + explorer = new ExplorerAppearance(); + tiler = new WindowTiler(); + frame = new ScreenFrameReservations(); + wallpaper = new WindowsWallpaper(); + mixer = new AudioMixerHost(); + spectrum = new WindowsSpectrum(); + media = new MediaController(); + audio = new SystemAudio(); + connections = new WindowsConnections(); + wifi = new WifiHost(); + telemetry = new PcTelemetry(); + fileSearch = new FileSearch(); + bars = new AudioBarsHost(); + DialogOpen = Environment.GetEnvironmentVariable("PANNEAUX_VERIFY_MATERIAL84") == "1" + || Environment.GetEnvironmentVariable("PANNEAUX_VERIFY_THICKNESS85") == "1"; + owner = coordinator; + root = Program.QtRoot ?? Path.GetFullPath(Path.Combine(Store.Root, "..", "Caelestia-Windows")); + cache = Path.Combine(Store.DataDir, "Caelestia"); + Directory.CreateDirectory(Path.Combine(cache, "icons")); + Safe.Try(() => + { + foreach (string stale in Directory.GetFiles(Path.Combine(cache, "icons"), "*.fb.png").Concat(Directory.GetFiles(Path.Combine( + cache, "icons"), "win-*.png"))) + File.Delete(stale); + }); + + dismissal = new OutsideDismiss(() => Send("close"), () => active && !DialogOpen, h => + { + GetWindowThreadProcessId(h, out uint pid); + return frontend != null && (pid == frontend.Id || pid == frontendWindowPid); + }); + + dictation = new DictationService(dispatcher, Send); + osdWatcher = new OsdWatcher(dispatcher, + (volume, muted) => + { + if (Store.State.Osd && !active && !Quiet) + Send("osd:volume:" + volume + ":" + ( + muted ? 1 : 0)); + }, + line => { if (Store.State.Osd && !active && !Quiet) Send(line); }); + dictation.KeyCaptured = vk => + { + if (vk is > 0 and < 255) + Store.State.DictationKey = vk; + dictation.Apply(Store.State.DictationEnabled, Store.State.DictationKey); + Store.Save(); + }; + dictation.Apply(Store.State.DictationEnabled, Store.State.DictationKey); + _ = Serve(); + cli = new CliServer(RunCli); + var launched = DateTime.UtcNow; + var start = + new ProcessStartInfo(Path.Combine(root, ".venv", "Scripts", "pythonw.exe")) + { + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = root, + RedirectStandardError = true + }; + // Le PySide6 livré est le seul valable : un site-packages utilisateur ne doit pas s'intercaler. + start.Environment["PYTHONNOUSERSITE"] = "1"; + start.ArgumentList.Add(Path.Combine(root, "frontend.py")); + start.ArgumentList.Add("--backend-pid"); + start.ArgumentList.Add(Environment.ProcessId.ToString()); + if (options.Mode == LaunchMode.Verify) + start.ArgumentList.Add("--verify"); + else if (options.Mode == LaunchMode.Review) + start.ArgumentList.Add("--preview"); + frontend = Process.Start(start) ?? throw new InvalidOperationException("Le moteur Qt n'a pas pu être lancé."); + AttachFrontendJob(frontend); + Store.Log("Frontend lancé · t+" + (int)(DateTime.Now - Process.GetCurrentProcess().StartTime).TotalMilliseconds + " ms"); + // Sans console, une trace Python disparaîtrait : on la garde dans glass.log. + frontend.ErrorDataReceived += (_, line) => + { + if (!disposed && !string.IsNullOrWhiteSpace(line.Data)) + Store.Log("Qt · " + + line.Data); + }; + frontend.BeginErrorReadLine(); + frontend.EnableRaisingEvents = true; + frontend.Exited += (_, _) => _ = dispatcher.BeginInvoke((Action)(() => + { + if (disposed) + return; + bool crashed = !active && (DateTime.UtcNow - launched).TotalSeconds < 10; + Store.Log((crashed ? "Le moteur Qt n'a pas démarré." : "Le moteur Qt s'est arrêté.") + " La barre Windows est rétablie."); + dockPreview.Close(); + tiler.SetEnabled(false); + frame.Disable(); + owner.SuspendTaskbar(); + owner.RestoreDesktopIcons(); + active = false; + CapturingShortcut = false; + dismissal.SetActive(false); + tiler.SetEnabled(false); + StopSampling(); + if (crashed && !options.IsAutomated) + Fail(); + })); + timer.Tick += Tick; + timer.Start(); + social = new SocialService(dispatcher, () => Send("social")); + _ = social.Start(); + bookmarks = new BookmarksService(dispatcher, () => Send("bookmarks")); + bookmarks.Start(); + networkChanged = () => + { + if (!disposed) + { + try + { + wake.Set(); + } + catch ( + ObjectDisposedException) + { /* Signal déjà libéré à l'arrêt : plus personne à réveiller. */ + } + } + }; + NetworkKindWatcher.Changed += networkChanged; + NetworkKindWatcher.Start(); + VirtualDesktops.Changed += networkChanged; + VirtualDesktops.Start(); + FullscreenWatcher.IgnoredPids = () => new[] { (uint)(Volatile.Read(ref frontend)?.Id ?? 0), Volatile.Read(ref frontendWindowPid) }; + FullscreenWatcher.Changed += FullscreenChanged; + FullscreenWatcher.Start(); + + foreach (var entry in Store.HomeFavorites().Take(16)) + LoadIcon(entry); + + } + + public void Dispose() + { + if (disposed) + return; + disposed = true; + if (frontend is { HasExited: false }) + { + Safe.Try(() => + { + frontend.Kill(); + frontend.WaitForExit(2000); + + }); + } + timer.Stop(); + wifi.Dispose(); + bars.Dispose(); + try + { + dictation.Dispose(); + } + catch (Exception ex) { Store.Log("Dictée (fermeture): " + ex.GetType().Name + ": " + ex.Message); } + osdWatcher?.Dispose(); + dockPreview.Dispose(); + social.Dispose(); + bookmarks.Dispose(); + explorer.Dispose(); + dismissal.Dispose(); + cli?.Dispose(); + fileSearch.Dispose(); + cancellation.Cancel(); + pipe?.Dispose(); + tiler.Dispose(); + frame.Dispose(); + wallpaper.Dispose(); + mixer.Dispose(); + spectrum.Dispose(); + media.Dispose(); + telemetry.Dispose(); + NetworkKindWatcher.Changed -= networkChanged; + NetworkKindWatcher.Stop(); + VirtualDesktops.Changed -= networkChanged; + VirtualDesktops.Stop(); + FullscreenWatcher.Changed -= FullscreenChanged; + FullscreenWatcher.Stop(); + audio.Dispose(); + frontend?.Dispose(); + wake.Dispose(); + cancellation.Dispose(); + } + +} diff --git a/src/backend/Shell/QuietPolicy.cs b/src/backend/Bridge/QuietPolicy.cs similarity index 55% rename from src/backend/Shell/QuietPolicy.cs rename to src/backend/Bridge/QuietPolicy.cs index 1b66e71..fd92124 100644 --- a/src/backend/Shell/QuietPolicy.cs +++ b/src/backend/Bridge/QuietPolicy.cs @@ -1,43 +1,35 @@ -// C# 12 / QuietPolicy.cs / Pure decisions of the fullscreen quiet mode: does a window cover its monitor, and which of forced / detected wins. +// C# 12 / QuietPolicy.cs / Décisions pures du mode silencieux plein écran : une fenêtre couvre-t-elle son moniteur, +// et de l'état forcé ou détecté, lequel l'emporte. using System; -namespace PanneauxGlass.Shell; +namespace PanneauxGlass; internal enum QuietForce { - Auto, - On, - Off, + Auto, On, Off } internal static class QuietPolicy { - // Chromium fullscreen leaves the bottom row free (3440x1439 on a 3440x1440 monitor): tolerate a few px. + // Chromium en plein écran laisse la dernière ligne libre (3440x1439 sur 3440x1440) : on tolère quelques pixels. internal const int Slack = 2; - // Does the window rectangle (l,t,r,b) cover the monitor rectangle (ml,mt,mr,mb)? internal static bool Covers(int l, int t, int r, int b, int ml, int mt, int mr, int mb, int slack = Slack) => mr > ml && mb > mt && l <= ml + slack && t <= mt + slack && r >= mr - slack && b >= mb - slack; - // Detection counts only when the preference is on and at least one monitor shows a fullscreen window. + // La détection ne compte que si la préférence est active et si au moins un moniteur affiche une fenêtre en plein écran. internal static bool Detected(bool enabled, int fullscreenMonitors) => enabled && fullscreenMonitors > 0; // A forced state wins over detection; auto follows detection. - internal static bool Effective(QuietForce forced, bool detected) => - forced switch - { - QuietForce.On => true, - QuietForce.Off => false, - _ => detected, - }; + internal static bool Effective(QuietForce forced, bool detected) => forced switch + { + QuietForce.On => true, + QuietForce.Off => false, + _ + => detected + }; - internal static string Label(QuietForce forced) => - forced switch - { - QuietForce.On => "on", - QuietForce.Off => "off", - _ => "auto", - }; + internal static string Label(QuietForce forced) => forced switch { QuietForce.On => "on", QuietForce.Off => "off", _ => "auto" }; internal static bool TryParse(string? text, out QuietForce forced) { diff --git a/src/backend/ControlCenter/ControlCenterWindow.Cards.cs b/src/backend/ControlCenter/ControlCenterWindow.Cards.cs new file mode 100644 index 0000000..c4e1f0e --- /dev/null +++ b/src/backend/ControlCenter/ControlCenterWindow.Cards.cs @@ -0,0 +1,298 @@ +// ControlCenterWindow : cartes du centre de contrôle (connexions, actions, média, niveaux, raccourcis). + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using Windows.Devices.Radios; + +namespace PanneauxGlass; + +internal sealed partial class ControlCenterWindow +{ + FrameworkElement ConnectionsCard() + { + radios.Clear(); + var grid = new UniformGrid { Columns = 2, Rows = 2 }; + foreach (var item in new[] + { + ("wifi", "\uE701", "Wi-Fi"), + ("bluetooth", "\uE702", "Bluetooth"), + ("network", "\uE839", "Réseau"), + ("settings", "\uE713", "Réglages") + }) + { + var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(1, 1, 1, 3) }; + var button = Circle(UI.IconButton(item.Item2, item.Item3, () => ConnectionAction(item.Item1))); + button.Width = 43; + button.Height = 43; + button.Margin = new Thickness(0, 0, 0, 4); + stack.Children.Add(button); + var label = UI.Text(item.Item3, 11, null, true); + label.HorizontalAlignment = HorizontalAlignment.Center; + stack.Children.Add(label); + var status = UI.Text(item.Item1 == "settings" ? "Windows" : "…", 9, UI.Dim); + status.HorizontalAlignment = HorizontalAlignment.Center; + status.TextTrimming = TextTrimming.CharacterEllipsis; + status.MaxWidth = 80; + stack.Children.Add(status); + radios[item.Item1] = (button, status); + button.MouseRightButtonUp += (_, e) => + { + OpenSetting(item.Item1 == "wifi" + ? "network-wifi" + : item.Item1 == "bluetooth" ? "bluetooth" : item.Item1 == "network" ? "network-status" : ""); + e.Handled = true; + }; + grid.Children.Add(stack); + } + var card = Card(grid); + card.Height = 184; + return card; + } + FrameworkElement ActionsCard() + { + var grid = new UniformGrid { Columns = 2, Rows = 2 }; + void Tile(string glyph, string label, string hint, Action run) + { + var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(1, 1, 1, 3) }; + var button = Circle(UI.IconButton(glyph, label, run)); + button.Width = 43; + button.Height = 43; + button.Margin = new Thickness(0, 0, 0, 4); + stack.Children.Add(button); + var text = UI.Text(label, 11, null, true); + text.HorizontalAlignment = HorizontalAlignment.Center; + stack.Children.Add(text); + var note = UI.Text(hint, 9, UI.Dim); + note.HorizontalAlignment = HorizontalAlignment.Center; + note.TextTrimming = TextTrimming.CharacterEllipsis; + note.MaxWidth = 96; + stack.Children.Add(note); + grid.Children.Add(stack); + } + Tile("\uE72E", "Verrouiller", "Session Windows", () => SystemActions.Lock(owner)); + Tile("\uE722", "Capture", "Zone d’écran", () => SystemActions.Screenshot(owner)); + Tile("\uE74D", "Corbeille", "Vider · confirmation", () => SystemActions.EmptyBin(owner)); + var power = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(1, 1, 1, 3) }; + Button? powerButton = null; + powerButton = Circle(UI.IconButton("\uE7E8", + "Alimentation", + () => + GlassMenu.Open(owner, + this, + powerButton!, + "Alimentation", + new[] + { + new MenuAction("Redémarrer", "\uE72C", () => SystemActions.Power(owner, + true)), + new MenuAction("Éteindre", "\uE7E8", () => SystemActions.Power(owner, + false)), + new MenuAction("Mettre en veille", "\uE708", () + => SystemActions.Sleep(owner)) + }))); + powerButton.Width = 43; + powerButton.Height = 43; + powerButton.Margin = new Thickness(0, 0, 0, 4); + power.Children.Add(powerButton); + var powerLabel = UI.Text("Alimentation", 11, null, true); + powerLabel.HorizontalAlignment = HorizontalAlignment.Center; + power.Children.Add(powerLabel); + var powerHint = UI.Text("Éteindre · veille", 9, UI.Dim); + powerHint.HorizontalAlignment = HorizontalAlignment.Center; + power.Children.Add(powerHint); + grid.Children.Add(power); + var card = Card(grid); + card.Height = 184; + return card; + } + FrameworkElement MediaCard() + { + var stack = new StackPanel(); + var header = new Grid(); + header.ColumnDefinitions.Add(new() + { + Width = GridLength.Auto + }); + header.ColumnDefinitions.Add(new() + { + Width = new GridLength(1, GridUnitType.Star) + }); + var artContent = new Grid(); + var placeholder = UI.Glyph("\uE8D6", 25); + placeholder.Opacity = .7; + artContent.Children.Add(placeholder); + artContent.Children.Add(cover); + var art = + new Border + { + Width = 48, + Height = 48, + CornerRadius = new CornerRadius(13), + Clip = new RectangleGeometry(new Rect(0, 0, 48, 48), 13, 13), + Background = UI.Brush("#20FFFFFF"), + Child = artContent, + Margin = new Thickness(0, 0, 10, 0) + }; + header.Children.Add(art); + var texts = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + appName.FontSize = 9; + appName.Margin = new Thickness(0, 0, 0, 4); + texts.Children.Add(appName); + foreach (var text in new[] { mediaTitle, artist }) + { + text.TextTrimming = TextTrimming.CharacterEllipsis; + texts.Children.Add(text); + } + Grid.SetColumn(texts, 1); + header.Children.Add(texts); + stack.Children.Add(header); + var transport = + new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 9, 0, 0) + }; + previous.Margin = new Thickness(0, 0, 10, 0); + next.Margin = new Thickness(10, 0, 0, 0); + transport.Children.Add(previous); + transport.Children.Add(play); + transport.Children.Add(next); + stack.Children.Add(transport); + stack.Children.Add(progress); + var times = new DockPanel(); + DockPanel.SetDock(duration, Dock.Right); + times.Children.Add(duration); + times.Children.Add(elapsed); + stack.Children.Add(times); + var layout = new Grid(); + layout.Children.Add(stack); + sessionPicker.HorizontalAlignment = HorizontalAlignment.Right; + sessionPicker.VerticalAlignment = VerticalAlignment.Top; + sessionPicker.Margin = new Thickness(0, -7, -6, 0); + layout.Children.Add(sessionPicker); + appName.Margin = new Thickness(0, 0, 15, 4); + var card = Card(layout); + card.Height = 184; + return card; + } + FrameworkElement Levels() + { + var grid = new Grid(); + grid.ColumnDefinitions.Add(new() + { + Width = new GridLength(1, GridUnitType.Star) + }); + grid.ColumnDefinitions.Add(new() + { + Width = new GridLength(12) + }); + int col = 2; + var prefs = Store.State.Controls; + foreach (var level in new[] { volume, brightness }) + { + if (level == volume && !prefs.ShowVolume || level == brightness && !prefs.ShowBrightness) + continue; + grid.ColumnDefinitions.Add(new() + { + Width = new GridLength(88) + }); + grid.ColumnDefinitions.Add(new() + { + Width = new GridLength(12) + }); + var stack = new StackPanel(); + stack.Children.Add(level); + var caption = UI.Text(level == volume ? "Volume" : "Luminosité", 11, UI.Dim); + caption.HorizontalAlignment = HorizontalAlignment.Center; + caption.Margin = new Thickness(0, 8, 0, 0); + if (level == brightness) + { + var link = UI.Button("Luminosité ›", () => OpenSetting("display")); + link.FontSize = 11; + link.Foreground = UI.Dim; + link.Background = Brushes.Transparent; + link.BorderBrush = Brushes.Transparent; + link.Padding = new Thickness(0); + link.Margin = new Thickness(0, 8, 0, 0); + link.ToolTip = "Réglages écran · ouvre les options Windows"; + stack.Children.Add(link); + } + else + stack.Children.Add(caption); + Grid.SetColumn(stack, col); + grid.Children.Add(stack); + col += 2; + } + var options = new StackPanel(); + var output = new StackPanel(); + var outputTitle = IconLabel("\uE7F5", "Sortie audio", 12); + output.Children.Add(outputTitle); + device.TextTrimming = TextTrimming.CharacterEllipsis; + device.Margin = new Thickness(0, 9, 0, 0); + output.Children.Add(device); + var change = UI.Text("Choisir la sortie ›", 10, UI.Dim); + change.Margin = new Thickness(0, 5, 0, 0); + output.Children.Add(change); + var outputButton = UI.Button("", () => OpenSetting("sound")); + outputButton.Content = output; + outputButton.HorizontalContentAlignment = HorizontalAlignment.Stretch; + outputButton.Padding = new Thickness(13, 13, 13, 13); + System.Windows.Automation.AutomationProperties.SetName(outputButton, "Choisir la sortie audio dans Windows"); + options.Children.Add(outputButton); + mute.Margin = new Thickness(0, 10, 0, 10); + options.Children.Add(mute); + var screen = UI.Button("", () => OpenSetting("quiethours")); + screen.Content = IconLabel("\uE708", "Concentration", 12); + screen.ToolTip = "Ouvrir les paramètres de concentration Windows"; + screen.HorizontalContentAlignment = HorizontalAlignment.Left; + screen.Padding = new Thickness(13, 11, 13, 11); + options.Children.Add(screen); + grid.Children.Add(options); + return grid; + } + FrameworkElement Shortcuts() + { + var stack = new StackPanel(); + var title = UI.Text("Raccourcis Windows", 13, null, true); + title.Margin = new Thickness(2, 0, 0, 12); + stack.Children.Add(title); + var grid = new UniformGrid { Columns = 4 }; + foreach (var shortcut in Store.State.Controls.Shortcuts) + { + var tile = new StackPanel { Margin = new Thickness(0, 0, 0, 11) }; + var button = Circle(UI.IconButton(shortcut.Glyph, shortcut.Name, () => owner.LaunchWindowsShortcut(shortcut))); + button.Width = 48; + button.Height = 48; + button.Background = new LinearGradientBrush(UI.Color("#30FFFFFF"), UI.Color("#13FFFFFF"), 80); + tile.Children.Add(button); + var label = UI.Text(shortcut.Name, 10, UI.Dim); + label.TextTrimming = TextTrimming.CharacterEllipsis; + label.TextAlignment = TextAlignment.Center; + label.Margin = new Thickness(1, 6, 1, 0); + label.ToolTip = shortcut.Name; + tile.Children.Add(label); + grid.Children.Add(tile); + } + var add = new StackPanel { Margin = new Thickness(0, 0, 0, 11) }; + var plus = Circle(UI.IconButton("\uE710", "Ajouter un raccourci Windows", () => owner.EditControls(this, true))); + plus.Width = 48; + plus.Height = 48; + add.Children.Add(plus); + var text = UI.Text("Ajouter", 10, UI.Dim); + text.TextAlignment = TextAlignment.Center; + text.Margin = new Thickness(0, 6, 0, 0); + add.Children.Add(text); + grid.Children.Add(add); + stack.Children.Add(grid); + return stack; + } +} diff --git a/src/backend/ControlCenter/ControlCenterWindow.Services.cs b/src/backend/ControlCenter/ControlCenterWindow.Services.cs new file mode 100644 index 0000000..f69695f --- /dev/null +++ b/src/backend/ControlCenter/ControlCenterWindow.Services.cs @@ -0,0 +1,281 @@ +// ControlCenterWindow : lecture et commandes média, connexions et luminosité. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using Windows.Devices.Radios; + +namespace PanneauxGlass; + +internal sealed partial class ControlCenterWindow +{ + async Task StartMedia() + { + try + { + await media.StartAsync(); + await RefreshMedia(); + } + catch (Exception ex) + { + mediaTitle.Text = "Lecture indisponible"; + artist.Text = "Ouvrir les commandes audio Windows"; + Store.Log("SMTC: " + ex.Message); + } + } + void RefreshAudio() + { + if (!IsVisible) + return; + audioReads++; + var updated = audio.Read(); + if (updated == audioState) + return; + audioState = updated; + syncing = true; + try + { + volume.IsEnabled = audioState.Available; + if (!volume.Dragging) + volume.Value = audioState.Volume; + volume.Glyph = audioState.Muted || audioState.Volume < 1 ? "\uE74F" : "\uE767"; + volume.InvalidateVisual(); + volume.ToolTip = audioState.Available ? "Volume système · glisser ou utiliser la molette" : "Aucune sortie audio disponible"; + device.Text = audioState.Device; + device.ToolTip = audioState.Device; + mute.IsEnabled = audioState.Available; + mute.Content = IconLabel(audioState.Muted ? "\uE74F" : "\uE767", audioState.Muted ? "Son coupé" : "Son activé", 12); + } + finally { syncing = false; } + } + async Task RefreshMedia() + { + if (!IsVisible || !Enabled("media") || refreshingMedia) + return; + refreshingMedia = true; + mediaReads++; + int expected = revision; + try + { + var state = await media.ReadAsync(); + if (state != null && expected == revision && IsVisible) + { + var before = mediaState; + mediaState = state; + ApplyMedia(before); + } + } + catch (Exception ex) + { + Feedback("Lecture indisponible"); + Store.Log("SMTC lecture: " + ex.Message); + } + finally { refreshingMedia = false; } + } + void ApplyMedia(MediaSnapshot? before = null) + { + var s = mediaState; + if (s == null) + return; + mediaTitle.Text = s.Title; + mediaTitle.ToolTip = s.Title; + artist.Text = s.Artist; + artist.ToolTip = s.Artist; + appName.Text = s.Available ? s.App.ToUpperInvariant() : "MUSIQUE"; + cover.Source = s.Artwork; + cover.Visibility = s.Artwork == null ? Visibility.Hidden : Visibility.Visible; + play.IsEnabled = s.Available && s.CanToggle; + previous.IsEnabled = s.Available && s.CanPrevious; + next.IsEnabled = s.Available && s.CanNext; + if (before?.Playing != s.Playing) + play.Content = UI.Glyph(s.Playing ? "\uE769" : "\uE768", 20); + if (play.Content is TextBlock glyph) + glyph.Foreground = UI.Brush("#254D74"); + if (!seeking) + { + progress.Maximum = Math.Max(1, s.Duration); + progress.Value = s.Position; + } + progress.IsEnabled = s.Available && s.CanSeek && s.Duration > 0; + elapsed.Text = Time(s.Position); + duration.Text = Time(s.Duration); + } + internal static string Time(double seconds) + { + var value = TimeSpan.FromSeconds(Math.Max(0, double.IsFinite(seconds) ? seconds : 0)); + return value.TotalHours >= 1 ? value.ToString(@"h\:mm\:ss") : value.ToString(@"m\:ss"); + } + void EndSeek() + { + if (!seeking) + return; + seeking = false; + owner.EndLayoutInteraction(); + _ = MediaCommand("seek", progress.Value); + } + async Task MediaCommand(string action, double position = 0) + { + try + { + if (!await media.CommandAsync(action, position)) + Feedback("Cette commande n’est pas disponible pour cette lecture."); + await RefreshMedia(); + } + catch (Exception ex) { Feedback(ex.Message); } + } + void OpenSessions() + { + var sessions = media.Sessions(); + var actions = new List { new("Automatique · priorité à Spotify", "\uE8D6", () => { + Store.State.Controls.MediaApp = ""; + Store.Save(); + _ = RefreshMedia(); }, string.IsNullOrEmpty(Store.State.Controls.MediaApp)) }; + foreach (var s in sessions) + actions.Add(new(s.name, "\uE8D6", () => + { + Store.State.Controls.MediaApp = s.id; + Store.Save(); + _ = RefreshMedia(); + }, Store.State.Controls.MediaApp == s.id)); + GlassMenu.Open(owner, this, sessionPicker, "Source musicale", actions); + } + async Task RefreshConnections() + { + if (!IsVisible || !Enabled("connections") || refreshingConnections) + return; + refreshingConnections = true; + int expected = revision; + try + { + var result = await connections.ReadAsync(); + if (IsVisible && expected == revision) + { + connectionState = result; + UpdateConnections(); + } + } + catch (Exception ex) + { + Feedback("Connexions indisponibles"); + Store.Log("Radios: " + ex.Message); + } + finally { refreshingConnections = false; } + } + void UpdateConnections() + { + if (connectionState is not { } s) + return; + subtitle.Text = s.Internet ? s.Network + " · Connecté à Internet" : "Windows · Hors connexion"; + foreach (string id in new[] { "wifi", "bluetooth", "network" }) + if (radios.TryGetValue(id, out var tile)) + { + bool on = id == "wifi" ? s.Wifi.On : id == "bluetooth" ? s.Bluetooth.On : s.Internet; + bool present = id == "wifi" ? s.Wifi.Present : id == "bluetooth" ? s.Bluetooth.Present : true; + tile.button.Background = UI.Brush(on ? id == "network" ? "#D93BB283" : "#DE247BF2" : "#20FFFFFF"); + tile.status.Text = !present + ? "Absent" + : id == "network" ? s.Ethernet ? "Ethernet" : s.Internet ? "Connecté" : "Hors ligne" : on ? "Activé" : "Désactivé"; + tile.button.ToolTip = id == "wifi" + ? s.Wifi.Present ? "Activer/désactiver le Wi-Fi · clic droit pour les réseaux" + : "Wi-Fi absent · ouvrir les réglages" + : id == "bluetooth" + ? s.Bluetooth.Present ? "Activer/désactiver Bluetooth · clic droit pour les appareils" + : "Bluetooth absent · ouvrir les réglages" + : "Ouvrir les connexions Windows"; + } + } + async void ConnectionAction(string id) + { + try + { + if (id == "wifi" || id == "bluetooth") + { + bool present = id == "wifi" ? connectionState?.Wifi.Present == true : connectionState?.Bluetooth.Present == true; + if (!present) + { + OpenSetting(id == "wifi" ? "network-wifi" : "bluetooth"); + return; + } + if (radios.TryGetValue(id, out var tile)) + tile.button.IsEnabled = false; + try + { + await owner.RunControlTask(async () => + { + await connections.ToggleAsync(id == "wifi" ? RadioKind.WiFi : RadioKind.Bluetooth); + await RefreshConnections(); + }); + } + finally { if (radios.TryGetValue(id, out tile)) tile.button.IsEnabled = true; } + } + else + OpenSetting(id == "network" ? "network-status" : ""); + } + catch (Exception ex) { Feedback(ex.Message); } + } + Native.Point ScreenPoint() + { + var dpi = VisualTreeHelper.GetDpi(this); + return new Native.Point { X = (int)((Left + Width / 2) * dpi.DpiScaleX), Y = (int)((Top + Height / 2) * dpi.DpiScaleY) }; + } + async Task ReadBrightness() + { + int expected = revision; + brightness.IsEnabled = false; + brightness.ToolTip = "Lecture du réglage de l’écran…"; + try + { + var state = await MonitorBrightness.ReadAsync(ScreenPoint()); + if (!IsVisible || expected != revision) + return; + syncing = true; + try + { + brightness.IsEnabled = state.Available; + brightness.Value = state.Value; + brightness.ToolTip = state.Available + ? state.Device + " · glisser pour régler" + : state.Error + " Clique sur Luminosité pour ouvrir les réglages écran."; + } + finally { syncing = false; } + } + catch (Exception ex) + { + brightness.ToolTip = "Réglage non disponible · clique sur Luminosité pour les réglages écran"; + Store.Log("Luminosité: " + ex.Message); + } + } + async Task WriteBrightness() + { + if (writingBrightness || !pendingBrightness.HasValue) + return; + writingBrightness = true; + double value = pendingBrightness.Value; + pendingBrightness = null; + var point = ScreenPoint(); + try + { + await MonitorBrightness.SetAsync(point, value); + } + catch (Exception ex) { Feedback(ex.Message); } + finally + { + writingBrightness = false; + if (pendingBrightness.HasValue && !brightnessTimer.IsEnabled) + brightnessTimer.Start(); + } + } + void Feedback(string message) + { + note.Text = message; + note.ToolTip = message; + } + void OpenSetting(string page) => owner.LaunchWindowsShortcut(new() { Name = "Réglages Windows", Target = "ms-settings:" + page }); +} diff --git a/src/backend/ControlCenter/ControlCenterWindow.cs b/src/backend/ControlCenter/ControlCenterWindow.cs new file mode 100644 index 0000000..723e7a6 --- /dev/null +++ b/src/backend/ControlCenter/ControlCenterWindow.cs @@ -0,0 +1,446 @@ +// C# 12, fichier ControlCenterWindow.cs, cible Windows 11 / .NET 8 WPF +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using Windows.Devices.Radios; + +namespace PanneauxGlass; + +internal sealed partial class ControlCenterWindow : GlassWindow +{ + // Regroupe les rafales d'événements média (changement de piste) en une seule relecture. + const int MediaNoticeMs = 250; + readonly Coordinator owner; + readonly SystemAudio audio = new(); + readonly WindowsConnections connections = new(); + readonly MediaController media = new(); + readonly DispatcherTimer mediaNotice = new() { Interval = TimeSpan.FromMilliseconds(MediaNoticeMs) }; + int mediaEvents, mediaReads, audioReads; + long activeSince; + readonly DispatcherTimer timer = new() + { + Interval = TimeSpan.FromSeconds(1) + }, brightnessTimer = new() + { + Interval = TimeSpan.FromMilliseconds(90) + }; + readonly Grid body = new(); + readonly TextBlock subtitle, note; + readonly GlassLevel volume = new("Volume système"), brightness = new("Luminosité de l’écran") { Glyph = "\uE706" }; + readonly TextBlock mediaTitle = UI.Text("Aucune lecture", 14, null, true), + artist = UI.Text("Lance Spotify pour commencer", 11, UI.Dim), + appName = UI.Text("MUSIQUE", 10, UI.Dim), + elapsed = UI.Text("0:00", 10, UI.Dim), + duration = UI.Text("0:00", 10, UI.Dim), + device = UI.Text("Sortie Windows", 12), + muteLabel = UI.Text("Son activé", 12); + readonly Image cover = new() { Width = 48, Height = 48, Stretch = Stretch.UniformToFill }; + readonly Button play, previous, next, mute, sessionPicker; + readonly Slider progress = new() + { + Minimum = 0, + Maximum = 1, + Height = 21, + IsMoveToPointEnabled = true, + SmallChange = 5, + LargeChange = 15 + }; + readonly Dictionary radios = new(); + AudioSnapshot audioState = new(false, 0, false, "Sortie Windows"); + ConnectionSnapshot? connectionState; + MediaSnapshot? mediaState; + bool syncing, seeking, refreshingMedia, refreshingConnections, writingBrightness; + int ticks, revision; + double? pendingBrightness; + string layoutKey = ""; + internal ControlCenterWindow(Coordinator coordinator) : base("Winlestia — centre de contrôle") + { + owner = coordinator; + Radius = 38; + ContentHost.CacheMode = null; + ContentHost.Margin = new Thickness(24, 25, 24, 22); + ContentHost.RowDefinitions.Add(new() + { + Height = GridLength.Auto + }); + ContentHost.RowDefinitions.Add(new() + { + Height = new GridLength(1, GridUnitType.Star) + }); + ContentHost.RowDefinitions.Add(new() + { + Height = GridLength.Auto + }); + var header = new DockPanel { Margin = new Thickness(1, 0, 1, 20) }; + var close = UI.IconButton("\uE76B", "Replier les commandes Windows", owner.ToggleControls); + DockPanel.SetDock(close, Dock.Right); + header.Children.Add(close); + var title = new StackPanel(); + title.Children.Add(UI.Text("Centre de contrôle", 23, null, true)); + subtitle = UI.Text("Windows · Tes commandes essentielles", 11, UI.Dim); + subtitle.Margin = new Thickness(0, 6, 0, 0); + title.Children.Add(subtitle); + header.Children.Add(title); + ContentHost.Children.Add(header); + var scroll = + new ScrollViewer + { + Content = body, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled + }; + Grid.SetRow(scroll, 1); + ContentHost.Children.Add(scroll); + var footer = new DockPanel { Margin = new Thickness(0, 15, 0, 0) }; + var edit = UI.Button("\uE70F Modifier", () => owner.EditControls(this)); + edit.Content = IconLabel("\uE70F", "Modifier", 12); + edit.Padding = new Thickness(12, 8, 12, 8); + System.Windows.Automation.AutomationProperties.SetName(edit, "Personnaliser les commandes Windows"); + DockPanel.SetDock(edit, Dock.Right); + footer.Children.Add(edit); + note = UI.Text("À ta façon", 11, UI.Dim); + note.VerticalAlignment = VerticalAlignment.Center; + note.TextTrimming = TextTrimming.CharacterEllipsis; + note.Margin = new Thickness(2, 0, 10, 0); + footer.Children.Add(note); + Grid.SetRow(footer, 2); + ContentHost.Children.Add(footer); + previous = MediaButton("\uE892", "Morceau précédent", () => _ = MediaCommand("previous")); + play = MediaButton("\uE768", "Lecture ou pause", () => _ = MediaCommand("toggle")); + next = MediaButton("\uE893", "Morceau suivant", () => _ = MediaCommand("next")); + play.Width = 42; + play.Height = 42; + play.Background = UI.Brush("#EAEEF8FF"); + if (play.Content is TextBlock pg) + pg.Foreground = UI.Brush("#254D74"); + sessionPicker = UI.IconButton("\uE712", "Choisir l’application musicale", OpenSessions); + sessionPicker.Width = 25; + sessionPicker.Height = 25; + sessionPicker.Background = Brushes.Transparent; + sessionPicker.BorderBrush = Brushes.Transparent; + mute = UI.Button("", () => + { + try + { + audio.SetMute(!audioState.Muted); + RefreshAudio(); + } + catch (Exception ex) { Feedback(ex.Message); } + }); + mute.HorizontalContentAlignment = HorizontalAlignment.Left; + mute.Padding = new Thickness(13, 11, 13, 11); + mute.Content = IconLabel("\uE767", "Son activé", 12); + System.Windows.Automation.AutomationProperties.SetName(mute, "Activer ou couper le son"); + volume.ValueChanged += (_, _) => + { + if (syncing) + return; + try + { + audio.SetVolume(volume.Value); + if (audioState.Muted && volume.Value > 0) + audio.SetMute(false); + } + catch (Exception ex) { Feedback(ex.Message); } + }; + brightness.ValueChanged += (_, _) => + { + if (syncing || !brightness.IsEnabled) + return; + pendingBrightness = brightness.Value; + if (!brightnessTimer.IsEnabled && !writingBrightness) + brightnessTimer.Start(); + }; + brightnessTimer.Tick += async (_, _) => + { + brightnessTimer.Stop(); + await WriteBrightness(); + }; + foreach (var level in new[] { volume, brightness }) + level.DraggingChanged += dragging => { if (dragging) owner.BeginLayoutInteraction(); else owner.EndLayoutInteraction(); }; + progress.PreviewMouseLeftButtonDown += (_, _) => + { + seeking = true; + owner.BeginLayoutInteraction(); + }; + progress.AddHandler(Mouse.PreviewMouseUpEvent, new MouseButtonEventHandler((_, _) => EndSeek()), true); + progress.AddHandler(Thumb.DragCompletedEvent, new DragCompletedEventHandler((_, _) => EndSeek()), true); + progress.KeyUp += (_, e) => + { + if (e.Key is Key.Left or Key.Right or Key.Home or Key.End or Key.PageDown or Key.PageUp) + _ = MediaCommand("seek", progress.Value); + }; + timer.Tick += (_, _) => + { + if (!IsVisible) + return; + if (Enabled("levels")) + RefreshAudio(); + if (Enabled("media")) + _ = RefreshMedia(); + if (++ticks % 5 == 0 && Enabled("connections")) + _ = RefreshConnections(); + }; + media.Changed += () => + { + if (IsVisible && Enabled("media")) + { + mediaEvents++; + if (!mediaNotice.IsEnabled) + mediaNotice.Start(); + } + }; + mediaNotice.Tick += (_, _) => + { + mediaNotice.Stop(); + if (IsVisible) + _ = RefreshMedia(); + }; + IsVisibleChanged += (_, _) => { if (IsVisible) Start(); else Stop(); }; + SizeChanged += (_, _) => Rebuild(); + Closed += (_, _) => + { + Stop(); + media.Dispose(); + audio.Dispose(); + }; + KeyDown += (_, e) => { if (e.Key == Key.Escape) owner.Collapse(); }; + Rebuild(); + } + internal static string ModuleName(string id) + => id switch + { + "connections" => "Connexions", + "media" => "Musique", + "levels" => "Volume et luminosité", + "actions" => "Actions rapides", + _ => "Raccourcis Windows" + }; + internal static StackPanel IconLabel(string glyph, string label, int size = 14) + { + var stack = new StackPanel { Orientation = Orientation.Horizontal }; + var icon = UI.Glyph(glyph, 19); + icon.Margin = new Thickness(0, 0, 11, 0); + stack.Children.Add(icon); + var text = UI.Text(label, size); + text.VerticalAlignment = VerticalAlignment.Center; + stack.Children.Add(text); + return stack; + } + Button MediaButton(string glyph, string label, Action action) + { + var button = Circle(UI.IconButton(glyph, label, action)); + button.Width = 32; + button.Height = 32; + button.Background = Brushes.Transparent; + button.BorderBrush = Brushes.Transparent; + return button; + } + static ControlTemplate? roundTemplate; + internal static Button Circle(Button button) + { + if (roundTemplate == null) + { + roundTemplate = (ControlTemplate)System.Windows.Markup.XamlReader.Parse(""" + + + + + + + + """); + roundTemplate.Seal(); + } + button.Template = roundTemplate; + return button; + } + Border Card(UIElement content, int radius = 28) + => new() + { + CornerRadius = new CornerRadius(radius), + Background = new LinearGradientBrush(UI.Color("#25D6ECFF"), UI.Color("#10FFFFFF"), 70), + BorderBrush = new LinearGradientBrush(UI.Color("#65F1FAFF"), UI.Color("#16FFFFFF"), 70), + BorderThickness = new Thickness(1), + Padding = new Thickness(13), + Child = content + }; + internal void Rebuild() + { + var prefs = Store.State.Controls; + string key = + ActualWidth + + "|" + + string.Join(",", prefs.Modules) + + "|" + + string.Join(",", prefs.HiddenModules) + + prefs.ShowVolume + + prefs.ShowBrightness + + string.Join("|", prefs.Shortcuts.Select(s => s.Id + s.Name + s.Glyph + s.Target)); + if (layoutKey == key) + return; + layoutKey = key; + body.Children.Clear(); + body.RowDefinitions.Clear(); + body.ColumnDefinitions.Clear(); + body.ColumnDefinitions.Add(new() + { + Width = new GridLength(1, GridUnitType.Star) + }); + body.ColumnDefinitions.Add(new() + { + Width = new GridLength(14) + }); + body.ColumnDefinitions.Add(new() + { + Width = new GridLength(1, GridUnitType.Star) + }); + // Les contrôles persistants ne sont rattachés à un nouveau parent que lorsque la disposition des modules change. + foreach (var element in new FrameworkElement[] + { + volume, + brightness, + cover, + play, + previous, + next, + sessionPicker, + progress, + mediaTitle, + artist, + appName, + elapsed, + duration, + device, + mute + }) + Detach(element); + int row = 0; + bool half = false; + foreach (string module in prefs.Modules.Where(m => !prefs.HiddenModules.Contains(m))) + { + bool wide = module is "levels" or "shortcuts"; + if (wide && half) + { + row++; + half = false; + } while (body.RowDefinitions.Count <= row) + body.RowDefinitions.Add(new() + { + Height = GridLength.Auto + }); + FrameworkElement content = module switch + { + "connections" => ConnectionsCard(), + "media" => MediaCard(), + "levels" => Levels(), + "actions" => ActionsCard(), + _ => Shortcuts() + }; + content.Margin = new Thickness(0, 0, 0, 12); + Grid.SetRow(content, row); + Grid.SetColumn(content, half ? 2 : 0); + if (wide) + { + Grid.SetColumnSpan(content, 3); + row++; + } + else if (half) + { + row++; + half = false; + } + else + half = true; + body.Children.Add(content); + } + if (body.Children.Count == 0) + { + var empty = UI.Text("Ajoute des commandes avec Modifier.", 14, UI.Dim); + empty.Margin = new Thickness(5, 25, 5, 25); + body.Children.Add(empty); + } + UpdateConnections(); + ApplyMedia(); + if (IsVisible) + RefreshServices(); + } + static void Detach(FrameworkElement element) + { + if (element.Parent is Panel panel) + panel.Children.Remove(element); + else if (element.Parent is Decorator decorator) + decorator.Child = null; + else if (element.Parent is ContentControl control) + control.Content = null; + } + bool Enabled(string module) => !Store.State.Controls.HiddenModules.Contains(module); + void RefreshServices() + { + if (Enabled("levels")) + RefreshAudio(); + else + audio.Dispose(); + if (Enabled("media")) + _ = StartMedia(); + else + media.Stop(); + if (Enabled("connections")) + _ = RefreshConnections(); + if (Enabled("levels") && Store.State.Controls.ShowBrightness) + _ = ReadBrightness(); + } + void Start() + { + revision++; + ticks = 0; + mediaEvents = mediaReads = audioReads = 0; + activeSince = Environment.TickCount64; + RefreshServices(); + timer.Start(); + } + void Stop() + { + if (activeSince != 0) + { + Store.Log("Activité commandes · ms=" + + (Environment.TickCount64 - activeSince) + + " · audio=" + + audioReads + + " · médias=" + + mediaReads + + " · notifications=" + + mediaEvents); + activeSince = 0; + } + revision++; + mediaNotice.Stop(); + timer.Stop(); + brightnessTimer.Stop(); + if (volume.IsMouseCaptured) + volume.ReleaseMouseCapture(); + if (brightness.IsMouseCaptured) + brightness.ReleaseMouseCapture(); + if (seeking) + { + seeking = false; + owner.EndLayoutInteraction(); + } + if (pendingBrightness.HasValue) + _ = WriteBrightness(); + media.Stop(); + audio.Dispose(); + } +} diff --git a/src/backend/Views/ControlEditorWindow.cs b/src/backend/ControlCenter/ControlEditorWindow.cs similarity index 54% rename from src/backend/Views/ControlEditorWindow.cs rename to src/backend/ControlCenter/ControlEditorWindow.cs index abc5f7e..703461b 100644 --- a/src/backend/Views/ControlEditorWindow.cs +++ b/src/backend/ControlCenter/ControlEditorWindow.cs @@ -1,4 +1,4 @@ -// language: C# 12, file: ControlEditorWindow.cs, target: Windows 11 / .NET 8 WPF +// C# 12, fichier ControlEditorWindow.cs, cible Windows 11 / .NET 8 WPF using System; using System.Collections.Generic; using System.IO; @@ -7,15 +7,12 @@ using System.Windows.Controls; using System.Windows.Media; -namespace PanneauxGlass.Views; +namespace PanneauxGlass; internal sealed class ControlEditorWindow : GlassWindow { - readonly Coordinator owner; - readonly StackPanel list = new(); - - internal ControlEditorWindow(Coordinator coordinator, bool addImmediately) - : base(Strings.PanneauxGlassCustomizeTheControls) + readonly Coordinator owner; readonly StackPanel list = new(); + internal ControlEditorWindow(Coordinator coordinator, bool addImmediately) : base("Winlestia — personnaliser les commandes") { owner = coordinator; Width = 680; @@ -23,16 +20,25 @@ internal ControlEditorWindow(Coordinator coordinator, bool addImmediately) WindowStartupLocation = WindowStartupLocation.CenterOwner; Radius = 32; ContentHost.Margin = new Thickness(28); - ContentHost.RowDefinitions.Add(new() { Height = GridLength.Auto }); - ContentHost.RowDefinitions.Add(new() { Height = new GridLength(1, GridUnitType.Star) }); - ContentHost.RowDefinitions.Add(new() { Height = GridLength.Auto }); + ContentHost.RowDefinitions.Add(new() + { + Height = GridLength.Auto + }); + ContentHost.RowDefinitions.Add(new() + { + Height = new GridLength(1, GridUnitType.Star) + }); + ContentHost.RowDefinitions.Add(new() + { + Height = GridLength.Auto + }); var header = new DockPanel { Margin = new Thickness(0, 0, 0, 22) }; - var close = UI.IconButton("\uE711", Strings.CloseCustomization, Close); + var close = UI.IconButton("\uE711", "Fermer la personnalisation", Close); DockPanel.SetDock(close, Dock.Right); header.Children.Add(close); var title = new StackPanel(); - title.Children.Add(UI.Text(Strings.TheControlsYourWay, 24, null, true)); - var sub = UI.Text(Strings.ShowArrangeAndAddSavedAutomatically, 12, UI.Dim); + title.Children.Add(UI.Text("Les commandes, à ta façon", 24, null, true)); + var sub = UI.Text("Afficher, ranger et ajouter · enregistrement automatique", 12, UI.Dim); sub.Margin = new Thickness(0, 7, 0, 0); title.Children.Add(sub); header.Children.Add(title); @@ -41,13 +47,14 @@ internal ControlEditorWindow(Coordinator coordinator, bool addImmediately) { Content = list, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - Padding = new Thickness(0, 0, 8, 0), + Padding = new Thickness(0, + 0, 8, 0) }; Grid.SetRow(scroll, 1); ContentHost.Children.Add(scroll); var footer = new DockPanel { Margin = new Thickness(0, 20, 0, 0) }; - var add = UI.Button(Strings.AddAShortcut, () => Edit(null)); - add.Content = ControlCenterWindow.IconLabel("\uE710", Strings.AddAShortcut, 13); + var add = UI.Button("Ajouter un raccourci", () => Edit(null)); + add.Content = ControlCenterWindow.IconLabel("\uE710", "Ajouter un raccourci", 13); add.Padding = new Thickness(17, 12, 17, 12); footer.Children.Add(add); var done = UI.Button("Terminer", Close); @@ -56,19 +63,10 @@ internal ControlEditorWindow(Coordinator coordinator, bool addImmediately) footer.Children.Add(done); Grid.SetRow(footer, 2); ContentHost.Children.Add(footer); - KeyDown += (_, e) => - { - if (e.Key == System.Windows.Input.Key.Escape) - Close(); - }; + KeyDown += (_, e) => { if (e.Key == System.Windows.Input.Key.Escape) Close(); }; Rebuild(); - Loaded += (_, _) => - { - if (addImmediately) - Dispatcher.BeginInvoke((Action)(() => Edit(null))); - }; + Loaded += (_, _) => { if (addImmediately) Dispatcher.BeginInvoke((Action)(() => Edit(null))); }; } - void Changed() { Store.State.Controls.Normalize(); @@ -76,7 +74,6 @@ void Changed() owner.ControlsChanged(); Rebuild(); } - void Rebuild() { list.Children.Clear(); @@ -86,45 +83,33 @@ void Rebuild() { var row = new DockPanel { Margin = new Thickness(0, 10, 0, 0) }; var order = new StackPanel { Orientation = Orientation.Horizontal }; - var up = UI.IconButton( - "\uE70E", - "Monter " + ControlCenterWindow.ModuleName(id), - () => + var up = UI.IconButton("\uE70E", "Monter " + ControlCenterWindow.ModuleName(id), () => + { + int index = prefs.Modules.IndexOf(id); + if (index > 0) { - int index = prefs.Modules.IndexOf(id); - if (index > 0) - { - prefs.Modules.RemoveAt(index); - prefs.Modules.Insert(index - 1, id); - Changed(); - } + prefs.Modules.RemoveAt(index); + prefs.Modules.Insert(index - 1, id); + Changed(); } - ); + }); up.IsEnabled = prefs.Modules.IndexOf(id) > 0; - var down = UI.IconButton( - "\uE70D", - "Descendre " + ControlCenterWindow.ModuleName(id), - () => + var down = UI.IconButton("\uE70D", "Descendre " + ControlCenterWindow.ModuleName(id), () => + { + int index = prefs.Modules.IndexOf(id); + if (index < prefs.Modules.Count - 1) { - int index = prefs.Modules.IndexOf(id); - if (index < prefs.Modules.Count - 1) - { - prefs.Modules.RemoveAt(index); - prefs.Modules.Insert(index + 1, id); - Changed(); - } + prefs.Modules.RemoveAt(index); + prefs.Modules.Insert(index + 1, id); + Changed(); } - ); + }); down.IsEnabled = prefs.Modules.IndexOf(id) < prefs.Modules.Count - 1; order.Children.Add(up); order.Children.Add(down); DockPanel.SetDock(order, Dock.Right); row.Children.Add(order); - var check = new CheckBox - { - Content = ControlCenterWindow.ModuleName(id), - IsChecked = !prefs.HiddenModules.Contains(id), - }; + var check = new CheckBox { Content = ControlCenterWindow.ModuleName(id), IsChecked = !prefs.HiddenModules.Contains(id) }; check.Click += (_, _) => { if (check.IsChecked == true) @@ -141,9 +126,11 @@ void Rebuild() { var check = new CheckBox { - Content = isVolume ? Strings.VolumeSlider : Strings.BrightnessSlider, - IsChecked = isVolume ? prefs.ShowVolume : prefs.ShowBrightness, - Margin = new Thickness(0, 0, 20, 0), + Content = isVolume ? "Curseur volume" : "Curseur luminosité", + IsChecked = isVolume + ? prefs.ShowVolume + : prefs.ShowBrightness, + Margin = new Thickness(0, 0, 20, 0) }; check.Click += (_, _) => { @@ -156,7 +143,7 @@ void Rebuild() sliders.Children.Add(check); } list.Children.Add(sliders); - list.Children.Add(UI.Text(Strings.WINDOWSSHORTCUTS, 11, UI.Dim, true)); + list.Children.Add(UI.Text("RACCOURCIS WINDOWS", 11, UI.Dim, true)); foreach (var shortcut in prefs.Shortcuts.ToArray()) { var row = new DockPanel { Margin = new Thickness(0, 10, 0, 0) }; @@ -167,18 +154,12 @@ void Rebuild() down.IsEnabled = prefs.Shortcuts.IndexOf(shortcut) < prefs.Shortcuts.Count - 1; actions.Children.Add(up); actions.Children.Add(down); - actions.Children.Add(UI.IconButton("\uE70F", Strings.Edit3 + shortcut.Name, () => Edit(shortcut))); - actions.Children.Add( - UI.IconButton( - "\uE711", - Strings.RemoveTheShortcut + shortcut.Name, - () => - { - prefs.Shortcuts.Remove(shortcut); - Changed(); - } - ) - ); + actions.Children.Add(UI.IconButton("\uE70F", "Modifier " + shortcut.Name, () => Edit(shortcut))); + actions.Children.Add(UI.IconButton("\uE711", "Retirer le raccourci " + shortcut.Name, () => + { + prefs.Shortcuts.Remove(shortcut); + Changed(); + })); DockPanel.SetDock(actions, Dock.Right); row.Children.Add(actions); var name = UI.Button("", () => Edit(shortcut)); @@ -190,50 +171,35 @@ void Rebuild() list.Children.Add(row); } } - void Move(WindowsShortcut shortcut, int by) { var items = Store.State.Controls.Shortcuts; - int index = items.IndexOf(shortcut), - target = index + by; + int index = items.IndexOf(shortcut), target = index + by; if (index < 0 || target < 0 || target >= items.Count) return; items.RemoveAt(index); items.Insert(target, shortcut); Changed(); } - void Edit(WindowsShortcut? shortcut) { - owner.RunControlDialog( - this, - () => + owner.RunControlDialog(this, () => + { + var dialog = new WindowsShortcutEditor(owner, shortcut) { - var dialog = new WindowsShortcutEditor(owner, shortcut) - { - Owner = this, - ShowInTaskbar = owner.PreviewMode, - }; - if (dialog.ShowDialog() == true) - Changed(); - } - ); + Owner = this, + ShowInTaskbar = owner.PreviewMode + }; + if (dialog.ShowDialog() == true) + Changed(); + }); } } - internal sealed class WindowsShortcutEditor : GlassWindow { - readonly Coordinator owner; - readonly WindowsShortcut? original; - readonly TextBox name = new(), - target = new(), - arguments = new(); - readonly TextBlock status; - readonly Button iconButton; - string glyph; - - internal WindowsShortcutEditor(Coordinator coordinator, WindowsShortcut? shortcut) - : base(Strings.PanneauxGlassWindowsShortcut) + readonly Coordinator owner; readonly WindowsShortcut? original; readonly TextBox name = new(), target = new(), arguments = new(); + readonly TextBlock status; readonly Button iconButton; string glyph; + internal WindowsShortcutEditor(Coordinator coordinator, WindowsShortcut? shortcut) : base("Winlestia — raccourci Windows") { owner = coordinator; original = shortcut; @@ -246,26 +212,24 @@ internal WindowsShortcutEditor(Coordinator coordinator, WindowsShortcut? shortcu var stack = new StackPanel(); ContentHost.Children.Add(stack); var header = new DockPanel(); - var close = UI.IconButton("\uE711", Strings.Close, Close); + var close = UI.IconButton("\uE711", "Fermer", Close); DockPanel.SetDock(close, Dock.Right); header.Children.Add(close); - header.Children.Add( - UI.Text(shortcut == null ? Strings.OneMoreShortcut : Strings.YourWindowsShortcut, 24, null, true) - ); + header.Children.Add(UI.Text(shortcut == null ? "Un raccourci de plus" : "Ton raccourci Windows", 24, null, true)); stack.Children.Add(header); - var presets = UI.Button(Strings.ChooseAWindowsCommand, () => OpenPresets()); + var presets = UI.Button("Choisir une commande Windows ⌄", () => OpenPresets()); presets.HorizontalAlignment = HorizontalAlignment.Left; presets.Margin = new Thickness(0, 22, 0, 20); presets.Padding = new Thickness(16, 10, 16, 10); presetAnchor = presets; stack.Children.Add(presets); - Label(stack, Strings.NAME); + Label(stack, "NOM"); name.Text = shortcut?.Name ?? ""; stack.Children.Add(name); - System.Windows.Automation.AutomationProperties.SetName(name, Strings.WindowsShortcutName); - Label(stack, Strings.WINDOWSSETTINGORAPP); + System.Windows.Automation.AutomationProperties.SetName(name, "Nom du raccourci Windows"); + Label(stack, "PARAMÈTRE WINDOWS OU APPLICATION"); target.Text = shortcut?.Target ?? ""; - System.Windows.Automation.AutomationProperties.SetName(target, Strings.WindowsShortcutTarget); + System.Windows.Automation.AutomationProperties.SetName(target, "Cible du raccourci Windows"); var choose = UI.Button("Parcourir…", ChooseFile); choose.Margin = new Thickness(10, 0, 0, 0); choose.Padding = new Thickness(13, 9, 13, 9); @@ -274,15 +238,15 @@ internal WindowsShortcutEditor(Coordinator coordinator, WindowsShortcut? shortcu row.Children.Add(choose); row.Children.Add(target); stack.Children.Add(row); - var hint = UI.Text(Strings.ExampleMsSettingsDisplayOrThe, 11, UI.Dim); + var hint = UI.Text("Exemple : ms-settings:display ou le chemin d’une application", 11, UI.Dim); hint.Margin = new Thickness(0, 7, 0, 0); stack.Children.Add(hint); Label(stack, "ARGUMENTS — FACULTATIF"); arguments.Text = shortcut?.Arguments ?? ""; - System.Windows.Automation.AutomationProperties.SetName(arguments, Strings.AppArguments); + System.Windows.Automation.AutomationProperties.SetName(arguments, "Arguments de l’application"); stack.Children.Add(arguments); iconButton = UI.Button("", OpenIcons); - iconButton.Content = ControlCenterWindow.IconLabel(glyph, Strings.ChooseTheIcon, 13); + iconButton.Content = ControlCenterWindow.IconLabel(glyph, "Choisir l’icône", 13); iconButton.HorizontalAlignment = HorizontalAlignment.Left; iconButton.Padding = new Thickness(16, 11, 16, 11); iconButton.Margin = new Thickness(0, 20, 0, 15); @@ -291,103 +255,52 @@ internal WindowsShortcutEditor(Coordinator coordinator, WindowsShortcut? shortcu status.TextWrapping = TextWrapping.Wrap; status.MinHeight = 38; stack.Children.Add(status); - var buttons = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - }; - var cancel = UI.Button(Strings.Cancel, Close); + var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right }; + var cancel = UI.Button("Annuler", Close); cancel.Padding = new Thickness(19, 12, 19, 12); buttons.Children.Add(cancel); - var save = UI.Button(shortcut == null ? Strings.Add : Strings.Save, Save); + var save = UI.Button(shortcut == null ? "Ajouter" : "Enregistrer", Save); save.Margin = new Thickness(12, 0, 0, 0); save.Padding = new Thickness(19, 12, 19, 12); buttons.Children.Add(save); stack.Children.Add(buttons); - KeyDown += (_, e) => - { - if (e.Key == System.Windows.Input.Key.Escape) - Close(); - }; + KeyDown += (_, e) => { if (e.Key == System.Windows.Input.Key.Escape) Close(); }; } - readonly Button presetAnchor; - static void Label(Panel panel, string text) { var label = UI.Text(text, 10, UI.Dim, true); label.Margin = new Thickness(0, 15, 0, 8); panel.Children.Add(label); } - - void OpenPresets() => - GlassMenu.Open( - owner, - this, - presetAnchor, - Strings.WindowsControls, - ControlPreferences - .Library() - .Select(s => new MenuAction( - s.Name, - s.Glyph, - () => - { - name.Text = s.Name; - target.Text = s.Target; - arguments.Text = s.Arguments; - glyph = s.Glyph; - iconButton.Content = ControlCenterWindow.IconLabel(glyph, Strings.ChooseTheIcon, 13); - } - )) - .ToArray() - ); - + void OpenPresets() => GlassMenu.Open(owner, this, presetAnchor, "Commandes Windows", ControlPreferences.Library().Select(s + => new MenuAction(s.Name, s.Glyph, () => + { + name.Text = s.Name; + target.Text = s.Target; + arguments.Text = s.Arguments; + glyph = s.Glyph; + iconButton.Content = ControlCenterWindow.IconLabel(glyph, "Choisir l’icône", 13); + })).ToArray()); void OpenIcons() { - var icons = new[] + var icons = new[] { ("Réglages", "\uE713"), ("Volume", "\uE767"), ("Wi-Fi", "\uE701"), ("Bluetooth", "\uE702"), ("Écran", + "\uE7F4"), + ("Capture", "\uE722"), ("Musique", "\uE8D6"), ("Dossier", "\uE8B7"), ("Calculatrice", "\uE8EF"), ("Horloge", "\uE917"), + ("Applications", "\uE71D"), ("Réseau", "\uE839"), ("Alimentation", "\uE7E8"), ("Personnalisation", "\uE790") }; + GlassMenu.Open(owner, this, iconButton, "Icône du raccourci", icons.Select(i => new MenuAction(i.Item1, i.Item2, () => { - (Strings.Settings, "\uE713"), - ("Volume", "\uE767"), - ("Wi-Fi", "\uE701"), - ("Bluetooth", "\uE702"), - (Strings.Screen, "\uE7F4"), - ("Capture", "\uE722"), - (Strings.KnownFolder("Music"), "\uE8D6"), - (Strings.Folder, "\uE8B7"), - (Strings.Calculator, "\uE8EF"), - ("Horloge", "\uE917"), - ("Applications", "\uE71D"), - (Strings.Network, "\uE839"), - ("Alimentation", "\uE7E8"), - (Strings.Customization, "\uE790"), - }; - GlassMenu.Open( - owner, - this, - iconButton, - Strings.ShortcutIcon, - icons - .Select(i => new MenuAction( - i.Item1, - i.Item2, - () => - { - glyph = i.Item2; - iconButton.Content = ControlCenterWindow.IconLabel(glyph, Strings.ChooseTheIcon, 13); - }, - glyph == i.Item2 - )) - .ToArray() - ); + glyph = i.Item2; + iconButton.Content = ControlCenterWindow.IconLabel(glyph, "Choisir l’icône", 13); + }, + glyph == i.Item2)).ToArray()); } - void ChooseFile() { var picker = new Microsoft.Win32.OpenFileDialog { - Title = Strings.ChooseAnAppOrAShortcut, - Filter = Strings.AppsAndShortcutsExeLnkAll, + Title = "Choisir une application ou un raccourci", + Filter = "Applications et raccourcis|*.exe;*.lnk|Tous les fichiers|*.*" }; if (picker.ShowDialog(this) == true) { @@ -396,30 +309,20 @@ void ChooseFile() name.Text = Path.GetFileNameWithoutExtension(picker.FileName); } } - - internal static bool ValidTarget(string value) => - !string.IsNullOrWhiteSpace(value) - && value.Length < 2048 - && !value.Contains('\n') - && !value.Contains('\r') - && !value.Contains('"') - && ( - !Uri.TryCreate(value, UriKind.Absolute, out var uri) - || uri.Scheme is not ("javascript" or "data" or "vbscript") - ); - + internal static bool ValidTarget(string value) => !string.IsNullOrWhiteSpace(value) && value.Length < 2048 && !value.Contains('\n') + && !value.Contains('\r') && !value.Contains('"') && (!Uri.TryCreate(value, UriKind.Absolute, out var uri) + || uri.Scheme is not ("javascript" or "data" or "vbscript")); void Save() { - string label = name.Text.Trim(), - destination = target.Text.Trim(); + string label = name.Text.Trim(), destination = target.Text.Trim(); if (string.IsNullOrWhiteSpace(label)) { - status.Text = Strings.AddANameForThisShortcut; + status.Text = "Ajoute un nom pour ce raccourci."; return; } if (!ValidTarget(destination)) { - status.Text = Strings.ChooseAnApplicationOrAValid; + status.Text = "Choisis une application ou une adresse de paramètre Windows valide."; return; } var shortcut = original ?? new WindowsShortcut(); diff --git a/src/backend/ControlCenter/ControlModels.cs b/src/backend/ControlCenter/ControlModels.cs new file mode 100644 index 0000000..40ffe2d --- /dev/null +++ b/src/backend/ControlCenter/ControlModels.cs @@ -0,0 +1,68 @@ +// C# 12, fichier ControlModels.cs, cible Windows 11 / .NET 8 +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PanneauxGlass; + +public sealed class WindowsShortcut +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string Name { get; set; } = ""; public string Target + { + get; set; + } = ""; public string Arguments { get; set; } = ""; public string Glyph { get; set; } = "\uE713"; +} +public sealed class ControlPreferences +{ + public List Modules { get; set; } = new() { "connections", "media", "levels", "actions", "shortcuts" }; + public List HiddenModules { get; set; } = new(); public bool ShowVolume { get; set; } = true; public bool ShowBrightness + { + get; + set; + } = true; + public List Shortcuts { get; set; } = Defaults(); public string MediaApp { get; set; } = ""; + public static List Defaults() => new(){ + new(){Id="capture",Name="Capture",Target="ms-screenclip:",Glyph="\uE722"},new( + ){Id="calculator",Name="Calculatrice",Target="calc.exe",Glyph="\uE8EF"}, + new(){Id="clock",Name="Horloge",Target="ms-clock:",Glyph="\uE917"},new( + ){Id="notepad",Name="Notes",Target="notepad.exe",Glyph="\uE70B"}, + new(){Id="explorer",Name="Fichiers",Target="explorer.exe",Glyph="\uE8B7"}, + new(){Id="sound",Name="Audio",Target="ms-settings:sound",Glyph="\uE767"},new( + ){Id="settings",Name="Réglages",Target="ms-settings:",Glyph="\uE713"} + }; + public static IEnumerable Library() => Defaults().Concat(new[]{ + new WindowsShortcut{Name="Concentration",Target="ms-settings:quiethours",Glyph="\uE708"},new WindowsShortcut{Name="Écran", + Target="ms-settings:display",Glyph="\uE7F4"},new WindowsShortcut{Name="Wi-Fi",Target="ms-settings:network-wifi", + Glyph="\uE701"}, + new WindowsShortcut{Name="Bluetooth",Target="ms-settings:bluetooth",Glyph="\uE702"}, + new WindowsShortcut{Name="Réseau",Target="ms-settings:network-status",Glyph="\uE839"},new WindowsShortcut{Name="Notifications", + Target="ms-settings:notifications",Glyph="\uE7F4"}, + new WindowsShortcut{Name="Stockage",Target="ms-settings:storagesense",Glyph="\uEDA2"},new WindowsShortcut{Name="Applications", + Target="ms-settings:appsfeatures",Glyph="\uE71D"}, + new WindowsShortcut{Name="Personnalisation",Target="ms-settings:personalization",Glyph="\uE790"}, + new WindowsShortcut{Name="Alimentation",Target="ms-settings:powersleep",Glyph="\uE7E8"}, + new WindowsShortcut{Name="Gestionnaire des tâches",Target="taskmgr.exe",Glyph="\uE9D9"},new WindowsShortcut{Name="Appareil photo", + Target="microsoft.windows.camera:",Glyph="\uE722"} + }); + public void Normalize() + { + Modules ??= new(); + HiddenModules ??= new(); + Shortcuts ??= Defaults(); + MediaApp ??= ""; + string[] valid = { "connections", "media", "levels", "actions", "shortcuts" }; + Modules = Modules.Where(valid.Contains).Distinct().Concat(valid.Except(Modules)).ToList(); + HiddenModules = HiddenModules.Where(valid.Contains).Distinct().ToList(); + Shortcuts = Shortcuts.Where(s => s != null && !string.IsNullOrWhiteSpace(s.Target)).Take(48).ToList(); + foreach (var s in Shortcuts) + { + if (string.IsNullOrWhiteSpace(s.Id)) + s.Id = Guid.NewGuid().ToString("N"); + if (string.IsNullOrWhiteSpace(s.Name)) + s.Name = s.Target; + if (string.IsNullOrWhiteSpace(s.Glyph)) + s.Glyph = "\uE713"; + s.Arguments ??= ""; + } + } +} diff --git a/src/backend/ControlCenter/ControlPairLayout.cs b/src/backend/ControlCenter/ControlPairLayout.cs new file mode 100644 index 0000000..946386a --- /dev/null +++ b/src/backend/ControlCenter/ControlPairLayout.cs @@ -0,0 +1,27 @@ +// C# 12, fichier ControlPairLayout.cs, cible Windows 11 / .NET 8 WPF +using System; +using System.Windows; + +namespace PanneauxGlass; +// La bande de l'horloge se pose directement sur la surface d'accueil et en partage la largeur ; le groupe reste centré. +internal sealed record ControlPairLayout(Rect Clock, Rect Home, Rect Controls, Rect LeftArrow, Rect RightArrow, Rect Pc) +{ + internal static ControlPairLayout Calculate(Rect work, bool expanded, bool pcExpanded = false, bool clockBand = true) + { + double controlsWidth = work.Width >= 1550 ? 480 : Math.Clamp(work.Width * .31, 350, 440), gap = 72; + double clockHeight = clockBand ? Math.Clamp(work.Height * .075, 84, 112) : 1, stack = clockBand ? clockHeight + 14 : 0; + double height = Math.Min(820, Math.Max(300, work.Height - 60 - stack)); + int sides = (expanded ? 1 : 0) + (pcExpanded ? 1 : 0); + double homeWidth = Math.Min(1200, Math.Max(400, work.Width - (sides > 0 ? sides * (controlsWidth + gap) + 144 : 160))), + groupWidth = homeWidth + sides * (controlsWidth + gap); + double x = work.X + (work.Width - groupWidth) / 2 + (pcExpanded ? controlsWidth + gap : 0), y = work.Y + (work.Height - (height + + stack)) / 2; + var clock = new Rect(x, y, homeWidth, clockHeight); + var home = new Rect(x, y + stack, homeWidth, height); + var controls = new Rect(home.Right + gap, home.Y, controlsWidth, home.Height); + var pc = new Rect(home.Left - gap - controlsWidth, home.Y, controlsWidth, home.Height); + double arrowX = home.Right + (expanded ? 11 : 16); + return new(clock, home, controls, new Rect(Math.Max(work.Left + 5, x - 66), home.Y + home.Height / 2 - 25, 50, 50), + new Rect(Math.Min(work.Right - 55, arrowX), home.Y + home.Height / 2 - 25, 50, 50), pc); + } +} diff --git a/src/backend/Shared/AtomicFile.cs b/src/backend/Core/AtomicFile.cs similarity index 93% rename from src/backend/Shared/AtomicFile.cs rename to src/backend/Core/AtomicFile.cs index b448fec..d39b56d 100644 --- a/src/backend/Shared/AtomicFile.cs +++ b/src/backend/Core/AtomicFile.cs @@ -8,7 +8,8 @@ namespace Winlestia.Shared; public static class AtomicFile { - // Text is written with the encoding's own preamble, so Encoding.UTF8 keeps its BOM and null means UTF-8 without BOM (File.WriteAllText default). + // Text is written with the encoding's own preamble, so Encoding.UTF8 keeps its BOM and null means UTF-8 without BOM + // (File.WriteAllText default). public static void WriteAllText(string path, string content, Encoding? encoding = null, string? backupPath = null) { var enc = encoding ?? new UTF8Encoding(false); @@ -28,7 +29,8 @@ public static void WriteAllText(string path, string content, Encoding? encoding public static void WriteAllBytes(string path, byte[] bytes, string? backupPath = null) => Write(path, s => s.Write(bytes, 0, bytes.Length), backupPath); - // The previous content stays untouched until the new content is fully on disk; on any failure the tmp file is removed and the exception rethrown. + // The previous content stays untouched until the new content is fully on disk; on any failure the tmp file is removed and the + // exception rethrown. public static void Write(string path, Action writer, string? backupPath = null) { string tmp = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; diff --git a/src/backend/Core/Coordinator.Boards.cs b/src/backend/Core/Coordinator.Boards.cs new file mode 100644 index 0000000..ca4f212 --- /dev/null +++ b/src/backend/Core/Coordinator.Boards.cs @@ -0,0 +1,67 @@ +// language: C# 12, file: Coordinator.Boards.cs, target: Windows 11 / .NET 8 WPF, sites, panneaux et entrées (lancement, édition, ajout) +using System.Linq; +using System.Windows.Input; + +namespace PanneauxGlass; + +internal sealed partial class Coordinator +{ + internal void RefreshWebsites() => websites.Refresh(); + internal void AddWebsite(WebShortcut? site = null, bool chooseImage = false) + => ShowModal(() => new WebShortcutWindow(this, site, chooseImage), DialogParent); + internal void RefreshBoards() + { + foreach (var board in Boards.Where(b => b.IsVisible)) + board.Refresh(false); + if (home.IsVisible) + home.Refresh(); + if (state.Detailed && state.Open) + websites.Refresh(); + } + internal bool HandleShortcut(KeyEventArgs e) + { + if (e.Key == Key.F2 && Keyboard.Modifiers == ModifierKeys.Control) + { + if (!state.PaletteOpen) + TogglePalette(); + return true; + } + if (e.Key == Key.H && Keyboard.Modifiers == ModifierKeys.Control) + { + ShowHome(); + return true; + } + if (Keyboard.Modifiers == ModifierKeys.Control && e.Key is >= Key.D1 and <= Key.D4) + { + string[] ids = { "apps", "games", "projects", "documents" }; + ShowWorkspace(ids[(int)(e.Key - Key.D1)]); + return true; + } + return false; + } + internal void Launch(Entry entry) => launcher.Launch(entry); + internal void Reveal(Entry e) => launcher.Reveal(e); + internal void AddPath(string path, string board) => launcher.AddPath(path, board); + internal void Settings(string? board = null) + { + if (ForwardToQt("settings") || state.SettingsOpen) + return; + if (!state.Open) + Open(); + ShowModal(() => new PersonalizeWindow(this, board), DialogParent); + } + internal void Edit(Entry e) => ShowModal(() => new EditWindow(this, e, false), DialogParent, resetAway: false); + internal void AddAccess(string board) + { + foreach (string path in dialogs.PickFiles()) + AddPath(path, board); + RefreshBoards(); + } + internal void AddFolder(string board) + { + string? folder = dialogs.PickFolder(); + if (folder != null) + AddPath(folder, board); + RefreshBoards(); + } +} diff --git a/src/backend/Core/Coordinator.BridgeOwner.cs b/src/backend/Core/Coordinator.BridgeOwner.cs new file mode 100644 index 0000000..09185c5 --- /dev/null +++ b/src/backend/Core/Coordinator.BridgeOwner.cs @@ -0,0 +1,48 @@ +// language: C# 12, file: Coordinator.BridgeOwner.cs, target: Windows 11 / .NET 8 WPF, délégation vers la couture du pont Qt +namespace PanneauxGlass; + +// Implémentation explicite : les membres internes du Coordinator restent inchangés, l'interface n'est qu'une vue étroite. +internal sealed partial class Coordinator : IBridgeOwner +{ + IShortcutBindings IBridgeOwner.Keys => Keys; + IShortcutEditor IBridgeOwner.Shortcuts => new ShortcutEditor(Shortcuts); + IClipboardService IBridgeOwner.QtClipboard => QtClipboard; + IIconSource IBridgeOwner.Icons => Icons; + bool IBridgeOwner.TaskbarEnabled => TaskbarEnabled; + + void IBridgeOwner.SetTheme(string id) => SetTheme(id); + void IBridgeOwner.SetTaskbar(bool enabled) => SetTaskbar(enabled); + void IBridgeOwner.SuspendTaskbar() => SuspendTaskbar(); + void IBridgeOwner.RestoreDesktopIcons() => RestoreDesktopIcons(); + void IBridgeOwner.ApplyClipboardSettings() => ApplyClipboardSettings(); + void IBridgeOwner.RefreshCatalogue() => RefreshCatalogue(); + void IBridgeOwner.RefreshCatalogueIfStale() => RefreshCatalogueIfStale(); + + void IBridgeOwner.Launch(Entry entry) => Launch(entry); + void IBridgeOwner.LaunchTarget(string target) => LaunchTarget(target); + void IBridgeOwner.Reveal(Entry entry) => Reveal(entry); + void IBridgeOwner.Edit(Entry entry) => Edit(entry); + void IBridgeOwner.RunQuickTab(WindowsShortcut tab) => RunQuickTab(tab); + void IBridgeOwner.WebSearch(string value) => WebSearch(value); + + void IBridgeOwner.AddAccess(string board) => AddAccess(board); + void IBridgeOwner.AddFolder(string board) => AddFolder(board); + void IBridgeOwner.AddWebsite() => AddWebsite(); + + void IBridgeOwner.OpenWorkspaces() => OpenWorkspaces(); + void IBridgeOwner.OpenTools() => OpenTools(); + void IBridgeOwner.OpenRuler() => OpenRuler(); + void IBridgeOwner.OpenNotes() => OpenNotes(); + void IBridgeOwner.OpenMemo() => OpenMemo(); + void IBridgeOwner.OpenLargeFiles() => OpenLargeFiles(); + void IBridgeOwner.OpenHelp() => OpenHelp(); + void IBridgeOwner.OpenFocus() => OpenFocus(); + void IBridgeOwner.OpenColorPicker() => OpenColorPicker(); + void IBridgeOwner.OpenCapture() => OpenCapture(); + void IBridgeOwner.OpenAudio() => OpenAudio(); + void IBridgeOwner.EditQuickTabs() => EditQuickTabs(); + void IBridgeOwner.ToggleNotifications() => ToggleNotifications(); + void IBridgeOwner.ToggleAlwaysOnTop() => ToggleAlwaysOnTop(); + void IBridgeOwner.TaskManager() => SystemActions.TaskManager(this); + void IBridgeOwner.PinImage(System.Windows.Media.Imaging.BitmapImage image) => PinWindow.Open(this, image); +} diff --git a/src/backend/Core/Coordinator.Catalogue.cs b/src/backend/Core/Coordinator.Catalogue.cs new file mode 100644 index 0000000..7044c6e --- /dev/null +++ b/src/backend/Core/Coordinator.Catalogue.cs @@ -0,0 +1,24 @@ +// language: C# 12, file: Coordinator.Catalogue.cs, target: Windows 11 / .NET 8 WPF, catalogue d'applications, balayage et mises à jour + +using System; +using Forms = System.Windows.Forms; + +namespace PanneauxGlass; + +internal sealed partial class Coordinator +{ + internal void RefreshCatalogue() => catalogue.Refresh(); + internal void RefreshCatalogueIfStale() => catalogue.RefreshIfStale(); + void DisplayChanged(object? sender, EventArgs e) + { + System.Windows.Application.Current.Dispatcher.BeginInvoke((Action)(() => + { + taskbar?.DisplaysChanged(); + QtShell?.DisplaysChanged(); + if (state.Open) + Arrange(); + else + rail.Hide(); + })); + } +} diff --git a/src/backend/Core/Coordinator.Desktop.cs b/src/backend/Core/Coordinator.Desktop.cs new file mode 100644 index 0000000..8f97fc3 --- /dev/null +++ b/src/backend/Core/Coordinator.Desktop.cs @@ -0,0 +1,89 @@ +// language: C# 12, file: Coordinator.Desktop.cs, target: Windows 11 / .NET 8 WPF, icônes du bureau, barre Windows, zone de notification +// et reprise après redémarrage d'Explorer +using System; +using System.Windows; +using Forms = System.Windows.Forms; + +namespace PanneauxGlass; + +internal sealed partial class Coordinator +{ + readonly uint explorerCreated = TaskbarNative.RegisterWindowMessage("TaskbarCreated"); + bool taskbarDeferred; + // TaskbarCreated : Explorer vient de (re)démarrer. La barre différée au lancement est activée, puis le service du Bureau + // réinstalle son hook. + void ExplorerCreated() + { + if (preview || disposed) + return; + Store.Log("Explorateur prêt (TaskbarCreated)."); + if (taskbarDeferred) + { + taskbarDeferred = false; + if (Store.State.ReplaceTaskbar && taskbar != null && !taskbar.Enable()) + { + Store.State.ReplaceTaskbar = false; + Store.Log("La barre Windows reste active : secours indisponible."); + } + } + desktopIcons.OnExplorerCreated(); + } + // Au lancement, la barre Windows n'est remplacée que si Explorer est prêt ; sinon TaskbarCreated reprendra la main. + void StartTaskbar() + { + if (!preview && Store.State.ReplaceTaskbar) + { + if (!TaskbarNative.ExplorerReady()) + { + taskbarDeferred = true; + Store.Log("Explorateur pas encore prêt : barre Windows laissée telle quelle jusqu'à TaskbarCreated."); + } + else if (!taskbar!.Enable()) + { + Store.State.ReplaceTaskbar = false; + Store.Log("La barre Windows reste active : secours indisponible."); + } + } + else if (preview && Program.DockPreview) + taskbar!.ShowPreview(); + } + void BuildTray() + { + tray.Show("Winlestia · " + UserName.First); + // Shell Qt : l'icône de la zone de notification permet d'atteindre les réglages même sans dock + // (le menu est affiché par le NotifyIcon lui-même au clic droit). + if (Program.QtMode) + { + var qtMenu = new Forms.ContextMenuStrip(); + qtMenu.Items.Add("Ouvrir le tableau de bord", null, (_, _) => QtShell?.Send("toggle")); + qtMenu.Items.Add("Paramètres de Winlestia", null, (_, _) => QtShell?.Send("settings")); + qtMenu.Items.Add("Contrôles Windows", null, (_, _) => QtShell?.Send("controls")); + qtMenu.Items.Add(new Forms.ToolStripSeparator()); + qtMenu.Items.Add("Retrouver le Bureau organisé", null, (_, _) => Rescue()); + qtMenu.Items.Add("Quitter", null, (_, _) => Application.Current.Shutdown()); + tray.SetMenu(qtMenu); + } + tray.RightClicked += () => + { + if (QtShell != null) + return; + if (!state.Open) + Open(); + GlassMenu.Open(this, DialogParent, state.Detailed ? (FrameworkElement)toolbar.ContentHost : home.ContentHost, "Winlestia", + new[] + { + new MenuAction("Ouvrir l’espace", "\uE8A9", Open), + new MenuAction("Commande rapide · " + Keys.PaletteLabel, "\uE721", TogglePalette), + new MenuAction("Note rapide", "\uE70B", OpenNotes), + new MenuAction("Minuteur…", "\uE917", OpenFocus), + new MenuAction("Personnaliser", "\uE713", () => Settings(), Separator: true), + new MenuAction("Actualiser les accès", "\uE72C", RefreshCatalogue), + new MenuAction("Retrouver le Bureau organisé", "\uE777", Rescue, Separator: true), + new MenuAction("Quitter Winlestia", "\uE711", () => Application.Current.Shutdown()) + }, true); + }; + tray.DoubleClicked += () => Open(); + } + internal void RestoreDesktopIcons() => desktopIcons.Restore(); + internal void Rescue() => launcher.Rescue(); +} diff --git a/src/backend/Core/Coordinator.Dialogs.cs b/src/backend/Core/Coordinator.Dialogs.cs new file mode 100644 index 0000000..d0d3ba4 --- /dev/null +++ b/src/backend/Core/Coordinator.Dialogs.cs @@ -0,0 +1,24 @@ +// language: C# 12, file: Coordinator.Dialogs.cs, target: Windows 11 / .NET 8 WPF, boîtes de dialogue des contrôles et raccourcis Windows +using System; +using System.Threading.Tasks; +using System.Windows; + +namespace PanneauxGlass; + +internal sealed partial class Coordinator +{ + internal void RunControlDialog(Window? parent, Action action) => dialogs.Scope(action); + internal Task RunControlTask(Func action) => dialogs.ScopeAsync(action); + // Fenêtre propriétaire des dialogues ouverts depuis l'espace : l'ancre quand il est ouvert, sinon aucune. + Window? OpenOwner => state.Open ? Anchor : null; + // Toutes les fenêtres modales suivent le même cycle : portée de dialogue, propriétaire, présence dans la barre en aperçu. + void ShowModal(Func create, Window? owner, bool resetAway = true) => dialogs.Scope(() => + { + var dialog = create(); + dialog.Owner = owner; + dialog.ShowInTaskbar = preview; + dialog.ShowDialog(); + }, resetAway); + internal void EditControls(Window parent, bool add = false) => ShowModal(() => new ControlEditorWindow(this, add), parent); + internal void LaunchWindowsShortcut(WindowsShortcut shortcut) => launcher.LaunchShortcut(shortcut); +} diff --git a/src/backend/Core/Coordinator.Feedback.cs b/src/backend/Core/Coordinator.Feedback.cs new file mode 100644 index 0000000..00fbf85 --- /dev/null +++ b/src/backend/Core/Coordinator.Feedback.cs @@ -0,0 +1,10 @@ +// language: C# 12, file: Coordinator.Feedback.cs, target: Windows 11 / .NET 8 WPF, toasts, notifications et boîtes de message +namespace PanneauxGlass; + +internal sealed partial class Coordinator +{ + internal void ShowToast(string text) => Toast.Show(text, Anchor); + internal void Notify(string title, string text) => tray.Notify(title, text); + internal bool Confirm(string text) => dialogs.Confirm(text); + internal void Message(string text) => dialogs.Message(text); +} diff --git a/src/backend/Core/Coordinator.Layout.cs b/src/backend/Core/Coordinator.Layout.cs new file mode 100644 index 0000000..50240c6 --- /dev/null +++ b/src/backend/Core/Coordinator.Layout.cs @@ -0,0 +1,76 @@ +// language: C# 12, file: Coordinator.Layout.cs, target: Windows 11 / .NET 8 WPF, disposition des fenêtres de l'accueil et de la vue +// complète +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace PanneauxGlass; + +internal sealed partial class Coordinator +{ + // Le thème « shell » remplace l'horloge de l'accueil par celle du dock : elle reste donc masquée. + static bool ShellTheme => Theme.Current.Id.StartsWith("shell", StringComparison.Ordinal); + // Les flèches changent de glyphe et d'infobulle selon que leur panneau est déplié. + static void UpdateArrow(GlassWindow arrow, bool expanded, (string Expanded, string Collapsed) glyphs, + (string Collapse, string Show) tips) + { + if (arrow.ContentHost.Children[0] is not Button button) + return; + button.Content = UI.Glyph(expanded ? glyphs.Expanded : glyphs.Collapsed); + button.ToolTip = expanded ? tips.Collapse : tips.Show; + System.Windows.Automation.AutomationProperties.SetName(button, button.ToolTip.ToString()); + } + internal void Arrange(bool animate = false) + { + if (!state.Open) + return; + var w = Screen.WorkingArea; + double d = VisualTreeHelper.GetDpi(home).DpiScaleX; + var work = new Rect(w.Left / d, w.Top / d, w.Width / d, w.Height / d); + if (!LayoutRules.FitsBoth(w.Width, d) && state.ControlsExpanded && state.PcExpanded) + state.ControlsExpanded = false; + var layout = LayoutRules.Detailed(work, Store.State.Margin, Store.State.Gap, Store.State.TopRatio, Store.State.LeftRatio); + PanelBounds = layout.Panel; + if (state.Detailed) + ArrangeDetailed(layout, animate); + else + ArrangeHome(work, animate); + } + void ArrangeHome(Rect work, bool animate) + { + var pair = ControlPairLayout.Calculate(work, state.ControlsExpanded, state.PcExpanded, !ShellTheme); + if (ShellTheme) + clock.HideImmediately(); + else + clock.TransitionTo(pair.Clock, 0, animate); + home.TransitionTo(pair.Home, 0, animate); + home.Refresh(); + leftArrow.TransitionTo(pair.LeftArrow, 1, animate); + rightArrow.TransitionTo(pair.RightArrow, 1, animate); + if (state.PcExpanded) + pc.TransitionTo(pair.Pc, 2, animate); + else + pc.HideImmediately(); + if (state.ControlsExpanded) + controls.TransitionTo(pair.Controls, 2, animate); + else + controls.HideImmediately(); + UpdateArrow(leftArrow, state.PcExpanded, ("\uE76C", "\uE76B"), ("Replier l’état du PC", "Afficher l’état du PC")); + UpdateArrow(rightArrow, state.ControlsExpanded, ("\uE76B", "\uE76C"), + ("Replier les commandes Windows", "Afficher les commandes Windows")); + } + void ArrangeDetailed(WorkspaceLayout layout, bool animate) + { + clock.HideImmediately(); + toolbar.MoveInto(layout.ToolbarX, layout.ToolbarY, layout.ToolbarWidth, LayoutRules.ToolbarHeight, 0, animate); + websites.Arrange(layout.ToolbarX - 16, layout.ToolbarY, layout.SideWidth, animate); + for (int i = 0; i < Boards.Count; i++) + { + var r = Boards[i].Model.Placement?.Resolve(PanelBounds) ?? PanelLayout.Clamp(layout.Targets[i], PanelBounds); + Boards[i].MoveInto(r.X, r.Y, r.Width, r.Height, i + 1, animate); + Boards[i].Refresh(false); + } + toolbar.Topmost = true; + } +} diff --git a/src/backend/Core/Coordinator.Palette.cs b/src/backend/Core/Coordinator.Palette.cs new file mode 100644 index 0000000..cd00e9c --- /dev/null +++ b/src/backend/Core/Coordinator.Palette.cs @@ -0,0 +1,129 @@ +// language: C# 12, file: Coordinator.Palette.cs, target: Windows 11 / .NET 8 WPF, palette de recherche, onglets rapides et notes +using System; +using System.Windows; + +namespace PanneauxGlass; + +internal sealed partial class Coordinator +{ + internal void ClosePalette() => palette?.Close(); + internal void TogglePalette() + { + if (ForwardToQt("palette")) + return; + if (state.PaletteOpen) + palette?.Close(); + else + ShowPalette(); + } + internal void ShowPalette() + { + if (ForwardToQt("palette") || state.PaletteOpen) + return; + state.PaletteOpen = true; + var dialog = new CommandPalette(this) { Owner = OpenOwner, ShowInTaskbar = preview }; + palette = dialog; + dialogs.Layers(true); + dialog.Closed += (_, _) => + { + state.PaletteOpen = false; + if (ReferenceEquals(palette, dialog)) + palette = null; + dialogs.Layers(state.SettingsOpen); + state.AwaySince = DateTime.MinValue; + }; + dialog.Show(); + Native.Raise(dialog); + } + internal void OpenFocus() => ShowModal(() => new FocusSetupWindow(this), OpenOwner); + internal void EditQuickTabs(Window? parent = null) => ShowModal(() => new QuickTabsWindow(this), parent ?? Anchor); + // Les onglets glass: pointent vers une vue du Coordinator ; tout le reste est un raccourci Windows. + internal void RunQuickTab(WindowsShortcut tab) + { + string target = tab.Target ?? ""; + if (!QuickTabRules.IsInternal(target)) + { + LaunchWindowsShortcut(tab); + return; + } + switch (QuickTabRules.Parse(target)) + { + case QuickTabView.Home: + ShowHome(); + break; + case QuickTabView.Apps: + ShowWorkspace("apps"); + break; + case QuickTabView.Games: + ShowWorkspace("games"); + break; + case QuickTabView.Projects: + ShowWorkspace("projects"); + break; + case QuickTabView.Documents: + ShowWorkspace("documents"); + break; + case QuickTabView.Workspace: + ShowWorkspace(); + break; + case QuickTabView.Note: + OpenNotes(); + break; + case QuickTabView.Focus: + OpenFocus(); + break; + case QuickTabView.Settings: + Settings(); + break; + case QuickTabView.Tools: + OpenTools(); + break; + case QuickTabView.Capture: + OpenCapture(); + break; + case QuickTabView.Text: + OpenCapture(textOnly: true); + break; + case QuickTabView.Clipboard: + OpenClipboard(); + break; + case QuickTabView.Audio: + OpenAudio(); + break; + case QuickTabView.Workspaces: + OpenWorkspaces(); + break; + case QuickTabView.Files: + OpenLargeFiles(); + break; + case QuickTabView.Help: + OpenHelp(); + break; + case QuickTabView.Pin: + ToggleAlwaysOnTop(); + break; + case QuickTabView.Memo: + OpenMemo(); + break; + case QuickTabView.Controls: + ToggleControls(); + break; + case QuickTabView.Pc: + TogglePc(); + break; + case QuickTabView.Palette: + TogglePalette(); + break; + default: + Message("Cet onglet rapide pointe vers une vue inconnue.\n" + target); + break; + } + } + internal void OpenNotes() + { + ShowModal(() => new NotesWindow(this), OpenOwner); + state.AwaySince = DateTime.MinValue; + } + internal void StartFocus(int seconds) => FocusTimer.Start(this, seconds); + internal void WebSearch(string value, bool address = false) => launcher.WebSearch(value, address); +} diff --git a/src/backend/Core/Coordinator.Preview.cs b/src/backend/Core/Coordinator.Preview.cs new file mode 100644 index 0000000..230e9c5 --- /dev/null +++ b/src/backend/Core/Coordinator.Preview.cs @@ -0,0 +1,63 @@ +// language: C# 12, file: Coordinator.Preview.cs, target: Windows 11 / .NET 8 WPF, aperçus de QA et export de la mise en page + +using System; +using System.Windows; +using System.Windows.Media; +using Forms = System.Windows.Forms; + +namespace PanneauxGlass; + +internal sealed partial class Coordinator +{ + internal void ProbeRightEdge() + { + Collapse(); + var screen = Forms.Screen.PrimaryScreen!; + double scale = VisualTreeHelper.GetDpi(toolbar).DpiScaleX; + var probe = + new Window + { + Title = "Winlestia — test du bord", + WindowStyle = WindowStyle.None, + ResizeMode = ResizeMode.NoResize, + ShowInTaskbar = true, + Topmost = true, + Width = 6, + Height = 90, + Left = screen.Bounds.Right / scale - 6, + Top = (screen.WorkingArea.Top + screen.WorkingArea.Height / 2) / scale, + Background = Brushes.SlateGray + }; + probe.Show(); + } + internal void ExportPreview(string file = "preview-layout.png") => previewExport.Export(file); + internal bool PaletteActivity => state.PaletteOpen; + internal bool PaletteVisible => palette?.IsVisible == true; + internal void PreviewPalette() + { + ShowPalette(); + if (GlassWindow.CaptureOffset.LengthSquared > 0) + ShowPaletteOffscreen(); + } + // La palette se centre seule sur l'écran ; les contrôles hors écran la ramènent hors du bureau. + void ShowPaletteOffscreen() + { + if (palette == null) + return; + palette.Left = GlassWindow.CaptureOffset.X; + palette.Top = GlassWindow.CaptureOffset.Y; + } + internal void PreviewPaletteQuery(string text) + { + ShowPalette(); + palette?.Prefill(text); + ShowPaletteOffscreen(); + } + internal void PreviewNotes() + { + var dialog = new NotesWindow(this) { Owner = home, ShowInTaskbar = true }; + dialog.ShowDialog(); + } + internal void PreviewFocus() => OpenFocus(); + internal void PreviewTabs() => EditQuickTabs(); +} diff --git a/src/backend/Core/Coordinator.Shell.cs b/src/backend/Core/Coordinator.Shell.cs new file mode 100644 index 0000000..1abffe2 --- /dev/null +++ b/src/backend/Core/Coordinator.Shell.cs @@ -0,0 +1,127 @@ +// language: C# 12, file: Coordinator.Shell.cs, target: Windows 11 / .NET 8 WPF, barre latérale, barre d'outils, barre des tâches et +// menus +using System; +using System.Linq; +using System.Windows; + +namespace PanneauxGlass; + +internal sealed partial class Coordinator +{ + void BuildRail() => ShellChrome.BuildRail(rail, Open); + void BuildToolbar() => paletteChip = ShellChrome.BuildToolbar(toolbar, state, Keys.PaletteLabel, + new ToolbarCommands(TogglePalette, ShowHome, ResetLayout, RefreshBoards, () => Settings(), Rescue, Collapse)); + internal void Open() + { + if (!ForwardToQt("toggle")) + OpenView(false); + } + internal bool TaskbarEnabled => taskbar?.Enabled == true; + internal (bool Running, long Samples) ShellSampling => home.ShellSampling; + internal string ShellPage => home.SelectedPage; + internal void ToggleTaskbarPin(Entry entry) + { + if (!Store.State.TaskbarPins.Remove(entry.Id)) + Store.State.TaskbarPins.Add(entry.Id); + Store.Save(); + taskbar?.Refresh(); + } + internal void SetTaskbar(bool enabled) + { + bool applied = !enabled || taskbar?.Enable() == true; + if (!enabled) + taskbar?.Disable(); + Store.State.ReplaceTaskbar = enabled && applied; + Store.Save(); + if (!applied) + Message("Le secours de la barre n’a pas démarré. La barre Windows reste disponible."); + } + // L'arrêt du moteur rétablit la barre Windows sans réécrire la préférence de l'utilisateur : + // un frontend planté ne doit pas décider que le dock est indésirable pour la suite. + internal void SuspendTaskbar() => taskbar?.Disable(); + internal void ShowShellPage(string id) + { + if (QtShell != null) + { + QtShell.Send(id == "dashboard" ? "toggle" : id == "media" ? "media" : id == "performance" ? "performance" : "access"); + return; + } + if (id == "files") + { + ShowWorkspace(); + return; + } + if (!state.Open) + Open(); + if (state.Detailed) + ShowHome(); + state.ControlsExpanded = state.PcExpanded = false; + home.SelectPage(id); + Arrange(true); + Native.Raise(home); + } + internal void LaunchTarget(string target) => launcher.LaunchTarget(target); + internal void TaskbarMenu(Window parent, FrameworkElement anchor) + => GlassMenu.Open(this, parent, anchor, "Ta barre des tâches", new[] + { + new MenuAction("Tableau de bord", "\uE8A9", () => ShowShellPage("dashboard")), + new MenuAction("Applications et fichiers", "\uE8B7", () => ShowWorkspace()), + new MenuAction("Personnaliser…", "\uE713", () => Settings(), Separator: true), + new MenuAction("Rétablir la barre Windows", "\uE777", () => SetTaskbar(false)), + new MenuAction("Quitter Winlestia", "\uE711", () => Application.Current.Shutdown()) + }, true); + internal void TaskbarAppMenu(Window parent, FrameworkElement anchor, Entry entry, bool pinned) + => GlassMenu.Open(this, parent, anchor, entry.Name, new[] + { + new MenuAction("Ouvrir une nouvelle fenêtre", "\uE8A7", () => Launch(entry)), + new MenuAction(pinned ? "Détacher de la barre" : "Épingler à la barre", "\uE718", () => TogglePinned(entry, pinned)), + new MenuAction("Tout mon espace", "\uE8B7", () => ShowWorkspace(), Separator: true) + }, true); + // Le menu épingle l'identifiant du catalogue, à défaut la cible pour les accès libres absents du catalogue. + void TogglePinned(Entry entry, bool pinned) + { + string id = Store.Entries.Any(e => e.Id == entry.Id) ? entry.Id : entry.Target; + if (pinned) + Store.State.TaskbarPins.Remove(entry.Id); + else if (!string.IsNullOrWhiteSpace(id) && !Store.State.TaskbarPins.Contains(id)) + Store.State.TaskbarPins.Add(id); + Store.Save(); + taskbar?.Refresh(); + } + internal void ToggleAlwaysOnTop(IntPtr? handle = null) + { + IntPtr target = handle ?? Native.GetForegroundWindow(); + if (target != IntPtr.Zero && Native.Owns(target)) + target = state.LastForeign; + if (target == IntPtr.Zero || !Native.IsWindow(target)) + { + ShowToast("Aucune fenêtre à épingler"); + return; + } + if (!Native.ToggleTopmost(target, out bool topmost)) + { + ShowToast("Windows a refusé ce changement"); + return; + } + string title = Native.Title(target); + if (title.Length > 38) + title = title[..37] + "…"; + ShowToast((topmost ? "Au premier plan · " : "Rangée normalement · ") + title); + } + internal void ApplyClipboardSettings() => clipboard.ApplySettings(); + internal void ToggleNotifications() + { + bool next = !SystemTools.NotificationsOn; + SystemTools.SetNotifications(next); + ShowToast(next ? "Notifications rétablies" : "Notifications coupées"); + } + internal void RestartExplorer() + { + if (!Confirm("Redémarrer l’Explorateur Windows ?\n\n" + + "La barre des tâches et le Bureau vont clignoter, tes fenêtres restent ouvertes.")) + return; + CloseDialogs(); + SystemTools.RestartExplorer(); + ShowToast("Explorateur redémarré"); + } +} diff --git a/src/backend/Core/Coordinator.Tools.cs b/src/backend/Core/Coordinator.Tools.cs new file mode 100644 index 0000000..d2f0832 --- /dev/null +++ b/src/backend/Core/Coordinator.Tools.cs @@ -0,0 +1,93 @@ +// language: C# 12, file: Coordinator.Tools.cs, target: Windows 11 / .NET 8 WPF, outils (capture, pipette, presse-papiers…) et raccourcis +// globaux +using System.Threading.Tasks; + +namespace PanneauxGlass; + +internal sealed partial class Coordinator +{ + const int CaptureCollapseDelayMs = 260; + const int ToolCollapseDelayMs = 180; + // Avec le shell Qt, l'outil s'ouvre côté frontend : vrai quand la commande a été transmise et qu'il n'y a plus rien à faire ici. + bool ForwardToQt(string command) + { + if (QtShell == null) + return false; + QtShell.Send(command); + return true; + } + // Replie l'espace et laisse l'animation finir avant l'outil plein écran, sinon il capturerait ou masquerait le panneau. + async Task CollapseThen(int delayMs) + { + CloseDialogs(); + CollapseImmediately(); + await Task.Delay(delayMs); + } + internal async void OpenCapture(bool textOnly = false) + { + if (ForwardToQt(textOnly ? "capture-ocr" : "capture-select")) + return; + await CollapseThen(CaptureCollapseDelayMs); + CaptureOverlay.Begin(this, textOnly); + } + internal async void OpenColorPicker() + { + await CollapseThen(ToolCollapseDelayMs); + ColorTools.OpenPicker(this); + } + internal async void OpenRuler() + { + await CollapseThen(ToolCollapseDelayMs); + ColorTools.OpenRuler(this); + } + internal void OpenMemo() + { + CollapseImmediately(); + PinWindow.OpenMemo(this); + } + internal void OpenClipboard() + { + if (!ForwardToQt("clipboard")) + OpenClipboardLegacy(); + } + internal ClipboardService QtClipboard => clipboard; + internal void OpenClipboardLegacy() => ShowModal(() => new ClipboardWindow(this, clipboard), OpenOwner); + internal void OpenAudio() + { + if (!ForwardToQt("audio")) + ShowModal(() => new AudioWindow(this), OpenOwner); + } + internal void OpenWorkspaces() => ShowModal(() => new WorkspaceWindow(this), OpenOwner); + internal void OpenLargeFiles() => ShowModal(() => new LargeFilesWindow(this), OpenOwner); + internal void OpenTools() + { + if (!ForwardToQt("tools")) + ShowModal(() => new ToolsWindow(this), OpenOwner); + } + internal void OpenHelp() => ShowModal(() => new HelpWindow(this), OpenOwner); + internal void RefreshPaletteLabels() + { + if (paletteChip != null) + { + paletteChip.Content = Keys.PaletteLabel; + paletteChip.ToolTip = "Commande rapide · " + Keys.PaletteLabel; + } + clock.RefreshLabels(); + palette?.ReloadTabs(); + } + internal void ToggleShortcut() + { + if (ForwardToQt("toggle")) + return; + if (state.Open) + { + Collapse(); + return; + } + var choice = Store.State.Shortcut.View; + bool full = choice == "workspace" || choice == "last" && state.Detailed, + windows = choice == "controls" || choice == "last" && state.ControlsExpanded, + status = choice == "pc" || choice == "last" && state.PcExpanded; + OpenView(full, windows, status); + } +} diff --git a/src/backend/Core/Coordinator.Views.cs b/src/backend/Core/Coordinator.Views.cs new file mode 100644 index 0000000..a4b7fc8 --- /dev/null +++ b/src/backend/Core/Coordinator.Views.cs @@ -0,0 +1,224 @@ +// language: C# 12, file: Coordinator.Views.cs, target: Windows 11 / .NET 8 WPF, ouverture, bascule et repli des vues +using System; +using System.Linq; +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; +using Forms = System.Windows.Forms; + +namespace PanneauxGlass; + +internal sealed partial class Coordinator +{ + void OpenView(bool full, bool showControls = false, bool showPc = false) + { + if (disposed) + return; + if (state.Closing) + CollapseImmediately(); + if (state.Open) + { + if (state.Detailed != full) + SwitchView(full, null); + else + Native.Raise(full ? toolbar : home); + return; + } + Native.GetCursorPos(out var mouse); + currentScreen = Forms.Screen.FromPoint(new System.Drawing.Point(mouse.X, mouse.Y)); + dismissal.SetActive(false); + state.Open = true; + state.Detailed = full; + state.ControlsExpanded = showControls; + state.PcExpanded = showPc; + if (state.ControlsExpanded && state.PcExpanded && !FitsBoth()) + state.PcExpanded = false; + rail.HideImmediately(); + state.AwaySince = DateTime.MinValue; + if (!full) + home.Reset(); + PinWindow.SpaceOpen(true, ToolbarHandle); + Arrange(true); + var focus = full ? (Window)toolbar : state.ControlsExpanded ? controls : state.PcExpanded ? pc : home; + Native.Raise(focus); + PinWindow.EnsureAbove(); + dismissal.SetActive(true); + Store.Log(full ? "Ouverture · complète" : "Ouverture · accueil"); + } + internal void ShowHome() + { + if (ForwardToQt("toggle")) + return; + if (state.Open) + SwitchView(false, null); + else + Open(); + } + internal void ShowWorkspace(string? board = null) + { + if (ForwardToQt("workspace:" + (board ?? "apps"))) + return; + if (state.Open) + SwitchView(true, board); + else + OpenView(true); + } + void SwitchView(bool full, string? board) + { + dismissal.SetActive(false); + GlassMenu.DismissActive(); + state.PopupOpen = false; + foreach (var window in Glass) + window.HideImmediately(); + websites.HideImmediately(); + state.Detailed = full; + state.ControlsExpanded = state.PcExpanded = false; + state.AwaySince = DateTime.MinValue; + if (!full) + home.Reset(); + Arrange(true); + var active = full ? (Window?)Boards.FirstOrDefault(b => b.Model.Id == board) ?? toolbar : home; + active.Activate(); + dismissal.SetActive(true); + Store.Log(full ? "Vue complète" : "Vue accueil"); + } + // Préparation commune aux panneaux latéraux : accueil ouvert, clics extérieurs suspendus. Faux si le frontend Qt a pris la main. + bool PrepareSidePanel(string qtCommand) + { + if (ForwardToQt(qtCommand)) + return false; + if (!state.Open) + Open(); + if (state.Detailed) + ShowHome(); + dismissal.SetActive(false); + GlassMenu.DismissActive(); + return true; + } + // Un panneau déplié reçoit le focus clavier ; replié, l'accueil le récupère. + void FocusSidePanel(GlassWindow panel, bool expanded) + { + Arrange(true); + (expanded ? panel : (Window)home).Activate(); + if (expanded) + { + panel.ContentHost.Focusable = true; + Keyboard.Focus(panel.ContentHost); + } + dismissal.SetActive(true); + } + internal void ToggleControls() + { + if (!PrepareSidePanel("controls")) + return; + state.ControlsExpanded = !state.ControlsExpanded; + if (state.ControlsExpanded && !FitsBoth()) + state.PcExpanded = false; + FocusSidePanel(controls, state.ControlsExpanded); + Store.Log(state.ControlsExpanded ? "Commandes Windows ouvertes" : "Commandes Windows repliées"); + } + internal void ControlsChanged() => controls.Rebuild(); + bool FitsBoth() => LayoutRules.FitsBoth(Screen.WorkingArea.Width, VisualTreeHelper.GetDpi(home).DpiScaleX); + internal void TogglePc() + { + if (!PrepareSidePanel("performance")) + return; + state.PcExpanded = !state.PcExpanded; + if (state.PcExpanded && !FitsBoth()) + state.ControlsExpanded = false; + FocusSidePanel(pc, state.PcExpanded); + Store.Log(state.PcExpanded ? "État PC ouvert" : "État PC replié"); + } + internal (long samples, bool running) PcActivity => (pc.Samples, pc.Sampling); + internal (bool open, bool closing, int visible) UiActivity + => (state.Open, state.Closing, Windows.Count(w => w.IsVisible && w != controller)); + internal void BeginLayoutInteraction() + { + state.Interacting = true; + state.AwaySince = DateTime.MinValue; + } + internal void EndLayoutInteraction() + { + state.Interacting = false; + state.AwaySince = DateTime.MinValue; + Store.Save(); + } + internal void ResetLayout() + { + foreach (var board in Boards) + board.Model.Placement = null; + Store.Save(); + Arrange(true); + } + // Un seul geste ferme toute la couche : aucune boîte de dialogue ne doit survivre à la surface qui l'a ouverte. + void CloseDialogs() + { + var open = Application.Current.Windows.OfType() + .Where(w => !Windows.Contains(w) && w is not FocusTimer) + .ToArray(); + foreach (var window in open) + { + try + { + window.Close(); + } + catch (Exception ex) { Store.Log("Fermeture dialogue: " + ex.Message); } + } + } + internal void SetTheme(string id) + { + Theme.Use(id); + Store.Save(); + foreach (var window in Application.Current.Windows.OfType().Where(w => !Program.QtMode || w.IsVisible)) + { + window.RefreshTheme(); + window.FlashTheme(); + } + clock.RefreshLabels(); + RefreshBoards(); + taskbar?.Refresh(); + } + internal void Collapse() + { + QtShell?.Send("close"); + if (!state.Open || state.Closing) + return; + CloseDialogs(); + state.Open = false; + dismissal.SetActive(false); + state.Closing = true; + var visible = Glass.Where(w => w.IsVisible).ToArray(); + int remaining = visible.Length + 1; + void Done() + { + if (--remaining == 0) + { + state.Closing = false; + state.RailAway = DateTime.UtcNow; + PinWindow.SpaceOpen(false, ToolbarHandle); + } + } + websites.SlideOut(Done); + for (int i = 0; i < visible.Length; i++) + visible[i].SlideOut(i, Done); + } + internal void CollapseImmediately() + { + QtShell?.Send("close"); + CloseDialogs(); + if (!state.Open && !state.Closing) + return; + state.Open = false; + dismissal.SetActive(false); + state.Closing = false; + GlassMenu.DismissActive(); + state.PopupOpen = false; + foreach (var window in Glass.Append(rail)) + window.HideImmediately(); + websites.HideImmediately(); + PinWindow.SpaceOpen(false, ToolbarHandle); + state.AwaySince = state.EdgeSince = DateTime.MinValue; + state.RailAway = DateTime.UtcNow; + Store.Log("Repli immédiat · clic extérieur ou changement d’application"); + } +} diff --git a/src/backend/Core/Coordinator.cs b/src/backend/Core/Coordinator.cs new file mode 100644 index 0000000..db5c21c --- /dev/null +++ b/src/backend/Core/Coordinator.cs @@ -0,0 +1,237 @@ +// language: C# 12, file: Coordinator.cs, target: Windows 11 / .NET 8 WPF, cycle de vie du shell natif et de l'espace de travail +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Interop; +using System.Windows.Threading; +using Forms = System.Windows.Forms; + +namespace PanneauxGlass; + +// Orchestration : les décisions et les dialogues vivent dans les services (Dialog, Launch, Tick, Shortcut, Catalogue…) et dans +// ShellState, l'état partagé que lisent aussi ces services. +internal sealed partial class Coordinator : IDisposable +{ + internal readonly IconLoader Icons = new(); + internal readonly List Boards = new(); + readonly Window controller = new() { Width = 1, Height = 1, ShowInTaskbar = false, WindowStyle = WindowStyle.None }; + readonly GlassWindow rail = new("Winlestia — poignée latérale"), toolbar = new("Winlestia — commandes"); + readonly GlassWindow leftArrow = new("Winlestia — explorer à gauche"), rightArrow = new("Winlestia — explorer à droite"); + internal QtShellBridge? QtShell; + // Vue des raccourcis globaux pour la page de réglages (liste, réaffectation avec retour arrière, conflits) + internal ShortcutManager Shortcuts = null!; + readonly ShellTaskbar? taskbar; + readonly WebStrip websites; + readonly OutsideDismiss dismissal; + readonly HomeWindow home; + readonly ControlCenterWindow controls; + readonly PcStatusWindow pc; + readonly ClockWindow clock; + readonly ShellState state = new(); + CommandPalette? palette; + Button? paletteChip; + internal Rect PanelBounds + { + get; private set; + } + static readonly TimeSpan TimerInterval = TimeSpan.FromMilliseconds(120); + Window DialogParent => QtShell != null ? controller : state.Detailed ? toolbar : home; + readonly DispatcherTimer timer = new() { Interval = TimerInterval }, debounce = new() { Interval = TimerInterval }; + readonly ITrayService tray = new TrayService(); + readonly ICatalogueService catalogue; + readonly IDesktopIconsService desktopIcons; + readonly IPreviewExportService previewExport; + readonly IDialogService dialogs; + readonly ILaunchService launcher; + readonly ITickController ticks; + internal readonly IShortcutBindings Keys; + readonly bool preview; + readonly IntPtr hwnd; + readonly HwndSource source; + readonly ClipboardService clipboard; + bool disposed; + Forms.Screen? currentScreen; + // Indicateurs partagés avec les fenêtres : l'API publique du Coordinator reste inchangée, l'état vit dans ShellState. + internal bool PopupOpen + { + get => state.PopupOpen; set => state.PopupOpen = value; + } + internal bool FavoritesOnly + { + get => state.FavoritesOnly; set => state.FavoritesOnly = value; + } + internal bool Pinned + { + get => state.Pinned; set => state.Pinned = value; + } + internal bool PreviewMode => preview; + // Fenêtres glass dans l'ordre d'animation du repli (l'index décale chaque glissement) ; la poignée est gérée à part. + IEnumerable Glass => Boards.Cast() + .Append(toolbar).Append(home).Append(controls).Append(pc).Append(clock).Append(leftArrow).Append(rightArrow); + IEnumerable Layered => Glass.Cast().Concat(websites.Windows); + IEnumerable Windows + => Layered.Append(rail).Append(controller).Concat(taskbar?.Windows ?? Array.Empty()); + internal Window Anchor => state.Detailed ? toolbar : home; + internal Forms.Screen Screen => currentScreen ?? Forms.Screen.PrimaryScreen!; + internal IntPtr ToolbarHandle => new WindowInteropHelper(toolbar).EnsureHandle(); + internal Coordinator(bool isPreview, bool interactionFixture = false, bool exportPreview = false, RunOptions? options = null) + { + preview = isPreview; + catalogue = new CatalogueService(preview, () => + { + RefreshBoards(); + QtShell?.CatalogueChanged(); + }); + desktopIcons = new DesktopIconsService(preview); + previewExport = new PreviewExportService(Icons); + dialogs = new DialogService(state, () => Layered, () => DialogParent); + launcher = new LaunchService(dialogs.Message, Collapse, CollapseImmediately, () => Open()); + Store.Load(); + ShellSettings.Migrate(preview); + Theme.Use(Program.ThemeOverride ?? Store.State.Theme); + UI.Install(); + clipboard = new ClipboardService(); + websites = new WebStrip(this); + ticks = new TickController(state, rail, new TickHost(() => Boards.Cast().Append(toolbar).Concat(websites.Windows), + () => Windows, () => taskbar?.PointerTick(), SetTaskbar, () => Open(), Collapse, () => QtShell != null && !state.Open)); + // HomeWindow lit Keys dans son constructeur : les liaisons existent avant toute fenetre. Le hook reste branche apres. + hwnd = new WindowInteropHelper(controller).EnsureHandle(); + source = HwndSource.FromHwnd(hwnd); + var global = new GlobalShortcut(hwnd, Store.State.Shortcut, Store.State.PaletteShortcut, Store.State.PinShortcut, + Store.State.CaptureShortcut, Store.State.WallpaperShortcut, Store.State.SettingsShortcut); + Keys = new ShortcutBindings(global, BuildRail, RefreshPaletteLabels); + foreach (var model in Store.State.Boards) + Boards.Add(new BoardWindow(this, model)); + home = new HomeWindow(this); + clock = new ClockWindow(this); + controls = new ControlCenterWindow(this); + pc = new PcStatusWindow(this); + leftArrow.Radius = rightArrow.Radius = 25; + leftArrow.ContentHost.Children.Add(UI.IconButton("", "Afficher l’état du PC", TogglePc)); + rightArrow.ContentHost.Children.Add(UI.IconButton("", "Afficher les commandes Windows", ToggleControls)); + source.AddHook(Hook); + Shortcuts = new ShortcutManager(global, () => Store.State, Store.Save, id => + { + if (id == "dashboard") + BuildRail(); + else if (id is "palette" or "pin" or "capture") + RefreshPaletteLabels(); + }); + Store.Log("Raccourcis · palette=" + Keys.PaletteLabel + " · capture=" + Keys.CaptureShortcutLabel + " · épinglage=" + + Keys.PinShortcutLabel + " · fonds d’écran=" + Keys.WallpaperLabel); + Pinned = preview; + BuildToolbar(); + BuildRail(); + PinWindow.RestoreAll(this); + foreach (var window in Windows) + window.Activated += (_, _) => PinWindow.EnsureAbove(); + dismissal = new OutsideDismiss(CollapseImmediately, + () => state.Open && !state.SettingsOpen && !state.Interacting && !state.PaletteOpen && !(preview && interactionFixture)); + timer.Tick += (_, _) => ticks.Tick(); + timer.Start(); + clock.Shortcut = HandleShortcut; + home.Shortcut = HandleShortcut; + toolbar.Shortcut = HandleShortcut; + foreach (var board in Boards) + board.Shortcut = HandleShortcut; + BuildTray(); + if (Program.QtMode) + QtShell = new QtShellBridge(this, options ?? RunOptions.Default); + taskbar = new ShellTaskbar(this); + StartTaskbar(); + Microsoft.Win32.SystemEvents.DisplaySettingsChanged += DisplayChanged; + if (preview) + { + Pinned = true; + foreach (var window in Windows) + window.ShowInTaskbar = window != controller; + Open(); + if (exportPreview) + previewExport.ScheduleExport(); + } + else if (!File.Exists(Path.Combine(Store.Root, "catalog.json")) + || File.GetLastWriteTimeUtc(Path.Combine(Store.Root, "catalog.json")) < DateTime.UtcNow.AddDays(-1)) + RefreshCatalogue(); + if (!preview) + catalogue.StartWatching(); + desktopIcons.Hide(); + if (!preview) + Native.MigrateAutostart(); + Store.Save(); + Store.Log("Démarrage Winlestia " + (typeof(Coordinator).Assembly.GetName().Version?.ToString(3) ?? "?") + " · preview=" + + preview + " · t+" + (int)(DateTime.Now - Process.GetCurrentProcess().StartTime).TotalMilliseconds + " ms"); + } + IntPtr Hook(IntPtr w, int msg, IntPtr wp, IntPtr lp, ref bool handled) + { + if (explorerCreated != 0 && (uint)msg == explorerCreated) + ExplorerCreated(); + if (msg != 0x0312) + return IntPtr.Zero; + if (QtShell?.CapturingShortcut == true) + { + handled = true; + QtShell.CaptureHotkey(lp); + return IntPtr.Zero; + } + var action = Keys.Resolve(wp.ToInt32()); + handled = action != HotkeyAction.None; + switch (action) + { + case HotkeyAction.Rescue: + Rescue(); + break; + case HotkeyAction.Palette: + if (!ShortcutGateRules.IsBlockedBySettings(action, state.SettingsOpen)) + TogglePalette(); + break; + case HotkeyAction.Settings: + if (QtShell != null) + QtShell.Send("settings"); + else + Settings(); + break; + case HotkeyAction.Wallpaper: + QtShell?.Send("wallpaper-switcher"); + break; + case HotkeyAction.Pin: + ToggleAlwaysOnTop(); + break; + case HotkeyAction.Capture: + if (!ShortcutGateRules.IsBlockedBySettings(action, state.SettingsOpen)) + OpenCapture(); + break; + case HotkeyAction.Toggle: + if (!ShortcutGateRules.IsBlockedBySettings(action, state.SettingsOpen)) + ToggleShortcut(); + break; + } + return IntPtr.Zero; + } + public void Dispose() + { + if (disposed) + return; + disposed = true; + catalogue.Dispose(); + taskbar?.Dispose(); + QtShell?.Dispose(); + RestoreDesktopIcons(); + desktopIcons.Dispose(); + dismissal.Dispose(); + Store.Save(); + timer.Stop(); + debounce.Stop(); + Microsoft.Win32.SystemEvents.DisplaySettingsChanged -= DisplayChanged; + Keys.Dispose(); + source.RemoveHook(Hook); + clipboard.Dispose(); + tray.Dispose(); + Icons.Dispose(); + foreach (var window in Windows.ToArray()) + window.Close(); + } +} diff --git a/src/backend/Core/FramePolicy.cs b/src/backend/Core/FramePolicy.cs new file mode 100644 index 0000000..684f4dc --- /dev/null +++ b/src/backend/Core/FramePolicy.cs @@ -0,0 +1,20 @@ +// C# 12 / FramePolicy.cs : décisions pures pour la bordure d'accent (toutes les fenêtres, ou seulement l'active). +using System; +namespace PanneauxGlass; + +internal static class FramePolicy +{ + internal static bool Carries(bool accentBorders, bool activeOnly, IntPtr window, IntPtr active) => accentBorders && (!activeOnly + || window == active); + // Changement de premier plan : fenêtre à colorer, précédente à restaurer (Zero = aucune), puis fenêtre suivie désormais. + // Un premier plan inéligible (panneau du shell, outil, fenêtre masquée) laisse tout en l'état. + internal static (IntPtr colour, IntPtr restore, IntPtr tracked) Foreground(bool accentBorders, bool activeOnly, bool eligible, + IntPtr foreground, IntPtr last) + { + if (!accentBorders || !activeOnly) + return (IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); + if (!eligible || foreground == IntPtr.Zero || foreground == last) + return (IntPtr.Zero, IntPtr.Zero, last); + return (foreground, last, foreground); + } +} diff --git a/src/backend/Storage/Models.cs b/src/backend/Core/Models.cs similarity index 73% rename from src/backend/Storage/Models.cs rename to src/backend/Core/Models.cs index f47ee8a..7f0e70b 100644 --- a/src/backend/Storage/Models.cs +++ b/src/backend/Core/Models.cs @@ -7,7 +7,7 @@ using System.Text; using System.Text.Json; -namespace PanneauxGlass.Storage; +namespace PanneauxGlass; public sealed class Entry { @@ -18,44 +18,54 @@ public sealed class Entry public string Category { get; set; } = ""; public string IconPath { get; set; } = ""; } - public sealed class Catalogue { public List Entries { get; set; } = new(); public string GeneratedAt { get; set; } = ""; public string[] Diagnostics { get; set; } = Array.Empty(); } - public sealed class BoardState { - public bool FavoritesOnly { get; set; } - public PanelPlacement? Placement { get; set; } + public bool FavoritesOnly + { + get; set; + } + public PanelPlacement? Placement + { + get; set; + } public string Id { get; set; } = ""; public string Title { get; set; } = ""; public string Accent { get; set; } = "#7ADCF4"; public string Filter { get; set; } = "Tous"; } - public sealed class WebShortcut { public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string Name { get; set; } = ""; public string Url { get; set; } = ""; - public string IconPath { get; set; } = ""; - public bool Fallback { get; set; } - public string SourceImagePath { get; set; } = ""; - public LogoCrop? Crop { get; set; } + public string IconPath { get; set; } = ""; public bool Fallback + { + get; set; + } + public string SourceImagePath { get; set; } = ""; public LogoCrop? Crop + { + get; set; + } public bool ShowGlass { get; set; } = true; } - public sealed class LogoCrop { - public double Zoom { get; set; } = 1; - public double X { get; set; } - public double Y { get; set; } + public double Zoom { get; set; } = 1; public double X + { + get; set; + } + public double Y + { + get; set; + } public bool Centered { get; set; } = true; } - public sealed class WorkspaceProfile { public string Id { get; set; } = Guid.NewGuid().ToString("N"); @@ -63,37 +73,42 @@ public sealed class WorkspaceProfile public List Targets { get; set; } = new(); public Dictionary Layout { get; set; } = new(); } - public sealed class ClipboardItem { public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string Kind { get; set; } = "text"; public string Text { get; set; } = ""; - public string ImagePath { get; set; } = ""; - public bool Pinned { get; set; } + public string ImagePath { get; set; } = ""; public bool Pinned + { + get; set; + } public string At { get; set; } = ""; } - public sealed class LauncherTool { public string Id { get; set; } = ""; public string Kind { get; set; } = ""; public string Name { get; set; } = ""; } - -// A sheet taped to the screen: a captured image or a handwritten memo. Everything that makes it -// feel placed — text, width, position, transparency, angle — is kept here and restored at startup. +// Une feuille collée à l'écran : image capturée ou mémo manuscrit. Tout ce qui la fait paraître posée +// (texte, largeur, position, transparence, angle) est conservé ici et restauré au démarrage. public sealed class PinNote { public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string Kind { get; set; } = "memo"; public string Text { get; set; } = ""; public string ImagePath { get; set; } = ""; - public double? Left { get; set; } - public double? Top { get; set; } + public double? Left + { + get; set; + } + public double? Top + { + get; set; + } public double Width { get; set; } = 300; public double Height { get; set; } = 0; public double Opacity { get; set; } = 1; public double Angle { get; set; } = -2.4; public string Created { get; set; } = DateTime.Now.ToString("O"); -} +} \ No newline at end of file diff --git a/src/backend/Core/Native.Window.cs b/src/backend/Core/Native.Window.cs new file mode 100644 index 0000000..47f9154 --- /dev/null +++ b/src/backend/Core/Native.Window.cs @@ -0,0 +1,338 @@ +// language: C# 12, file: Native.Window.cs, target: Windows 11 / .NET 8, verre natif et icônes du shell +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows.Threading; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Microsoft.Win32; +using Forms = System.Windows.Forms; + +namespace PanneauxGlass; + +internal static partial class Native +{ + // schtasks peut durer jusqu'à 15 s : exécuté en fond, l'état demandé reste visible via Autostart pendant ce temps. + internal static void SetAutostart(bool value) + { + // Nom historique conservé pour compatibilité (valeur "Panneaux" de la clé Run des installations existantes). + System.Threading.Volatile.Write(ref autostartDesired, value ? 1 : 0); + System.Threading.Tasks.Task.Run(() => + { + lock (autostartGate) + { + try + { + if (value) + { + if (AutostartTask.Create()) + DropRun( + ); + else + { + Store.Log( + "Démarrage: tâche indisponible, repli sur la clé Run."); + using var key = Registry.CurrentUser.CreateSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Run"); + key.SetValue("Panneaux", + "\"" + Environment.ProcessPath + "\""); + } + } + else + { + AutostartTask.Delete(); + DropRun(); + } + } + catch (Exception ex) + { + Store.Log( + "Démarrage: " + ex.Message); + } + finally { System.Threading.Volatile.Write(ref autostartDesired, -1); } + } + }); + } + internal static bool DesktopHidden() + { + using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced"); + return Convert.ToInt32(key?.GetValue("HideIcons", 0) ?? 0) != 0; + } + internal static void Desktop(bool hidden) + { + string helper = Path.Combine(Store.Root, "SECOURS_BUREAU.exe"); + if (!File.Exists(helper)) + throw new FileNotFoundException("Le composant natif du Bureau est absent."); + using var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(helper, hidden ? "--hide --silent" + : "--show-only --silent") + { + UseShellExecute = false, + CreateNoWindow = true + }); + if (process == null || !process.WaitForExit(3000) || process.ExitCode != 0) + throw new InvalidOperationException("Le Bureau Windows n’a pas appliqué le réglage."); + if (DesktopHidden() != hidden || !DesktopView.Enforce(hidden)) + throw new InvalidOperationException("Le Bureau Windows n’a pas retenu le réglage."); + } +} +internal sealed class IconLoader : IIconSource, IDisposable +{ + [StructLayout(LayoutKind.Sequential)] + struct Size + { + public int Width, Height; + } + [ComImport, Guid("bcc18b79-ba16-442f-80c4-8a59c30c463b"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IImageFactory + { + [PreserveSig] int GetImage(Size size, uint flags, out IntPtr bitmap); + } + [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)] + static extern int SHCreateItemFromParsingName(string path, + IntPtr bind, ref Guid iid, [MarshalAs(UnmanagedType.Interface)] out IImageFactory factory); + readonly BlockingCollection<(Entry entry, Action callback)> jobs = new(); readonly ConcurrentDictionary images = new(); + sealed record Request(Action Callback, string Scope); + readonly object pendingLock = new(); readonly Dictionary> pending = new(); bool disposed; + readonly Thread worker; internal int LoadedCount => images.Count; internal int Failures; + internal IconLoader() + { + // Nom historique conservé pour compatibilité (nom de thread vu dans les journaux et débogueurs). + worker = new Thread(Work) { IsBackground = true, Name = "Panneaux shell icons" }; + worker.SetApartmentState(ApartmentState.STA); + worker.Start(); + } + public void Load(Entry entry, Action callback, string scope = "") + { + string key = entry.Id + "|" + entry.IconPath; + if (images.TryGetValue(key, out var value)) + { + callback(value); + return; + } + lock (pendingLock) + { + if (disposed) + return; + if (images.TryGetValue(key, out value)) + { + callback(value); + return; + } + if (pending.TryGetValue(key, out var listeners)) + { + listeners.Add(new(callback, scope)); + return; + } + pending[key] = new() { new(callback, scope) }; + jobs.Add((entry, callback)); + } + } + internal void CancelScope(string scope) + { + lock (pendingLock) + { + foreach (var key in pending.Keys.ToArray()) + { + pending[key].RemoveAll(r => r.Scope == scope); + if (pending[key].Count == 0) + pending.Remove(key); + } + } + } + void Work() + { + foreach (var job in jobs.GetConsumingEnumerable()) + { + string key = job.entry.Id + "|" + job.entry.IconPath; + lock (pendingLock) + { + if (disposed) + break; + if (!pending.ContainsKey(key)) + continue; + } + if (!images.TryGetValue(key, out var image)) + { + image = ExtractOrFallback(key, job.entry); + } + Request[] listeners; + lock (pendingLock) + { + listeners = pending.TryGetValue(key, out var list) ? list.ToArray() : Array.Empty(); + pending.Remove(key); + } + var result = image; + System.Windows.Application.Current?.Dispatcher.BeginInvoke(DispatcherPriority.Background, (Action)(() => + { + if ( + !disposed) + foreach (var listener in listeners) + listener.Callback(result); + })); + } + } + // Une tuile lettre ou dossier remplace une extraction échouée mais n'est jamais retenue comme icône : le chargement + // suivant, après le délai de nouvel essai (ou un rechargement du catalogue), extrait de nouveau. + readonly ConcurrentDictionary failed = new( + ); readonly ConcurrentDictionary logged = new( + ); readonly System.Runtime.CompilerServices.ConditionalWeakTable placeholders = new(); + ImageSource ExtractOrFallback(string key, Entry entry) + { + int count = 0; + if (failed.TryGetValue(key, out var previous)) + { + count = previous.Count; + if (!CatalogueWatchRules.IconRetryDue(count, previous.At, DateTime.UtcNow)) + return previous.Plate; + } + var real = Extract(entry); + if (real != null) + { + images[key] = real; + failed.TryRemove(key, out _); + return real; + } + if (Directory.Exists(entry.Target)) + { + var folder = FolderFallback(); + images[key] = folder; + return folder; + } + Failures++; + var plate = InitialPlate(entry); + placeholders.Add(plate, new object()); + failed[key] = (DateTime.UtcNow, count + 1, plate, entry, SourceStamp(entry)); + if (logged.TryAdd(key, 0)) + Store.Log("Icône de secours: " + entry.Name); + return plate; + } + public bool IsPlaceholder(ImageSource image) => placeholders.TryGetValue(image, out _); + public int FailureCount(Entry entry) => failed.TryGetValue(entry.Id + "|" + entry.IconPath, out var f) ? f.Count : 0; + // Date d'écriture la plus récente de la source d'icône et de la cible (fichiers locaux seulement) ; une source qui + // apparaît ou change donne à son icône une nouvelle série d'essais. + static DateTime SourceStamp(Entry entry) + { + DateTime stamp = DateTime.MinValue; + foreach (string path in new[] { entry.IconPath, entry.Target }) + { + try + { + if (string.IsNullOrWhiteSpace(path) || path.StartsWith(@"\\", StringComparison.Ordinal) || !Path.IsPathRooted(path)) + continue; + DateTime at = File.Exists(path) ? File.GetLastWriteTimeUtc(path) : Directory.Exists(path) ? Directory.GetLastWriteTimeUtc( + path) : DateTime.MinValue; + if (at > stamp) + stamp = at; + } + catch (Exception) + { + // Entrée récente illisible : ignorée. + } + } + return stamp; + } + public void ResetFailures() + { + foreach (var item in failed) + if (SourceStamp(item.Value.Entry) != item.Value.Stamp) + failed.TryRemove(item.Key, out _); + } + static ImageSource? Extract(Entry entry) + { + var paths = CatalogueWatchRules.PreferTargetForIcon(entry.IconPath) ? new[] { entry.Target, entry.IconPath } + : new[] { entry.IconPath, entry.Target }; + foreach (string path in paths) + { + if (string.IsNullOrWhiteSpace(path)) + continue; + try + { + if (File.Exists(path) && Path.GetExtension(path).ToLowerInvariant() is ".png" or ".jpg" or ".jpeg" or ".ico") + { + var bi = new BitmapImage(); + bi.BeginInit(); + bi.CacheOption = BitmapCacheOption.OnLoad; + bi.UriSource = new Uri(path, UriKind.Absolute); + bi.DecodePixelWidth = 96; + bi.EndInit(); + bi.Freeze(); + return bi; + } + Guid iid = typeof(IImageFactory).GUID; + int hr = SHCreateItemFromParsingName(path, IntPtr.Zero, ref iid, out var factory); + if (hr != 0) + continue; + try + { + if (factory.GetImage(new Size { Width = 96, Height = 96 }, 0x100 | 0x4, out var bitmap) == 0 && bitmap != IntPtr.Zero) + { + try + { + var source = Imaging.CreateBitmapSourceFromHBitmap(bitmap, IntPtr.Zero, Int32Rect.Empty, + BitmapSizeOptions.FromEmptyOptions()); + source.Freeze(); + return source; + } + finally { Native.DeleteObject(bitmap); } + } + } + finally { Marshal.ReleaseComObject(factory); } + } + catch (Exception ex) { Store.Log("Icône " + path + ": " + ex.Message); } + } + return null; + } + internal static ImageSource? Sample(Entry entry) => Extract(entry); + static ImageSource FolderFallback() + { + var group = new DrawingGroup(); + using (var dc = group.Open()) + { + dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromRgb(248, 194, 78)), null, new Rect(5, 23, 80, 58), 9, 9); + dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromRgb(255, 215, 118)), null, new Rect(5, 16, 38, 20), 6, 6); + } + group.Freeze(); + var image = new DrawingImage(group); + image.Freeze(); + return image; + } + // Un logo d'application manquant devient une tuile lettre, jamais un dossier jaune : une ligne se lit toujours + // comme une appli précise, et la palette n'affiche jamais d'emplacement vide. + internal static ImageSource InitialPlate(Entry entry) + { + var colors = new[] { Color.FromRgb(0x3D, 0x7E, 0xA6), Color.FromRgb(0xC2, 0x5B, 0x35), Color.FromRgb(0x4C, 0x8F, 0x6D), + Color.FromRgb(0x7A, 0x5C, 0xA8), Color.FromRgb(0xC0, 0x8A, 0x2E), Color.FromRgb(0xB0, 0x4A, 0x6A) }; + string name = string.IsNullOrWhiteSpace(entry.Name) ? "?" : entry.Name.Trim(); + Color fill = colors[Math.Abs(name.ToUpperInvariant()[0] * 31 + name.Length * 17) % colors.Length]; + var group = new DrawingGroup(); + using (var dc = group.Open()) + { + dc.DrawRoundedRectangle(new SolidColorBrush(fill), null, new Rect(3, 3, 90, 90), 24, 24); + string letter = name[..1].ToUpperInvariant(); + var text = new FormattedText(letter, System.Globalization.CultureInfo.InvariantCulture, FlowDirection.LeftToRight, + new Typeface("Segoe UI Semibold"), 50, Brushes.White, 1.0); + dc.DrawText(text, new Point((96 - text.Width) / 2, (96 - text.Height) / 2)); + } + group.Freeze(); + var image = new DrawingImage(group); + image.Freeze(); + return image; + } + public void Dispose() + { + lock (pendingLock) + { + disposed = true; + pending.Clear(); + jobs.CompleteAdding(); + } + } +} diff --git a/src/backend/Core/Native.cs b/src/backend/Core/Native.cs new file mode 100644 index 0000000..a45cbc0 --- /dev/null +++ b/src/backend/Core/Native.cs @@ -0,0 +1,338 @@ +// language: C# 12, file: Native.cs, target: Windows 11 / .NET 8, verre natif et icônes du shell +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows.Threading; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Microsoft.Win32; +using Forms = System.Windows.Forms; + +namespace PanneauxGlass; + +internal static partial class Native +{ + internal static bool Layered; + [DllImport("GlassBackdrop.dll")] static extern int BackdropAttach(IntPtr content); + [DllImport("GlassBackdrop.dll")] static extern int BackdropUpdate(IntPtr content, double radius, bool visible, bool topmost); + [DllImport("GlassBackdrop.dll")] internal static extern void BackdropDetach(IntPtr content); + internal static void SyncBackdrop(Window window, int radius) + { + if (!window.AllowsTransparency || Theme.Current.Opaque) + return; + var hwnd = new WindowInteropHelper(window).Handle; + if (hwnd != IntPtr.Zero) + { + int hr = BackdropUpdate(hwnd, Math.Min(radius, Math.Min(window.ActualWidth, window.ActualHeight) / 2), window.IsVisible, + window.Topmost); + if (hr < 0) + Store.Log("Backdrop update: 0x" + hr.ToString("X")); + } + } + [StructLayout(LayoutKind.Sequential)] + struct BlurRegion + { + public uint Flags; public int Enabled; public IntPtr Region; public int Transition; + } + [DllImport("dwmapi.dll")] static extern int DwmEnableBlurBehindWindow(IntPtr hwnd, ref BlurRegion blur); + [StructLayout(LayoutKind.Sequential)] + internal struct Point + { + public int X, Y; + } + [StructLayout(LayoutKind.Sequential)] + internal struct Rect + { + public int Left, Top, Right, Bottom; + } + [StructLayout(LayoutKind.Sequential)] + struct Margins + { + public int Left, Right, Top, Bottom; + } + [StructLayout(LayoutKind.Sequential)] + struct Accent + { + public int State, Flags, Gradient, Animation; + } + [StructLayout(LayoutKind.Sequential)] + struct CompositionData + { + public int Attribute; public IntPtr Data; public UIntPtr Size; + } + [DllImport("user32.dll")] internal static extern bool GetCursorPos(out Point point); + [DllImport("user32.dll")] internal static extern IntPtr GetForegroundWindow(); + internal delegate IntPtr LowLevelMouseProc(int code, IntPtr message, IntPtr data); + [DllImport("user32.dll", SetLastError = true)] + internal static extern IntPtr SetWindowsHookExW(int id, LowLevelMouseProc procedure, + IntPtr module, uint thread); + [DllImport("user32.dll")] internal static extern bool UnhookWindowsHookEx(IntPtr hook); + [DllImport("user32.dll")] internal static extern IntPtr CallNextHookEx(IntPtr hook, int code, IntPtr message, IntPtr data); + [DllImport("kernel32.dll")] internal static extern IntPtr GetModuleHandleW(string? name); + [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hwnd, out Rect rect); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassName(IntPtr hwnd, StringBuilder name, int length); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowTextW(IntPtr hwnd, StringBuilder name, int length); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint process); + [DllImport("user32.dll")] internal static extern bool IsWindow(IntPtr hwnd); + internal static bool Owns(IntPtr hwnd) + { + if (hwnd == IntPtr.Zero) + return false; + GetWindowThreadProcessId(hwnd, out uint pid); + return pid == (uint)Environment.ProcessId; + } + internal static string Title(IntPtr hwnd) + { + var text = new StringBuilder(256); + GetWindowTextW(hwnd, text, 256); + return text.ToString(); + } + // Utilisé par la touche globale Toujours au premier plan : une fenêtre étrangère voit seulement son style étendu basculé. + internal static bool ToggleTopmost(IntPtr hwnd, out bool topmost) + { + topmost = false; + if (hwnd == IntPtr.Zero || !IsWindow(hwnd)) + return false; + long style = GetWindowLongPtrW(hwnd, -20).ToInt64(); + topmost = (style & 0x8) == 0; + return SetWindowPos(hwnd, topmost ? new IntPtr(-1) : new IntPtr(-2), 0, 0, 0, 0, 0x0001 | 0x0002 | 0x0010); + } + [DllImport("user32.dll")] static extern bool SetWindowCompositionAttribute(IntPtr hwnd, ref CompositionData data); + [DllImport("dwmapi.dll")] static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size); + [DllImport("dwmapi.dll")] static extern int DwmExtendFrameIntoClientArea(IntPtr hwnd, ref Margins margins); + [DllImport("user32.dll", SetLastError = true)] + internal static extern bool RegisterHotKey(IntPtr hwnd, int id, uint modifiers, + uint key); + [DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr window, IntPtr insertAfter, int x, int y, int cx, int cy, uint flags); + [DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr window); + internal static void Raise(Window window) + { + var handle = new WindowInteropHelper(window).Handle; + if (handle == IntPtr.Zero) + return; + SetWindowPos(handle, new IntPtr(-1), 0, 0, 0, 0, 0x0001 | 0x0002 | 0x0010); + window.Activate(); + SetForegroundWindow(handle); + } + [DllImport("user32.dll")] internal static extern bool UnregisterHotKey(IntPtr hwnd, int id); + [DllImport("user32.dll")] static extern int SetWindowRgn(IntPtr hwnd, IntPtr region, bool redraw); + internal static void ClearRegion(Window window) + { + var handle = new WindowInteropHelper(window).Handle; + if (handle != IntPtr.Zero) + SetWindowRgn(handle, IntPtr.Zero, true); + } + [DllImport("user32.dll")] static extern int GetWindowRgn(IntPtr hwnd, IntPtr region); + [DllImport("gdi32.dll")] static extern bool PtInRegion(IntPtr region, int x, int y); + [DllImport("gdi32.dll")] static extern IntPtr CreateRoundRectRgn(int l, int t, int r, int b, int width, int height); + [DllImport("gdi32.dll")] internal static extern bool DeleteObject(IntPtr obj); + [DllImport("user32.dll")] static extern uint GetDpiForWindow(IntPtr hwnd); + [DllImport("user32.dll")] static extern IntPtr GetWindowLongPtrW(IntPtr hwnd, int index); + [DllImport("user32.dll")] static extern IntPtr SetWindowLongPtrW(IntPtr hwnd, int index, IntPtr value); + // WS_EX_TRANSPARENT fait de la feuille une image et non une cible : chaque clic atteint les panneaux dessous. + internal static long ExtendedStyle(IntPtr hwnd) => hwnd == IntPtr.Zero ? 0 : GetWindowLongPtrW(hwnd, -20).ToInt64(); + internal static void ClickThrough(IntPtr hwnd, bool through) + { + if (hwnd == IntPtr.Zero) + return; + long style = GetWindowLongPtrW(hwnd, -20).ToInt64(); + long next = through ? (style | 0x20 | 0x08000000) : (style & ~(0x20L | 0x08000000L)); + if (next != style) + SetWindowLongPtrW(hwnd, -20, new IntPtr(next)); + } + internal static void PlaceBelow(IntPtr hwnd, IntPtr anchor) + { + if (hwnd == IntPtr.Zero || anchor == IntPtr.Zero) + return; + SetWindowPos(hwnd, anchor, 0, 0, 0, 0, 0x0001 | 0x0002 | 0x0010); + } + internal static void KeepAbove(IntPtr hwnd) + { + if (hwnd != IntPtr.Zero) + SetWindowPos(hwnd, new IntPtr(-1), 0, 0, 0, 0, 0x0001 | 0x0002 | 0x0010); + } + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr hwnd); + [DllImport("dwmapi.dll")] static extern int DwmGetWindowAttribute(IntPtr hwnd, int attr, out Rect value, int size); + internal static object Describe(Window window) + { + var h = new WindowInteropHelper(window).Handle; + GetWindowRect(h, out var r); + DwmGetWindowAttribute(h, 9, out var frame, 16); + var region = CreateRoundRectRgn(0, 0, 1, 1, 0, 0); + int kind = GetWindowRgn(h, region); + bool corner = PtInRegion(region, 1, 1); + DeleteObject(region); + return new + { + window.Title, + Hwnd = h.ToInt64(), + Visible = IsWindowVisible(h), + WpfVisible = window.IsVisible, + window.Left, + window.Top, + window.Width, + window.Height, + window.ActualWidth, + window.ActualHeight, + window.Opacity, + RegionKind = kind, + CornerIncluded = corner, + Style = GetWindowLongPtrW(h, -16).ToInt64(), + ExStyle = GetWindowLongPtrW(h, -20).ToInt64(), + Rect = new[] { r.Left, r.Top, r.Right, r.Bottom }, + DwmFrame = new[] { frame.Left, frame.Top, frame.Right, frame.Bottom } + }; + } + internal static void Glass(Window window, double tint, bool light, int radius = 32) + { + var hwnd = new WindowInteropHelper(window).Handle; + if (hwnd == IntPtr.Zero) + return; + var source = HwndSource.FromHwnd(hwnd); + if (source?.CompositionTarget != null) + source.CompositionTarget.BackgroundColor = Colors.Transparent; + var margins = window.AllowsTransparency || Theme.Current.Opaque ? new Margins() : new Margins + { + Left = -1, + Right = -1, + Top = -1, + Bottom = -1 + }; + DwmExtendFrameIntoClientArea(hwnd, ref margins); + int dark = light ? 0 : 1; + DwmSetWindowAttribute(hwnd, 20, ref dark, 4); + // Une seule surface acrylique, découpée par notre région. Sans cela, le cadre/fond arrondi séparé de DWM + // laisse derrière elle un second rectangle moins arrondi. + int corner = 1; + DwmSetWindowAttribute(hwnd, 33, ref corner, 4); + int border = unchecked((int)0xFFFFFFFE); + DwmSetWindowAttribute(hwnd, 34, ref border, 4); + int backdrop = 1; + DwmSetWindowAttribute(hwnd, 38, ref backdrop, 4); + // L'acrylique d'accent garde les quatre surfaces indépendantes translucides quand le focus passe de l'une à l'autre. + var policy = new Accent + { + State = window.AllowsTransparency || Theme.Current.Opaque ? 0 : 4, + Flags = window.AllowsTransparency + || Theme.Current.Opaque ? 0 : 2, + Gradient = unchecked((int)(((uint)(tint * 255) << 24) | (light ? 0x00F8F3ECu + : 0x003C2418u))) + }; + IntPtr memory = Marshal.AllocHGlobal(Marshal.SizeOf()); + try + { + Marshal.StructureToPtr(policy, memory, false); + var data = new CompositionData { Attribute = 19, Data = memory, Size = (UIntPtr)Marshal.SizeOf() }; + SetWindowCompositionAttribute(hwnd, ref data); + } + finally { Marshal.FreeHGlobal(memory); } + if (window.AllowsTransparency) + { + var blur = new BlurRegion { Flags = 1, Enabled = 0 }; + DwmEnableBlurBehindWindow(hwnd, ref blur); + if (Theme.Current.Opaque) + { + BackdropDetach(hwnd); + } + else if (window is not GlassWindow glass || glass.MaterialVisible) + { + int hr = BackdropAttach(hwnd); + if (hr < 0) + Store.Log("Backdrop attach: 0x" + hr.ToString("X")); + } + } + Round(window, radius); + SyncBackdrop(window, radius); + } + internal static void Round(Window w, int radius) + { + var hwnd = new WindowInteropHelper(w).Handle; + if (hwnd == IntPtr.Zero || w.ActualWidth < 1 || w.ActualHeight < 1) + return; + double scale = GetDpiForWindow(hwnd) / 96d; + IntPtr region = CreateRoundRectRgn(0, 0, (int)Math.Ceiling(w.ActualWidth * scale) + 1, (int)Math.Ceiling(w.ActualHeight * scale) + + 1, (int)(radius * 2 * scale), (int)(radius * 2 * scale)); + if (SetWindowRgn(hwnd, region, true) == 0) + DeleteObject(region); + SyncBackdrop(w, radius); + } + internal static bool Fullscreen(Forms.Screen screen, IEnumerable own) + { + var hwnd = GetForegroundWindow(); + foreach (var window in own) + if (new WindowInteropHelper(window).Handle == hwnd) + return false; + var cls = new StringBuilder(128); + GetClassName(hwnd, cls, 128); + if (cls.ToString() is "Progman" or "WorkerW" or "Shell_TrayWnd") + return false; + if (!GetWindowRect(hwnd, out var rect)) + return false; + var b = screen.Bounds; + return rect.Left <= b.Left && rect.Top <= b.Top && rect.Right >= b.Right && rect.Bottom >= b.Bottom; + } + static bool RunPresent() + { + // Nom historique conservé pour compatibilité (valeur "Panneaux" de la clé Run). + using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run"); + return Convert.ToString(key?.GetValue("Panneaux", ""))?.Contains("Glass-v5", StringComparison.OrdinalIgnoreCase) == true; + } + static void DropRun() + { + using var key = Registry.CurrentUser.CreateSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run"); + // Nom historique conservé pour compatibilité (valeur "Panneaux" de la clé Run). + key.DeleteValue("Panneaux", false); + } + static readonly object autostartGate = new(); static int autostartDesired = -1; // -1 : aucune demande en cours + // Pendant une demande, l'état visible est celui demandé ; sinon Run ou tâche (l'état de la tâche est attendu au plus 3 s + // au premier appel). + internal static bool Autostart + { + get + { + int want = System.Threading.Volatile.Read(ref autostartDesired); + return want >= 0 ? want == 1 : RunPresent() || AutostartTask.Present; + } + } + // Au lancement, hors du thread d'interface : une valeur Run (installeur, ancienne version) devient la tâche de session. Ne réactive + // jamais un démarrage désactivé : sans valeur Run, rien n'est créé, et le verrou sérialise avec SetAutostart. + internal static void MigrateAutostart() + { + System.Threading.Tasks.Task.Run(() => + { + lock (autostartGate) + { + try + { + AutostartTask.Refresh(); + if (AutostartPolicy.Plan(RunPresent( + ), Environment.ProcessPath ?? "") == AutostartPolicy.Step.CreateTaskThenDropRun) + { + if (AutostartPolicy.DropRunAfter( + AutostartTask.Create())) + { + DropRun(); + Store.Log( + "Démarrage migré de la clé Run vers la tâche WinlestiaShell."); + } + else + Store.Log( + "Démarrage: clé Run conservée (tâche indisponible)."); + } + } + catch (Exception ex) + { + Store.Log("Migration du démarrage: " + + ex.Message); + } + } + }); + } +} diff --git a/src/backend/Storage/Preferences.cs b/src/backend/Core/Preferences.cs similarity index 62% rename from src/backend/Storage/Preferences.cs rename to src/backend/Core/Preferences.cs index 8a152eb..cbc9a32 100644 --- a/src/backend/Storage/Preferences.cs +++ b/src/backend/Core/Preferences.cs @@ -1,5 +1,4 @@ -// Preferences: the user settings serialized to preferences-glass.json (member order is the JSON order). - +// language: C# 12, file: Preferences.cs, target: Windows 11 / .NET 8, préférences persistées de l'utilisateur using System; using System.Collections.Generic; using System.Globalization; @@ -7,38 +6,37 @@ using System.Linq; using System.Text; using System.Text.Json; - -namespace PanneauxGlass.Storage; +namespace PanneauxGlass; public sealed class Preferences { - public Dictionary CustomPalette { get; set; } = - new() - { - { "background", "#fff8f6" }, - { "text", "#261916" }, - { "primary", "#895143" }, - { "secondary", "#775b52" }, - { "tertiary", "#68613c" }, - }; + public Dictionary CustomPalette + { + get; set; + } = + new() { { "background", "#fff8f6" }, { "text", "#261916" }, { "primary", "#895143" }, { "secondary", "#775b52" }, { "tertiary", + "#68613c" } }; public bool CustomGlass { get; set; } = false; public double CustomGlassOpacity { get; set; } = .12; public bool AccentBorders { get; set; } = true; public bool ExplorerTheme { get; set; } = false; public List ExplorerFolders { get; set; } = new(); - - // default on (one-time migration FilesDefaultsMigrated for existing installs): HKCU Directory/Drive shell handler - // -> Winlestia Fichiers (see FilesHandler.cs) - public bool FilesDefaultHandler { get; set; } = true; - - // set once the default-on / default-pinned migration ran; a later opt-out is never undone - public bool FilesDefaultsMigrated { get; set; } - - // set once the old "Winlestia Fichiers" dock pin was removed (the app has its own dock button now); see - // FilesPinMigration - public bool FilesPinRemoved { get; set; } - - // default on: keep a hidden Winlestia Fichiers resident so folders open instantly + public bool FilesDefaultHandler + { + get; set; + } = + // Actif par défaut (migration unique FilesDefaultsMigrated pour les installations existantes) : + // gestionnaire shell HKCU Directory/Drive -> Winlestia Fichiers (voir FilesHandler.cs). + true; + public bool FilesDefaultsMigrated + { + get; set; + } // posé une fois la migration « actif / épinglé par défaut » exécutée ; un refus ultérieur n'est jamais annulé + public bool FilesPinRemoved + { + get; set; + } // posé une fois l'ancien épinglage « Winlestia Fichiers » du dock retiré (l'appli a son bouton) ; voir FilesPinMigration + // actif par défaut : garde un Winlestia Fichiers masqué résident pour que les dossiers s'ouvrent instantanément public bool FilesPreload { get; set; } = true; public bool AutoTile { get; set; } = false; public bool DesktopLogo { get; set; } = false; @@ -47,16 +45,34 @@ public sealed class Preferences public bool DesktopIndicator { get; set; } = true; public bool DockDrawerOpen { get; set; } = false; public bool ActiveBorderOnly { get; set; } = false; - public bool? ReducedEffects { get; set; } + public bool? ReducedEffects + { + get; set; + } public int TopActivationMargin { get; set; } = 2; public double DockWidth { get; set; } = 0; - public int ShellRevision { get; set; } + public int ShellRevision + { + get; set; + } + // depuis le schéma 2 : « masquer la barre Windows » seulement ; le dock relève de DockVisible public bool ReplaceTaskbar { get; set; } = true; + public int SettingsSchema + { + get; set; + } // 0 si absent du fichier (déclenche Store.MigrateSettings) ; les fichiers écrits par cette version portent 2 + public bool DockVisible { get; set; } = true; + public bool BookmarksRailVisible { get; set; } = true; + public bool SocialRailVisible { get; set; } = true; + // Win+Alt+S : ouvre la page de réglages + public ShortcutPreferences SettingsShortcut { get; set; } = new() { Modifiers = 9, Key = 0x53 }; public string RailSide { get; set; } = "left"; public string PreviousTheme { get; set; } = ""; public int FrameThickness { get; set; } = 16; - public double GlassOpacity { get; set; } = .95; - public int GlassOpacityRevision { get; set; } + public double GlassOpacity { get; set; } = .95; public int GlassOpacityRevision + { + get; set; + } public bool AutoTheme { get; set; } = false; public bool ScreenFrame { get; set; } = true; public List TaskbarPins { get; set; } = new(); @@ -84,8 +100,10 @@ public sealed class Preferences public bool ClipboardImages { get; set; } = true; public int ClipboardLimit { get; set; } = 30; public List Workspaces { get; set; } = new(); - public List ColorHistory { get; set; } = new(); - public bool TourDone { get; set; } + public List ColorHistory { get; set; } = new(); public bool TourDone + { + get; set; + } public List Pins { get; set; } = new(); public ShortcutPreferences PinShortcut { get; set; } = new() { Modifiers = 2, Key = 0x77 }; public ShortcutPreferences CaptureShortcut { get; set; } = new() { Modifiers = 2, Key = 0x72 }; @@ -110,104 +128,34 @@ public sealed class Preferences public List Websites { get; set; } = new(); public List LauncherTools { get; set; } = new(); public List QuickTabs { get; set; } = DefaultQuickTabs(); - - public static List DefaultQuickTabs() => - new() + public static List DefaultQuickTabs() => new(){ + new(){Id="tab-apps",Name="Applications",Target="glass:apps",Glyph="\uE71D"},new( + ){Id="tab-games",Name="Jeux",Target="glass:games",Glyph="\uE7FC"}, + new(){Id="tab-projects",Name="Projets",Target="glass:projects",Glyph="\uE943"},new() { - new() - { - Id = "tab-apps", - Name = "Applications", - Target = "glass:apps", - Glyph = "\uE71D", - }, - new() - { - Id = "tab-games", - Name = "Jeux", - Target = "glass:games", - Glyph = "\uE7FC", - }, - new() - { - Id = "tab-projects", - Name = "Projets", - Target = "glass:projects", - Glyph = "\uE943", - }, - new() - { - Id = "tab-documents", - Name = "Documents", - Target = "glass:documents", - Glyph = "\uE8B7", - }, - new() - { - Id = "tab-note", - Name = "Note", - Target = "glass:note", - Glyph = "\uE70B", - }, - new() - { - Id = "tab-focus", - Name = "Minuteur", - Target = "glass:focus", - Glyph = "\uE917", - }, - new() - { - Id = "tab-tools", - Name = "Atelier", - Target = "glass:tools", - Glyph = "\uE74C", - }, - new() - { - Id = "tab-capture", - Name = "Capture", - Target = "glass:capture", - Glyph = "\uE722", - }, - }; - + Id="tab-documents", + Name="Documents", + Target="glass:documents", + Glyph="\uE8B7" + }, + new(){Id="tab-note",Name="Note",Target="glass:note",Glyph="\uE70B"},new( + ){Id="tab-focus",Name="Minuteur",Target="glass:focus",Glyph="\uE917"}, + new(){Id="tab-tools",Name="Atelier",Target="glass:tools",Glyph="\uE74C"}, + new(){Id="tab-capture",Name="Capture",Target="glass:capture",Glyph="\uE722"} + }; public Dictionary BoardOverrides { get; set; } = new(); public Dictionary Names { get; set; } = new(); public Dictionary Categories { get; set; } = new(); public Dictionary Icons { get; set; } = new(); public List Boards { get; set; } = Defaults(); - - public static List Defaults() => - new() + public static List Defaults() + => new() { - new() - { - Id = "apps", - Title = "Applications", - Accent = "#91DFF5", - Filter = "Sélection", - }, - new() - { - Id = "games", - Title = "Jeux", - Accent = "#ADA7FF", - }, - new() - { - Id = "projects", - Title = "Projets", - Accent = "#8BE6C5", - }, - new() - { - Id = "documents", - Title = "Documents & médias", - Accent = "#F0D195", - }, + new() { Id = "apps", Title = "Applications", Accent = "#91DFF5", Filter = "Sélection" }, + new() { Id = "games", Title = "Jeux", Accent = "#ADA7FF" }, + new() { Id = "projects", Title = "Projets", Accent = "#8BE6C5" }, + new() { Id = "documents", Title = "Documents & médias", Accent = "#F0D195" } }; - public void Normalize() { FrameThickness = Math.Clamp(FrameThickness, 4, 40); @@ -234,7 +182,7 @@ public void Normalize() Scratch ??= ""; if (Scratch.Length > 20000) Scratch = Scratch[..20000]; - if (Theme != "custom" && PanneauxGlass.Views.Theme.Find(Theme) == null) + if (Theme != "custom" && PanneauxGlass.Theme.Find(Theme) == null) Theme = "nuit"; CustomGlassOpacity = double.IsFinite(CustomGlassOpacity) ? Math.Clamp(CustomGlassOpacity, .02, .95) : .12; CustomPalette ??= new(); @@ -248,18 +196,13 @@ public void Normalize() { workspace.Targets ??= new(); workspace.Layout ??= new(); - workspace.Targets = workspace - .Targets.Where(t => !string.IsNullOrWhiteSpace(t)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Take(24) - .ToList(); + workspace.Targets = workspace.Targets.Where(t => !string.IsNullOrWhiteSpace(t)).Distinct( + StringComparer.OrdinalIgnoreCase).Take(24).ToList(); } ColorHistory ??= new(); ColorHistory = ColorHistory.Where(c => !string.IsNullOrWhiteSpace(c)).Distinct().Take(24).ToList(); Pins ??= new(); - Pins = Pins.Where(p => p != null && (p.Kind == "memo" || !string.IsNullOrWhiteSpace(p.ImagePath))) - .Take(24) - .ToList(); + Pins = Pins.Where(p => p != null && (p.Kind == "memo" || !string.IsNullOrWhiteSpace(p.ImagePath))).Take(24).ToList(); foreach (var pin in Pins) { if (string.IsNullOrWhiteSpace(pin.Id)) @@ -279,17 +222,62 @@ public void Normalize() if (pin.Top is double top && !double.IsFinite(top)) pin.Top = null; } - PinShortcut ??= new() { Modifiers = 2, Key = 0x77 }; + PinShortcut ??= new() + { + Modifiers = 2, + Key = 0x77 + }; PinShortcut.Normalize(); - CaptureShortcut ??= new() { Modifiers = 2, Key = 0x72 }; + CaptureShortcut ??= new() + { + Modifiers = 2, + Key = 0x72 + }; CaptureShortcut.Normalize(); - WallpaperShortcut ??= new() { Modifiers = 9, Key = 0x57 }; + SettingsShortcut ??= new() + { + Modifiers = 9, + Key = 0x53 + }; + if (!GlobalShortcut.ValidWallpaper(SettingsShortcut.Modifiers, SettingsShortcut.Key)) + { + SettingsShortcut.Modifiers = 9; + SettingsShortcut.Key = 0x53; + } + { + var others = new[] { Shortcut, PaletteShortcut, WallpaperShortcut, CaptureShortcut, PinShortcut }.Where(x + => x != null).ToArray(); + if (others.Any(x => x.Modifiers == SettingsShortcut.Modifiers && x.Key == SettingsShortcut.Key)) + { // le raccourci des réglages entre en collision avec une autre combinaison : on prend le premier défaut libre + foreach (var (cm, ck) in new[] { (9u, 0x53u), (3u, 0x79u), (3u, 0x7Au) }) + { + if (others.Any(x => x.Modifiers == cm && x.Key == ck)) + continue; + Store.Log("Raccourci des paramètres identique à un autre raccourci : remplacé par " + ShortcutCombo.Format(cm, ck)); + SettingsShortcut = new() + { + Modifiers = cm, + Key = ck + }; + break; + } + } + } + WallpaperShortcut ??= new() + { + Modifiers = 9, + Key = 0x57 + }; if (!GlobalShortcut.ValidWallpaper(WallpaperShortcut.Modifiers, WallpaperShortcut.Key)) { WallpaperShortcut.Modifiers = 9; WallpaperShortcut.Key = 0x57; } - PaletteShortcut ??= new() { Modifiers = 2, Key = 0x71 }; + PaletteShortcut ??= new() + { + Modifiers = 2, + Key = 0x71 + }; PaletteShortcut.Normalize(); QuickTabs ??= DefaultQuickTabs(); QuickTabs = QuickTabs.Where(t => t != null && !string.IsNullOrWhiteSpace(t.Target)).Take(12).ToList(); @@ -312,14 +300,8 @@ public void Normalize() Names ??= new(); Categories ??= new(); Icons ??= new(); - if ( - Boards == null - || Boards.Count != 4 - || !Boards - .Select(x => x.Id) - .OrderBy(x => x) - .SequenceEqual(new[] { "apps", "documents", "games", "projects" }) - ) + if (Boards == null || Boards.Count != 4 || !Boards.Select(x => x.Id).OrderBy(x => x).SequenceEqual(new[] { "apps", "documents", + "games", "projects" })) Boards = Defaults(); foreach (var b in Boards) { @@ -329,10 +311,7 @@ public void Normalize() { _ = System.Windows.Media.ColorConverter.ConvertFromString(b.Accent); } - catch - { - b.Accent = "#91DFF5"; - } + catch { b.Accent = "#91DFF5"; } b.Filter ??= "Tous"; if (b.Placement?.Valid == false) b.Placement = null; diff --git a/src/backend/Platform/ProcessSnapshot.cs b/src/backend/Core/ProcessSnapshot.cs similarity index 99% rename from src/backend/Platform/ProcessSnapshot.cs rename to src/backend/Core/ProcessSnapshot.cs index 578e230..47a7bf1 100644 --- a/src/backend/Platform/ProcessSnapshot.cs +++ b/src/backend/Core/ProcessSnapshot.cs @@ -7,7 +7,7 @@ using System.Runtime.InteropServices; using Microsoft.Win32; -namespace PanneauxGlass.Platform; +namespace PanneauxGlass; internal sealed class ProcessSnapshot { diff --git a/src/backend/Core/Program.cs b/src/backend/Core/Program.cs new file mode 100644 index 0000000..c8a9933 --- /dev/null +++ b/src/backend/Core/Program.cs @@ -0,0 +1,340 @@ +// language: C# 12, file: Program.cs, target: Windows 11 / .NET 8 WPF, point d'entrée et arguments de ligne de commande +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Threading; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Win32; +using Forms = System.Windows.Forms; + +namespace PanneauxGlass; + +internal static class Program +{ + // Un relevé par seconde : la télémétrie PC calcule des taux, il faut un vrai écart entre deux lectures. + const int PcProbeIntervalMs = 1000; + // Sondage de l'arrivée des moniteurs du fond (30 tours max, soit 3 s) avant d'écrire la sonde. + const int WallpaperProbePollMs = 100; + + internal static EventWaitHandle ShowSignal = null!, QuitSignal = null!, TaskbarRestoreSignal = null!; + internal static bool DockPreview; + internal static bool QtMode; + internal static string? QtRoot; + internal static string? ThemeOverride; + // Le Coordinator est partagé entre Startup (création) et Exit (libération) : il survit à la méthode qui l'a créé. + static Coordinator? coordinator; + [STAThread] + internal static int Main(string[] args) + { + var options = ParseOptions(args); + // Reprise de la barre des tâches : doit rester avant tout le reste et ne dépendre d'aucun état de démarrage ultérieur, + // car on l'appelle justement quand le shell est cassé. + if (HandleRecoveryCommands(args) is int recovery) + return recovery; + ConfigureRendering(args); + if (HandleDiagnosticCommands(args) is int diagnostic) + return diagnostic; + return StartShell(args, options); + } + static RunOptions ParseOptions(string[] args) + { + Store.Root = AppDomain.CurrentDomain.BaseDirectory; + QtMode = !args.Contains("--legacy-shell") && (args.Contains("--qt-shell") || File.Exists(Path.Combine(Store.Root, + "caelestia.enabled"))); + var options = RunOptions.FromArgs(args); + QtRoot = args.FirstOrDefault(a => a.StartsWith("--qt-root="))?.Split('=', 2)[1]; + DockPreview = args.Contains("--dock-preview"); + return options; + } + static int? HandleRecoveryCommands(string[] args) + { + if (args.Length >= 3 && args[0] == "--taskbar-guard" && int.TryParse(args[1], out var parentId)) + return TaskbarNative.Guard(parentId, args[2]); + if (args.Contains("--restore-taskbar")) + { + try + { + using var recovery = EventWaitHandle.OpenExisting(@"Local\PanneauxGlassTaskbarRestore"); + recovery.Set(); + } + catch (Exception) + { + // Pas d'événement de reprise ouvert : la barre des tâches n'est pas à réveiller. + } + TaskbarNative.RestoreSaved(); + return 0; + } + return null; + } + static void ConfigureRendering(string[] args) + { + Native.Layered = !args.Contains("--legacy-glass"); + ThemeOverride = args.FirstOrDefault(a => a.StartsWith("--theme=", StringComparison.OrdinalIgnoreCase))?.Split('=', 2)[1]; + } + // Sondes et vérifications en ligne de commande : elles s'exécutent sans instance unique ni fenêtre. + static int? HandleDiagnosticCommands(string[] args) + { + if (args.Contains("--taskbar-state")) + { + var snapshot = TaskbarNative.Snapshot(); + File.WriteAllText(Path.Combine(Store.Root, "taskbar-state.json"), JsonSerializer.Serialize(new + { + At = DateTimeOffset.Now, + snapshot.State, + Visible = snapshot.Visible.Count, + Bars = TaskbarNative.ExplorerBars().Select(h => new { Hwnd = h.ToInt64(), Visible = TaskbarNative.IsWindowVisible(h) }), + WorkAreas = Forms.Screen.AllScreens.Select(s => new { Screen = s.DeviceName, s.Bounds, s.WorkingArea }) + }, Store.Json)); + return 0; + } + if (args.Contains("--probe-pc")) + { + using var pc = new PcTelemetrySource(); + var readings = new List(); + for (int i = 0; i < 4; i++) + { + readings.Add(pc.Read()); + Thread.Sleep(PcProbeIntervalMs); + } + File.WriteAllText(Path.Combine(Store.Root, "probe-pc.json"), JsonSerializer.Serialize(readings, Store.Json)); + return 0; + } + if (args.Length >= 2 && args[0] == "--capture-frames") + return WgcCapture.Cli(args); + if (args.Length >= 3 && args[0] == "--extract-frames") + return WallpaperMedia.ExtractFramesCli(args); + if (args.Contains("--probe-wallpaper")) + { + using var wallpaper = new WindowsWallpaper(); + for (int i = 0; i < 30 && wallpaper.Monitors.Length == 0; i++) + Thread.Sleep(WallpaperProbePollMs); + File.WriteAllText(Path.Combine(Store.Root, "wallpaper-probe.json"), JsonSerializer.Serialize(wallpaper.Monitors, Store.Json)); + return 0; + } + if (args.Contains("--verify-screen-frame")) + return FrameVerification.Run(); + if (args.Contains("--verify-tiling-latency")) + return TilingLatencyVerification.Run(); + if (args.Contains("--verify-social-count")) + return SocialCountVerification.Run(); + if (args.Contains("--verify-dock-preview")) + return DockPreviewVerification.Run(args); + if (args.Contains("--verify-audio-mixer")) + return AudioMixerVerification.Run(); + if (args.Contains("--verify-qt-keys")) + return QtShellVerification.Run(); + if (args.Contains("--verify-shortcuts")) + { + var verificationApp = new System.Windows.Application(); + return InteractionVerification.Shortcuts(); + } + if (args.Contains("--verify-controls") || args.Contains("--verify-controls-write")) + return ControlVerification.Run(args.Contains("--verify-controls-write")).GetAwaiter().GetResult(); + if (args.Contains("--verify-shell")) + return ShellVerification.Run(); + if (args.Contains("--verify-workspace")) + return WorkspaceVerification.Run(); + if (args.Contains("--verify-tools")) + return VerificationTools.Run(); + if (args.Contains("--verify")) + return Verification.Run(); + if (args.Contains("--verify-web")) + return Verification.Web(); + if (args.Contains("--restore")) + { + Process.Start(new ProcessStartInfo(Path.Combine(Store.Root, "SECOURS_BUREAU.exe")) { UseShellExecute = true }); + return 0; + } + return null; + } + static int StartShell(string[] args, RunOptions options) + { + bool first; + using var mutex = new Mutex(true, @"Local\PanneauxGlassV5", out first); + ShowSignal = new(false, EventResetMode.AutoReset, @"Local\PanneauxGlassV5Show"); + QuitSignal = new(false, EventResetMode.AutoReset, @"Local\PanneauxGlassV5Quit"); + TaskbarRestoreSignal = new(false, EventResetMode.AutoReset, @"Local\PanneauxGlassTaskbarRestore"); + Store.Log("Args " + Environment.CommandLine + " · pid=" + Environment.ProcessId + " · premier=" + first); + if (!first) + { + if (args.Contains("--quit")) + QuitSignal.Set(); + else + ShowSignal.Set(); + return 0; + } + if (args.Contains("--quit")) + return 0; + var app = new System.Windows.Application { ShutdownMode = ShutdownMode.OnExplicitShutdown }; + if (args.Contains("--diagnostic")) + return RunDiagnosticWindow(app); + app.DispatcherUnhandledException += (_, e) => + { + Store.Log("UI: " + e.Exception); + e.Handled = true; + }; + AppDomain.CurrentDomain.UnhandledException += (_, e) => Store.Log("FATAL: " + e.ExceptionObject); + app.Startup += (_, _) => + { + try + { + StartCoordinator(args, options); + } + catch (Exception ex) + { + Store.Log("Démarrage: " + ex); + System.Windows.MessageBox.Show( + "Winlestia n’a pas démarré. Le secours reste disponible dans Documents\\SECOURS_BUREAU.exe.\n\n" + ex.Message, + "Winlestia"); + app.Shutdown(1); + } + }; + app.Exit += (_, _) => + { + FilesPreload.Quit(); + coordinator?.Dispose(); + }; + app.Startup += (_, _) => + { + FilesPreload.Start(); + FilesDefaultHandler.Start(); + }; + int code = app.Run(); + mutex.ReleaseMutex(); + return code; + } + static int RunDiagnosticWindow(System.Windows.Application app) + { + var test = + new Window + { + Title = "Winlestia — diagnostic WPF", + Width = 600, + Height = 360, + Background = Brushes.SteelBlue, + Content = UI.Text("Rendu WPF", 36), + WindowStartupLocation = WindowStartupLocation.CenterScreen + }; + test.Closed += (_, _) => app.Shutdown(); + app.Run(test); + return 0; + } + static void StartCoordinator(string[] args, RunOptions options) + { + if (args.Contains("--corner-proof")) + { + CornerProof.Show(); + return; + } + bool offscreen = args.Contains("--render-themes") || args.Contains("--render-palette"); + if (offscreen) + { + GlassWindow.CaptureOffset = new Vector(-40000, -40000); + if (!args.Contains("--qa-ui")) + args = args.Append("--qa-ui").ToArray(); + if (!args.Contains("--preview")) + args = args.Append("--preview").ToArray(); + } + coordinator = new Coordinator( + args.Contains("--preview"), args.Contains("--qa-ui"), args.Contains("--export-preview"), options); + if (offscreen) + { + string? only = args.FirstOrDefault(a => a.StartsWith("--only=", StringComparison.OrdinalIgnoreCase))?.Split('=', 2)[1]; + if (args.Contains("--render-themes")) + ThemeRender.Themes(coordinator, only); + else + ThemeRender.Palette( + coordinator, + args.FirstOrDefault(a => a.StartsWith("--query=", StringComparison.OrdinalIgnoreCase))?.Split('=', + 2)[1] ?? "chrome"); + } + RunOpenActions(coordinator, args); + RunPreviewActions(coordinator, args); + } + static void RunOpenActions(Coordinator coordinator, string[] args) + { + if (args.Contains("--show") && !args.Contains("--preview")) + coordinator.Open(); + if (args.Contains("--workspace")) + coordinator.ShowWorkspace(); + if (args.Contains("--controls")) + { + if (!args.Contains("--preview") && !args.Contains("--show")) + coordinator.Open(); + coordinator.ToggleControls(); + } + if (args.Contains("--pc")) + { + if (!args.Contains("--preview") && !args.Contains("--show")) + coordinator.Open(); + coordinator.TogglePc(); + } + if (args.Contains("--verify-shell-live")) + ShellLiveCheck.Run(coordinator); + if (args.Contains("--verify-interactions")) + InteractionVerification.Run(coordinator); + } + static void RunPreviewActions(Coordinator coordinator, string[] args) + { + if (args.Contains("--preview-palette")) + coordinator.PreviewPalette(); + if (args.Contains("--preview-notes")) + coordinator.PreviewNotes(); + if (args.Contains("--preview-focus")) + coordinator.PreviewFocus(); + if (args.Contains("--preview-tabs")) + coordinator.PreviewTabs(); + if (args.Contains("--preview-tools")) + coordinator.OpenTools(); + if (args.Contains("--preview-clipboard")) + coordinator.OpenClipboard(); + if (args.Contains("--preview-audio")) + coordinator.OpenAudio(); + if (args.Contains("--preview-workspaces")) + coordinator.OpenWorkspaces(); + if (args.Contains("--preview-largefiles")) + coordinator.OpenLargeFiles(); + if (args.Contains("--preview-help")) + coordinator.OpenHelp(); + if (args.Contains("--preview-settings")) + coordinator.Settings(); + if (args.Contains("--preview-pipette")) + coordinator.OpenColorPicker(); + if (args.Contains("--preview-ruler")) + coordinator.OpenRuler(); + if (args.Contains("--preview-pin")) + PinWindow.OpenMemo( + coordinator, + "Penser à envoyer le devis avant vendredi.\n\nRappeler Zoé pour l’écran secondaire."); + if (args.Contains("--preview-pin-image")) + { + var shot = ScreenShot.Capture(new System.Drawing.Rectangle(0, 0, 520, 320)); + PinWindow.Open(coordinator, shot); + } + if (args.Contains("--theme-preview")) + coordinator.SetTheme(Theme.Current.Id); + if (args.Contains("--edge-test")) + coordinator.ProbeRightEdge(); + if (args.Contains("--benchmark-controls")) + ControlPerformanceProbe.Run(coordinator); + if (args.Contains("--benchmark-home")) + PerformanceProbe.Run(coordinator, "motion-home-5.2.json"); + if (args.Contains("--benchmark-workspace")) + PerformanceProbe.Run(coordinator, "motion-workspace-5.2.json", true); + if (args.Contains("--benchmark")) + PerformanceProbe.Run(coordinator, + args.Contains("--baseline") + ? "motion-before.json" + : "motion-after.json"); + } +} diff --git a/src/backend/Core/RunOptions.cs b/src/backend/Core/RunOptions.cs new file mode 100644 index 0000000..2d8137f --- /dev/null +++ b/src/backend/Core/RunOptions.cs @@ -0,0 +1,27 @@ +// C# 12 / RunOptions.cs / Windows 11. Options de lancement figées une fois pour toutes dans Program.Main. +using System.Collections.Generic; +using System.Linq; +namespace PanneauxGlass; + +// Normal : session réelle. Review : le frontend tourne en aperçu. Verify : le frontend tourne en vérification. +// Les deux derniers modes servent à la QA : ils ne doivent ni tuiler les fenêtres du bureau ni afficher d'erreur fatale. +internal enum LaunchMode +{ + Normal, + Review, + Verify +} + +// Immuable : le pont et le coordinateur reçoivent l'instance au lieu de lire des drapeaux statiques mutables. +internal sealed record RunOptions(LaunchMode Mode = LaunchMode.Normal) +{ + internal static readonly RunOptions Default = new(); + + internal bool IsAutomated => Mode != LaunchMode.Normal; + + // --qt-verify l'emporte sur --qt-review quand les deux sont donnés (même priorité que le lancement du frontend). + internal static RunOptions FromArgs(IReadOnlyCollection args) + => new(args.Contains("--qt-verify") ? LaunchMode.Verify + : args.Contains("--qt-review") ? LaunchMode.Review + : LaunchMode.Normal); +} diff --git a/src/backend/Core/Safe.cs b/src/backend/Core/Safe.cs new file mode 100644 index 0000000..2042429 --- /dev/null +++ b/src/backend/Core/Safe.cs @@ -0,0 +1,22 @@ +using System; + +// Aides pour les erreurs ignorées exprès : l'appel nomme l'intention, là où un catch vide ne dit rien à un relecteur. +// Espace de noms global : le fichier est lié tel quel à Winlestia.Files (voir Winlestia.Files.csproj). +internal static class Safe +{ + // Nettoyage au mieux (fichier temporaire, désabonnement, libération COM) : s'il échoue, il n'y a plus rien à réparer. + public static void Try(Action action) + { + try + { + action(); + } + catch (Exception) + { + // Volontaire : un nettoyage raté (objet déjà libéré, fichier verrouillé) ne doit pas masquer l'opération principale. + } + } + + // Libération au mieux : un objet déjà libéré ou une ressource native disparue ne se libère pas deux fois. + public static void Dispose(IDisposable? disposable) => Try(() => disposable?.Dispose()); +} diff --git a/src/backend/Core/Services/CatalogueRules.cs b/src/backend/Core/Services/CatalogueRules.cs new file mode 100644 index 0000000..cf7b67b --- /dev/null +++ b/src/backend/Core/Services/CatalogueRules.cs @@ -0,0 +1,55 @@ +// language: C# 12, file: CatalogueRules.cs, target: Windows 11 / .NET 8, décisions pures du catalogue d'applications +using System; +using System.Collections.Generic; +using System.IO; + +namespace PanneauxGlass; + +// Isolées du service pour être testables sans Store, sans disque réel ni dispatcher. +internal static class CatalogueRules +{ + // Seules les cibles absolues avec extension sont jugées : un URI, un "shell:" ou un chemin relatif ne prouve jamais l'absence. + internal static bool TargetGone(string target) + { + try + { + if (string.IsNullOrWhiteSpace(target) + || target.StartsWith("shell:", StringComparison.OrdinalIgnoreCase) + || target.Contains("://") + || target.StartsWith(@"\\", StringComparison.Ordinal) + || !Path.IsPathRooted(target) + || Path.GetExtension(target).Length == 0) + return false; + return !File.Exists(target) && !Directory.Exists(target); + } + catch { return false; } + } + // Le doublon se juge aussi parmi les entrées retenues dans le même lot : la surveillance peut voir deux fois le même raccourci. + internal static List NewEntries(IEnumerable existing, IEnumerable found) + { + var known = new List(existing); + var added = new List(); + foreach (var entry in found) + { + if (known.Exists(x => x.Id == entry.Id || (x.Section == entry.Section && x.Name.Equals(entry.Name, + StringComparison.OrdinalIgnoreCase)))) + continue; + known.Add(entry); + added.Add(entry); + } + return added; + } + // Un dossier supprimé emporte ses descendants ; le séparateur final évite de confondre "C:\A" et "C:\AB". + internal static bool IsUnderDeleted(string target, IEnumerable deleted) + { + foreach (var d in deleted) + if (target.Equals(d, StringComparison.OrdinalIgnoreCase) + || target.StartsWith(d + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } + // -1 = lecture impossible ou première mesure : aucune conclusion tant qu'il n'y a pas deux mesures valides. + internal static bool PackagesChanged(int previous, int current) => previous >= 0 && current >= 0 && current != previous; + internal static bool NeedsInventory(bool packagesChanged, bool missingChanged, int missingCount) + => packagesChanged || (missingChanged && missingCount > 0); +} diff --git a/src/backend/Core/Services/CatalogueService.cs b/src/backend/Core/Services/CatalogueService.cs new file mode 100644 index 0000000..784160c --- /dev/null +++ b/src/backend/Core/Services/CatalogueService.cs @@ -0,0 +1,158 @@ +// language: C# 12, file: CatalogueService.cs, target: Windows 11 / .NET 8, inventaire et surveillance du catalogue d'applications +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace PanneauxGlass; + +internal interface ICatalogueService : IDisposable +{ + void Refresh(); + void RefreshIfStale(); + void StartWatching(); +} + +// Le service ne connaît ni les planches ni le pont Qt : tout changement du catalogue passe par le rappel `changed`. +internal sealed class CatalogueService : ICatalogueService +{ + readonly bool preview; + readonly Action changed; + AppCatalogueWatcher? watcher; + readonly RefreshGate gate = new(); + DateTime? attempt; + int packageCount = -1; + bool disposed; + internal CatalogueService(bool preview, Action changed) + { + this.preview = preview; + this.changed = changed; + } + public async void Refresh() + { + if (!gate.Request()) + return; + attempt = DateTime.UtcNow; + try + { + var start = + new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + @"WindowsPowerShell\v1.0\powershell.exe"), + "-NoProfile -STA -ExecutionPolicy Bypass -File \"" + Path.Combine(Store.Root, "Inventory.ps1") + "\"") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + using var process = Process.Start(start)!; + try + { + process.PriorityClass = ProcessPriorityClass.BelowNormal; + } + catch (Exception err) when (err is System.ComponentModel.Win32Exception or InvalidOperationException) + { + Store.Log("Coordinator.RefreshCatalogue : " + err.GetType().Name + ": " + err.Message); + } + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + Store.Log("Inventaire: " + await output + await error); + if (process.ExitCode == 0 && !disposed) + { + Store.LoadCatalogue(); + changed(); + } + } + catch (Exception ex) { Store.Log("Inventaire: " + ex.Message); } + finally { if (gate.Complete() && !disposed) Refresh(); } + } + // À l'ouverture de la palette : les applis Store/MSIX échappent à la surveillance du menu Démarrer, un vieux catalogue + // est donc relu en tâche de fond. + public void RefreshIfStale() + { + if (preview || disposed) + return; + string file = Path.Combine(Store.Root, "catalog.json"); + DateTime? written = File.Exists(file) ? File.GetLastWriteTimeUtc(file) : null; + if (CatalogueWatchRules.PaletteNeedsRefresh(written, attempt, DateTime.UtcNow)) + Refresh(); + } + public void StartWatching() + { + if (preview) + return; + watcher = new AppCatalogueWatcher(System.Windows.Application.Current.Dispatcher, AddEntries, RemoveEntries, Refresh, Tick); + watcher.Start(); + } + // Les raccourcis vus par la surveillance s'affichent aussitôt ; l'inventaire qui suit les remplace par les entrées complètes. + int AddEntries(List found) + { + if (disposed) + return 0; + var added = CatalogueRules.NewEntries(Store.Entries, found); + Store.Entries.AddRange(added); + if (added.Count > 0) + changed(); + return added.Count; + } + // Un raccourci (ou dossier) supprimé retire ses entrées aussitôt ; les entrées personnalisées des préférences sont intactes. + int RemoveEntries(List deleted) + { + if (disposed) + return 0; + int count = Store.Entries.RemoveAll(x => !Store.State.Custom.Contains(x) && CatalogueRules.IsUnderDeleted(x.Target, deleted)); + if (count > 0) + changed(); + return count; + } + // Chaque minute : les ids dont le fichier a disparu sont masqués (favoris compris), et un changement de la liste des + // paquets installés déclenche un inventaire limité en fréquence. + void Tick() + { + if (preview || disposed || gate.Busy) + return; + var snapshot = Store.Entries.Select(x => (x.Id, x.Target)).ToArray(); + System.Threading.Tasks.Task.Run(() => + { + var missing = new HashSet(); + foreach (var item in snapshot) + if (CatalogueRules.TargetGone(item.Target)) + missing.Add(item.Id); + int count = -1; + try + { + using var key = + Microsoft.Win32.Registry.CurrentUser.OpenSubKey( + @"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages"); + count = key?.SubKeyCount ?? -1; + } + catch (Exception ex) when (ex is System.Security.SecurityException or IOException or UnauthorizedAccessException) + { + Store.Log("Coordinator.CatalogueTick : " + ex.GetType().Name + ": " + ex.Message); + } + System.Windows.Application.Current?.Dispatcher.BeginInvoke((Action)(() => + { + if (disposed) + return; + bool packages = CatalogueRules.PackagesChanged(packageCount, count); + if (count >= 0) + packageCount = count; + bool missingChanged = !Store.Missing.SetEquals(missing); + if (missingChanged) + { + Store.Missing = missing; + changed(); + } + if (CatalogueRules.NeedsInventory(packages, missingChanged, missing.Count)) + watcher?.RequestRefresh(); + })); + }); + } + public void Dispose() + { + disposed = true; + watcher?.Dispose(); + } +} diff --git a/src/backend/Core/Services/DesktopIconsService.cs b/src/backend/Core/Services/DesktopIconsService.cs new file mode 100644 index 0000000..1687d9d --- /dev/null +++ b/src/backend/Core/Services/DesktopIconsService.cs @@ -0,0 +1,129 @@ +// language: C# 12, file: DesktopIconsService.cs, target: Windows 11 / .NET 8, masquage des icônes du Bureau et hook de réaffirmation +using System; +using System.Windows.Threading; + +namespace PanneauxGlass; + +internal interface IDesktopIconsService : IDisposable +{ + void Hide(); + void Restore(); + void OnExplorerCreated(); +} + +// Décisions pures du masquage : le service ne fait que les appliquer à des minuteurs. +internal static class DesktopIconsRules +{ + internal static readonly int[] Reasserts = { 3, 10 }; + // Le Bureau d'Explorer se publie après le démarrage : on retente brièvement puis on abandonne pour ne pas boucler à vie. + internal static TimeSpan? RetryDelay(int failedAttempts) + => failedAttempts is >= 1 and < 4 ? TimeSpan.FromSeconds(failedAttempts == 1 ? 1 : 2) : null; + // Un hook ne s'installe ni en aperçu, ni après l'arrêt, ni si l'utilisateur a déjà repris la main sur le Bureau. + internal static bool CanGuard(bool preview, bool disposed, bool suspended) => !(preview || disposed || suspended); +} + +internal sealed class DesktopIconsService : IDesktopIconsService +{ + readonly bool preview; + // Lus par le hook depuis un autre thread. + volatile bool managed, suspended; + volatile bool disposed; + DesktopIconsGuard? guard; + internal DesktopIconsService(bool preview) => this.preview = preview; + // Le Bureau d'Explorer se publie après le démarrage : un appel unique arrivait trop tôt, + // rendait la main sans erreur, et les icônes restaient visibles pour toute la session. + public void Hide() + { + if (preview) + return; + suspended = false; + int attempt = 0; + void Apply() + { + try + { + Native.Desktop(true); + managed = true; + if (attempt == 0) + Store.Log("Icônes du Bureau masquées."); + } + catch (Exception ex) + { + attempt++; + var delay = DesktopIconsRules.RetryDelay(attempt); + if (delay != null) + { + Store.Log("Masquage du Bureau, nouvel essai: " + ex.Message); + var retry = new DispatcherTimer { Interval = delay.Value }; + retry.Tick += (_, _) => + { + retry.Stop(); + Apply(); + }; + retry.Start(); + } + else + Store.Log("Masquage du Bureau impossible: " + ex.Message); + } + } + Apply(); + StartGuard(); + foreach (int delay in DesktopIconsRules.Reasserts) + { + var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(delay) }; + timer.Tick += (_, _) => + { + timer.Stop(); + if (disposed || preview) + return; + Apply(); + }; + timer.Start(); + } + } + // Hook Explorer du Bureau : réaffirme FWF_NOICONS si un moteur de fond d'écran recrée la vue. Un seul hook, réinstallé après + // TaskbarCreated. + void StartGuard(bool reinstall = false) + { + if (!DesktopIconsRules.CanGuard(preview, disposed, suspended)) + return; + guard ??= new DesktopIconsGuard(() => managed, () => + { + Native.Desktop(true); + managed = true; + }, () => + { + suspended = true; + managed = false; + }); + if (reinstall || !guard.Installed) + guard.Install(); + } + // TaskbarCreated : Explorer vient de (re)démarrer, le hook est réinstallé et le Bureau réaffirmé. + public void OnExplorerCreated() + { + if (suspended) + return; + StartGuard(true); + guard?.Request(true); + } + public void Restore() + { + suspended = true; + guard?.Uninstall(); + if (!managed) + return; + try + { + Native.Desktop(false); + managed = false; + Store.Log("Icônes du Bureau rétablies."); + } + catch (Exception ex) { Store.Log("Rétablissement du Bureau: " + ex.Message); } + } + public void Dispose() + { + disposed = true; + guard?.Dispose(); + } +} diff --git a/src/backend/Core/Services/DialogService.cs b/src/backend/Core/Services/DialogService.cs new file mode 100644 index 0000000..01c2a42 --- /dev/null +++ b/src/backend/Core/Services/DialogService.cs @@ -0,0 +1,129 @@ +// language: C# 12, file: DialogService.cs, target: Windows 11 / .NET 8 WPF, portée des dialogues, boîtes de message et sélecteurs +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Interop; +using Forms = System.Windows.Forms; + +namespace PanneauxGlass; + +internal interface IDialogService +{ + void Layers(bool foreground); + void Scope(Action body, bool resetAway = true); + Task ScopeAsync(Func body); + bool Confirm(string text); + void Message(string text); + string[] PickFiles(); + string? PickFolder(); +} + +internal sealed class DialogService : IDialogService +{ + sealed class DialogOwner : Forms.IWin32Window + { + public IntPtr Handle + { + get; + } + public DialogOwner(Window window) => Handle = new WindowInteropHelper(window).Handle; + } + readonly ShellState state; + readonly Func> layered; + readonly Func parent; + internal DialogService(ShellState state, Func> layered, Func parent) + { + this.state = state; + this.layered = layered; + this.parent = parent; + } + // Pendant un dialogue les panneaux passent derrière lui : sans cela leur Topmost recouvrirait la boîte. + public void Layers(bool foreground) + { + PinWindow.Lower(foreground); + foreach (var window in layered()) + window.Topmost = !foreground; + } + // Le repli automatique est suspendu (SettingsOpen) le temps du dialogue ; l'état précédent est rétabli même sur exception. + public void Scope(Action body, bool resetAway = true) + { + bool previous = state.SettingsOpen; + state.SettingsOpen = true; + Layers(true); + try + { + body(); + } + finally + { + Leave(previous, resetAway); + } + } + public async Task ScopeAsync(Func body) + { + bool previous = state.SettingsOpen; + state.SettingsOpen = true; + Layers(true); + try + { + await body(); + } + finally + { + Leave(previous, true); + } + } + void Leave(bool previous, bool resetAway) + { + state.SettingsOpen = previous; + Layers(previous); + // Le minuteur d'absence repart de zéro : la souris a pu quitter les panneaux pendant le dialogue. + if (resetAway) + state.AwaySince = DateTime.MinValue; + } + // La fenêtre active du process sert de propriétaire, sinon la boîte s'ouvrirait derrière le dialogue déjà affiché. + Window ActiveOwner() => Application.Current.Windows.Cast().FirstOrDefault(w => w.IsActive) ?? parent(); + public bool Confirm(string text) + { + bool yes = false; + Scope(() => yes = MessageBox.Show(ActiveOwner(), text, "Winlestia", MessageBoxButton.YesNo, MessageBoxImage.Question) + == MessageBoxResult.Yes); + return yes; + } + public void Message(string text) + => Scope(() => MessageBox.Show(ActiveOwner(), text, "Winlestia", MessageBoxButton.OK, MessageBoxImage.Information), false); + public string[] PickFiles() + { + var chosen = Array.Empty(); + Scope(() => + { + using var picker = new Forms.OpenFileDialog + { + Title = "Choisir un fichier, une application ou un raccourci", + Filter = "Tous les fichiers|*.*", + Multiselect = true + }; + if (picker.ShowDialog(new DialogOwner(parent())) == Forms.DialogResult.OK) + chosen = picker.FileNames; + }, false); + return chosen; + } + public string? PickFolder() + { + string? chosen = null; + Scope(() => + { + using var picker = new Forms.FolderBrowserDialog + { + Description = "Ajouter un dossier à ton espace", + UseDescriptionForTitle = true, + ShowNewFolderButton = false + }; + if (picker.ShowDialog(new DialogOwner(parent())) == Forms.DialogResult.OK) + chosen = picker.SelectedPath; + }, false); + return chosen; + } +} diff --git a/src/backend/Core/Services/LaunchRules.cs b/src/backend/Core/Services/LaunchRules.cs new file mode 100644 index 0000000..9c01cb8 --- /dev/null +++ b/src/backend/Core/Services/LaunchRules.cs @@ -0,0 +1,41 @@ +// language: C# 12, file: LaunchRules.cs, target: Windows 11 / .NET 8, décisions pures de lancement, recherche web et classement des accès +using System; +using System.IO; + +namespace PanneauxGlass; + +// Extraites de Coordinator.Boards/Dialogs/Palette : aucun processus n'est démarré ici, seules les cibles sont décidées. +internal static class LaunchRules +{ + // Laisse le temps à l'espace de se replier avant l'outil de capture d'écran de Windows, sinon il capturerait le panneau. + internal const int ScreenClipDelayMs = 120; + // L'« Explorateur de fichiers » Windows épinglé (lnk ou explorer.exe nu) ouvre Winlestia Fichiers quand il est le gestionnaire par + // défaut. + internal static bool IsPlainExplorer(string? target) + { + var name = Path.GetFileName(target ?? ""); + return name.Equals("File Explorer.lnk", StringComparison.OrdinalIgnoreCase) + || name.Equals("Explorateur de fichiers.lnk", StringComparison.OrdinalIgnoreCase) + || name.Equals("explorer.exe", StringComparison.OrdinalIgnoreCase); + } + // Les cibles shell: ne sont pas des chemins : seul explorer.exe sait les résoudre. + internal static bool IsShellTarget(string target) => target.StartsWith("shell:", StringComparison.OrdinalIgnoreCase); + internal static bool IsScreenClip(string target) => target.StartsWith("ms-screenclip:", StringComparison.OrdinalIgnoreCase); + // Une saisie sans schéma est une adresse à compléter ; sinon la saisie part en recherche DuckDuckGo. + internal static string WebTarget(string value, bool address) + { + if (address && !value.Contains("://")) + value = "https://" + value; + return address ? value : "https://duckduckgo.com/?q=" + Uri.EscapeDataString(value); + } + // Rubrique d'affichage d'un accès ajouté à la main selon le panneau qui le reçoit. + internal static string SectionFor(string board) + => board switch + { + "apps" => "Applications", + "games" => "Jeux", + "projects" => "Projets", + _ => "Dossiers" + }; + internal static string SelectArguments(string target) => "/select,\"" + target + "\""; +} diff --git a/src/backend/Core/Services/LaunchService.cs b/src/backend/Core/Services/LaunchService.cs new file mode 100644 index 0000000..4a51dbd --- /dev/null +++ b/src/backend/Core/Services/LaunchService.cs @@ -0,0 +1,145 @@ +// language: C# 12, file: LaunchService.cs, target: Windows 11 / .NET 8 WPF, lancement des accès, raccourcis Windows et recherche web +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace PanneauxGlass; + +internal interface ILaunchService +{ + void Launch(Entry entry); + void Reveal(Entry entry); + void LaunchTarget(string target); + void LaunchShortcut(WindowsShortcut shortcut); + void WebSearch(string value, bool address); + void AddPath(string path, string board); + void Rescue(); +} + +internal sealed class LaunchService : ILaunchService +{ + readonly Action message; + readonly Action collapse, collapseNow, open; + // Les retours vers l'espace (message, repli, ouverture) restent des délégués : le service ne connaît aucune fenêtre. + internal LaunchService(Action message, Action collapse, Action collapseNow, Action open) + { + this.message = message; + this.collapse = collapse; + this.collapseNow = collapseNow; + this.open = open; + } + public void Launch(Entry entry) + { + try + { + if (Store.State.FilesDefaultHandler && LaunchRules.IsPlainExplorer(entry.Target) && File.Exists(FilesDefaultHandler.ExePath)) + { + FilesDefaultHandler.Open(); + Done(entry); + return; + } + if (LaunchRules.IsShellTarget(entry.Target)) + Process.Start(new ProcessStartInfo("explorer.exe", entry.Target) { UseShellExecute = true }); + else + { + var start = new ProcessStartInfo(entry.Target) { UseShellExecute = true, WindowStyle = ProcessWindowStyle.Normal }; + if (File.Exists(entry.Target)) + start.WorkingDirectory = Path.GetDirectoryName(entry.Target); + Process.Start(start); + } + Done(entry); + } + catch (Exception ex) { message("L’accès « " + entry.Name + " » n’a pas pu s’ouvrir.\n" + ex.Message); } + } + void Done(Entry entry) + { + Store.RecordLaunch(entry.Id); + Store.Save(); + collapse(); + } + public void Reveal(Entry entry) + { + try + { + if (Directory.Exists(entry.Target)) + Process.Start("explorer.exe", "\"" + entry.Target + "\""); + else if (File.Exists(entry.Target)) + Process.Start("explorer.exe", LaunchRules.SelectArguments(entry.Target)); + else + message("Cet accès utilise un protocole de lancement Windows."); + } + catch (Exception ex) { message(ex.Message); } + } + public void LaunchTarget(string target) + { + collapseNow(); + try + { + Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); + } + catch (Exception ex) { message(ex.Message); } + } + public async void LaunchShortcut(WindowsShortcut shortcut) + { + collapseNow(); + try + { + if (LaunchRules.IsScreenClip(shortcut.Target)) + await Task.Delay(LaunchRules.ScreenClipDelayMs); + Process.Start(new ProcessStartInfo(shortcut.Target) { Arguments = shortcut.Arguments, UseShellExecute = true }); + } + catch (Exception ex) + { + open(); + message("Le raccourci « " + shortcut.Name + " » n’a pas pu s’ouvrir.\n" + ex.Message); + } + } + public void WebSearch(string value, bool address) + { + try + { + Process.Start(new ProcessStartInfo(LaunchRules.WebTarget(value, address)) { UseShellExecute = true }); + collapseNow(); + } + catch (Exception ex) { message("La recherche n’a pas pu s’ouvrir.\n" + ex.Message); } + } + public void AddPath(string path, string board) + { + if (!File.Exists(path) && !Directory.Exists(path)) + return; + var existing = Store.Entries.FirstOrDefault(e => e.Target.Equals(path, StringComparison.OrdinalIgnoreCase)); + if (existing != null) + { + Store.State.BoardOverrides[existing.Id] = board; + Store.State.Hidden.Remove(existing.Id); + } + else + { + var entry = new Entry + { + Id = "custom:" + Guid.NewGuid().ToString("N"), + Name = Directory.Exists(path) ? new DirectoryInfo(path).Name : Path.GetFileNameWithoutExtension(path), + Target = path, + IconPath = path, + Section = LaunchRules.SectionFor(board), + Category = "Mes accès" + }; + Store.State.Custom.Add(entry); + Store.State.BoardOverrides[entry.Id] = board; + Store.Entries.Add(entry); + } + Store.Save(); + } + public void Rescue() + { + string executable = Path.Combine(Store.Root, "SECOURS_BUREAU.exe"); + if (!File.Exists(executable)) + { + message("Le secours indépendant est absent.\nDocuments\\SECOURS_BUREAU.exe peut également être utilisé."); + return; + } + Process.Start(new ProcessStartInfo(executable) { UseShellExecute = true }); + } +} diff --git a/src/backend/Core/Services/LayoutRules.cs b/src/backend/Core/Services/LayoutRules.cs new file mode 100644 index 0000000..6018679 --- /dev/null +++ b/src/backend/Core/Services/LayoutRules.cs @@ -0,0 +1,37 @@ +// language: C# 12, file: LayoutRules.cs, target: Windows 11 / .NET 8 WPF, calculs purs de disposition de l'espace +using System; +using System.Windows; + +namespace PanneauxGlass; + +// Résultat du calcul : zone des panneaux, barre d'outils centrée, largeur de la bande de sites et quatre cibles par défaut. +internal readonly record struct WorkspaceLayout(Rect Panel, double ToolbarX, double ToolbarY, double ToolbarWidth, double SideWidth, + Rect[] Targets); + +// Extraites d'Arrange : le Coordinator lit l'écran et les fenêtres, ces règles ne dépendent que de nombres. +internal static class LayoutRules +{ + // En dessous de cette largeur (unités WPF), les commandes et l'état du PC ne tiennent pas côte à côte. + internal const double FitsBothWidth = 1700; + internal const double ToolbarHeight = 64; + internal static bool FitsBoth(double workWidthPixels, double dpi) => workWidthPixels / dpi >= FitsBothWidth; + // La marge demandée est plafonnée à 4,5 % de la largeur pour ne pas écraser les panneaux sur un petit écran. + internal static WorkspaceLayout Detailed(Rect work, double margin, double gap, double topRatio, double leftRatio) + { + double sx = work.X, sy = work.Y, sw = work.Width, sh = work.Height; + margin = Math.Min(margin, sw * .045); + double full = sw - margin * 2, top = sy + 108, bottom = sy + sh - margin; + var panel = new Rect(sx + margin, top, full, Math.Max(310, bottom - top)); + double usable = bottom - top - gap, h1 = usable * topRatio, h2 = usable - h1; + double left = (full - gap) * leftRatio, right = full - gap - left; + double toolbarWidth = Math.Min(1130, Math.Max(780, full * .56)), side = Math.Max(110, (full - toolbarWidth - 32) / 2); + var targets = new[] + { + new Rect(sx + margin, top, left, h1), + new Rect(sx + margin + left + gap, top, right, h1), + new Rect(sx + margin, top + h1 + gap, left, h2), + new Rect(sx + margin + left + gap, top + h1 + gap, right, h2) + }; + return new WorkspaceLayout(panel, sx + (sw - toolbarWidth) / 2, sy + 24, toolbarWidth, side, targets); + } +} diff --git a/src/backend/Core/Services/PreviewExportService.cs b/src/backend/Core/Services/PreviewExportService.cs new file mode 100644 index 0000000..5cc4493 --- /dev/null +++ b/src/backend/Core/Services/PreviewExportService.cs @@ -0,0 +1,85 @@ +// language: C# 12, file: PreviewExportService.cs, target: Windows 11 / .NET 8 WPF, export PNG de la mise en page des fenêtres visibles +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Windows; +using System.Windows.Media; +using System.Windows.Threading; + +namespace PanneauxGlass; + +internal interface IPreviewExportService +{ + void Export(string file = "preview-layout.png"); + void ScheduleExport(); +} + +internal static class PreviewExportRules +{ + internal const double Margin = 20; + internal static readonly TimeSpan Delay = TimeSpan.FromSeconds(5); + // Cadre commun de toutes les fenêtres, élargi d'une marge pour que les ombres et les halos ne soient pas rognés. + internal static Rect? CaptureBounds(System.Collections.Generic.IReadOnlyCollection windows) + { + if (windows.Count == 0) + return null; + double left = windows.Min(w => w.Left) - Margin, + top = windows.Min(w => w.Top) - Margin, + right = windows.Max(w => w.Right) + Margin, + bottom = windows.Max(w => w.Bottom) + Margin; + return new Rect(left, top, right - left, bottom - top); + } + // Arrondi au pixel supérieur : un bitmap tronqué laisserait un liseré non rendu sur le bord. + internal static (int Width, int Height) PixelSize(Rect bounds) => ((int)Math.Ceiling(bounds.Width), (int)Math.Ceiling(bounds.Height)); +} + +internal sealed class PreviewExportService : IPreviewExportService +{ + readonly IconLoader icons; + internal PreviewExportService(IconLoader icons) => this.icons = icons; + // Laisse le temps aux fenêtres de l'aperçu de s'afficher et de charger leurs icônes avant la capture. + public void ScheduleExport() + { + var capture = new DispatcherTimer { Interval = PreviewExportRules.Delay }; + capture.Tick += (_, _) => + { + capture.Stop(); + Export(); + }; + capture.Start(); + } + public void Export(string file = "preview-layout.png") + { + try + { + var views = System.Windows.Application.Current.Windows.Cast().OfType().Where(w => w.IsVisible).ToList(); + File.WriteAllText(Path.Combine(Store.Root, "window-metrics.json"), JsonSerializer.Serialize(views.Select(Native.Describe), + Store.Json)); + var frame = PreviewExportRules.CaptureBounds(views.Select(w => new Rect(w.Left, w.Top, w.Width, w.Height)).ToList()); + if (frame == null) + return; + var bounds = frame.Value; + var visual = new DrawingVisual(); + using (var dc = visual.RenderOpen()) + { + dc.DrawRectangle(Brushes.Black, null, new Rect(0, 0, bounds.Width, bounds.Height)); + foreach (var view in views) + dc.DrawRectangle(new VisualBrush(view.Surface), null, new Rect(view.Left - bounds.Left, view.Top - bounds.Top, + view.ActualWidth, view.ActualHeight)); + } + var (width, height) = PreviewExportRules.PixelSize(bounds); + var bitmap = new System.Windows.Media.Imaging.RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); + bitmap.Render(visual); + using (visual.RenderOpen()) + { + } + var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder(); + encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(bitmap)); + using var stream = File.Create(Path.Combine(Store.Root, file)); + encoder.Save(stream); + Store.Log("Export aperçu · icônes=" + icons.LoadedCount + " · génériques=" + icons.Failures); + } + catch (Exception ex) { Store.Log("Export: " + ex); } + } +} diff --git a/src/backend/Core/Services/QuickTabRules.cs b/src/backend/Core/Services/QuickTabRules.cs new file mode 100644 index 0000000..8a933dd --- /dev/null +++ b/src/backend/Core/Services/QuickTabRules.cs @@ -0,0 +1,50 @@ +// language: C# 12, file: QuickTabRules.cs, target: Windows 11 / .NET 8, décisions pures des onglets rapides (cibles glass:) +using System; + +namespace PanneauxGlass; + +internal enum QuickTabView +{ + Unknown, Home, Apps, Games, Projects, Documents, Workspace, Note, Focus, Settings, Tools, Capture, Text, Clipboard, Audio, + Workspaces, Files, Help, Pin, Memo, Controls, Pc, Palette +} + +// Extraites de RunQuickTab : le Coordinator ne garde que l'aiguillage vers ses propres vues. +internal static class QuickTabRules +{ + internal const string Prefix = "glass:"; + // Une cible glass: désigne une vue interne ; toute autre cible est un raccourci Windows lancé tel quel. + internal static bool IsInternal(string? target) => (target ?? "").StartsWith(Prefix, StringComparison.OrdinalIgnoreCase); + // Le nom est insensible à la casse et aux espaces : les onglets saisis à la main ne doivent pas échouer pour une majuscule. + internal static QuickTabView Parse(string? target) + { + if (!IsInternal(target)) + return QuickTabView.Unknown; + return target![Prefix.Length..].Trim().ToLowerInvariant() switch + { + "home" => QuickTabView.Home, + "apps" => QuickTabView.Apps, + "games" => QuickTabView.Games, + "projects" => QuickTabView.Projects, + "documents" => QuickTabView.Documents, + "workspace" => QuickTabView.Workspace, + "note" => QuickTabView.Note, + "focus" => QuickTabView.Focus, + "settings" => QuickTabView.Settings, + "tools" => QuickTabView.Tools, + "capture" => QuickTabView.Capture, + "text" => QuickTabView.Text, + "clipboard" => QuickTabView.Clipboard, + "audio" => QuickTabView.Audio, + "workspaces" => QuickTabView.Workspaces, + "files" => QuickTabView.Files, + "help" => QuickTabView.Help, + "pin" => QuickTabView.Pin, + "memo" => QuickTabView.Memo, + "controls" => QuickTabView.Controls, + "pc" => QuickTabView.Pc, + "palette" => QuickTabView.Palette, + _ => QuickTabView.Unknown + }; + } +} diff --git a/src/backend/Core/Services/ShellChrome.cs b/src/backend/Core/Services/ShellChrome.cs new file mode 100644 index 0000000..b4ff438 --- /dev/null +++ b/src/backend/Core/Services/ShellChrome.cs @@ -0,0 +1,133 @@ +// language: C# 12, file: ShellChrome.cs, target: Windows 11 / .NET 8 WPF, contenu de la poignée latérale et de la barre d'outils +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace PanneauxGlass; + +// Actions de la barre d'outils : la construction visuelle ne connaît ni le Coordinator ni les autres fenêtres. +internal sealed record ToolbarCommands(Action Palette, Action Home, Action ResetLayout, Action RefreshBoards, Action Settings, + Action Rescue, Action Collapse); + +// Extraits de BuildRail/BuildToolbar : construction d'éléments WPF sans état propre, l'état partagé arrive par ShellState. +internal static class ShellChrome +{ + // Fond des boutons à bascule : teinte d'accent quand la bascule est active, voile discret sinon. + static Brush Toggled(bool on) => on ? UI.Brush("#45C5E8FF") : UI.Brush("#0FFFFFFF"); + internal static void BuildRail(GlassWindow rail, Action open) + { + rail.ContentHost.Children.Clear(); + rail.Width = Store.State.RailWidth; + rail.KeepOpen = true; + rail.Radius = 32; + var hit = UI.Button("", open); + hit.Padding = new Thickness(0); + hit.BorderThickness = new Thickness(0); + hit.BorderBrush = Brushes.Transparent; + hit.Background = Brushes.Transparent; + hit.HorizontalContentAlignment = HorizontalAlignment.Stretch; + hit.VerticalContentAlignment = VerticalAlignment.Stretch; + hit.ToolTip = "Ouvrir ton espace · " + GlobalShortcut.Label(Store.State.Shortcut.Modifiers, Store.State.Shortcut.Key); + System.Windows.Automation.AutomationProperties.SetName(hit, "Ouvrir Winlestia"); + var layout = new Grid(); + layout.Children.Add(new Border + { + Width = 3, + Height = 100, + CornerRadius = new CornerRadius(2), + Background = UI.Accent, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }); + var glyph = UI.Glyph("\uE8A9", 20); + glyph.VerticalAlignment = VerticalAlignment.Top; + glyph.Margin = new Thickness(0, 28, 0, 0); + glyph.Opacity = .9; + layout.Children.Add(glyph); + layout.Children.Add(new Border + { + Width = 6, + Height = 6, + CornerRadius = new CornerRadius(3), + Background = UI.Accent, + VerticalAlignment = VerticalAlignment.Bottom, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 0, 0, 28) + }); + hit.Content = layout; + rail.ContentHost.Children.Add(hit); + } + // Renvoie la pastille de la commande rapide : le Coordinator doit pouvoir rafraîchir son libellé quand le raccourci change. + internal static Button BuildToolbar(GlassWindow toolbar, ShellState state, string paletteLabel, ToolbarCommands commands) + { + toolbar.Radius = 30; + toolbar.Height = LayoutRules.ToolbarHeight; + toolbar.ContentHost.Margin = new Thickness(18, 10, 12, 10); + var row = new Grid(); + row.ColumnDefinitions.Add(new() + { + Width = GridLength.Auto + }); + row.ColumnDefinitions.Add(new() + { + Width = new GridLength(1, GridUnitType.Star) + }); + row.ColumnDefinitions.Add(new() + { + Width = GridLength.Auto + }); + var brand = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 30, 0) + }; + var logo = UI.Glyph("\uE8A9", 22); + logo.Margin = new Thickness(5, 0, 12, 0); + brand.Children.Add(logo); + brand.Children.Add(UI.Text("Winlestia", 18, null, true)); + var dj = UI.Text(" / " + UserName.First, 13, UI.Dim); + dj.VerticalAlignment = VerticalAlignment.Center; + brand.Children.Add(dj); + row.Children.Add(brand); + // Le champ vit désormais dans la commande rapide (Ctrl F2) : la barre ne garde que l'espace et ses raccourcis. + var hint = UI.Text("Ctrl F2 · tout ton espace en une ligne", 12, UI.Dim); + hint.VerticalAlignment = VerticalAlignment.Center; + hint.HorizontalAlignment = HorizontalAlignment.Center; + hint.Opacity = .75; + Grid.SetColumn(hint, 1); + row.Children.Add(hint); + var actions = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(24, 0, 0, 0) }; + var favorite = UI.IconButton("\uE734", "Afficher uniquement les favoris", () => + { + state.FavoritesOnly = !state.FavoritesOnly; + commands.RefreshBoards(); + }); + favorite.Click += (_, _) => favorite.Background = Toggled(state.FavoritesOnly); + var chip = UI.Button(paletteLabel, commands.Palette); + chip.Padding = new Thickness(11, 7, 11, 7); + chip.FontSize = 12; + chip.ToolTip = "Commande rapide · " + paletteLabel; + System.Windows.Automation.AutomationProperties.SetName(chip, "Ouvrir la commande rapide"); + actions.Children.Add(chip); + actions.Children.Add(UI.IconButton("\uE80F", "Revenir à l’accueil", commands.Home)); + actions.Children.Add(UI.IconButton("\uE8A9", "Rétablir la disposition des panneaux", commands.ResetLayout)); + actions.Children.Add(favorite); + var pin = UI.IconButton("\uE718", "Garder l’espace ouvert", () => { state.Pinned = !state.Pinned; }); + pin.Background = Toggled(state.Pinned); + pin.Click += (_, _) => pin.Background = Toggled(state.Pinned); + actions.Children.Add(pin); + actions.Children.Add(UI.IconButton("\uE713", "Personnaliser l’espace", commands.Settings)); + actions.Children.Add(UI.IconButton("\uE777", "Retrouver le Bureau organisé", commands.Rescue)); + actions.Children.Add(UI.IconButton("\uE711", "Replier l’espace", commands.Collapse)); + foreach (Button button in actions.Children) + button.Margin = new Thickness(3, 0, 0, 0); + Grid.SetColumn(actions, 2); + row.Children.Add(actions); + toolbar.ContentHost.Children.Add(row); + toolbar.KeyDown += (_, e) => { if (e.Key == Key.Escape) commands.Collapse(); }; + return chip; + } +} diff --git a/src/backend/Core/Services/ShellState.cs b/src/backend/Core/Services/ShellState.cs new file mode 100644 index 0000000..215370d --- /dev/null +++ b/src/backend/Core/Services/ShellState.cs @@ -0,0 +1,62 @@ +// language: C# 12, file: ShellState.cs, target: Windows 11 / .NET 8, état partagé de l'espace (ouvert, repli, dialogues) +using System; + +namespace PanneauxGlass; + +// Ces indicateurs étaient des champs privés du Coordinator ; le dialogue, la boucle d'horloge et la barre d'outils les lisent aussi. +// Un objet d'état unique évite de leur donner une référence au Coordinator entier. +internal sealed class ShellState +{ + internal bool Open + { + get; set; + } + internal bool Closing + { + get; set; + } + internal bool Detailed + { + get; set; + } + internal bool Interacting + { + get; set; + } + internal bool SettingsOpen + { + get; set; + } + internal bool PaletteOpen + { + get; set; + } + internal bool ControlsExpanded + { + get; set; + } + internal bool PcExpanded + { + get; set; + } + internal bool PopupOpen + { + get; set; + } + internal bool Pinned + { + get; set; + } + internal bool FavoritesOnly + { + get; set; + } + internal DateTime AwaySince { get; set; } = DateTime.MinValue; + internal DateTime EdgeSince { get; set; } = DateTime.MinValue; + internal DateTime RailAway { get; set; } = DateTime.MinValue; + // Dernière fenêtre étrangère au premier plan : cible de l'épinglage quand le raccourci est pressé depuis Panneaux. + internal IntPtr LastForeign + { + get; set; + } +} diff --git a/src/backend/Core/Services/ShortcutBindings.cs b/src/backend/Core/Services/ShortcutBindings.cs new file mode 100644 index 0000000..f12c626 --- /dev/null +++ b/src/backend/Core/Services/ShortcutBindings.cs @@ -0,0 +1,172 @@ +// language: C# 12, file: ShortcutBindings.cs, target: Windows 11 / .NET 8, raccourcis globaux : libellés, états, réaffectation, aiguillage +using System; + +namespace PanneauxGlass; + +internal enum HotkeyAction +{ + None, Rescue, Palette, Settings, Wallpaper, Pin, Capture, Toggle +} + +// Règle pure : quand les réglages sont ouverts, Palette, Capture et Toggle sont ignorés ; les autres actions passent toujours. +internal static class ShortcutGateRules +{ + public static bool IsBlockedBySettings(HotkeyAction action, bool settingsOpen) => settingsOpen + && action is HotkeyAction.Palette or HotkeyAction.Capture or HotkeyAction.Toggle; +} + +internal interface IShortcutBindings : IDisposable +{ + string ShortcutStatus + { + get; + } + string PaletteStatus + { + get; + } + string PinStatus + { + get; + } + string WallpaperStatus + { + get; + } + string CaptureStatus + { + get; + } + string PaletteLabel + { + get; + } + string PinShortcutLabel + { + get; + } + string WallpaperLabel + { + get; + } + string CaptureShortcutLabel + { + get; + } + bool SetShortcut(uint modifiers, uint key, string view, out string error); + bool SetPaletteShortcut(uint modifiers, uint key, out string error); + bool SetPinShortcut(uint modifiers, uint key, out string error); + bool SetCaptureShortcut(uint modifiers, uint key, out string error); + bool SetWallpaperShortcut(uint modifiers, uint key, out string error); + HotkeyAction Resolve(int id); +} + +// Les réaffectations passent par ici pour que l'état persisté et l'enregistrement Windows ne divergent jamais : la préférence n'est +// écrite que si Windows a accepté la combinaison. +internal sealed class ShortcutBindings : IShortcutBindings +{ + // Identifiant du raccourci de secours du Bureau, enregistré à part des quatre liaisons modifiables. + internal const int RescueId = 2; + readonly Action rebuildRail, refreshLabels; + internal ShortcutBindings(GlobalShortcut global, Action rebuildRail, Action refreshLabels) + { + Global = global; + this.rebuildRail = rebuildRail; + this.refreshLabels = refreshLabels; + } + // Hors de l'interface : seul l'adaptateur réel a besoin de RegisterHotKey, les doublures n'ont rien à fabriquer. + GlobalShortcut Global + { + get; + } + public string ShortcutStatus => Global.Error; + public string PaletteStatus => Global.PaletteError; + public string PinStatus => Global.PinError; + public string WallpaperStatus => Global.WallpaperError; + public string CaptureStatus => Global.CaptureError; + public string PaletteLabel => Label(Store.State.PaletteShortcut); + public string PinShortcutLabel => Label(Store.State.PinShortcut); + public string CaptureShortcutLabel => Label(Store.State.CaptureShortcut); + // Le libellé suit la combinaison réellement active : une combinaison refusée ne doit pas s'afficher comme valide. + public string WallpaperLabel + => Global.WallpaperActive ? GlobalShortcut.Label(Global.WallpaperModifiers, Global.WallpaperKey) + : Label(Store.State.WallpaperShortcut); + static string Label(ShortcutPreferences preferences) => GlobalShortcut.Label(preferences.Modifiers, preferences.Key); + public bool SetWallpaperShortcut(uint modifiers, uint key, out string error) + { + if (!Global.TrySetWallpaper(modifiers, key, out error)) + return false; + Store.State.WallpaperShortcut = new() + { + Modifiers = modifiers, + Key = key + }; + Store.Save(); + return true; + } + public bool SetPaletteShortcut(uint modifiers, uint key, out string error) + { + if (!Global.TrySetPalette(modifiers, key, out error)) + return false; + Store.State.PaletteShortcut = new() + { + Modifiers = modifiers, + Key = key + }; + Store.Save(); + refreshLabels(); + return true; + } + public bool SetPinShortcut(uint modifiers, uint key, out string error) + { + if (!Global.TrySetPin(modifiers, key, out error)) + return false; + Store.State.PinShortcut = new() + { + Modifiers = modifiers, + Key = key + }; + Store.Save(); + refreshLabels(); + return true; + } + public bool SetCaptureShortcut(uint modifiers, uint key, out string error) + { + if (!Global.TrySetCapture(modifiers, key, out error)) + return false; + Store.State.CaptureShortcut = new() + { + Modifiers = modifiers, + Key = key + }; + Store.Save(); + refreshLabels(); + return true; + } + public bool SetShortcut(uint modifiers, uint key, string view, out string error) + { + if (!Global.TrySet(modifiers, key, out error)) + return false; + Store.State.Shortcut = new() + { + Modifiers = modifiers, + Key = key, + View = view + }; + Store.State.Shortcut.Normalize(); + Store.Save(); + rebuildRail(); + return true; + } + // L'ordre compte : le secours l'emporte sur toute liaison, et la bascule du lanceur (id 1 historique) vient en dernier. + public HotkeyAction Resolve(int id) + => id == RescueId ? HotkeyAction.Rescue + : Global.MatchesPalette(id) ? HotkeyAction.Palette + : Global.MatchesSettings(id) ? HotkeyAction.Settings + : Global.MatchesWallpaper(id) ? HotkeyAction.Wallpaper + : Global.MatchesPin(id) ? HotkeyAction.Pin + : Global.MatchesCapture(id) ? HotkeyAction.Capture + : Global.Matches(id) ? HotkeyAction.Toggle + : HotkeyAction.None; + public void Dispose() => Global.Dispose(); +} diff --git a/src/backend/Core/Services/TickController.cs b/src/backend/Core/Services/TickController.cs new file mode 100644 index 0000000..4d071c8 --- /dev/null +++ b/src/backend/Core/Services/TickController.cs @@ -0,0 +1,142 @@ +// language: C# 12, file: TickController.cs, target: Windows 11 / .NET 8 WPF, boucle d'horloge, poignée latérale et repli automatique +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Media; +using Forms = System.Windows.Forms; + +namespace PanneauxGlass; + +internal interface ITickController +{ + void Tick(); +} + +// Rappels vers le Coordinator : la boucle décide, le Coordinator ouvre et replie. +internal sealed record TickHost(Func> HoverWindows, Func> AllWindows, Action PointerTick, + Action SetTaskbar, Action Open, Action Collapse, Func QtIdle); + +internal sealed class TickController : ITickController +{ + readonly ShellState state; + readonly GlassWindow rail; + readonly TickHost host; + Native.Point lastMouse; + IntPtr lastForeground; + string railScreen = ""; + internal TickController(ShellState state, GlassWindow rail, TickHost host) + { + this.state = state; + this.rail = rail; + this.host = host; + } + public void Tick() + { + try + { + if (Signals() || host.QtIdle()) + return; + host.PointerTick(); + if (state.Open && TickRules.AutoHideSuspended(state.Detailed, state.Interacting, state.Pinned, state.SettingsOpen, + state.PopupOpen, Store.State.AutoHide)) + return; + if (!Native.GetCursorPos(out var mouse)) + return; + IntPtr foreground = Native.GetForegroundWindow(); + bool stationary = mouse.X == lastMouse.X && mouse.Y == lastMouse.Y && foreground == lastForeground; + lastMouse = mouse; + lastForeground = foreground; + if (foreground != IntPtr.Zero && !Native.Owns(foreground)) + state.LastForeign = foreground; + if (stationary && !state.Open && !rail.IsVisible && state.EdgeSince == DateTime.MinValue) + return; + var screen = Forms.Screen.FromPoint(new System.Drawing.Point(mouse.X, mouse.Y)); + if (!state.Open && !state.Closing) + TickRail(screen, mouse); + else if (state.Open && !state.Pinned && !state.SettingsOpen && !state.PopupOpen && Store.State.AutoHide) + TickBoards(mouse); + } + catch (Exception ex) { Store.Log("Tick: " + ex.Message); } + } + // Signaux d'un second processus : retour de la barre Windows, arrêt, bascule. Vrai quand le tour est consommé. + bool Signals() + { + if (Program.TaskbarRestoreSignal.WaitOne(0)) + { + host.SetTaskbar(false); + return true; + } + if (Program.QuitSignal.WaitOne(0)) + { + Store.Log("QuitSignal reçu : arrêt demandé par un second processus --quit (voir la ligne 'Args' correspondante)"); + Application.Current.Shutdown(); + return true; + } + if (Program.ShowSignal.WaitOne(0)) + { + if (state.Open) + host.Collapse(); + else + host.Open(); + return true; + } + return false; + } + void TickRail(Forms.Screen screen, Native.Point mouse) + { + var work = screen.WorkingArea; + bool atEdge = TickRules.AtEdge(Store.State.RailSide, mouse.X, mouse.Y, screen.Bounds.Left, screen.Bounds.Right, work.Top, + work.Bottom); + if ((atEdge || rail.IsVisible) && Store.State.PauseFullscreen && Native.Fullscreen(screen, host.AllWindows())) + { + rail.Hide(); + state.EdgeSince = DateTime.MinValue; + return; + } + if (atEdge) + { + state.RailAway = DateTime.MinValue; + if (state.EdgeSince == DateTime.MinValue) + state.EdgeSince = DateTime.UtcNow; + if (TickRules.EdgeDwellReached(state.EdgeSince, DateTime.UtcNow) && (!rail.IsVisible || railScreen != screen.DeviceName)) + { + double scale = VisualTreeHelper.GetDpi(rail).DpiScaleX; + var place = TickRules.RailPlacement(Store.State.RailSide, screen.Bounds.Left, screen.Bounds.Right, work.Top, + work.Height, scale, Store.State.RailWidth, Store.State.RailHeight); + rail.MoveInto(place.X, place.Y, Store.State.RailWidth, place.Height, 0, false); + railScreen = screen.DeviceName; + } + return; + } + state.EdgeSince = DateTime.MinValue; + if (Contains(rail, mouse)) + { + state.RailAway = DateTime.MinValue; + return; + } + if (state.RailAway == DateTime.MinValue) + state.RailAway = DateTime.UtcNow; + if (TickRules.RailAwayExceeded(state.RailAway, DateTime.UtcNow)) + rail.Hide(); + } + void TickBoards(Native.Point mouse) + { + if (host.HoverWindows().Any(w => Contains(w, mouse))) + { + state.AwaySince = DateTime.MinValue; + return; + } + if (state.AwaySince == DateTime.MinValue) + state.AwaySince = DateTime.UtcNow; + if (TickRules.BoardAwayExceeded(state.AwaySince, DateTime.UtcNow)) + host.Collapse(); + } + static bool Contains(Window window, Native.Point point) + { + if (!window.IsVisible) + return false; + var dpi = VisualTreeHelper.GetDpi(window).DpiScaleX; + return TickRules.Inside(window.Left, window.Top, window.Width, window.Height, dpi, point.X, point.Y); + } +} diff --git a/src/backend/Core/Services/TickRules.cs b/src/backend/Core/Services/TickRules.cs new file mode 100644 index 0000000..ca73732 --- /dev/null +++ b/src/backend/Core/Services/TickRules.cs @@ -0,0 +1,38 @@ +// language: C# 12, file: TickRules.cs, target: Windows 11 / .NET 8, décisions pures de la boucle d'horloge (poignée latérale, repli) +using System; + +namespace PanneauxGlass; + +// Extraites de Tick() : la boucle lit la souris et les fenêtres, ces règles ne dépendent que de nombres et de dates. +internal static class TickRules +{ + internal const int EdgeBand = 4; + internal const int EdgeMargin = 24; + internal const int EdgeDwellMs = 150; + internal const int RailAwayMs = 650; + internal const int BoardAwayMs = 1800; + internal const double RailGap = 12; + // Bande de 4 px sur le bord choisi, hors des 24 px de coin : sans cela le coin d'écran déclencherait la poignée par accident. + internal static bool AtEdge(string side, int x, int y, int boundsLeft, int boundsRight, int workTop, int workBottom) + => (side == "right" ? x >= boundsRight - EdgeBand && x <= boundsRight : x <= boundsLeft + EdgeBand && x >= boundsLeft) + && y > workTop + EdgeMargin + && y < workBottom - EdgeMargin; + // La poignée n'apparaît qu'après un court maintien (>=) pour ignorer le passage de la souris vers un autre écran. + internal static bool EdgeDwellReached(DateTime since, DateTime now) => (now - since).TotalMilliseconds >= EdgeDwellMs; + internal static bool RailAwayExceeded(DateTime since, DateTime now) => (now - since).TotalMilliseconds > RailAwayMs; + internal static bool BoardAwayExceeded(DateTime since, DateTime now) => (now - since).TotalMilliseconds > BoardAwayMs; + // Le repli automatique est suspendu dès que l'utilisateur interagit, épingle ou ouvre une boîte de dialogue. + internal static bool AutoHideSuspended(bool detailed, bool interacting, bool pinned, bool settingsOpen, bool popupOpen, bool autoHide) + => !detailed || interacting || pinned || settingsOpen || popupOpen || !autoHide; + // Position et hauteur de la poignée en unités WPF (pixels / échelle), centrée verticalement dans la zone de travail. + internal static (double X, double Y, double Height) RailPlacement(string side, int boundsLeft, int boundsRight, int workTop, + int workHeight, double scale, double railWidth, double heightRatio) + { + double height = workHeight / scale * heightRatio; + double x = side == "right" ? boundsRight / scale - railWidth - RailGap : boundsLeft / scale + RailGap; + return (x, workTop / scale + (workHeight / scale - height) / 2, height); + } + // Rectangle en unités WPF comparé à un point en pixels : la fenêtre est ramenée en pixels avec le DPI de la fenêtre. + internal static bool Inside(double left, double top, double width, double height, double dpi, int x, int y) + => x >= left * dpi && y >= top * dpi && x < (left + width) * dpi && y < (top + height) * dpi; +} diff --git a/src/backend/Core/Services/TrayService.cs b/src/backend/Core/Services/TrayService.cs new file mode 100644 index 0000000..f746fb5 --- /dev/null +++ b/src/backend/Core/Services/TrayService.cs @@ -0,0 +1,58 @@ +// language: C# 12, file: TrayService.cs, target: Windows 11 / .NET 8, icône de la zone de notification et bulles +using System; +using Forms = System.Windows.Forms; + +namespace PanneauxGlass; + +internal interface ITrayService : IDisposable +{ + event Action? DoubleClicked; + event Action? RightClicked; + void Show(string tooltip); + void SetMenu(Forms.ContextMenuStrip menu); + void Notify(string title, string text); +} + +internal sealed class TrayService : ITrayService +{ + // Durée demandée à Windows ; il l'ignore souvent au profit du réglage d'accessibilité de l'utilisateur. + internal const int BalloonMilliseconds = 4000; + readonly Forms.NotifyIcon icon = new(); + public event Action? DoubleClicked; + public event Action? RightClicked; + // Seul le clic droit ouvre le menu propre à l'application : le clic gauche reste réservé au double-clic. + internal static bool OpensMenu(Forms.MouseButtons button) => button == Forms.MouseButtons.Right; + internal TrayService() + { + icon.MouseUp += (_, e) => + { + if (OpensMenu(e.Button)) + RightClicked?.Invoke(); + }; + icon.DoubleClick += (_, _) => DoubleClicked?.Invoke(); + } + public void Show(string tooltip) + { + icon.Icon = System.Drawing.Icon.ExtractAssociatedIcon(Environment.ProcessPath!) ?? System.Drawing.SystemIcons.Application; + icon.Text = tooltip; + icon.Visible = true; + } + public void SetMenu(Forms.ContextMenuStrip menu) => icon.ContextMenuStrip = menu; + public void Notify(string title, string text) + { + try + { + icon.BalloonTipTitle = title; + icon.BalloonTipText = text; + icon.BalloonTipIcon = Forms.ToolTipIcon.Info; + icon.ShowBalloonTip(BalloonMilliseconds); + } + catch (Exception ex) { Store.Log("Notification: " + ex.Message); } + } + public void Dispose() + { + icon.Visible = false; + icon.ContextMenuStrip?.Dispose(); + icon.Dispose(); + } +} diff --git a/src/backend/Storage/Store.cs b/src/backend/Core/Store.cs similarity index 60% rename from src/backend/Storage/Store.cs rename to src/backend/Core/Store.cs index 7e3906b..27f09ab 100644 --- a/src/backend/Storage/Store.cs +++ b/src/backend/Core/Store.cs @@ -1,5 +1,4 @@ -// Store: persistence, data directory, logging and catalogue lookups. - +// language: C# 12, file: Store.cs, target: Windows 11 / .NET 8, stockage local : chemins, état, journal et sérialisation using System; using System.Collections.Generic; using System.Globalization; @@ -8,17 +7,14 @@ using System.Text; using System.Text.Json; using Winlestia.Shared; - -namespace PanneauxGlass.Storage; +namespace PanneauxGlass; public static class Store { public static string Root = AppDomain.CurrentDomain.BaseDirectory; - - // User data (preferences, log, clipboard, pins) lives outside the install folder so it survives - // uninstall/reinstall. WINLESTIA_DATA_DIR overrides it (tests). - static string? dirEnv, - dirCached; + // Les données utilisateur (préférences, journal, presse-papiers, épingles) vivent hors du dossier d'installation + // pour survivre à une désinstallation/réinstallation. WINLESTIA_DATA_DIR le remplace (tests). + static string? dirEnv, dirCached; static bool dirInit; public static string DataDir { @@ -27,28 +23,26 @@ public static string DataDir string? env = Environment.GetEnvironmentVariable("WINLESTIA_DATA_DIR"); if (dirInit && dirEnv == env && dirCached != null) return dirCached; - string d = !string.IsNullOrEmpty(env) - ? env - : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Panneaux"); + // Nom historique conservé pour compatibilité (dossier de données des installations existantes). + string d = env is { Length: > 0 } o ? o : Path.Combine(Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData), "Panneaux"); try { Directory.CreateDirectory(d); } - catch { } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Store.Log("Models.get : " + ex.GetType().Name + ": " + ex.Message); + } dirEnv = env; dirCached = d; dirInit = true; return d; } } - public static readonly JsonSerializerOptions Json = new() - { - PropertyNameCaseInsensitive = true, - WriteIndented = true, - }; + public static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true, WriteIndented = true }; public static Preferences State = new(); public static List Entries = new(); - public static string Fold(string? value) { var b = new StringBuilder(); @@ -57,17 +51,14 @@ public static string Fold(string? value) b.Append(c); return b.ToString(); } - static readonly object logLock = new(); - public static void Log(string text) { try { lock (logLock) { - string f = Path.Combine(DataDir, "glass.log"), - line = DateTimeOffset.Now.ToString("O") + " " + text + Environment.NewLine; + string f = Path.Combine(DataDir, "glass.log"), line = DateTimeOffset.Now.ToString("O") + " " + text + Environment.NewLine; try { File.AppendAllText(f, line, Encoding.UTF8); @@ -79,19 +70,12 @@ public static void Log(string text) } } } - catch { } - } - - static bool InDataDir(string path) - { - string root = - Path.GetFullPath(DataDir).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - + Path.DirectorySeparatorChar; - return Path.GetFullPath(path).StartsWith(root, StringComparison.OrdinalIgnoreCase); + catch (Exception) + { + // Le journal ne peut pas se journaliser lui-même. + } } - - public static T Read(string path) - where T : new() + public static T Read(string path) where T : new() { if (!File.Exists(path)) return new T(); @@ -102,7 +86,7 @@ public static T Read(string path) } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { - // Transient (locked, AV scan...): the file is not known to be corrupt, so never touch disk here. + // Transitoire (verrou, antivirus...) : le fichier n'est pas connu comme corrompu, on ne touche jamais au disque ici. System.Threading.Thread.Sleep(50); try { @@ -110,26 +94,24 @@ public static T Read(string path) } catch (Exception ex2) when (ex2 is IOException || ex2 is UnauthorizedAccessException) { - Log(Path.GetFileName(path) + ": read failed: " + ex2.Message); + Log(Path.GetFileName(path) + ": lecture impossible: " + ex2.Message); return new T(); } } - catch (DecoderFallbackException ex) - { - return Recover(path, ex); - } + catch (Exception ex) { return Recover(path, ex); } try { return JsonSerializer.Deserialize(text, Json) ?? new T(); } - catch (JsonException ex) - { - return Recover(path, ex); - } + catch (Exception ex) { return Recover(path, ex); } } - - static T Recover(string path, Exception ex) - where T : new() + static bool InDataDir(string path) + { + string root = Path.GetFullPath(DataDir).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + return Path.GetFullPath(path).StartsWith(root, StringComparison.OrdinalIgnoreCase); + } + static T Recover(string path, Exception ex) where T : new() { Log(Path.GetFileName(path) + ": " + ex.Message); KeepCorrupt(path); @@ -141,29 +123,23 @@ static T Recover(string path, Exception ex) var recovered = JsonSerializer.Deserialize(File.ReadAllText(bak, Encoding.UTF8), Json); if (recovered != null) { - Log(Path.GetFileName(path) + ": restored from .bak"); - // Put the good copy back as main: the next Save would otherwise roll the corrupt main over the good .bak. + Log(Path.GetFileName(path) + ": restauré depuis .bak"); + // La bonne copie redevient le principal : sinon le prochain Save écraserait le .bak sain par le principal corrompu. try { File.Copy(bak, path, true); } - catch (Exception ex2) - { - Log(Path.GetFileName(path) + ": could not restore the main file: " + ex2.Message); - } + catch (Exception ex2) { Log(Path.GetFileName(path) + ": restauration du principal impossible: " + ex2.Message); } return recovered; } } - catch (Exception ex2) - { - Log(Path.GetFileName(path) + ".bak: " + ex2.Message); - } + catch (Exception ex2) { Log(Path.GetFileName(path) + ".bak: " + ex2.Message); } } - Log(Path.GetFileName(path) + ": default values used"); + Log(Path.GetFileName(path) + ": valeurs par défaut utilisées"); return new T(); } - - // A corrupt user file is about to be replaced by defaults on the next Save: keep the bytes (one copy, kept apart from the rolling .bak). + // Un fichier utilisateur illisible va être remplacé par les défauts au prochain Save : on garde ses octets (une copie, à part du + // .bak glissant). static void KeepCorrupt(string path) { try @@ -171,14 +147,10 @@ static void KeepCorrupt(string path) if (!InDataDir(path)) return; File.Copy(path, path + ".corrupt.bak", true); - Log(Path.GetFileName(path) + ": unreadable file copy kept (.corrupt.bak)"); - } - catch (Exception ex) - { - Log(Path.GetFileName(path) + ": could not copy .corrupt.bak: " + ex.Message); + Log(Path.GetFileName(path) + ": copie du fichier illisible conservée (.corrupt.bak)"); } + catch (Exception ex) { Log(Path.GetFileName(path) + ": copie .corrupt.bak impossible: " + ex.Message); } } - public static void Save() { try @@ -187,58 +159,42 @@ public static void Save() string path = Path.Combine(DataDir, "preferences-glass.json"); AtomicFile.WriteAllText(path, JsonSerializer.Serialize(State, Json), Encoding.UTF8, path + ".bak"); } - catch (Exception ex) - { - Log("Enregistrement: " + ex.Message); - } + catch (Exception ex) { Log("Enregistrement: " + ex.Message); } } - - // One-shot migration from the install folder (legacy location). Copy prefs (old binaries keep working), move big - // folders, never overwrite, never throw. + // Migration unique depuis le dossier d'installation (ancien emplacement) : copie des préférences (les anciens binaires + // continuent de marcher), déplacement des gros dossiers, jamais d'écrasement, jamais d'exception. public static void MigrateLegacyData(string legacyRoot, string dataDir) { try { Directory.CreateDirectory(dataDir); - string prefs = Path.Combine(dataDir, "preferences-glass.json"), - old = Path.Combine(legacyRoot, "preferences-glass.json"); + string prefs = Path.Combine(dataDir, "preferences-glass.json"), old = Path.Combine(legacyRoot, "preferences-glass.json"); if (!File.Exists(prefs) && File.Exists(old)) { try { File.Copy(old, prefs, false); } - catch (Exception ex) - { - Log("Migration preferences-glass.json: " + ex.Message); - } + catch (Exception ex) { Log("Migration preferences-glass.json: " + ex.Message); } } try { - string bak = Path.Combine(legacyRoot, "preferences-glass.json.bak"), - bakTo = Path.Combine(dataDir, "preferences-glass.json.bak"); + string bak = Path.Combine(legacyRoot, "preferences-glass.json.bak"), bakTo = Path.Combine(dataDir, + "preferences-glass.json.bak"); if (File.Exists(bak) && !File.Exists(bakTo)) File.Copy(bak, bakTo, false); } - catch (Exception ex) - { - Log("Migration preferences-glass.json.bak: " + ex.Message); - } + catch (Exception ex) { Log("Migration preferences-glass.json.bak: " + ex.Message); } foreach (string folder in new[] { "clipboard", "pins" }) { - string from = Path.Combine(legacyRoot, folder), - to = Path.Combine(dataDir, folder), - tmp = to + ".migrating"; + string from = Path.Combine(legacyRoot, folder), to = Path.Combine(dataDir, folder), tmp = to + ".migrating"; if (Directory.Exists(tmp) && !Directory.Exists(to)) { try { Directory.Delete(tmp, true); } - catch (Exception ex) - { - Log("Migration " + folder + " (reliquat): " + ex.Message); - } + catch (Exception ex) { Log("Migration " + folder + " (reliquat): " + ex.Message); } } if (!Directory.Exists(from) || Directory.Exists(to)) continue; @@ -248,7 +204,7 @@ public static void MigrateLegacyData(string legacyRoot, string dataDir) } catch (Exception ex) { - Log("Migration " + folder + " (move): " + ex.Message); + Log("Migration " + folder + " (déplacement): " + ex.Message); try { if (Directory.Exists(tmp)) @@ -256,10 +212,7 @@ public static void MigrateLegacyData(string legacyRoot, string dataDir) CopyTree(from, tmp); Directory.Move(tmp, to); } - catch (Exception ex2) - { - Log("Migration " + folder + " (copy): " + ex2.Message); - } + catch (Exception ex2) { Log("Migration " + folder + " (copie): " + ex2.Message); } } } } @@ -269,10 +222,12 @@ public static void MigrateLegacyData(string legacyRoot, string dataDir) { Log("Migration: " + ex.Message); } - catch { } + catch ( + Exception) + { /* Le journal ne peut pas se journaliser lui-même. */ + } } } - static void CopyTree(string from, string to) { Directory.CreateDirectory(to); @@ -285,7 +240,6 @@ static void CopyTree(string from, string to) foreach (string d in Directory.GetDirectories(from)) CopyTree(d, Path.Combine(to, Path.GetFileName(d))); } - public static void Load() { MigrateLegacyData(Root, DataDir); @@ -302,10 +256,14 @@ public static void Load() if (doc.RootElement.TryGetProperty("Favorites", out var f)) State.Favorites = f.Deserialize>() ?? new(); } - catch { } + catch (Exception err) when (err is IOException or UnauthorizedAccessException or JsonException) + { + Store.Log("Models.Load : " + err.GetType().Name + ": " + err.Message); + } } } State.Normalize(); + MigrateSettings(State); if (State.GlassOpacityRevision < 1) { if (Math.Abs(State.GlassOpacity - .12) < 1e-9) @@ -314,7 +272,17 @@ public static void Load() } LoadCatalogue(); } - + // Schéma 2 (refonte des réglages) : le dock était affiché exactement quand ReplaceTaskbar était actif ; les deux sont + // désormais indépendants, l'ancien état est donc copié une fois. Toute autre clé reste intacte. + public const int SettingsSchemaCurrent = 2; + public static void MigrateSettings(Preferences p) + { + if (p.SettingsSchema < SettingsSchemaCurrent) + { + p.DockVisible = p.ReplaceTaskbar; + p.SettingsSchema = SettingsSchemaCurrent; + } + } public static void LoadCatalogue() { Entries = Read(Path.Combine(Root, "catalog.json")).Entries; @@ -337,43 +305,33 @@ public static void LoadCatalogue() if (healed) Save(); } - - // Self-heal for "dock:" entries whose exe vanished (Squirrel apps move app-1.0.9265 -> app-1.0.9266). The id is - // kept (favourites/pins reference it); only Target/IconPath change. - // Returns true only when the entry was healed AND belongs to State.Custom (the persisted store): catalog.json - // entries are healed in memory only, so no prefs rewrite per start. + // Auto-réparation des entrées « dock: » dont l'exe a disparu (les applis Squirrel passent de app-1.0.9265 à app-1.0.9266). + // L'id est conservé (favoris et épingles le référencent) ; seuls Target/IconPath changent. + // Renvoie true seulement si l'entrée a été réparée ET appartient à State.Custom (le stockage persistant) : les entrées + // de catalog.json sont réparées en mémoire uniquement, pour ne pas réécrire les préférences à chaque démarrage. static bool HealDockTarget(Entry entry) { - if ( - !entry.Id.StartsWith("dock:", StringComparison.Ordinal) + if (!entry.Id.StartsWith("dock:", StringComparison.Ordinal) || !SafeToProbe(entry.Target) || File.Exists(entry.Target) - || Directory.Exists(entry.Target) - ) + || Directory.Exists(entry.Target)) return false; string? fixedPath = HealStaleExe(entry.Target); if (fixedPath == null) return false; - if ( - string.IsNullOrEmpty(entry.IconPath) + if (string.IsNullOrEmpty(entry.IconPath) || entry.IconPath.Equals(entry.Target, StringComparison.OrdinalIgnoreCase) - || (SafeToProbe(entry.IconPath) && !File.Exists(entry.IconPath)) - ) + || (SafeToProbe(entry.IconPath) && !File.Exists(entry.IconPath))) entry.IconPath = fixedPath; entry.Target = fixedPath; return State.Custom.Any(c => ReferenceEquals(c, entry)); } - - // Never touch UNC paths or drives that are missing / not ready (a File.Exists on those can stall the UI thread). + // Ne jamais toucher aux chemins UNC ni aux lecteurs absents ou non prêts (un File.Exists dessus peut figer le thread UI). static bool SafeToProbe(string? path) { try { - if ( - string.IsNullOrWhiteSpace(path) - || path.StartsWith(@"\\", StringComparison.Ordinal) - || !Path.IsPathRooted(path) - ) + if (string.IsNullOrWhiteSpace(path) || path.StartsWith(@"\\", StringComparison.Ordinal) || !Path.IsPathRooted(path)) return false; string? root = Path.GetPathRoot(path); if (string.IsNullOrEmpty(root) || root.StartsWith(@"\\", StringComparison.Ordinal)) @@ -381,14 +339,10 @@ static bool SafeToProbe(string? path) var drive = new DriveInfo(root); return drive.IsReady && Directory.Exists(root); } - catch - { - return false; - } + catch { return false; } } - - // Squirrel only: root/app-X/name.exe -> newest sibling root/app-Y/name.exe. Numeric version order, "-suffix" - // stripped; on a tie the build without suffix wins. + // Squirrel seulement : racine/app-X/nom.exe -> frère le plus récent racine/app-Y/nom.exe. Ordre numérique des versions, + // suffixe « -xxx » ignoré ; à égalité, la build sans suffixe l'emporte. public static string? HealStaleExe(string path) { try @@ -398,12 +352,10 @@ static bool SafeToProbe(string? path) string name = Path.GetFileName(path); string? parentDir = Path.GetDirectoryName(path); string? root = parentDir == null ? null : Path.GetDirectoryName(parentDir); - if ( - string.IsNullOrEmpty(name) + if (string.IsNullOrEmpty(name) || root == null || !Path.GetFileName(parentDir)!.StartsWith("app-", StringComparison.OrdinalIgnoreCase) - || !Directory.Exists(root) - ) + || !Directory.Exists(root)) return null; string? best = null; Version? bestVersion = null; @@ -416,10 +368,7 @@ static bool SafeToProbe(string? path) int dash = tail.IndexOf('-'); bool suffix = dash >= 0; Version.TryParse(suffix ? tail[..dash] : tail, out var version); - int cmp = - best == null ? 1 - : version == null ? (bestVersion == null ? 0 : -1) - : bestVersion == null ? 1 + int cmp = best == null ? 1 : version == null ? (bestVersion == null ? 0 : -1) : bestVersion == null ? 1 : version.CompareTo(bestVersion); if (cmp > 0 || (cmp == 0 && bestSuffix && !suffix)) { @@ -430,10 +379,12 @@ static bool SafeToProbe(string? path) } return best == null ? null : Path.Combine(best, name); } - catch { } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Store.Log("Models.HealStaleExe : " + ex.GetType().Name + ": " + ex.Message); + } return null; } - static void RepairMovedTarget(Entry entry) { if (File.Exists(entry.Target) || Directory.Exists(entry.Target)) @@ -443,10 +394,8 @@ static void RepairMovedTarget(Entry entry) foreach (string folder in new[] { "02_Logiciels_et_Outils", "04_Documents_Administratifs" }) { string prefix = Path.Combine(documents, folder); - if ( - !entry.Target.Equals(prefix, StringComparison.OrdinalIgnoreCase) - && !entry.Target.StartsWith(prefix + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) - ) + if (!entry.Target.Equals(prefix, StringComparison.OrdinalIgnoreCase) + && !entry.Target.StartsWith(prefix + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) continue; string replacement = Path.Combine(desktop, folder) + entry.Target[prefix.Length..]; if (!Directory.Exists(replacement) && !File.Exists(replacement)) @@ -456,7 +405,6 @@ static void RepairMovedTarget(Entry entry) entry.Target = replacement; } } - public static string BoardFor(Entry e) { if (State.BoardOverrides.TryGetValue(e.Id, out var board)) @@ -466,37 +414,33 @@ public static string BoardFor(Entry e) "Applications" => "apps", "Jeux" => "games", "Projets" => "projects", - _ => "documents", + _ => "documents" }; } - + // Ids dont la cible a disparu (désinstallée) : masqués mais pas oubliés, une réinstallation ramène donc les favoris. + internal static HashSet Missing = new(); internal static IEnumerable Visible(string scope = "Tous", string query = "") { var words = Fold(query).Split(' ', StringSplitOptions.RemoveEmptyEntries); return Entries.Where(e => !State.Hidden.Contains(e.Id) - && (scope == "Tous" || BoardFor(e) == scope) - && words.All(w => Fold(e.Name + " " + e.Category + " " + e.Target).Contains(w)) - ); + && !Missing.Contains(e.Id) + && (scope == "Tous" || BoardFor(e) == scope) + && words.All(w => Fold(e.Name + " " + e.Category + " " + e.Target).Contains(w))); } - - internal static IEnumerable HomeFavorites(string scope = "Tous", string query = "") => - Visible(scope, query) - .Where(e => State.Favorites.Contains(e.Id)) - .OrderBy(e => - { - int i = State.Recent.IndexOf(e.Id); - return i < 0 ? int.MaxValue : i; - }) - .ThenBy(e => - { - int i = State.Favorites.IndexOf(e.Id); - return i < 0 ? int.MaxValue : i; - }); - - internal static IEnumerable RecentEntries(string scope = "Tous", string query = "") => - Visible(scope, query).Where(e => State.Recent.Contains(e.Id)).OrderBy(e => State.Recent.IndexOf(e.Id)); - + internal static IEnumerable HomeFavorites(string scope = "Tous", + string query = "") => Visible(scope, query).Where(e => + State.Favorites.Contains(e.Id)).OrderBy(e => + { + int i = State.Recent.IndexOf(e.Id); + return i < 0 ? int.MaxValue : i; + }).ThenBy(e => + { + int i = State.Favorites.IndexOf(e.Id); + return i < 0 ? int.MaxValue : i; + }); + internal static IEnumerable RecentEntries(string scope = "Tous", string query = "") + => Visible(scope, query).Where(e => State.Recent.Contains(e.Id)).OrderBy(e => State.Recent.IndexOf(e.Id)); internal static void RecordLaunch(string id) { State.Recent.RemoveAll(x => x == id); @@ -505,14 +449,9 @@ internal static void RecordLaunch(string id) State.LaunchCounts.TryGetValue(id, out int count); State.LaunchCounts[id] = count == int.MaxValue ? count : count + 1; } - - public static IEnumerable Sorted(IEnumerable source) => - source - .OrderBy(e => - { - int n = State.Order.IndexOf(e.Id); - return n < 0 ? 100000 : n; - }) - .ThenByDescending(e => State.Favorites.Contains(e.Id)) - .ThenBy(e => e.Name, StringComparer.CurrentCultureIgnoreCase); + public static IEnumerable Sorted(IEnumerable source) => source.OrderBy(e => + { + int n = State.Order.IndexOf(e.Id); + return n < 0 ? 100000 : n; + }).ThenByDescending(e => State.Favorites.Contains(e.Id)).ThenBy(e => e.Name, StringComparer.CurrentCultureIgnoreCase); } diff --git a/src/backend/Core/UserName.cs b/src/backend/Core/UserName.cs new file mode 100644 index 0000000..97ddf75 --- /dev/null +++ b/src/backend/Core/UserName.cs @@ -0,0 +1,42 @@ +// language: C# 12, file: UserName.cs, target: Windows 11 / .NET 8, prénom de l'utilisateur Windows pour les libellés +using System; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; + +namespace PanneauxGlass; + +// Prénom affiché dans les salutations : nom d'affichage Windows, sinon nom de session. Lu une seule fois. +internal static class UserName +{ + static string? cached; + public static string First => cached ??= FirstOf(DisplayName(), Environment.UserName); + + // Partie pure : prénom du nom d'affichage, sinon repli sur le nom de session. + // Active Directory rend parfois « Nom, Prénom » : la virgule inverse l'ordre, donc on prend ce qui la suit. + internal static string FirstOf(string? display, string? fallback) + { + string name = (display ?? "").Trim(); + int comma = name.IndexOf(','); + if (comma >= 0) + name = name[(comma + 1)..]; + string first = name.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault() ?? ""; + if (first.Length == 0) + first = (fallback ?? "").Trim(); + return first.Length == 0 ? "toi" : first; + } + + static string? DisplayName() + { + try + { + int size = 256; + var sb = new StringBuilder(size); + return GetUserNameEx(3, sb, ref size) ? sb.ToString() : null; + } + catch (Exception) { return null; } + } + + [DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern bool GetUserNameEx(int nameFormat, StringBuilder name, ref int size); +} diff --git a/src/backend/Desktop/DesktopIconsGuard.cs b/src/backend/Desktop/DesktopIconsGuard.cs index 74603c9..efdb773 100644 --- a/src/backend/Desktop/DesktopIconsGuard.cs +++ b/src/backend/Desktop/DesktopIconsGuard.cs @@ -1,78 +1,36 @@ -// language: C# 12, file: DesktopIconsGuard.cs, target: Windows 11 / .NET 8, event-driven re-application of FWF_NOICONS +// C# 12, fichier DesktopIconsGuard.cs, cible Windows 11 / .NET 8, réapplication de FWF_NOICONS pilotée par événements using System; using System.Runtime.InteropServices; using System.Text; using System.Threading; -namespace PanneauxGlass.Desktop; +namespace PanneauxGlass; // Wallpaper Engine / DesktopHut peuvent faire recréer SHELLDLL_DefView après nous : le drapeau FWF_NOICONS est alors perdu. // Hook hors contexte limité au processus Explorer du Bureau ; aucun minuteur périodique, aucun hook souris. internal sealed class DesktopIconsGuard : IDisposable { delegate void WinEventProc(IntPtr hook, uint evt, IntPtr hwnd, int idObject, int idChild, uint thread, uint time); - [StructLayout(LayoutKind.Sequential)] struct Msg { - public IntPtr Hwnd; - public uint Message; - public UIntPtr W; - public IntPtr L; - public uint Time; - public int X, - Y; + public IntPtr Hwnd; public uint Message; public UIntPtr W; public IntPtr L; public uint Time; public int X, Y; } - - [DllImport("user32.dll")] - static extern IntPtr SetWinEventHook( - uint min, - uint max, - IntPtr module, - WinEventProc proc, - uint pid, - uint thread, - uint flags - ); - - [DllImport("user32.dll")] - static extern bool UnhookWinEvent(IntPtr hook); - - [DllImport("user32.dll")] - static extern int GetMessage(out Msg message, IntPtr hwnd, uint min, uint max); - - [DllImport("user32.dll")] - static extern bool PostThreadMessage(uint thread, uint message, UIntPtr w, IntPtr l); - - [DllImport("user32.dll")] - static extern IntPtr GetShellWindow(); - - [DllImport("user32.dll")] - static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid); - [DllImport("user32.dll")] - static extern IntPtr GetParent(IntPtr hwnd); - - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - static extern int GetClassName(IntPtr hwnd, StringBuilder text, int count); - - [DllImport("kernel32.dll")] - static extern uint GetCurrentThreadId(); - - readonly Func managed; - readonly Action reassert, - relinquished; - readonly object gate = new(); - readonly DesktopReassertPolicy policy = new(); - readonly Timer timer; - Thread? thread; - uint threadId; - WinEventProc? proc; - bool active, - running, - disposed, - forced; - int logged; + static extern IntPtr SetWinEventHook(uint min, uint max, IntPtr module, WinEventProc proc, uint pid, + uint thread, uint flags); + [DllImport("user32.dll")] static extern bool UnhookWinEvent(IntPtr hook); + [DllImport("user32.dll")] static extern int GetMessage(out Msg message, IntPtr hwnd, uint min, uint max); + [DllImport("user32.dll")] static extern bool PostThreadMessage(uint thread, uint message, UIntPtr w, IntPtr l); + [DllImport("user32.dll")] static extern IntPtr GetShellWindow(); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid); + [DllImport("user32.dll")] static extern IntPtr GetParent(IntPtr hwnd); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassName(IntPtr hwnd, StringBuilder text, int count); + [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId(); + + readonly Func managed; readonly Action reassert, relinquished; readonly object gate = new(); + readonly DesktopReassertPolicy policy = new(); readonly Timer timer; + Thread? thread; uint threadId; WinEventProc? proc; bool active, running, disposed, forced; int logged; internal bool Installed { @@ -107,7 +65,7 @@ internal bool Install() uint pid = 0; if (shell == IntPtr.Zero || GetWindowThreadProcessId(shell, out pid) == 0 || pid == 0) { - Store.Log("Desktop watcher: Explorer not ready."); + Store.Log("Surveillance du Bureau: Explorateur pas prêt."); return false; } using var ready = new ManualResetEventSlim(false); @@ -118,15 +76,16 @@ internal bool Install() var worker = new Thread(() => { id = GetCurrentThreadId(); - IntPtr a = SetWinEventHook(0x8000, 0x8000, IntPtr.Zero, callback, pid, 0, 0), - b = SetWinEventHook(0x8002, 0x8002, IntPtr.Zero, callback, pid, 0, 0), - c = SetWinEventHook(0x800F, 0x800F, IntPtr.Zero, callback, pid, 0, 0); + IntPtr a = SetWinEventHook(0x8000, 0x8000, IntPtr.Zero, callback, pid, 0, 0), b = SetWinEventHook(0x8002, 0x8002, IntPtr.Zero, + callback, pid, 0, 0), c = SetWinEventHook(0x800F, 0x800F, IntPtr.Zero, callback, pid, 0, 0); installed = a != IntPtr.Zero && b != IntPtr.Zero && c != IntPtr.Zero; ready.Set(); try { if (installed) - while (GetMessage(out _, IntPtr.Zero, 0, 0) > 0) { } + while (GetMessage(out _, IntPtr.Zero, 0, 0) > 0) + { + } } finally { @@ -140,13 +99,13 @@ internal bool Install() }) { IsBackground = true, - Name = "Desktop icons hook", + Name = "Desktop icons hook" }; worker.SetApartmentState(ApartmentState.STA); worker.Start(); if (!ready.Wait(2000) || !installed) { - Store.Log("Desktop watch: hook unavailable."); + Store.Log("Surveillance du Bureau: hook indisponible."); return false; } lock (gate) @@ -161,7 +120,7 @@ internal bool Install() active = true; policy.Reset(); } - Store.Log("Desktop watcher installed (Explorer pid " + pid + ")."); + Store.Log("Surveillance du Bureau installée (Explorateur pid " + pid + ")."); return true; } @@ -212,7 +171,6 @@ internal void Request(bool force) Arm(now); } } - void Arm(DateTime now) { var delay = policy.Delay(now); @@ -237,14 +195,16 @@ void Fire(object? state) bool gaveUp = false; try { - // HideIcons à 0 : quelqu'un a volontairement rétabli les icônes (SECOURS_BUREAU --show-only, menu, case Windows). On se retire. + // HideIcons à 0 : quelqu'un a volontairement rétabli les icônes (SECOURS_BUREAU --show-only, menu, case Windows). On se + // retire. bool intent; lock (gate) - intent = forced; // après un redémarrage d'Explorer, HideIcons peut valoir 0 sans choix de l'utilisateur (le premier masquage a échoué) + // après un redémarrage d'Explorer, HideIcons peut valoir 0 sans choix de l'utilisateur (le premier masquage a échoué) + intent = forced; if (!intent && !DesktopReassertPolicy.StillWanted(Native.DesktopHidden() ? 1 : 0)) { gaveUp = true; - Store.Log("Desktop icons restored elsewhere: watcher removed."); + Store.Log("Icônes du Bureau rétablies par ailleurs : surveillance retirée."); relinquished(); Uninstall(); return; @@ -253,26 +213,10 @@ void Fire(object? state) lock (gate) forced = false; if (logged++ < 20) - Store.Log("Desktop icons re-asserted (Explorer)."); - } - catch (Exception ex) - { - if (logged++ < 20) - Store.Log("Desktop re-assert: " + ex.Message); - } - finally - { - lock (gate) - { - running = false; - if (!disposed && !gaveUp) - { - var end = DateTime.UtcNow; - policy.Ran(end); - Arm(end); - } - } + Store.Log("Icônes du Bureau réaffirmées (Explorateur)."); } + catch (Exception ex) { if (logged++ < 20) Store.Log("Réaffirmation du Bureau: " + ex.Message); } + finally { lock (gate) { running = false; if (!disposed && !gaveUp) { var end = DateTime.UtcNow; policy.Ran(end); Arm(end); } } } } public void Dispose() diff --git a/src/backend/Desktop/DesktopReassertPolicy.cs b/src/backend/Desktop/DesktopReassertPolicy.cs index 38d3cb3..0293bc9 100644 --- a/src/backend/Desktop/DesktopReassertPolicy.cs +++ b/src/backend/Desktop/DesktopReassertPolicy.cs @@ -1,7 +1,8 @@ -// Pure timing and class rules for re-applying the hidden desktop icons (no Store, no WinEvent): linked into tests/DesktopReassert. +// Règles pures de temporisation et de classes pour réappliquer le masquage des icônes du bureau (sans Store ni WinEvent) : liées dans +// tests/DesktopReassert. using System; -namespace PanneauxGlass.Desktop; +namespace PanneauxGlass; static class DesktopEventFilter { @@ -14,15 +15,9 @@ internal static bool Relevant(string className, string parentClassName) => // et silence de 1,5 s après chaque exécution : SECOURS_BUREAU change lui-même la vue, ses événements ne relancent rien. sealed class DesktopReassertPolicy { - internal static readonly TimeSpan Debounce = TimeSpan.FromMilliseconds(400), - MinGap = TimeSpan.FromSeconds(2), - Mute = TimeSpan.FromMilliseconds(1500), - Follow = TimeSpan.FromSeconds(3), - MaxWait = TimeSpan.FromSeconds(2); - DateTime lastRun = DateTime.MinValue, - due = DateTime.MaxValue, - pendingSince = DateTime.MaxValue; - bool follow; + internal static readonly TimeSpan Debounce = TimeSpan.FromMilliseconds(400), MinGap = TimeSpan.FromSeconds(2), + Mute = TimeSpan.FromMilliseconds(1500), Follow = TimeSpan.FromSeconds(3), MaxWait = TimeSpan.FromSeconds(2); + DateTime lastRun = DateTime.MinValue, due = DateTime.MaxValue, pendingSince = DateTime.MaxValue; bool follow; // Retourne false si l'événement est ignoré (silence après une exécution). force : redémarrage d'Explorer. internal bool Event(DateTime now, bool force = false) @@ -36,25 +31,18 @@ internal bool Event(DateTime now, bool force = false) } if (due == DateTime.MaxValue) pendingSince = now; - DateTime wanted = now + Debounce, - cap = pendingSince + MaxWait; + DateTime wanted = now + Debounce, cap = pendingSince + MaxWait; if (wanted > cap) wanted = cap; DateTime floor = lastRun == DateTime.MinValue ? DateTime.MinValue : lastRun + MinGap; due = wanted > floor ? wanted : floor; return true; } - - // HideIcons (HKCU ExplorerAdvanced) à 0 : l'utilisateur ou SECOURS_BUREAU a volontairement rétabli les icônes, on ne les recache pas. + // HideIcons (HKCU ExplorerAdvanced) à 0 : l'utilisateur ou SECOURS_BUREAU a volontairement rétabli les icônes, on ne les recache + // pas. internal static bool StillWanted(int hideIcons) => hideIcons != 0; - internal bool Due(DateTime now) => due <= now; - - internal TimeSpan? Delay(DateTime now) => - due == DateTime.MaxValue ? null - : due > now ? due - now - : TimeSpan.Zero; - + internal TimeSpan? Delay(DateTime now) => due == DateTime.MaxValue ? null : due > now ? due - now : TimeSpan.Zero; // Fin d'une exécution : la première planifie le suivi à +3 s, le suivi ne planifie rien. internal void Ran(DateTime end) { @@ -72,7 +60,6 @@ internal void Ran(DateTime end) } pendingSince = DateTime.MaxValue; } - internal void Reset() { lastRun = DateTime.MinValue; diff --git a/src/backend/Desktop/DesktopRecents.cs b/src/backend/Desktop/DesktopRecents.cs index ecae645..18113ae 100644 --- a/src/backend/Desktop/DesktopRecents.cs +++ b/src/backend/Desktop/DesktopRecents.cs @@ -1,43 +1,29 @@ -// C#12 / DesktopRecents.cs / Windows11. Bounded chronological history, one background STA, no Shell COM on UI. +// C# 12 / DesktopRecents.cs / Windows 11. Historique chronologique borné, un STA d'arrière-plan, aucun COM du shell sur l'UI. using System; -using System.Collections.Concurrent; -using System.Collections.Generic; using System.IO; using System.Linq; +using System.Collections.Generic; using System.Runtime.InteropServices; -using System.Text.Json; +using System.Collections.Concurrent; using System.Threading; - -namespace PanneauxGlass.Desktop; +using System.Text.Json; +namespace PanneauxGlass; internal static class DesktopRecents { internal sealed record Recent(Entry Entry, DateTime At); - - internal static readonly Dictionary Entries = new(); - static readonly Dictionary history = new(StringComparer.OrdinalIgnoreCase); - static readonly ConcurrentQueue<(uint pid, DateTime at)> queue = new(); - static readonly AutoResetEvent wake = new(false); - static readonly string file = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Panneaux", - "recent-launches.json" - ); - static readonly Thread worker; - static uint lastPid; - static object[] values = Array.Empty(); - static DateTime nextScan; - - [DllImport("user32.dll")] - static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); - + internal static readonly Dictionary Entries = new(); static readonly Dictionary history = new(StringComparer.OrdinalIgnoreCase); + static readonly ConcurrentQueue<(uint pid, DateTime at)> queue = new(); static readonly AutoResetEvent wake = new(false); + static readonly string file = Path.Combine(Store.DataDir, "recent-launches.json"); + static readonly Thread worker; static uint lastPid; static object[] values = Array.Empty(); static DateTime nextScan; + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); static DesktopRecents() { worker = new Thread(Loop) { IsBackground = true, Name = "Winlestia recent items" }; worker.SetApartmentState(ApartmentState.STA); worker.Start(); } - internal static object[] Values { get @@ -46,7 +32,6 @@ internal static object[] Values return values; } } - internal static void Observe(IntPtr hwnd) { GetWindowThreadProcessId(hwnd, out uint pid); @@ -59,22 +44,18 @@ internal static void Observe(IntPtr hwnd) wake.Set(); } } - internal static void RefreshSoon() { nextScan = DateTime.MinValue; wake.Set(); } - internal static Recent[] Chronological(IEnumerable source) => RecentOrdering.Sort(source); - static void Put(Entry e, DateTime at) { if (history.TryGetValue(e.Target, out var old) && old.At >= at) return; history[e.Target] = new(e, at); } - static void Loop() { try @@ -82,14 +63,20 @@ static void Loop() foreach (var item in JsonSerializer.Deserialize(File.ReadAllText(file)) ?? Array.Empty()) Put(item.Entry, item.At); } - catch { } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + Store.Log("DesktopRecents.Loop : " + ex.GetType().Name + ": " + ex.Message); + } object? shell = null; var shortcuts = new Dictionary(); try { shell = Activator.CreateInstance(Type.GetTypeFromProgID("WScript.Shell")!); } - catch { } + catch (Exception ex) + { + Store.Log("DesktopRecents.Loop : " + ex.GetType().Name + ": " + ex.Message); + } while (true) { bool changed = false; @@ -98,43 +85,30 @@ static void Loop() { using var process = System.Diagnostics.Process.GetProcessById((int)item.pid); string path = process.MainModule?.FileName ?? ""; - if ( - path.StartsWith( - Path.GetFullPath(Path.Combine(Store.Root, "..")) + Path.DirectorySeparatorChar, - StringComparison.OrdinalIgnoreCase - ) - || !File.Exists(path) - || path.StartsWith( - Environment.GetFolderPath(Environment.SpecialFolder.Windows) + Path.DirectorySeparatorChar, - StringComparison.OrdinalIgnoreCase - ) - ) + if (path.StartsWith(Path.GetFullPath(Path.Combine(Store.Root, "..")) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase) || !File.Exists(path) + || path.StartsWith(Environment.GetFolderPath(Environment.SpecialFolder.Windows) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase)) continue; - Put( - new() - { - Id = "recent:" + path, - Name = - System.Diagnostics.FileVersionInfo.GetVersionInfo(path).FileDescription - ?? process.ProcessName, - Target = path, - }, - item.at - ); + Put(new() + { + Id = "recent:" + path, + Name = System.Diagnostics.FileVersionInfo.GetVersionInfo(path).FileDescription ?? process.ProcessName, + Target = path + }, item.at); changed = true; } - catch { } + catch (Exception) + { + // Processus fermé ou inaccessible : ignoré. + } if (DateTime.UtcNow >= nextScan) { nextScan = DateTime.UtcNow.AddSeconds(15); try { - foreach ( - string linkPath in Directory - .EnumerateFiles(Environment.GetFolderPath(Environment.SpecialFolder.Recent), "*.lnk") - .OrderByDescending(File.GetLastWriteTimeUtc) - .Take(64) - ) + foreach (string linkPath in Directory.EnumerateFiles(Environment.GetFolderPath(Environment.SpecialFolder.Recent), + "*.lnk").OrderByDescending(File.GetLastWriteTimeUtc).Take(64)) { DateTime at = File.GetLastWriteTimeUtc(linkPath); string target = ""; @@ -149,31 +123,30 @@ string linkPath in Directory target = ((dynamic)link).TargetPath; shortcuts[linkPath] = (at, target); } - catch { } - finally + catch (Exception) { - if (link != null) - Marshal.FinalReleaseComObject(link); + // Raccourci illisible : ignoré. } + finally { if (link != null) Marshal.FinalReleaseComObject(link); } } if (!string.IsNullOrEmpty(target) && (File.Exists(target) || Directory.Exists(target))) { - Put( - new() - { - Id = "recent:" + target, - Name = Path.GetFileNameWithoutExtension(linkPath), - Target = target, - }, - at - ); + Put(new() + { + Id = "recent:" + target, + Name = Path.GetFileNameWithoutExtension(linkPath), + Target = target + }, at); changed = true; } } if (shortcuts.Count > 128) shortcuts.Clear(); } - catch { } + catch (Exception) + { + // Raccourci récent illisible : ignoré. + } } if (changed) { @@ -186,27 +159,24 @@ string linkPath in Directory Entries.Clear(); foreach (var item in ordered) Entries[item.Entry.Id] = item.Entry; - values = ordered - .Take(24) - .Select(r => new - { - id = r.Entry.Id, - name = r.Entry.Name, - kind = "systemRecent", - icon = Directory.Exists(r.Entry.Target) ? "folder" : "apps", - comment = r.Entry.Target, - at = r.At, - }) - .ToArray(); + values = ordered.Take(24).Select(r => new + { + id = r.Entry.Id, + name = r.Entry.Name, + kind = "systemRecent", + icon = Directory.Exists(r.Entry.Target) ? "folder" : "apps", + comment = r.Entry.Target, + at = r.At + }).ToArray(); } try { Directory.CreateDirectory(Path.GetDirectoryName(file)!); Winlestia.Shared.AtomicFile.WriteAllText(file, JsonSerializer.Serialize(ordered)); } - catch (Exception ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { - Store.Log("Desktop recents write: " + ex.Message); + Store.Log("DesktopRecents.Loop : " + ex.GetType().Name + ": " + ex.Message); } } wake.WaitOne(2000); @@ -216,11 +186,6 @@ string linkPath in Directory internal static class RecentOrdering { - internal static DesktopRecents.Recent[] Sort(IEnumerable source) => - source - .GroupBy(r => r.Entry.Target, StringComparer.OrdinalIgnoreCase) - .Select(g => g.MaxBy(r => r.At)!) - .OrderByDescending(r => r.At) - .Take(64) - .ToArray(); + internal static DesktopRecents.Recent[] Sort(IEnumerable source) => source.GroupBy(r => r.Entry.Target, + StringComparer.OrdinalIgnoreCase).Select(g => g.MaxBy(r => r.At)!).OrderByDescending(r => r.At).Take(64).ToArray(); } diff --git a/src/backend/Desktop/DesktopView.cs b/src/backend/Desktop/DesktopView.cs new file mode 100644 index 0000000..f2b1fb6 --- /dev/null +++ b/src/backend/Desktop/DesktopView.cs @@ -0,0 +1,63 @@ +// État réel des icônes du Bureau, lu sur la fenêtre d'Explorer (SysListView32 sous SHELLDLL_DefView). +// HideIcons dans le registre ne suffit pas : Explorer peut garder les icônes affichées malgré FWF_NOICONS (vu le 2026-11-06). +// Repli : la commande native « Afficher les icônes du Bureau » (WM_COMMAND 0x7402), qui bascule l'état réel. +using System; +using System.Runtime.InteropServices; +using System.Threading; + +namespace PanneauxGlass; + +internal static class DesktopView +{ + // Laisse l'Explorateur appliquer la bascule des icônes avant de relire leur état. + const int IconToggleSettleMs = 250; + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string? cls, + string? name); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr hwnd); + [DllImport("user32.dll")] + static extern IntPtr SendMessageTimeout(IntPtr hwnd, uint msg, IntPtr w, IntPtr l, uint flags, uint timeout, + out IntPtr result); + + static IntPtr DefView() + { + foreach (var cls in new[] { "Progman", "WorkerW" }) + { + IntPtr w = IntPtr.Zero; + while ((w = FindWindowEx(IntPtr.Zero, w, cls, null)) != IntPtr.Zero) + { + IntPtr dv = FindWindowEx(w, IntPtr.Zero, "SHELLDLL_DefView", null); + if (dv != IntPtr.Zero) + return dv; + } + } + return IntPtr.Zero; + } + + // null : Bureau introuvable (Explorer absent ou en redémarrage). + internal static bool? IconsVisible() + { + IntPtr dv = DefView(); + if (dv == IntPtr.Zero) + return null; + IntPtr lv = FindWindowEx(dv, IntPtr.Zero, "SysListView32", null); + return lv != IntPtr.Zero && IsWindowVisible(lv); + } + + // Aligne l'état réel sur `hidden`. Renvoie true si l'état final correspond (ou si le Bureau est introuvable). + internal static bool Enforce(bool hidden) + { + for (int attempt = 0; attempt < 2; attempt++) + { + var visible = IconsVisible(); + if (visible == null) + return true; + if (visible.Value != hidden) + return true; + SendMessageTimeout(DefView(), 0x0111, (IntPtr)0x7402, IntPtr.Zero, 0x0002, 1000, out _); + Thread.Sleep(IconToggleSettleMs); + } + var final = IconsVisible(); + return final == null || final.Value != hidden; + } +} diff --git a/src/backend/Desktop/FramePolicy.cs b/src/backend/Desktop/FramePolicy.cs deleted file mode 100644 index f8cbd21..0000000 --- a/src/backend/Desktop/FramePolicy.cs +++ /dev/null @@ -1,28 +0,0 @@ -// C#12 / FramePolicy.cs: pure decisions for the accent border (all windows, or only the active one). -using System; - -namespace PanneauxGlass.Desktop; - -internal static class FramePolicy -{ - // Does an ordinary window (not a themed Explorer window) carry the accent border? - internal static bool Carries(bool accentBorders, bool activeOnly, IntPtr window, IntPtr active) => - accentBorders && (!activeOnly || window == active); - - // Foreground change: window to colour and previous window to restore (Zero = none), plus the window tracked from now on. - // An ineligible foreground (shell panel, tool or hidden window) leaves everything as it was. - internal static (IntPtr colour, IntPtr restore, IntPtr tracked) Foreground( - bool accentBorders, - bool activeOnly, - bool eligible, - IntPtr foreground, - IntPtr last - ) - { - if (!accentBorders || !activeOnly) - return (IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); - if (!eligible || foreground == IntPtr.Zero || foreground == last) - return (IntPtr.Zero, IntPtr.Zero, last); - return (foreground, last, foreground); - } -} diff --git a/src/backend/Desktop/FullscreenWatcher.cs b/src/backend/Desktop/FullscreenWatcher.cs deleted file mode 100644 index d683eaa..0000000 --- a/src/backend/Desktop/FullscreenWatcher.cs +++ /dev/null @@ -1,635 +0,0 @@ -// Which monitors currently show a fullscreen foreground window (YouTube fullscreen, video player, borderless game): the shell chrome must hide there. -// Event-driven: SetWinEventHook (WINEVENT_OUTOFCONTEXT) for EVENT_SYSTEM_FOREGROUND, MINIMIZESTART and EVENT_OBJECT_LOCATIONCHANGE (foreground process) arms a one-shot 40 ms debounce timer (re-arming = debounce) -// plus a settle chain of re-checks (see SettleDelays); while a monitor is fullscreen a 1 s safety-net timer also re-checks. The recompute runs on a thread-pool thread. -// OUTOFCONTEXT hooks deliver through the message loop of the thread that installed them: they live on a dedicated hook thread blocked in GetMessage; Start() may be called from any thread. -// Fullscreen = foreground, visible, non-iconic window (not ours, not shell windows) whose DWM frame or window rect covers its whole monitor and which has no caption (a maximized window keeps its caption). -// Optionally QUNS_RUNNING_D3D_FULL_SCREEN (exclusive D3D) is mapped to the foreground window's monitor. Current = monitor device names (\\.\DISPLAY1), raised through Changed only when the set changes. -using System; -using System.Linq; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace PanneauxGlass.Desktop; - -internal static class FullscreenWatcher -{ - static readonly object gate = new(); - static readonly object computeGate = new(); - static Timer? debounce, - settle, - watch; - - // Browsers enter fullscreen seconds after the events that announced it (observed up to ~5 s with Edge, no event at the end): settle re-checks at +1.5 s, +3 s, +6 s after the last event. - static readonly int[] SettleDelays = { 300, 600, 1000, 1500, 3000, 6000 }; - const int SafetyNetMs = 1000; // only armed while a monitor is fullscreen; events (foreground, minimize, location) drive the normal exit - static int settleStep; - static bool started; - - // Hook handles belong to the hook thread that created them (thread-static: an old thread's tail never touches a newer run's handles). - [ThreadStatic] - static IntPtr hookForeground, - hookLocation, - hookMinimize; - - [ThreadStatic] - static int myGen; // generation of the run this hook thread belongs to - static int gen; // incremented by Start, guarded by gate - static WinEventProc? proc; // kept alive for the lifetime of the hooks - static volatile string[] current = Array.Empty(); - internal static string[] Current => current; - - // Raised (thread-pool thread, outside any lock) only when the set changes; subscribers must not throw (exceptions are swallowed). - internal static event Action? Changed; - - // Extra process ids to ignore (frontend / backend helpers); this process is always ignored. - internal static Func? IgnoredPids; - - // Test hook: also consider windows of this process. - internal static bool IncludeOwnProcessForTest; - - delegate void WinEventProc(IntPtr hook, uint evt, IntPtr hwnd, int idObject, int idChild, uint thread, uint time); - - // Out-of-context WinEvent callbacks are delivered only while the hooking thread pumps messages, so the hooks live on a - // dedicated thread blocked in GetMessage (no CPU when idle). Hooking from a non-pumping thread delayed events by seconds. - static Thread? hookThread; - static uint hookThreadId; - - internal static void Start() - { - var ready = new ManualResetEventSlim(false); - lock (gate) - { - if (started) - return; - started = true; - debounce = new Timer(_ => Recompute(), null, Timeout.Infinite, Timeout.Infinite); - settle = new Timer(_ => Settle(), null, Timeout.Infinite, Timeout.Infinite); - watch = new Timer(_ => Recompute(), null, Timeout.Infinite, Timeout.Infinite); - proc = OnEvent; - int g = ++gen; - hookThread = new Thread(() => HookLoop(ready, g)) { IsBackground = true, Name = "FullscreenWatcher" }; - hookThread.Start(); - } - ready.Wait(2000); - Task.Run(Recompute); - } - - static void HookLoop(ManualResetEventSlim ready, int g) - { - PeekMessageW(out MSG m, IntPtr.Zero, 0, 0, 0); // creates this thread's message queue before Stop can post to it - myGen = g; - lock (gate) - { - if (!started || proc == null || gen != g) - { - ready.Set(); - return; - } - hookThreadId = GetCurrentThreadId(); - hookForeground = SetWinEventHook( - EVENT_SYSTEM_FOREGROUND, - EVENT_SYSTEM_FOREGROUND, - IntPtr.Zero, - proc, - 0, - 0, - WINEVENT_OUTOFCONTEXT - ); - hookMinimize = SetWinEventHook( - EVENT_SYSTEM_MINIMIZESTART, - EVENT_SYSTEM_MINIMIZESTART, - IntPtr.Zero, - proc, - 0, - 0, - WINEVENT_OUTOFCONTEXT - ); - RehookLocation(GetForegroundWindow()); - } - ready.Set(); - while (GetMessageW(out m, IntPtr.Zero, 0, 0) > 0) - { - if (m.hwnd == IntPtr.Zero && m.message == WM_APP_REHOOK) - { - lock (gate) - { - if (started && gen == g) - RehookLocation(GetForegroundWindow()); - } - continue; - } - TranslateMessage(ref m); - DispatchMessageW(ref m); - } - // Always unhook our own handles; only touch the shared statics if no newer run has taken over. - if (hookForeground != IntPtr.Zero) - UnhookWinEvent(hookForeground); - if (hookMinimize != IntPtr.Zero) - UnhookWinEvent(hookMinimize); - if (hookLocation != IntPtr.Zero) - UnhookWinEvent(hookLocation); - hookForeground = hookMinimize = hookLocation = IntPtr.Zero; - lock (gate) - { - if (gen == g) - { - hookThreadId = 0; - hookedPid = 0; - } - } - } - - internal static void Stop() - { - Thread? t; - lock (gate) - { - if (!started) - return; - started = false; - if (hookThreadId != 0) - PostThreadMessageW(hookThreadId, WM_QUIT, IntPtr.Zero, IntPtr.Zero); - t = hookThread; - hookThread = null; - debounce?.Dispose(); - debounce = null; - settle?.Dispose(); - settle = null; - watch?.Dispose(); - watch = null; - } - bool joined = t?.Join(2000) ?? true; // the hook thread unhooks itself (UnhookWinEvent belongs to the installing thread) - lock (gate) - { - if (!started && joined) - proc = null; - } // on timeout the hooks may still be alive: keep the delegate rooted - lock (computeGate) - { - current = Array.Empty(); - } // waits for an in-flight Recompute; SetSet re-checks `started`. No Changed: subscribers are being torn down. - } - - // LOCATIONCHANGE is scoped to the foreground window's process so mouse-cursor moves elsewhere never wake us. - // Hook thread only, caller holds gate. `target` is the window the FOREGROUND event named: GetForegroundWindow() can - // still return the previous window when the event is processed (seen with a new Edge window), which left the hook on the wrong process. - static uint hookedPid; - const uint WM_APP_REHOOK = 0x8000 + 0x46; - - static void RehookLocation(IntPtr target) - { - if (target == IntPtr.Zero || proc == null || myGen != gen) - return; - GetWindowThreadProcessId( - GetAncestor(target, GA_ROOTOWNER) is var r && r != IntPtr.Zero ? r : target, - out uint pid - ); - if (pid == 0 || (pid == hookedPid && hookLocation != IntPtr.Zero)) - return; - if (hookLocation != IntPtr.Zero) - { - UnhookWinEvent(hookLocation); - hookLocation = IntPtr.Zero; - } - hookedPid = pid; - hookLocation = SetWinEventHook( - EVENT_OBJECT_LOCATIONCHANGE, - EVENT_OBJECT_LOCATIONCHANGE, - IntPtr.Zero, - proc, - pid, - 0, - WINEVENT_OUTOFCONTEXT - ); // whole process: browsers resize from another thread - } - - static void OnEvent(IntPtr hook, uint evt, IntPtr hwnd, int idObject, int idChild, uint thread, uint time) - { - if ( - evt == EVENT_OBJECT_LOCATIONCHANGE - && (idObject != OBJID_WINDOW || idChild != 0 || hwnd == IntPtr.Zero || GetAncestor(hwnd, GA_ROOT) != hwnd) - ) - return; - lock (gate) - { - if (!started) - return; - if (evt == EVENT_SYSTEM_FOREGROUND) - RehookLocation(hwnd); - debounce?.Change(40, Timeout.Infinite); - settleStep = 0; - settle?.Change(SettleDelays[0], Timeout.Infinite); - } - } - - static void Settle() - { - Recompute(); - lock (gate) - { - if (started && settle != null && ++settleStep < SettleDelays.Length) - settle.Change(SettleDelays[settleStep] - SettleDelays[settleStep - 1], Timeout.Infinite); - } - } - - static void Recompute() - { - lock (gate) - { - if (!started) - return; - } - bool changed = false; - lock (computeGate) - { // serialized: an older computation can never overwrite a newer one - try - { - changed = SetSet(Compute()); - } - catch - { /* keep the previous value */ - } - } - // While a monitor is fullscreen, a slow safety net (SafetyNetMs) catches an exit no event announced, e.g. a crash (no cost otherwise). - lock (gate) - { - if (!started) - return; - watch?.Change(current.Length > 0 ? SafetyNetMs : Timeout.Infinite, Timeout.Infinite); - // Self-heal: if the location hook is not on the active window's process, ask the hook thread to move it. - if (hookThreadId != 0 && ForegroundRootPid() is var fpid && fpid != 0 && fpid != hookedPid) - PostThreadMessageW(hookThreadId, WM_APP_REHOOK, IntPtr.Zero, IntPtr.Zero); - } - if (changed) - { - try - { - Changed?.Invoke(); - } - catch { } - } - } - - static uint ForegroundRootPid() - { - var f = GetForegroundWindow(); - if (f == IntPtr.Zero) - return 0; - var r = GetAncestor(f, GA_ROOTOWNER); - GetWindowThreadProcessId(r != IntPtr.Zero ? r : f, out uint pid); - return pid; - } - - // Stores a new set; true when it differs (order-insensitive). Refuses once stopped. - static bool SetSet(string[] names) - { - lock (gate) - { - if (!started) - return false; - } - var sorted = names.Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray(); - if (sorted.SequenceEqual(current)) - return false; - current = sorted; - return true; - } - - internal static void RaiseChangedForTest(string[] names) - { - if (SetSet(names)) - { - try - { - Changed?.Invoke(); - } - catch { } - } - } - - // Public for the probe test. The active (foreground) window decides its own monitor: fullscreen only if it fills it. - // Other monitors: the topmost eligible window (EnumWindows is z-ordered) covering at least half of the monitor decides. - internal static string[] Compute() - { - uint[]? ignored = null; // snapshot once: the delegate must not run per window while computeGate is held - try - { - ignored = IgnoredPids?.Invoke(); - } - catch { } - var decided = new System.Collections.Generic.Dictionary(); - var fg = GetForegroundWindow(); - if (fg != IntPtr.Zero) - { - var root = GetAncestor(fg, GA_ROOTOWNER); - if (root != IntPtr.Zero) - fg = root; // a menu/popup counts as its owner - if (Eligible(fg, ignored) && !IsOverlay(fg)) - { - var mon = MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST); - var info = new MONITORINFOEX { cbSize = Marshal.SizeOf() }; - if (mon != IntPtr.Zero && GetMonitorInfoW(mon, ref info)) - decided[mon] = IsFullscreen(fg, info.rcMonitor) ? info.szDevice : null; - } - } - EnumWindows( - (h, _) => - { - if (!Eligible(h, ignored) || IsOverlay(h)) - return true; - var mon = MonitorFromWindow(h, MONITOR_DEFAULTTONEAREST); - if (mon == IntPtr.Zero || decided.ContainsKey(mon)) - return true; - var info = new MONITORINFOEX { cbSize = Marshal.SizeOf() }; - if (!GetMonitorInfoW(mon, ref info)) - return true; - // Small windows floating above (fullscreen "press Esc" bubble, tooltips, PiP, 1x1 helpers, game/chat overlays) - // must not decide the monitor: only a window covering at least half of it does. - if (!CoversHalf(h, info.rcMonitor)) - return true; - decided[mon] = IsFullscreen(h, info.rcMonitor) ? info.szDevice : null; - return true; - }, - IntPtr.Zero - ); - var result = decided.Values.Where(v => v != null).Select(v => v!).ToList(); - fg = GetForegroundWindow(); - if ( - fg != IntPtr.Zero - && SHQueryUserNotificationState(out int state) == 0 - && state == QUNS_RUNNING_D3D_FULL_SCREEN - && Eligible(fg, ignored) - ) - { - var mon = MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST); - var info = new MONITORINFOEX { cbSize = Marshal.SizeOf() }; - if (mon != IntPtr.Zero && GetMonitorInfoW(mon, ref info)) - result.Add(info.szDevice); - } - return result.ToArray(); - } - - static bool Eligible(IntPtr hwnd, uint[]? ignored) - { - if (!IsWindowVisible(hwnd) || IsIconic(hwnd)) - return false; - if (DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, out int cloaked, sizeof(int)) == 0 && cloaked != 0) - return false; - GetWindowThreadProcessId(hwnd, out uint pid); - if (!IncludeOwnProcessForTest && pid == (uint)Environment.ProcessId) - return false; - if (ignored != null && ignored.Contains(pid)) - return false; - var sb = new StringBuilder(128); - GetClassName(hwnd, sb, 128); - var cls = sb.ToString(); - if ( - cls - is "Progman" - or "WorkerW" - or "Shell_TrayWnd" - or "Shell_SecondaryTrayWnd" - or "Windows.UI.Core.CoreWindow" - or "MultitaskingViewFrame" - or "XamlExplorerHostIslandWindow" - or "ForegroundStaging" - or "TaskSwitcherWnd" - ) - return false; - if ( - cls.Contains("LockScreen", StringComparison.OrdinalIgnoreCase) - || cls.Contains("LockApp", StringComparison.OrdinalIgnoreCase) - ) - return false; - if (cls != "CabinetWClass" && IsExplorer(pid)) - return false; - return true; - } - - static readonly System.Collections.Generic.Dictionary explorerCache = new(); - - static bool IsExplorer(uint pid) - { - lock (explorerCache) - { - if (explorerCache.TryGetValue(pid, out bool v)) - return v; - bool r = false; - var h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid); - if (h != IntPtr.Zero) - { - try - { - var b = new StringBuilder(520); - int n = b.Capacity; - if (QueryFullProcessImageName(h, 0, b, ref n)) - r = string.Equals( - System.IO.Path.GetFileName(b.ToString()), - "explorer.exe", - StringComparison.OrdinalIgnoreCase - ); - } - finally - { - CloseHandle(h); - } - } - if (explorerCache.Count > 256) - explorerCache.Clear(); - explorerCache[pid] = r; - return r; - } - } - - // Click-through overlays (layered + transparent) never count, even when they span the screen. - static bool IsOverlay(IntPtr hwnd) - { - long ex = GetWindowLongPtr(hwnd, GWL_EXSTYLE).ToInt64(); - // e.g. Cua.AgentCursorOverlay (layered+transparent), NVIDIA CEF-OSC-WIDGET (layered+noactivate): screen-sized, never an app. - return (ex & WS_EX_LAYERED) != 0 && (ex & (WS_EX_TRANSPARENT | WS_EX_NOACTIVATE)) != 0; - } - - static bool CoversHalf(IntPtr hwnd, RECT m) - { - if ( - DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, out RECT r, Marshal.SizeOf()) != 0 - && !GetWindowRect(hwnd, out r) - ) - return false; - long w = Math.Min(r.Right, m.Right) - Math.Max(r.Left, m.Left), - h = Math.Min(r.Bottom, m.Bottom) - Math.Max(r.Top, m.Top); - if (w <= 0 || h <= 0) - return false; - long area = (long)(m.Right - m.Left) * (m.Bottom - m.Top); - return area > 0 && w * h * 2 >= area; - } - - static bool IsFullscreen(IntPtr hwnd, RECT m) - { - bool full; - if (DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, out RECT d, Marshal.SizeOf()) == 0) - full = Covers(d, m); // DWM bounds are authoritative: a maximized window's outer rect overhangs - else - full = GetWindowRect(hwnd, out RECT r) && Covers(r, m); - if (!full) - return false; - uint style = unchecked((uint)GetWindowLongPtr(hwnd, GWL_STYLE).ToInt64()); - if ((style & WS_CAPTION) == WS_CAPTION) - return false; // maximized, not fullscreen - // Frameless maximized window (e.g. Electron) next to an auto-hidden taskbar: zoomed, unless a pure WS_POPUP (borderless game). - if (IsZoomed(hwnd) && !((style & WS_POPUP) != 0 && (style & WS_THICKFRAME) == 0)) - return false; - return true; - } - - // Chromium fullscreen leaves the bottom row free (3440x1439 on a 3440x1440 monitor): tolerate a few px. - static bool Covers(RECT r, RECT m) => - QuietPolicy.Covers(r.Left, r.Top, r.Right, r.Bottom, m.Left, m.Top, m.Right, m.Bottom); - - const uint EVENT_SYSTEM_FOREGROUND = 3, - EVENT_SYSTEM_MINIMIZESTART = 0x16, - PROCESS_QUERY_LIMITED_INFORMATION = 0x1000, - EVENT_OBJECT_LOCATIONCHANGE = 0x800B, - WINEVENT_OUTOFCONTEXT = 0; - const int OBJID_WINDOW = 0, - GWL_STYLE = -16, - DWMWA_CLOAKED = 14, - DWMWA_EXTENDED_FRAME_BOUNDS = 9, - QUNS_RUNNING_D3D_FULL_SCREEN = 3; - const uint WS_CAPTION = 0xC00000, - WS_POPUP = 0x80000000, - WS_THICKFRAME = 0x40000; - const long WS_EX_LAYERED = 0x80000, - WS_EX_TRANSPARENT = 0x20, - WS_EX_NOACTIVATE = 0x8000000; - const int GWL_EXSTYLE = -20; - const uint GA_ROOT = 2, - GA_ROOTOWNER = 3; - const uint WM_QUIT = 0x12; - - [StructLayout(LayoutKind.Sequential)] - struct MSG - { - public IntPtr hwnd; - public uint message; - public IntPtr wParam, - lParam; - public uint time; - public int ptX, - ptY, - lPrivate; - } - - [DllImport("user32.dll")] - static extern int GetMessageW(out MSG m, IntPtr h, uint min, uint max); - - [DllImport("user32.dll")] - static extern bool PeekMessageW(out MSG m, IntPtr h, uint min, uint max, uint remove); - - [DllImport("user32.dll")] - static extern bool TranslateMessage(ref MSG m); - - [DllImport("user32.dll")] - static extern IntPtr DispatchMessageW(ref MSG m); - - [DllImport("user32.dll")] - static extern bool PostThreadMessageW(uint tid, uint msg, IntPtr w, IntPtr l); - - [DllImport("kernel32.dll")] - static extern uint GetCurrentThreadId(); - - [DllImport("user32.dll")] - static extern IntPtr GetAncestor(IntPtr h, uint flags); - - const uint MONITOR_DEFAULTTONEAREST = 2; - - [StructLayout(LayoutKind.Sequential)] - struct RECT - { - public int Left, - Top, - Right, - Bottom; - } - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - struct MONITORINFOEX - { - public int cbSize; - public RECT rcMonitor, - rcWork; - public uint dwFlags; - - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] - public string szDevice; - } - - [DllImport("user32.dll")] - static extern IntPtr SetWinEventHook( - uint min, - uint max, - IntPtr mod, - WinEventProc proc, - uint pid, - uint thread, - uint flags - ); - - [DllImport("user32.dll")] - static extern bool UnhookWinEvent(IntPtr hook); - - [DllImport("user32.dll")] - static extern IntPtr GetForegroundWindow(); - - [DllImport("user32.dll")] - static extern bool IsWindowVisible(IntPtr h); - - [DllImport("user32.dll")] - static extern bool IsIconic(IntPtr h); - - [DllImport("user32.dll")] - static extern bool IsZoomed(IntPtr h); - - [DllImport("user32.dll")] - static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); - - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - static extern int GetClassName(IntPtr h, StringBuilder s, int n); - - [DllImport("user32.dll")] - static extern IntPtr MonitorFromWindow(IntPtr h, uint flags); - - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - static extern bool GetMonitorInfoW(IntPtr mon, ref MONITORINFOEX info); - - [DllImport("user32.dll")] - static extern bool GetWindowRect(IntPtr h, out RECT r); - - [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] - static extern IntPtr GetWindowLongPtr(IntPtr h, int index); - - [DllImport("dwmapi.dll")] - static extern int DwmGetWindowAttribute(IntPtr h, int attr, out RECT value, int size); - - [DllImport("dwmapi.dll")] - static extern int DwmGetWindowAttribute(IntPtr h, int attr, out int value, int size); - - delegate bool EnumProc(IntPtr h, IntPtr l); - - [DllImport("user32.dll")] - static extern bool EnumWindows(EnumProc cb, IntPtr l); - - [DllImport("kernel32.dll")] - static extern IntPtr OpenProcess(uint access, bool inherit, uint pid); - - [DllImport("kernel32.dll")] - static extern bool CloseHandle(IntPtr h); - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] - static extern bool QueryFullProcessImageName(IntPtr h, uint flags, StringBuilder s, ref int n); - - [DllImport("shell32.dll")] - static extern int SHQueryUserNotificationState(out int state); -} diff --git a/src/backend/Desktop/VirtualDesktops.cs b/src/backend/Desktop/VirtualDesktops.cs index 7ae897d..63eb310 100644 --- a/src/backend/Desktop/VirtualDesktops.cs +++ b/src/backend/Desktop/VirtualDesktops.cs @@ -1,8 +1,10 @@ -// C# 12 / VirtualDesktops.cs / Windows 11. Virtual desktop indicator support WITHOUT any undocumented COM interface. -// Read: HKCU registry only (VirtualDesktopIDs, CurrentVirtualDesktop - per-session key first, general key as fallback, optional Desktops\{GUID}\Name). -// Changes: no registry notification (Windows 11 does not signal RegNotifyChangeKeyValue for these writes): out-of-context WinEvent hooks (foreground, cloak/uncloak) -// on one dedicated message-loop thread re-read the registry 150 ms and 900 ms after the last event. At rest the thread blocks in GetMessage: no timer, no polling, no wakeup. Stop() posts WM_QUIT and joins it. -// Switch: only when asked by a user gesture; Win+Ctrl+Left/Right through SendInput, repeated by the gap between current and target. Modifiers are always released. +// C# 12 / VirtualDesktops.cs / Windows 11. Prise en charge de l'indicateur de bureaux virtuels SANS aucune interface COM non documentée. +// Lecture : registre HKCU uniquement (VirtualDesktopIDs, CurrentVirtualDesktop - clé par session d'abord, clé générale en repli, +// Desktops\{GUID}\Name facultatif). Changements : pas de notification du registre (Windows 11 ne signale pas RegNotifyChangeKeyValue pour +// ces écritures) : des hooks WinEvent hors contexte (premier plan, cloak/uncloak) sur un thread dédié à boucle de messages relisent le +// registre 150 ms et 900 ms après le dernier événement. Au repos le thread reste bloqué dans GetMessage : ni minuteur, ni sondage, ni +// réveil. Stop() poste WM_QUIT et le joint. Bascule : seulement sur geste de l'utilisateur ; Win+Ctrl+Gauche/Droite via SendInput, +// répété selon l'écart entre le bureau courant et la cible. Les modificateurs sont toujours relâchés. using System; using System.Collections.Generic; using System.Diagnostics; @@ -11,27 +13,25 @@ using System.Threading; using Microsoft.Win32; -namespace PanneauxGlass.Desktop; +namespace PanneauxGlass; internal static class VirtualDesktops { internal sealed record Snapshot(int Count, int Current, string[] Names); - const string Explorer = @"Software\Microsoft\Windows\CurrentVersion\Explorer"; const string General = Explorer + @"\VirtualDesktops"; static readonly object gate = new(); - - // Serializes user gestures (switch / new / close) so two quick actions never interleave. Separate from gate: Refresh must stay possible while Close waits. + // Sérialise les gestes de l'utilisateur (bascule / création / fermeture) pour que deux actions rapides ne s'entremêlent jamais. + // Distinct de gate : Refresh doit rester possible pendant que Close attend. static readonly object actionGate = new(); static volatile Snapshot snapshot = new(0, 0, Array.Empty()); internal static Snapshot Current => snapshot; - - // Raised on the watcher thread only when the snapshot differs; subscribers must not throw (swallowed). + // Levé sur le thread du watcher seulement quand l'instantané change ; les abonnés ne doivent pas lever d'exception (avalée). internal static event Action? Changed; static Thread? worker; static uint workerId; - static WinEventProc? proc; // static: the GC must never collect the hook delegate + static WinEventProc? proc; // statique : le GC ne doit jamais collecter le délégué du hook internal static void Start() { @@ -65,21 +65,21 @@ internal static void Stop() PostThreadMessageW(id, WM_QUIT, IntPtr.Zero, IntPtr.Zero); t.Join(2000); } - catch { } + catch (Exception) + { + // Arrêt au mieux : le fil a pu se terminer seul. + } } static string SessionRoot => Explorer + @"\SessionInfo\" + Process.GetCurrentProcess().SessionId; static string SessionKey => SessionRoot + @"\VirtualDesktops"; - // Hook thread state (touched only by the watcher thread: WinEvent callbacks run inside its DispatchMessage). - [ThreadStatic] - static long lastEventTick; - - [ThreadStatic] - static int step; // 0 idle, 1 waiting for the 150 ms re-read, 2 waiting for the 900 ms re-read - - [ThreadStatic] - static uint timerId; // a thread timer (no hwnd) gets its id FROM Windows: the requested id only selects an existing timer to replace + // État du thread de hook (touché uniquement par le thread du watcher : les callbacks WinEvent s'exécutent dans son + // DispatchMessage). + [ThreadStatic] static long lastEventTick; + [ThreadStatic] static int step; // 0 repos, 1 attente de la relecture à 150 ms, 2 attente de la relecture à 900 ms + // un minuteur de thread (sans hwnd) reçoit son id de Windows : l'id demandé ne sert qu'à choisir un minuteur existant à remplacer + [ThreadStatic] static uint timerId; static void Arm(uint ms) { @@ -87,7 +87,6 @@ static void Arm(uint ms) if (id != UIntPtr.Zero) timerId = (uint)id; } - static void Disarm() { if (timerId != 0) @@ -97,9 +96,8 @@ static void Disarm() static void Run(ManualResetEventSlim ready) { - PeekMessageW(out MSG m, IntPtr.Zero, 0, 0, 0); // creates this thread's message queue before Stop can post to it - IntPtr hFg = IntPtr.Zero, - hCloak = IntPtr.Zero; + PeekMessageW(out MSG m, IntPtr.Zero, 0, 0, 0); // crée la file de messages de ce thread avant que Stop puisse y poster + IntPtr hFg = IntPtr.Zero, hCloak = IntPtr.Zero; try { lock (gate) @@ -107,26 +105,12 @@ static void Run(ManualResetEventSlim ready) if (worker == null) return; workerId = GetCurrentThreadId(); - } // Stop already ran: leave + } // Stop already ran: leave proc = OnEvent; - hFg = SetWinEventHook( - EVENT_SYSTEM_FOREGROUND, - EVENT_SYSTEM_FOREGROUND, - IntPtr.Zero, - proc, - 0, - 0, - WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS - ); - hCloak = SetWinEventHook( - EVENT_OBJECT_CLOAKED, - EVENT_OBJECT_UNCLOAKED, - IntPtr.Zero, - proc, - 0, - 0, - WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS - ); + hFg = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, IntPtr.Zero, proc, 0, 0, + WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); + hCloak = SetWinEventHook(EVENT_OBJECT_CLOAKED, EVENT_OBJECT_UNCLOAKED, IntPtr.Zero, proc, 0, 0, + WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); ready.Set(); Refresh(); while (GetMessageW(out m, IntPtr.Zero, 0, 0) > 0) @@ -140,7 +124,10 @@ static void Run(ManualResetEventSlim ready) DispatchMessageW(ref m); } } - catch { } + catch (Exception) + { + // Fil de messages : l'arrêt est géré par le finally, rien d'autre à rapporter. + } finally { Disarm(); @@ -158,7 +145,7 @@ static void OnEvent(IntPtr hook, uint evt, IntPtr hwnd, int idObject, int idChil return; lastEventTick = Environment.TickCount64; step = 1; - Arm(150); // passing the live id replaces the pending timer + Arm(150); // passer l'id vivant remplace le minuteur en attente } static void OnTimer() @@ -179,22 +166,21 @@ static void OnTimer() { step = 0; Disarm(); - } // idle again: nothing armed + } // idle again: nothing armed } static void Refresh() { lock (gate) - { // watcher thread and SwitchTo's caller both land here: a slow read must not overwrite a newer one + // le thread de surveillance et l'appelant de SwitchTo arrivent ici tous deux : une lecture lente ne doit pas écraser une plus + // récente + { Snapshot next; try { next = Read(); } - catch - { - next = new Snapshot(0, 0, Array.Empty()); - } + catch { next = new Snapshot(0, 0, Array.Empty()); } var old = snapshot; if (old.Count == next.Count && old.Current == next.Current && old.Names.SequenceEqual(next.Names)) return; @@ -204,7 +190,10 @@ static void Refresh() { Changed?.Invoke(); } - catch { } + catch (Exception) + { + // Un abonné défaillant ne doit pas arrêter la veille des bureaux. + } } internal static Snapshot Read() @@ -217,25 +206,16 @@ internal static Snapshot Read() using (var session = Registry.CurrentUser.OpenSubKey(SessionKey, false)) cur = session?.GetValue("CurrentVirtualDesktop") as byte[]; cur ??= general.GetValue("CurrentVirtualDesktop") as byte[]; - int index = - ids.Count == 0 || cur is not { Length: 16 } - ? 0 - : Math.Max(0, ids.FindIndex(i => i.AsSpan().SequenceEqual(cur))); + int index = ids.Count == 0 || cur is not { Length: 16 } ? 0 : Math.Max(0, ids.FindIndex(i => i.AsSpan().SequenceEqual(cur))); var names = new string[ids.Count]; for (int i = 0; i < ids.Count; i++) { try { - using var d = general.OpenSubKey( - @"Desktops\{" + new Guid(ids[i]).ToString().ToUpperInvariant() + "}", - false - ); + using var d = general.OpenSubKey(@"Desktops\{" + new Guid(ids[i]).ToString().ToUpperInvariant() + "}", false); names[i] = (d?.GetValue("Name") as string) ?? ""; } - catch - { - names[i] = ""; - } + catch { names[i] = ""; } } return new Snapshot(ids.Count, index, names); } @@ -251,16 +231,11 @@ internal static List ParseIds(byte[]? data) } // ---- switching ---- - const ushort VkLWin = 0x5B, - VkControl = 0x11, - VkLeft = 0x25, - VkRight = 0x27, - VkD = 0x44, - VkF4 = 0x73; - + const ushort VkLWin = 0x5B, VkControl = 0x11, VkLeft = 0x25, VkRight = 0x27, VkD = 0x44, VkF4 = 0x73; internal readonly record struct Key(ushort Vk, bool Up); - // Pure plan, unit-tested without sending anything: modifiers down, one arrow press per step, modifiers up (always last). + // Plan pur, testé sans rien envoyer : modificateurs enfoncés, un appui de flèche par pas, modificateurs relâchés (toujours en + // dernier). internal static List PlanKeys(int current, int target, int count) { var keys = new List(); @@ -292,7 +267,7 @@ internal static bool SwitchTo(int target, int relative = 0) static bool SwitchCore(int target, int relative) { - Refresh(); // a gesture may come before the pending debounce: never act on a stale snapshot + Refresh(); // un geste peut précéder l'anti-rebond en attente : ne jamais agir sur un instantané périmé var s = snapshot; if (s.Count < 2) return false; @@ -300,19 +275,11 @@ static bool SwitchCore(int target, int relative) return Send(PlanKeys(s.Current, dest, s.Count)); } - // New desktop: Win+Ctrl+D (Windows creates it and switches to it). Pure plan, same release order as PlanKeys. - internal static List PlanNew() => - new() - { - new Key(VkLWin, false), - new Key(VkControl, false), - new Key(VkD, false), - new Key(VkD, true), - new Key(VkControl, true), - new Key(VkLWin, true), - }; + // Nouveau bureau : Win+Ctrl+D (Windows le crée et y bascule). Plan pur, même ordre de relâchement que PlanKeys. + internal static List PlanNew() => new() { new Key(VkLWin, false), new Key(VkControl, false), new Key(VkD, false), new Key(VkD, + true), new Key(VkControl, true), new Key(VkLWin, true) }; - // User gesture entry point (the "+" in the dock). + // Point d'entrée du geste de l'utilisateur (le « + » du dock). internal static bool CreateNew() { lock (actionGate) @@ -324,28 +291,15 @@ internal static bool CreateNew() } } - // Close the current desktop: Win+Ctrl+F4 (windows move to the neighbouring desktop). Same release order as PlanNew. - internal static List PlanClose() => - new() - { - new Key(VkLWin, false), - new Key(VkControl, false), - new Key(VkF4, false), - new Key(VkF4, true), - new Key(VkControl, true), - new Key(VkLWin, true), - }; + // Ferme le bureau courant : Win+Ctrl+F4 (les fenêtres passent au bureau voisin). Même ordre de relâchement que PlanNew. + internal static List PlanClose() => new() { new Key(VkLWin, false), new Key(VkControl, false), new Key(VkF4, false), new Key(VkF4, + true), new Key(VkControl, true), new Key(VkLWin, true) }; - // Desktop to go back to after closing `closed` while the user was on `origin`; -1 = nothing to restore (the user was on the closed one). - internal static int ReturnIndex(int origin, int closed) => - origin == closed ? -1 - : origin > closed ? origin - 1 - : origin; + // Bureau où revenir après la fermeture de `closed` pendant que l'utilisateur était sur `origin` ; -1 = rien à restaurer + // (l'utilisateur était sur le bureau fermé). + internal static int ReturnIndex(int origin, int closed) => origin == closed ? -1 : origin > closed ? origin - 1 : origin; - const int SwitchTimeoutMs = 1500, - CloseTimeoutMs = 1500, - SettleMs = 120, - StepMs = 30; + const int SwitchTimeoutMs = 1500, CloseTimeoutMs = 1500, SettleMs = 120, StepMs = 30; static Snapshot? TryRead() { @@ -353,13 +307,10 @@ internal static int ReturnIndex(int origin, int closed) => { return Read(); } - catch - { - return null; - } + catch { return null; } } - // Bounded wait on the registry (user gesture only, never at idle). + // Attente bornée sur le registre (geste de l'utilisateur uniquement, jamais au repos). static bool WaitFor(Func ok, int timeoutMs) { long end = Environment.TickCount64 + timeoutMs; @@ -374,8 +325,9 @@ static bool WaitFor(Func ok, int timeoutMs) } } - // User gesture entry point (right-click on a dot, 0-based index). Safety: the key chord only goes out once the registry shows the - // target as current desktop with the expected count; otherwise nothing is closed and the user is sent back where they were. + // Point d'entrée du geste de l'utilisateur (clic droit sur un point, index base 0). Sécurité : la combinaison de touches n'est + // envoyée que lorsque le registre montre + // la cible comme bureau courant avec le nombre attendu ; sinon rien n'est fermé et l'utilisateur est ramené où il était. internal static bool Close(int index) { lock (actionGate) @@ -384,8 +336,7 @@ internal static bool Close(int index) var s = snapshot; if (s.Count <= 1 || index < 0 || index >= s.Count) return false; - int origin = s.Current, - count = s.Count; + int origin = s.Current, count = s.Count; if (index != origin) { if (!SwitchCore(index, 0)) @@ -397,7 +348,7 @@ internal static bool Close(int index) } Thread.Sleep(SettleMs); } - var now = TryRead(); // last look right before the destructive chord + var now = TryRead(); // dernier contrôle juste avant l'accord destructif if (now == null || now.Count != count || now.Current != index) { if (index != origin) @@ -438,14 +389,8 @@ static bool Send(List keys) } } } - catch - { - ok = false; - } - finally - { - ReleaseModifiers(); - } + catch { ok = false; } + finally { ReleaseModifiers(); } return ok; } @@ -458,8 +403,13 @@ static bool SendKey(ushort vk, bool up) type = 1, U = new InputUnion { - ki = new KEYBDINPUT { wVk = vk, dwFlags = (up ? 2u : 0u) | (IsExtended(vk) ? 1u : 0u) }, - }, + ki = new KEYBDINPUT + { + wVk = vk, + dwFlags = (up ? 2u : 0u) | (IsExtended(vk) + ? 1u : 0u) + } + } }; return SendInput(1, new[] { input }, Marshal.SizeOf()) == 1; } @@ -472,109 +422,54 @@ static void ReleaseModifiers() { SendKey(vk, true); } - catch { } + catch (Exception) + { + // Relâchement de touche au mieux : la touche peut déjà être relâchée. + } } } [StructLayout(LayoutKind.Sequential)] struct KEYBDINPUT { - public ushort wVk; - public ushort wScan; - public uint dwFlags; - public uint time; - public IntPtr dwExtraInfo; + public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; } - [StructLayout(LayoutKind.Sequential)] struct MOUSEINPUT { - public int dx; - public int dy; - public uint mouseData; - public uint dwFlags; - public uint time; - public IntPtr dwExtraInfo; + public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; } - [StructLayout(LayoutKind.Explicit)] struct InputUnion { - [FieldOffset(0)] - public MOUSEINPUT mi; - - [FieldOffset(0)] - public KEYBDINPUT ki; + [FieldOffset(0)] public MOUSEINPUT mi; [FieldOffset(0)] public KEYBDINPUT ki; } - [StructLayout(LayoutKind.Sequential)] struct INPUT { - public uint type; - public InputUnion U; + public uint type; public InputUnion U; } - - [DllImport("user32.dll", SetLastError = true)] - static extern uint SendInput(uint n, INPUT[] inputs, int size); - - const uint EVENT_SYSTEM_FOREGROUND = 0x0003, - EVENT_OBJECT_CLOAKED = 0x8017, - EVENT_OBJECT_UNCLOAKED = 0x8018, - WINEVENT_OUTOFCONTEXT = 0, - WINEVENT_SKIPOWNPROCESS = 2, - WM_QUIT = 0x12, - WM_TIMER = 0x113; + [DllImport("user32.dll", SetLastError = true)] static extern uint SendInput(uint n, INPUT[] inputs, int size); + const uint EVENT_SYSTEM_FOREGROUND = 0x0003, EVENT_OBJECT_CLOAKED = 0x8017, EVENT_OBJECT_UNCLOAKED = 0x8018, WINEVENT_OUTOFCONTEXT = 0, + WINEVENT_SKIPOWNPROCESS = 2, WM_QUIT = 0x12, WM_TIMER = 0x113; const int OBJID_WINDOW = 0; delegate void WinEventProc(IntPtr hook, uint evt, IntPtr hwnd, int idObject, int idChild, uint thread, uint time); - [StructLayout(LayoutKind.Sequential)] struct MSG { - public IntPtr hwnd; - public uint message; - public IntPtr wParam, - lParam; - public uint time; - public int ptX, - ptY, - lPrivate; + public IntPtr hwnd; public uint message; public IntPtr wParam, lParam; public uint time; public int ptX, ptY, lPrivate; } - [DllImport("user32.dll")] - static extern IntPtr SetWinEventHook( - uint min, - uint max, - IntPtr mod, - WinEventProc proc, - uint pid, + static extern IntPtr SetWinEventHook(uint min, uint max, IntPtr mod, WinEventProc proc, uint pid, uint thread, - uint flags - ); - - [DllImport("user32.dll")] - static extern bool UnhookWinEvent(IntPtr hook); - - [DllImport("user32.dll")] - static extern int GetMessageW(out MSG m, IntPtr h, uint min, uint max); - - [DllImport("user32.dll")] - static extern bool PeekMessageW(out MSG m, IntPtr h, uint min, uint max, uint remove); - - [DllImport("user32.dll")] - static extern bool TranslateMessage(ref MSG m); - - [DllImport("user32.dll")] - static extern IntPtr DispatchMessageW(ref MSG m); - - [DllImport("user32.dll")] - static extern bool PostThreadMessageW(uint tid, uint msg, IntPtr w, IntPtr l); - - [DllImport("user32.dll")] - static extern UIntPtr SetTimer(IntPtr h, uint id, uint ms, IntPtr proc); - - [DllImport("user32.dll")] - static extern bool KillTimer(IntPtr h, uint id); - - [DllImport("kernel32.dll")] - static extern uint GetCurrentThreadId(); + uint flags); + [DllImport("user32.dll")] static extern bool UnhookWinEvent(IntPtr hook); + [DllImport("user32.dll")] static extern int GetMessageW(out MSG m, IntPtr h, uint min, uint max); + [DllImport("user32.dll")] static extern bool PeekMessageW(out MSG m, IntPtr h, uint min, uint max, uint remove); + [DllImport("user32.dll")] static extern bool TranslateMessage(ref MSG m); + [DllImport("user32.dll")] static extern IntPtr DispatchMessageW(ref MSG m); + [DllImport("user32.dll")] static extern bool PostThreadMessageW(uint tid, uint msg, IntPtr w, IntPtr l); + [DllImport("user32.dll")] static extern UIntPtr SetTimer(IntPtr h, uint id, uint ms, IntPtr proc); + [DllImport("user32.dll")] static extern bool KillTimer(IntPtr h, uint id); + [DllImport("kernel32.dll")] static extern uint GetCurrentThreadId(); } diff --git a/src/backend/Desktop/WallpaperApply.cs b/src/backend/Desktop/WallpaperApply.cs deleted file mode 100644 index 3284ab9..0000000 --- a/src/backend/Desktop/WallpaperApply.cs +++ /dev/null @@ -1,201 +0,0 @@ -// C# 12 / WallpaperApply.cs / Sets the wallpaper through IDesktopWallpaper (all displays or one) and lists displays. Event-driven: every call builds its COM object on a short-lived STA thread and releases it before returning. -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Runtime.InteropServices; -using System.Threading; - -namespace PanneauxGlass.Desktop; - -internal sealed record WallpaperDisplay( - int id, - string device, - string name, - int width, - int height, - bool primary, - string path -); - -internal static class WallpaperApply -{ - static readonly Guid Clsid = new("C2CF3110-460E-4FC1-B9D0-8A1C0C9CC4BD"); - - [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - static extern bool SystemParametersInfo(uint action, uint param, string value, uint flags); - - // COM needs an STA thread and must stay off the UI thread: run on a dedicated one, abandon it after 5 s (a hung explorer must not freeze the shell). - sealed class Abandon - { - public volatile bool Set; - } - - static T OnSta(Func work, T fallback, out bool expired) - { - T result = fallback; - var abandon = new Abandon(); - var thread = new Thread(() => - { - try - { - result = work(abandon); - } - catch (Exception ex) - { - Store.Log("Wallpaper: " + ex.Message); - } - }) - { - IsBackground = true, - Name = "Wallpaper STA", - }; - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - expired = !thread.Join(5000); - if (expired) - { - abandon.Set = true; - return fallback; - } - return result; - } - - // Primary first, then the order Windows reports. A display without a usable rectangle (unplugged) is skipped. - static List Enumerate(IDesktopWallpaper api) - { - var found = new List<(string device, Native.Rect rect, string path)>(); - if (api.GetMonitorDevicePathCount(out uint count) != 0) - return new(); - for (uint i = 0; i < count; i++) - { - if ( - api.GetMonitorDevicePathAt(i, out string device) != 0 - || string.IsNullOrEmpty(device) - || api.GetMonitorRECT(device, out var rect) != 0 - || rect.Right <= rect.Left - || rect.Bottom <= rect.Top - ) - continue; - api.GetWallpaper(device, out string? path); - found.Add((device, rect, path ?? "")); - } - var ordered = found.OrderBy(f => f.rect.Left == 0 && f.rect.Top == 0 ? 0 : 1).ToList(); - return ordered - .Select( - (f, n) => - new WallpaperDisplay( - n + 1, - f.device, - WallpaperLogic.DisplayName( - n + 1, - f.rect.Right - f.rect.Left, - f.rect.Bottom - f.rect.Top, - n == 0 - ), - f.rect.Right - f.rect.Left, - f.rect.Bottom - f.rect.Top, - n == 0, - f.path - ) - ) - .ToList(); - } - - static T WithApi(Func use, T fallback, out bool expired) => - OnSta( - abandon => - { - object? instance = null; - try - { - instance = Activator.CreateInstance(Type.GetTypeFromCLSID(Clsid)!); - return use((IDesktopWallpaper)instance!, abandon); - } - finally - { - if (instance != null && Marshal.IsComObject(instance)) - Marshal.ReleaseComObject(instance); - } - }, - fallback, - out expired - ); - - internal static List List() => - WithApi((api, _) => Enumerate(api), new List(), out _); - - // Process names are read once per call (picker opening, applying, CLI list); nothing watches them. - internal static string[] Animated() - { - var names = new List(); - try - { - foreach (var p in Process.GetProcesses()) - { - try - { - names.Add(p.ProcessName); - } - catch { } - finally - { - p.Dispose(); - } - } - } - catch { } - return WallpaperLogic.Animated(names); - } - - // display 0 = all displays. Normal effects: IDesktopWallpaper.SetWallpaper, which is the call Windows itself makes for its fade; light effects: SPI_SETDESKWALLPAPER, no extra transition requested. - // An unplugged or unknown display is a refusal with a message, never an exception. - internal static (int code, string text) Apply(string path, int display, bool light) - { - if (display <= 0 && light) - return SystemParametersInfo(0x14, 0, path, 3) ? (0, "ok") : (1, "Windows refused the wallpaper."); - int code = WithApi( - (api, abandon) => - { - string? monitor = null; - if (display > 0) - { - var target = Enumerate(api).FirstOrDefault(d => d.id == display); - if (target == null) - return 2; - monitor = target.device; - } - if (abandon.Set) - return 3; // the caller already gave up: do not change the wallpaper after the fact - return api.SetWallpaper(monitor!, path) == 0 ? 0 : 1; - }, - -1, - out bool expired - ); - if (expired) - return (1, Strings.WindowsDidNotRespond); - if (code == 0) - return (0, "ok"); - if (code == 2) - return (2, "Display not found (see 'winlestia wallpaper list')."); - if (code == 3) - return (1, Strings.WindowsDidNotRespond); - if (display <= 0 && SystemParametersInfo(0x14, 0, path, 3)) - return (0, "ok"); // COM unavailable: keep the former behaviour - return (1, "Windows refused the wallpaper."); - } - - // From the picker: the path already comes from the scanned list (any picker format, webp included), so only local-path sanity is checked here. - internal static (int code, string text) ApplyKnown(string path, int display, bool light) - { - if ( - string.IsNullOrWhiteSpace(path) - || path.Length > 259 - || path.StartsWith(@"\\") - || !System.IO.Path.IsPathFullyQualified(path) - || !System.IO.File.Exists(path) - ) - return (2, "Wallpaper file not found."); - return Apply(path, display, light); - } -} diff --git a/src/backend/Desktop/WallpaperLogic.cs b/src/backend/Desktop/WallpaperLogic.cs deleted file mode 100644 index 20e5df2..0000000 --- a/src/backend/Desktop/WallpaperLogic.cs +++ /dev/null @@ -1,41 +0,0 @@ -// C# 12 / WallpaperLogic.cs / Pure helpers for the wallpaper picker: animated-wallpaper process names and display labels. No I/O, no Windows API. -using System; -using System.Collections.Generic; -using System.Linq; - -namespace PanneauxGlass.Desktop; - -internal static class WallpaperLogic -{ - // Executable name (lower case, with .exe) -> product name. Process names only; no window or content is read. Add a line to cover another product. - internal static readonly (string exe, string label)[] AnimatedHosts = - { - ("desktophut studio.exe", "DesktopHut"), - ("wallpaper32.exe", "Wallpaper Engine"), - ("wallpaper64.exe", "Wallpaper Engine"), - ("lively.exe", "Lively Wallpaper"), - ("livelywpf.exe", "Lively Wallpaper"), - ("wallpaper.service.exe", "Wallpaper X"), - ("wallpaper x launcher.exe", "Wallpaper X"), - }; - - // Process names may come with or without ".exe" and in any case (Process.ProcessName has no extension). - internal static string[] Animated(IEnumerable processNames) - { - var seen = new HashSet( - processNames.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => Normalize(n!)), - StringComparer.Ordinal - ); - return AnimatedHosts.Where(h => seen.Contains(h.exe)).Select(h => h.label).Distinct().ToArray(); - } - - static string Normalize(string name) - { - name = name.Trim().ToLowerInvariant(); - return name.EndsWith(".exe", StringComparison.Ordinal) ? name : name + ".exe"; - } - - // Neutral label for the CLI; the picker builds its own translated label from the same fields. - internal static string DisplayName(int id, int width, int height, bool primary) => - "Display " + id + " (" + width + "x" + height + ")" + (primary ? " primary" : ""); -} diff --git a/src/backend/Desktop/WallpaperMedia.cs b/src/backend/Desktop/WallpaperMedia.cs deleted file mode 100644 index 683ba4b..0000000 --- a/src/backend/Desktop/WallpaperMedia.cs +++ /dev/null @@ -1,176 +0,0 @@ -// C# 12 / WallpaperMedia.cs / Windows. One-shot helpers for the Automatique theme: video frame sampling (Media Foundation via Windows.Media.Editing) and live-host capture (PrintWindow). -using System; -using System.Drawing; -using System.Drawing.Imaging; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using System.Windows.Media.Imaging; - -namespace PanneauxGlass.Desktop; - -internal static class WallpaperMedia -{ - // CLI: Panneaux.Glass.exe --extract-frames