# Historique des versions
## [4.1.0-beta.7] - 2026-09-10
### OwlSetup se soumet tout seul à winget
Le manifeste de la 4.0.0 a été écrit à la main et soumis dans
`microsoft/winget-pkgs` ([PR #432216](https://github.com/microsoft/winget-pkgs/pull/432216)),
où les dix étapes de validation sont passées — y compris l'installation réelle
dans une machine virtuelle vierge et l'analyse antivirus, malgré un binaire non
signé.
Recommencer à la main à chaque version serait la garantie de ne plus le faire.
`release.yml` gagne donc un job `winget` qui, après **chaque version stable**,
laisse `komac` recopier le manifeste précédent en y remplaçant version, URL et
empreinte, puis ouvre la pull request depuis le fork `OwlNetGeekFR/winget-pkgs`.
**Les préversions en sont exclues.** winget ne distribue que des stables : une
bêta poussée là-bas serait proposée à tous ses utilisateurs.
### Le motif par défaut aurait déclaré trois installateurs
L'action fournit un motif par défaut, `.(exe|msi|msix|appx)(bundle){0,1}$`, qui
n'est **pas ancré**. Or la Release publie trois exécutables :
`OwlSetup-Setup.exe`, `OwlSetup.exe` et `PC-Setup.exe`. Les trois auraient été
déclarés comme installateurs du même paquet. Un seul en est un.
`tests/Test-WingetSubmission.ps1` applique le motif à la liste réelle des
fichiers publiés — extraite de `release.yml`, pas recopiée à côté — et exige
exactement une correspondance. Avec le motif par défaut, il en compte trois et
échoue.
Il garde aussi trois pannes silencieuses, celles qui ne font pas rougir un job :
- la condition `is-prerelease == 'false'` compare une **sortie de job**. Si
cette sortie disparaît, elle vaut la chaîne vide, la comparaison est fausse et
plus rien n'est jamais soumis — sans erreur, sans trace ;
- l'identifiant `OwlNetGeekFR.OwlSetup` est confronté à l'identité que déclare
`installer/OwlSetup.iss`. C'est par lui que winget rattache une mise à jour à
l'installation existante : s'ils divergent, les utilisateurs cessent d'être
mis à jour ;
- le jeton doit venir des secrets du dépôt.
Sept sabotages, sept détections.
### Ce qu'il reste à brancher
Le job attend un secret `WINGET_TOKEN`, un jeton personnel de portée
`public_repo`. **Son absence ne fait pas échouer la publication** : le job pose
un avertissement et s'arrête là, parce que la Release, elle, est déjà valide.
Il suppose aussi que le paquet **existe déjà** chez winget — l'action refuse de
créer un premier manifeste. Il ne sera donc opérationnel qu'une fois la
PR #432216 fusionnée par un modérateur.
## [4.1.0-beta.6] - 2026-09-01
### Lancer OwlSetup deux fois ne fait plus disparaître une fenêtre
WebView2 verrouille son dossier de données. Une seconde instance échouait donc à
créer son environnement et se fermait aussitôt — et **pas toujours la
seconde** : sur trois lancements rapprochés mesurés en 4.1.0-beta.5, deux ont vu
une instance sortir immédiatement, parfois la première. Un double-clic de trop
suffisait à faire disparaître la fenêtre sans un mot.
Un verrou nommé, posé **avant l'extraction des ressources**, règle les deux
problèmes : la seconde instance rend la main tout de suite, et elle **ramène au
premier plan la fenêtre déjà ouverte** — en la restaurant si elle était réduite.
Placer le garde avant l'extraction évite aussi que deux processus écrivent en
même temps dans les mêmes fichiers.
Le verrou porte sur la session Windows : deux utilisateurs connectés en
parallèle gardent chacun leur instance.
**Le mode ligne de commande n'est pas concerné.** Les verbes CLI et les relais
élevés rendent la main avant le garde : lancer `OwlSetup --update` pendant que
l'interface est ouverte reste possible, et le test le vérifie.
### Un test qui avait l'air bon et ne validait rien
La première version de `tests/Test-SingleInstance.ps1` vérifiait que la seconde
instance sort proprement et que la première survit. Elle passait — et elle
passait **aussi sur un binaire dont le garde avait été retiré**, trois fois sur
trois.
La raison est instructive : sans garde, WebView2 fait échouer la seconde
instance, qui sort elle aussi avec le code 0. Le comportement observable était
identique. Ce que le garde apporte, c'est le **retour au premier plan** — et
c'est la seule chose qui le distingue.
Le test réduit donc d'abord la fenêtre existante, puis vérifie qu'elle est
restaurée et remise devant. Retirer le garde le fait maintenant échouer trois
fois sur trois, avec le bon message.
### Le chemin de publication était moins vérifié que celui des propositions
`Test-InterfaceStartup.ps1` ne tournait que dans le job des pull requests. Un
tag pouvait donc publier un binaire dont l'interface ne démarre pas. Le
démarrage graphique et le garde d'instance tournent désormais dans **les deux
jobs**.
### Une nouvelle bibliothèque native, déclarée
`Test-NativeInteropHardening.ps1` tient la liste des DLL importées et exige un
examen à chaque ajout. Il a signalé `user32` de lui-même. Elle rejoint la liste
avec sa justification : `SetForegroundWindow`, `ShowWindow` et `IsIconic`
servent à rappeler la fenêtre existante. C'est une KnownDLL, donc protégée du
détournement, et l'attribut de recherche est posé malgré tout comme sur toutes
les autres.
**Vérifié :** 61 fichiers de tests PowerShell, 248 tests JavaScript, hôte
recompilé sans avertissement, garde éprouvé par trois sabotages.
## [4.1.0-beta.5] - 2026-09-01
### Rien ne vérifiait qu'un logiciel du catalogue existe encore
Le catalogue propose 93 applications. Si un paquet est renommé ou retiré du
dépôt WinGet, OwlSetup continue de l'afficher comme installable, et
l'installation échoue chez l'utilisateur. Aucun contrôle ne le voyait venir — et
sur 93 entrées, ce n'est qu'une question de temps.
`tools/check-winget-ids.mjs` interroge `microsoft/winget-pkgs`, la source dont
WinGet lui-même se sert. Pas `winget.exe` : il n'est pas disponible de façon
fiable sur un runner GitHub.
**80/80** aujourd'hui. La base est donc propre, et le premier échec sera un vrai
signal.
Le premier essai en avait signalé **douze** de plus. C'étaient des faux
positifs : les entrées `manualInstall` — installation guidée vers le site de
l'éditeur (`guided.RustDesk`, `VMware.WorkstationPro`) ou application web
(`web.GoogleGemini`) — portent un identifiant interne à OwlSetup qui n'a jamais
existé chez WinGet. Avec l'entrée Microsoft Store, treize entrées sont écartées,
et le contrôle porte sur les 80 qui passent réellement par WinGet.
Validé par deux sabotages : un identifiant WinGet inventé, et une entrée guidée
privée de son drapeau — donc interrogée à tort.
### Le contrôle du catalogue ne surveillait pas le catalogue
`catalog-health.yml` se déclenchait sur `app.js`, les logos et son propre
script. Mais **la source de vérité est `beta/catalog/apps.json` depuis la
4.0.0-beta.11**, et ce chemin n'a jamais figuré dans ses déclencheurs. Corriger
un identifiant ou une URL ne lançait donc aucun contrôle.
Les déclencheurs couvrent désormais `beta/catalog/**` et
`catalog.generated.js`. Le workflow tourne aussi **une fois par semaine** : un
paquet disparaît de WinGet sans qu'on ait rien changé, et aucun déclencheur lié
au dépôt ne peut l'attraper.
### L'échec intermittent est expliqué
La 4.1.0-beta.3 laissait une question ouverte : le test de démarrage graphique
avait échoué une fois, sortie immédiate et code 0, sans que j'en trouve la
cause.
La voici. **WebView2 verrouille son dossier de données.** Une seconde instance
lancée pendant qu'une première tourne échoue à créer son environnement et sort
aussitôt. Or `Test-ReleaseCandidateReadiness.ps1` rejoue toute la suite : lors
d'une passe complète, ce test s'exécute deux fois de suite. Mesuré : sur trois
lancements rapprochés, deux ont vu une instance sortir immédiatement.
Le test attend maintenant que la place soit libre. Ce n'est pas une reprise
déguisée — la cause est connue et l'attente porte précisément sur elle.
### Ce que ça révèle du produit, et qui reste à corriger
OwlSetup n'a **aucun garde d'instance unique**. Un utilisateur qui lance
l'application une seconde fois voit une fenêtre disparaître sans explication, ou
une boîte d'erreur. C'est un défaut visible, distinct du test, et il mérite son
propre lot : soit ramener au premier plan l'instance déjà ouverte, soit le dire
clairement.
**Vérifié :** 60 fichiers de tests PowerShell (passe complète, zéro échec),
248 tests JavaScript, 80 identifiants contrôlés en 53 secondes.
## [4.1.0-beta.4] - 2026-09-01
### Une seule description du build
`build.ps1` listait lui-même les références et les ressources à passer à
`csc.exe`. `beta/csharp/OwlSetup.csproj` répétait la même chose pour les
analyseurs Roslyn. Deux descriptions du même programme, tenues en phase à la
main — et la 4.1.0-beta.3 avait montré ce que ça coûte : le projet avait perdu
cinq attributs d'assembly sans que personne le voie.
`build.ps1` compile désormais **via le projet**, ici comme en CI. Il y perd
55 lignes : la liste des ressources, celle des références, la génération
manuelle des attributs d'assembly, et le téléchargement à la main du paquet
NuGet WebView2 — que le projet restaure tout seul.
Conséquences :
- les **analyseurs de sécurité portent sur le binaire réellement livré**, plus
sur une copie censée lui ressembler ;
- l'étape « Analyse de sécurité » disparaît de la CI : elle faisait doublon avec
la compilation ;
- **le SDK .NET devient nécessaire** pour construire l'application. `build.ps1`
le dit explicitement s'il manque.
Le shim console `.com` reste compilé par `csc.exe`, et c'est assumé : c'est un
exécutable d'une page, sans ressource ni dépendance.
### Un test de parité qui n'a plus de parité à vérifier
`tests/Test-BuildParity.ps1` existait pour comparer les deux descriptions. Il
n'y en a plus qu'une, donc la comparaison n'a plus d'objet — et son garde-fou
l'a signalé de lui-même : « extraction des ressources cassée (0 trouvées), le
test ne prouverait rien ».
Il garde maintenant quelque chose de plus utile : les ressources **déclarées par
le projet** contre celles que **l'hôte extrait réellement** au démarrage. Deux
côtés genuinement différents — le build et le code qui le consomme — là où une
oubliée d'un côté fait échouer l'autre au lancement. Il vérifie aussi que
`build.ps1` ne se remet pas à compiler l'application lui-même.
Validé par quatre sabotages : délégation retirée, retour à `csc` pour
l'application, ressource retirée du projet, attribut d'assembly perdu.
`tests/Test-BrandingResource.ps1` lisait lui aussi les arguments `csc` : il
interroge désormais le projet.
**Vérifié :** 60 fichiers de tests PowerShell, 248 tests JavaScript, binaire
reconstruit par le nouveau chemin — 123 ressources, version 4.1.0.0, société
renseignée — puis démarrage graphique, mode CLI et aller-retour d'installation.
## [4.1.0-beta.3] - 2026-09-01
### Le second chemin de build produisait un binaire sans identité
Le plan prévoit d'adopter `beta/csharp/OwlSetup.csproj` comme chemin de build
officiel. Avant de basculer, il fallait vérifier que son binaire remplace
vraiment celui de `build.ps1`. Mesure faite :
| | `build.ps1` (csc) | `.csproj` (msbuild) |
| --- | --- | --- |
| Ressources embarquées | 123 | 123, **contenu identique au bit près** |
| Version produit | 4.1.0-beta.2 | 4.1.0-beta.2 |
| Version fichier | 4.1.0.0 | **0.0.0.0** |
| Société | OwlNetGeekFR | **(vide)** |
Le projet déclare pourtant `Company`, `Product`, `Description`, `FileVersion` et
`AssemblyVersion`. Mais il pose aussi `GenerateAssemblyInfo=false`, pour ne pas
entrer en conflit avec le fichier qu'il génère lui-même — si bien que **ces cinq
propriétés étaient décoratives**. MSBuild ne les émettait nulle part.
Personne ne l'avait vu : ce projet ne servait qu'à l'analyse Roslyn, jamais à
produire le binaire livré. Le jour de la bascule, il aurait expédié une version
sans identité.
Sa cible `GenerateBuildInfo` émet désormais les **sept** attributs que
`build.ps1` génère à la main, et les deux binaires portent les mêmes
métadonnées. `tests/Test-BuildParity.ps1` compare la liste des deux côtés —
validé en retirant `AssemblyCompany` puis `AssemblyFileVersion`.
**La bascule n'est pas faite pour autant.** L'équivalence avait un trou, il est
bouché ; changer de chemin de build mérite son propre lot, avec le binaire
msbuild passé au crible des mêmes essais.
### Un test qui punissait l'usage normal
`Test-InstallerRoundTrip.ps1` refusait de tourner si un OwlSetup était installé
— l'`AppId` étant partagé, il écraserait son entrée de désinstallation. Mais il
le faisait en **échouant**, ce qui mettait toute la suite au rouge chez
quiconque utilise OwlSetup sur sa machine de développement.
C'est le cas normal, et c'est arrivé dès la première fois. Le test s'abstient
désormais avec un message clair. Le trou reste fermé là où il compte : la CI
passe `-Requis`, et son runner n'a jamais d'installation.
### Un échec intermittent, non reproduit et non masqué
Le test de démarrage graphique a échoué **une fois** — sortie immédiate, code 0,
sans message — puis jamais en onze lancements. Les pistes examinées sont
écartées : pas de verrou d'instance unique, le mode CLI exige un argument, et
copier le binaire juste avant de le lancer réussit cinq fois sur cinq.
Aucune reprise automatique n'a été ajoutée : elle masquerait le symptôme. Le
message d'échec porte maintenant la durée avant sortie, le code, et le nombre
de processus OwlSetup et WebView2 présents — de quoi trancher à la prochaine
occurrence au lieu de recommencer l'enquête.
**Vérifié :** 60 fichiers de tests PowerShell, 248 tests JavaScript, les deux
binaires comparés ressource par ressource.
## [4.1.0-beta.2] - 2026-09-01
### L'installateur installe-t-il ? Personne ne le vérifiait
La CI contrôlait que `OwlSetup-Setup.exe` pesait plus d'un méga-octet. Rien
d'autre. C'est pourtant le fichier que télécharge un nouvel utilisateur, et un
installateur cassé est le pire défaut possible juste après une publication.
`tests/Test-InstallerRoundTrip.ps1` l'installe pour de bon dans un dossier
temporaire, **en français puis en anglais**, et vérifie l'exécutable posé, le
désinstalleur, l'entrée de désinstallation, le raccourci du menu Démarrer et son
commentaire. Puis il désinstalle et vérifie que tout est parti — fichiers, clé
de registre, raccourci.
Il refuse de tourner si un OwlSetup est déjà installé : l'`AppId` étant partagé,
il écraserait l'entrée de désinstallation de l'installation réelle.
Validé par deux sabotages : libellé français désaccentué, raccourci du menu
Démarrer retiré.
### Le BOM ne corrigeait rien — mesure faite
La 4.1.0-beta.1 ajoutait un BOM UTF-8 à `installer/OwlSetup.iss` en précisant
qu'aucun défaut n'avait été observé. Maintenant qu'on sait installer, la
question se tranche : **l'installateur recompilé sans BOM pose un raccourci
français aux accents corrects.** Inno Setup 6.7.3 détecte l'UTF-8 tout seul.
L'assistant de la 4.0.0 était donc intact. Le BOM reste — la documentation
d'Inno le demande, et rien ne garantit sa version sur un autre poste — mais le
commentaire du test dit désormais qu'il ne corrige aucun défaut constaté.
Le vrai contrôle des accents est l'aller-retour, qui lit le commentaire du
raccourci **réellement posé**.
**Vérifié :** 60 fichiers de tests PowerShell, 248 tests JavaScript, aller-retour
d'installation exécuté sur la machine de développement.
## [4.1.0-beta.1] - 2026-09-01
### L'assistant d'installation ne parlait que français
La 4.0.0 a livré une interface anglaise complète — 1488 chaînes vérifiées à
chaque build. Mais l'**assistant d'installation**, le premier écran que voit un
nouvel utilisateur, ne déclarait que le français.
L'anglais est ajouté, en tête pour servir de repli, avec
`ShowLanguageDialog=auto` : un système français obtient le français, un système
anglais l'anglais, et ni l'un ni l'autre ne voit de boîte de dialogue
supplémentaire. Un système allemand ou espagnol tombe sur l'anglais plutôt que
sur du français.
Les libellés propres à OwlSetup — raccourci Bureau, commentaires d'icônes,
proposition de lancement — étaient écrits en dur en français : ils seraient
restés français dans un assistant anglais. Ils passent par `[CustomMessages]`,
dans les deux langues.
### Un `.iss` sans BOM
`installer/OwlSetup.iss` était en UTF-8 **sans BOM**. Inno Setup lit alors le
fichier dans la page de codes de la machine qui compile — CP1252 sur le runner
GitHub — et « Créer » y devient « Créer ».
**Je n'ai pas pu constater l'état d'avant :** Inno compresse ces libellés dans
l'installateur, et seule la description de version, sans accent, est lisible en
clair dans le binaire publié. Le BOM est ajouté parce que la documentation
d'Inno l'exige, pas parce qu'un défaut a été observé.
> **Suite en 4.1.0-beta.2 : il n'y avait pas de défaut.** L'installateur a été
> recompilé sans BOM, installé, et le raccourci français affiche des accents
> corrects. Inno Setup 6.7.3 détecte l'UTF-8 seul. L'assistant de la 4.0.0
> était donc intact. Le BOM reste, par conformité à la documentation, mais il ne
> corrigeait rien.
### Les scripts de build se déclaraient encore en 3.7.0
`build.ps1`, `build-installer.ps1` et `build-stable.ps1` gardaient `3.7.0`
comme version par défaut, et `OwlSetup.iss` **3.6.0**. Compiler sans argument
produisait donc un binaire qui s'annonçait 3.7.0, deux versions après.
Ce n'était pas un oubli : `tests/Test-StableReleasePreparation.ps1` **exigeait**
cette valeur en dur, et lisait les notes de la 3.7.0. Le test protégeait
l'erreur qu'il aurait dû empêcher.
Il dérive désormais la version courante du **CHANGELOG** et compare les trois
valeurs par défaut à celle-ci. Il ne peut plus se périmer, et il vérifie les
notes de la version réellement publiée.
### Ce que ça a révélé dans les notes de la 4.0.0
Dégelé, le test a immédiatement signalé que mes notes de la 4.0.0 **ne
mentionnaient pas la quarantaine réversible** — une réassurance que les notes de
la 3.7.0 donnaient, et qui compte pour une fonction dont le nom évoque la
suppression. La section manquante est ajoutée.
### Ce qui garde tout ça
`tests/Test-InstallerPackage.ps1` vérifie le BOM, les deux langues,
`ShowLanguageDialog=auto`, l'absence de libellé en dur, la présence de chaque
message dans les deux langues, la validité de chaque `{cm:...}`, et que les
quatre versions par défaut suivent le CHANGELOG.
Validé par cinq sabotages : BOM retiré, anglais retiré, libellé remis en dur,
message sans version anglaise, version par défaut périmée.
**Reste à faire :** rien ne vérifie encore que l'installateur *installe*. La CI
contrôle seulement qu'il pèse plus d'un méga-octet. Une installation silencieuse
suivie d'une désinstallation, dans un dossier temporaire, est le prochain pas —
elle demande Inno Setup, absent de la machine de développement.
**Vérifié :** 59 fichiers de tests PowerShell, 248 tests JavaScript.
## [4.0.0] - 2026-09-01
Première version **stable** de la ligne 4.0. Les notes destinées aux
utilisateurs sont dans `RELEASE-NOTES-4.0.0.md`.
### Le workflow ne savait pas publier une version stable
Le premier tag `v4.0.0` a échoué à la dernière étape, sur
`no matches found for `-``. La compilation, le contrôle du binaire et
l'installateur étaient passés : seule la création de la Release a cédé.
La cause n'était pas `gh` mais **PowerShell** :
```powershell
$prereleaseFlags = if ($estPreversion) { @("--prerelease", "--latest=false") } else { @("--latest") }
```
La branche stable ne renvoie **qu'un seul élément**. PowerShell déroule alors le
tableau, et `$prereleaseFlags` devient une **chaîne**. Le splat `@prereleaseFlags`
découpe une chaîne **caractère par caractère** : `gh` reçoit `-`, `-`, `l`, `a`,
… et prend le premier `-` pour un motif de fichier.
La branche préversion, elle, a deux éléments : elle reste un tableau et
fonctionne. **Le défaut dormait depuis son introduction et ne pouvait surgir
qu'à la première version stable publiée par ce workflow** — la 3.7.0 avait été
publiée avec le code précédent.
Une contrainte de type explicite (`[string[]]`) suffit à l'empêcher.
### Un test qui exécute la ligne du workflow
`tests/Test-ReleaseFlags.ps1` n'inspecte pas le texte du fichier : il **extrait
l'affectation réelle**, l'exécute pour les deux branches, et observe ce que le
splat produit. Un argument d'un seul caractère est la signature exacte du
défaut. Le test vérifie aussi qu'une stable est marquée `--latest` et qu'une
préversion ne peut jamais l'être.
Validé par trois sabotages : contrainte de type retirée, stable qui n'est plus
`--latest`, préversion qui le deviendrait.
**Deux pièges rencontrés en écrivant ce test**, tous deux dus à une frontière de
fonction, et notés dans le fichier : `return $tableau` déroule à son tour un
tableau à un seul élément — le test devenait victime du défaut qu'il surveille
et échouait sur un workflow pourtant correct. Et une fonction auxiliaire nommée
`R` était résolue comme l'alias `Invoke-History`, les alias primant sur les
fonctions. Tout se passe désormais au niveau du script.
**Vérifié :** 58 fichiers de tests PowerShell, 248 tests JavaScript.
## [4.0.0-beta.65] - 2026-09-01
### Lot 8 — l'interface anglaise est complète
Les **256 chaînes** de `OwlSetupWebView.cs` qui restaient en français sont
traduites. L'audit passe de 84 % à **1488/1488, 100 %**, et la dette déclarée en
4.0.0-beta.61 est vidée : `beta/i18n-dette.json` ne contient plus rien.
Un anglophone ne voit plus de français dans les messages de l'hôte.
### Le filtre qui masquait 29 chaînes — et des fautes de français
En vérifiant un message construit en trois morceaux, une chaîne d'interface est
apparue qui n'était **ni traduite ni signalée** : `"WinGet n'a pas pu terminer
cette "`. Le filtre technique de l'audit écartait tout littéral contenant le mot
`winget`, pour éviter de compter les lignes de commande. Il écartait donc aussi
tout **message affiché** mentionnant WinGet.
Vingt-neuf chaînes étaient masquées, dont « Contrôle de WinGet », « Mettre
WinGet à jour » ou « WinGet est indisponible ». Le filtre ne s'applique
désormais qu'au nom de l'outil **suivi d'un verbe ou d'un drapeau**.
La même heuristique servait à chercher les accents manquants : **huit fautes
supplémentaires** étaient donc cachées aux utilisateurs français, comme
« Verification WinGet apres desinstallation » ou « Le logiciel n'a pas ete
trouve dans les sources WinGet ». Corrigées, avec les 33 de la beta.61.
### Un message en trois morceaux
`"WinGet n'a pas pu terminer cette "` + l'opération + `". Le rapport contient
les détails techniques (code "` + le code. La règle de fragment de tête aurait
traduit le début et laissé le reste en français — une phrase à moitié traduite,
exactement ce qu'on veut éviter. Un motif `englishPatterns` s'en charge, et la
valeur capturée passe par le dictionnaire : « mise à jour » devient bien
« update ». Vérifié sur les trois opérations possibles.
### Huit messages sortis de l'audit
`CliOut` et `CliErr` écrivent sur la console du shim CLI, jamais dans le DOM.
L'audit réclamait une traduction anglaise pour huit messages que l'observateur
de `i18n.js` ne verra jamais. Ils sont exclus, comme l'étaient déjà les appels
`Console.Write*`.
### Un garde sur l'espace finale
Les 46 fragments de tête (`"Dossier restauré : "`) doivent garder leur espace
finale **des deux côtés**. Une traduction qui la perd produit
« Folder restored:C:\Temp » — un défaut discret que personne ne signale.
`beta/test/i18n-fragments.test.js` l'interdit, dans les deux sens : une espace
perdue comme une espace ajoutée à une phrase complète. Validé par trois
sabotages.
### Ne pas passer i18n.js à prettier
Ce fichier est hors du périmètre de `npm run format:check`, qui tourne depuis
`beta/`. Le formater retire les guillemets des clés qui sont des identifiants
valides (`Accueil:` au lieu de `"Accueil":`), et l'audit ne les reconnaît plus.
C'est arrivé deux fois — la seconde a produit un diff de 2 533 lignes, annulé en
rejouant les scripts d'insertion. L'avertissement est désormais en tête du
fichier.
**Vérifié :** 248 tests JavaScript, 57 fichiers de tests PowerShell,
`prettier --check` au vert, audit i18n à 100 %, hôte C# recompilé.
## [4.0.0-beta.64] - 2026-09-01
### Les cinq alertes de sécurité ne touchaient pas le produit livré
GitHub signalait **5 vulnérabilités** sur `main` — une critique, une haute, trois
moyennes. Vérification faite avant de toucher quoi que ce soit : **aucune
n'atteint `OwlSetup.exe`**.
| Paquet | Portée | Ce qui est visé |
| --- | --- | --- |
| `vitest` (critique) | `development` | le serveur de l'UI Vitest, quand il écoute |
| `vite` (haute + 2 moyennes) | `development` | le serveur de développement |
| `esbuild` (moyenne) | `development` | le serveur de développement |
Les trois sont des dépendances de test. Le chemin de build livré n'en utilise
aucune : `build-js.mjs` et `build-css.mjs` concatènent avec Node seul, il n'y a
pas de bundler, et `build.ps1` enchaîne ces scripts puis `csc.exe`. Le dépôt ne
lance ni `vitest --ui` ni serveur de développement — `npm test` est
`vitest run`.
L'exposition réelle était donc nulle. Le bruit, lui, était réel : cinq alertes
permanentes finissent par masquer celle qui comptera.
### Ce qui change
`vitest` et `@vitest/coverage-v8` passent de **2.1.9 à 4.1.11**, ce qui entraîne
`vite` en 8.2.2 et fait **disparaître `esbuild`** de l'arbre. `npm audit`
rapporte désormais **0 vulnérabilité**.
Les majeures d'`eslint` (9 → 10) et de `globals` (15 → 17) sont **laissées de
côté** : elles n'ont aucun rapport avec ces alertes et toucheraient la
configuration de lint. Un lot de sécurité ne doit pas emporter une migration
d'outillage au passage.
### Épingles d'actions alignées
Les workflows utilisaient `actions/checkout` en **v4 dans `quality.yml` et v6
dans `release.yml`** — deux workflows qui ne récupèrent pas le dépôt de la même
façon. Tout est aligné sur les majeures courantes : `checkout@v7`,
`setup-node@v7`.
**Vérifié :** 243 tests JavaScript avec la version du dépôt (et non celle du
cache `npx`), 57 fichiers de tests PowerShell, `npm audit` à zéro.
## [4.0.0-beta.63] - 2026-08-31
### Le contrôle qualité était rouge depuis neuf commits, et c'était moi
En vérifiant la CI de la 4.0.0-beta.62, le workflow `quality` a échoué sur
**prettier**. Il échouait déjà, et pas depuis peu :
| Commit | Date | `quality` |
| --- | --- | --- |
| `e6a947e` | 30/08 09:40 | ✅ dernier vert |
| `03fa776` | 30/08 09:46 | ❌ |
| … neuf commits … | | ❌ |
| `a81ec09` (beta.62) | 31/08 | ❌ |
La cause est **`beta/PLAN-AMELIORATION.md`**, rejoint en beta.59 par
`beta/test/package-id.test.js`. Deux fichiers que j'écris à chaque lot, jamais
passés au formateur du dépôt.
### Pourquoi je ne l'ai pas vu
En 4.0.0-beta.58 j'ai écrit que « prettier n'est branché sur aucun workflow ».
C'était faux : mon `grep` était sensible à la casse et a manqué l'étape
`Prettier` de `quality.yml`, dont le nom commence par une majuscule. J'ai ensuite
travaillé neuf lots durant sur cette conclusion erronée, en lançant mes propres
contrôles au lieu de ceux du dépôt.
### Une seconde erreur, dans le diagnostic
En cherchant l'étendue du problème, `prettier --check` local a signalé **six**
fichiers, et j'en ai conclu que quatre échouaient avant moi. C'était faux aussi :
ce dépôt est en `core.autocrlf=true`, donc ma copie de travail est en CRLF, que
prettier refuse — alors que la CI, sous Linux, les voit en LF. Le journal de la
CI ne listait bien que **mes deux** fichiers.
Les deux sont formatés, `prettier --check` passe, et le reformatage est vérifié
neutre : les scripts régénèrent `app.js` et `catalog.generated.js` au bit près.
### Ce que j'en retiens
Un contrôle rouge en permanence n'apprend plus rien à personne — c'est le même
défaut que les trois lots précédents ont corrigé ailleurs, sauf qu'ici il était
visible depuis le début. Et un `grep` n'est pas une vérification : lancer les
contrôles du dépôt aurait donné la réponse en dix secondes.
**Vérifié :** 243 tests JavaScript, 57 fichiers de tests PowerShell,
`prettier --check` au vert.
## [4.0.0-beta.62] - 2026-08-31
### Lot 4 — le premier test qui lance vraiment l'interface
Neuf fichiers de tests exécutaient déjà le binaire, mais tous par le **mode
CLI**, qui rend la main avant même d'extraire les ressources. Le chemin
**graphique** n'était couvert par rien : `Bootstrap.Main`, l'extraction des
ressources embarquées, la résolution des assemblies WebView2, l'initialisation
de `CoreWebView2`, le contrôle d'intégrité, la création de la fenêtre. C'est
exactement le chemin qu'un mainteneur ne peut valider qu'en cliquant — et c'est
ainsi que la RC a été validée.
`tests/Test-InterfaceStartup.ps1` lance le vrai binaire, attend que l'interface
soit chargée, vérifie les ressources, puis referme proprement.
### Le piège que le sabotage a révélé
Ma première version attendait une **fenêtre** (`MainWindowHandle`). Elle aurait
réussi sur un démarrage raté : quand `InitializeWebView` échoue, l'hôte affiche
une `MessageBox` — et comme aucune `Form` n'existe encore, cette boîte **devient
la fenêtre principale du processus**.
Le signal retenu est donc le **processus enfant `msedgewebview2`** : il n'existe
que si `CoreWebView2` s'est réellement initialisé, et il ne dépend ni de la
version ni du canal.
Vérifié en compilant un binaire **privé de `styles.css`** : le test échoue bien
sur « OwlSetup s'est arrêté pendant le démarrage ». Deux autres sabotages
passent aussi — dépôt désynchronisé du binaire, exécutable absent en mode
`-Requis`.
### Le binaire embarque-t-il l'interface courante ?
Le test compare les ressources extraites aux fichiers du dépôt. Si `build.ps1`
échouait à régénérer `app.js` ou `styles.css`, l'exécutable servirait une
interface périmée sans que rien ne le signale. Rien ne le vérifiait.
### En CI, après la compilation
L'étape est placée **après** `build.ps1` : avant, l'exécutable n'existe pas et
le test se contenterait de passer en silence. `-Requis` le fait échouer si le
binaire manque, pour que cette étape ne puisse pas devenir un contrôle vide.
### Relevé en chemin : le contrôle d'intégrité promet plus qu'il ne vérifie
`Extract` réécrit les cinq ressources depuis l'assembly à **chaque** lancement
(`FileMode.Create`), quelques millisecondes avant que
`VerifyInterfaceIntegrity` ne compare ces mêmes fichiers à ces mêmes ressources.
Modifier `%LOCALAPPDATA%\PCSetup\App2\app.js` entre deux sessions n'a donc aucun
effet : le fichier est écrasé avant d'être lu. **Ce n'est pas une faille** — le
contenu servi est toujours celui de l'assembly, et la protection vient de
l'écrasement. Mais le message « L'interface locale de OwlSetup a été modifiée ou
endommagée » laisse croire à une vérification qui ne peut échouer que sur une
écriture corrompue. C'est noté au plan, pas corrigé ici.
### Correction de numérotation
La 4.0.0-beta.61 avait créé un second « Lot 7 » dans le plan, en collision avec
le mode CLI. La traduction de l'hôte devient le **lot 8**.
**Vérifié :** 243 tests JavaScript, 57 fichiers de tests PowerShell, démarrage
graphique réel validé par trois sabotages.
## [4.0.0-beta.61] - 2026-08-31
### i18n — l'audit annonçait 100 % parce qu'il ne regardait pas l'hôte
`audit-i18n.mjs` scannait `index.html` et `app.js`, et affichait
**1227/1227 chaînes, 100 %**. Il ne lisait pas `OwlSetupWebView.cs`.
Or l'hôte C# produit lui aussi du texte affiché — titres d'étape, messages
d'erreur, libellés de résultat — qui arrive dans le DOM par les messages postés
à la WebView. Une fois l'hôte scanné, la couverture réelle est de **84 %**, et
**234 chaînes** attendent leur traduction. Un utilisateur anglophone voit
l'interface en anglais et tout ce qui vient du natif en français.
### Le français, d'abord
Plus gênant que l'anglais manquant : **33 chaînes montrées aux utilisateurs
français avec des accents manquants**, à côté de 159 correctement accentuées.
« Operation terminee avec succes. », « Le logiciel est deja installe. Utilisez
plutot Mettre a jour ou Reparer. », « Windows a refuse l'acces. » — dans la
langue principale de l'application.
Une hypothèse était à écarter avant de corriger : une contrainte d'encodage
aurait pu justifier de les éviter, `csc.exe` du .NET Framework ne lisant l'UTF-8
que s'il y a un BOM, et `OwlSetupWebView.cs` n'en a pas. Vérification faite sur
les deux binaires — celui de `build.ps1` et celui du `.csproj` — les accents
sont corrects dans les deux. Pas de contrainte : juste une incohérence. Les 33
sont corrigées.
**Trois chaînes non accentuées sont volontairement laissées telles quelles.**
Ce ne sont pas des messages : `text.Contains("aucun package trouve")` compare la
sortie française de winget, et le code gère déjà ses deux formes, accentuée et
abîmée (`aucun package trouvé`). Les corriger casserait la détection.
### Un mécanisme au lieu de cent règles
L'hôte construit ses messages par concaténation : `"Emplacement demandé : "` +
un chemin, `"Analyse de "` + un nom + `"..."`. Le littéral n'est alors qu'un
fragment de tête, et la chaîne réelle n'existe dans aucun dictionnaire.
Traiter ces cas un par un aurait demandé une expression régulière par message.
`i18n.js` gagne à la place une décomposition générale : **un littéral qui finit
par une espace est un fragment de tête**, par construction — une phrase ne finit
jamais par une espace. Ces fragments sont donc des clés du dictionnaire avec
leur espace finale, ne peuvent pas se confondre avec un message entier, et la
valeur qui suit passe par le dictionnaire en correspondance exacte seulement,
comme les groupes capturés des motifs existants. Jamais de phrase à moitié
traduite.
L'audit reçoit le miroir exact de cette règle, comme pour les décompositions
précédentes.
### Une dette écrite, qui ne peut que baisser
Traduire les 234 chaînes dans le même lot aurait mélangé un changement de
mécanisme et deux cents traductions relues à la va-vite. Elles sont donc
**écrites dans `beta/i18n-dette.json`**, et `--check` sert de cliquet :
- une chaîne non traduite absente de la liste fait échouer ;
- une chaîne de la liste désormais traduite fait échouer aussi, pour que le
fichier soit réduit au fur et à mesure et ne dorme jamais.
Validé par trois sabotages : nouvelle chaîne française dans l'hôte, chaîne de la
dette traduite sans régénérer le fichier, dette vidée sans rien traduire —
**les trois échouent**.
Le message de fin ne dit plus « couverture complète » tant que la dette existe,
mais « aucune régression (1232/1466, 234 en dette déclarée) ». C'est exactement
le genre de message rassurant qui avait laissé croire à 100 %.
### Ce qui reste
Les 234 traductions, et une poignée de messages en trois morceaux
(`"…cette " + operation + ". Le rapport…"`) qui demanderont un motif plutôt
qu'un fragment de tête. Le mécanisme est en place pour les recevoir.
**Vérifié :** 243 tests JavaScript, 56 fichiers de tests PowerShell, hôte C#
recompilé (0 avertissement).
## [4.0.0-beta.60] - 2026-08-31
### Lot 3 — l'analyse de sécurité activée en beta.56 ne tournait nulle part
La 4.0.0-beta.56 annonçait « analyse de sécurité activée » : `EnableNETAnalyzers`,
`AnalysisModeSecurity=All`, et neuf règles `CA5xxx` traitées en erreur dans
`beta/csharp/OwlSetup.csproj`. Tout cela était exact — et **sans effet**.
- `build.ps1`, qui produit le binaire livré, compile avec **`csc.exe` du .NET
Framework**. Ce compilateur ne connaît pas les analyseurs Roslyn.
- `quality.yml` dit lui-même « ici on ne compile rien » : front-end seulement.
- `release.yml` n'appelait que `build.ps1`.
Le `.csproj` n'était donc construit par **aucune** CI. Il portait d'ailleurs sa
propre mise en garde : « Etat : traduction fidèle de build.ps1, **à valider par
un premier build côté mainteneur** ».
C'est le même défaut que celui corrigé à la beta.59, sous une autre forme : une
barrière qui a l'apparence d'une barrière et ne barre rien.
### Ce qui change
`release.yml` construit désormais le `.csproj` avant le build de production, sur
les PR touchant l'hôte, `build.ps1`, ou `beta/csharp/**`. Le projet compile en
**10 secondes, 0 avertissement, 0 erreur** — la barrière part d'une base propre.
**La preuve qu'elle barre :** en retirant un seul des quinze attributs
`[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]` posés en beta.56,
le build échoue sur `error CA5392`. Avant ce lot, exactement la même
modification passait en silence.
### Deux descriptions du même build
L'analyse ne vaut que si le `.csproj` compile bien **la même chose** que
`build.ps1`. Rien ne le garantissait : ce sont deux descriptions parallèles,
maintenues à la main, et une seule était vérifiée.
`tests/Test-BuildParity.ps1` les tient désormais ensemble — 15 ressources
embarquées comparées par leur nom logique (celui que `GetManifestResourceStream`
utilise), les références non implicites, la cible de compilation, et le fait que
la barrière de sécurité reste armée. Le `NoWarn` est compté : passer de deux
règles écartées à trois fait échouer le test, parce que chaque exclusion demande
une justification écrite.
Validé par cinq sabotages : ressource retirée du `.csproj`, ressource ajoutée à
`build.ps1` seulement, cible passée en `net472`, étape de CI vidée, déclencheur
`beta/csharp/**` retiré — **les cinq échouent**.
### Un test qui se laissait satisfaire par un commentaire
Le contrôle « la CI construit bien ce projet » cherchait la chaîne
`beta/csharp/OwlSetup.csproj` dans le workflow. En vidant l'étape pour la
tester, elle a continué de passer : le **commentaire** au-dessus de l'étape
mentionne le projet, et cela suffisait. Le motif vise maintenant la commande
elle-même. Sans le sabotage, ce test serait entré au dépôt en paraissant bon.
**Vérifié :** 243 tests JavaScript, 56 fichiers de tests PowerShell, `.csproj`
construit localement (0 avertissement).
## [4.0.0-beta.59] - 2026-08-30
### Lot 3 — le front ne disait plus la même règle que l'hôte, et le test qui devait s'en apercevoir mentait
La 4.0.0-beta.57 avait unifié la validation des identifiants de paquet **côté
hôte**, sur la plus stricte des trois formes trouvées. Elle n'a pas touché au
front-end. Résultat : depuis deux versions, l'interface acceptait des
identifiants que `OwlSetupWebView.cs` refuse.
La règle était écrite **six fois, en trois versions** :
| Lieu | Motif | ≥ 2 car. | Borne |
| --- | --- | --- | --- |
| `package-id.js` (module partagé) | `[A-Za-z0-9.+_-]*` | non | aucune |
| `legacy.js` — sélection restaurée | idem, recopié | non | aucune |
| `package-id.test.js` — copie figée | idem, recopié | non | aucune |
| `parity.test.js` — copie figée | idem, recopié | non | aucune |
| `legacy.js` — recherche étendue (×2) | `[A-Za-z0-9._+\-]{1,127}` | oui | 128 |
| **`OwlSetupWebView.cs`** (la seule qui décide) | `[A-Za-z0-9._+\-]{1,127}` | oui | 128 |
C'est la même dérive qu'au lot 3 côté C# — 27 copies, trois règles — mais côté
front, et sur le module dont c'est précisément le rôle de refléter l'hôte.
### Le test qui affirmait le contraire
`package-id.test.js` contenait ceci :
```js
it("est identique a la regex de l'hote C# (…)", () => {
expect(PACKAGE_ID_PATTERN.source).toBe("^[A-Za-z0-9][A-Za-z0-9.+_-]*$");
});
```
Il ne comparait pas le module à l'hôte : il le comparait à une **copie littérale
écrite dans le test lui-même**. Quand l'hôte a changé, la copie n'a pas bougé,
le test a continué de passer, et il a affirmé pendant deux versions une égalité
devenue fausse. Un test qui ne va pas chercher l'autre côté de la frontière ne
garde pas cette frontière — il garde sa propre copie.
Il lit désormais `OwlSetupWebView.cs`, en extrait `PackageIdPattern`, et compare
de deux façons : la chaîne (aux conventions d'échappement près, documentées) et
le **comportement** sur un corpus de vingt cas limites. Une reformulation
équivalente en apparence seulement ne passe pas.
### Ce que ça changeait en pratique
Peu, et il faut le dire : les 93 applications du catalogue mesurent entre 7 et
39 caractères — aucune n'est concernée, et c'est vérifié par un test. Les seuls
identifiants pouvant sortir des bornes viennent de la découverte d'applications
installées et de la recherche WinGet étendue, où ils sont possibles sans avoir
été observés. L'écart était réel mais dormant.
Le point n'est pas le dégât évité, c'est que l'invariant est maintenant
**vérifié** au lieu d'être affirmé à tort. Une application découverte avec un
identifiant hors bornes entrait dans le catalogue, s'affichait, se laissait
sélectionner — et l'installation ne faisait rien, sans message. Le front refuse
désormais ce que l'hôte refusera.
### Nettoyage
Les trois copies inline de `legacy.js` sont retirées ; la restauration de la
sélection passe par `sanitizePackageIds`, dont c'était déjà le rôle exact. Un
garde vérifie qu'aucune copie ne revient — en visant la forme
`[A-Za-z0-9][A-Za-z0-9`, pour ne pas confondre avec la regex de recherche
étendue, qui est une autre règle sur une autre donnée.
Les quatre contrôles ont été validés en cassant le code : hôte durci sans le
front, front relâché sans l'hôte, `PackageIdPattern` renommé, copie inline
réintroduite — **les quatre échouent bien**.
### Audit sans suite : l'export de scripts PowerShell
Les trois générateurs (`OwlSetup-Installer.ps1`, `Mettre-a-jour-mon-PC.ps1`,
`Liberer-espace-disque.ps1`) construisent du PowerShell par interpolation de
chaînes en JavaScript, et n'avaient jamais été audités. **Aucune faille :**
`generateUpdateScript` n'interpole rien, `generateCleanupScript` n'assemble que
des littéraux choisis par des clés fixes, et les deux valeurs de
`generateScript` sont contraintes — `app.id` est validé sur ses trois chemins
d'entrée, `app.source` n'est jamais écrit depuis une donnée externe.
C'est vrai par accident plutôt que par construction : `mergeDiscoveredInstalledApps`
lit `item.source` deux lignes au-dessus de l'endroit où il construit l'objet
sans le poser. Échapper ces valeurs à la construction reste à faire.
**Vérifié :** 243 tests JavaScript, 55 fichiers de tests PowerShell.
## [4.0.0-beta.58] - 2026-08-30
### Lot 2 — le routeur de 827 lignes, découpé sans en changer une seule
`handleInstallMessage` recevait **tous** les messages de l'hôte C# : 97 branches
`message.type === "…"` à plat, **829 lignes**. À elle seule, cette fonction
pesait **23,6 % de tout le code fonctionnel** de `legacy.js` — onze fois la
suivante (`renderWindowsUpdates`, 75 lignes). Toute panne d'affichage commençait
par une lecture en diagonale de ces 829 lignes.
Elle est maintenant **dix fonctions de domaine** et un répartiteur de treize
lignes :
| Domaine | Types | Lignes |
| --- | ---: | ---: |
| `handleQuarantineAndCleanupMessage` | 8 | 120 |
| `handleInstallFlowMessage` | 10 | 107 |
| `handleSystemAndConfigMessage` | 17 | 102 |
| `handleUpdateAndInstalledMessage` | 5 | 93 |
| `handleSimulationMessage` | 10 | 86 |
| `handleUninstallAndRepairMessage` | 6 | 83 |
| `handleDiskAndBatchMessage` | 8 | 82 |
| `handleHealthAndWindowsUpdateMessage` | 16 | 75 |
| `handleToolAndSecurityMessage` | 4 | 60 |
| `handleHistoryMessage` | 12 | 40 |
La plus grosse fonction du fichier passe de **829 à 120 lignes**, de 23,6 % à
3,4 % du total.
**Aucun corps de branche n'a été touché.** Le découpage a été fait
mécaniquement, puis vérifié en comparant les 827 lignes d'origine à la
concaténation des dix nouveaux corps : **identiques, ligne pour ligne**. Le
comportement ne peut pas avoir changé — seul le choix du domaine est nouveau.
Une condition préalable rendait l'opération sûre : sur les 97 branches, **95
testent uniquement `message.type`**, et les deux seules conditions composées
(la paire `uninstall-residues-complete`, distinguée par `message.context`)
tombent dans le même domaine. Aucune branche ne dépendait donc de la chute vers
la suivante.
### Un garde qui ne gardait rien
`if (!message) return;` se trouvait **après** le premier accès à
`message.type` : un message nul levait une exception au lieu d'être écarté. Il
est remonté dans le répartiteur, où il protège réellement.
### Le mode de panne que ce découpage introduit, et son filet
Le répartiteur choisit le domaine dans une table `MESSAGE_DOMAINS`. Cette table
peut désormais **diverger du code** : une branche non déclarée n'est jamais
atteinte, un type déclaré sans branche route vers rien. Dans les deux cas, le
message est ignoré en silence — exactement le genre de panne que
`beta/test/ipc-contract.test.js` existait pour empêcher.
Trois contrôles ont donc été ajoutés à ce test : la table décrit exactement les
branches de chaque fonction, aucun type n'est réclamé par deux domaines, aucune
branche ne vit hors des domaines. Ils ont été validés en cassant délibérément le
code — type retiré de la table, type déclaré sans branche, type dupliqué entre
deux domaines, branche déplacée dans un dispatcher parallèle : **les quatre
sabotages sont détectés**.
**Vérifié :** 237 tests JavaScript, 55 fichiers de tests PowerShell, `app.js`
régénéré et contrôlé syntaxiquement.
## [4.0.0-beta.57] - 2026-08-30
### Lot 3 — trois regex de sécurité pour une seule règle
Audit des **41 appels** à winget : chaque valeur interpolée dans une ligne de
commande est bien assainie — identifiants par expression régulière, noms et
chemins par retrait des guillemets, fichiers d'export construits sur un GUID.
Aucun trou.
**Mais la règle elle-même était écrite 27 fois, sous trois formes qui ne
disaient pas la même chose :**
| Forme | Occurrences | Accepte un caractère ? | Longueur maximale |
| --- | ---: | --- | --- |
| `[A-Za-z0-9.+_-]*` | 21 | oui | aucune |
| `[A-Za-z0-9.+_-]{0,127}` | 4 | oui | 128 |
| `[A-Za-z0-9._+\-]{1,127}` | 2 | **non** | 128 |
Le même identifiant pouvait donc être **accepté à une entrée et refusé à une
autre**, selon le chemin emprunté. C'est précisément la dérive qui avait laissé
`Installer-selection.ps1` sur l'ancienne regex jusqu'à la 4.0.0-beta.53 : une
règle recopiée finit toujours par diverger.
Une seule déclaration désormais — `PackageIdPattern` / `IsValidPackageId` —
alignée sur **la plus stricte des trois**. Les 93 applications du catalogue
mesurent entre 7 et 39 caractères : aucune n'est concernée. Les quatre appels
venant de la classe `Bootstrap` sont qualifiés, une méthode statique d'une autre
classe ne s'appelant pas sans préfixe.
**Deux tests existants ont dû changer de critère.** Ils vérifiaient la
*présence du littéral* dans le source : les laisser tels quels aurait exigé la
duplication qu'on venait de retirer. Ils vérifient maintenant l'appel à la
source unique, le comportement du motif étant couvert à part.
Garde : `tests/Test-PackageIdValidation.ps1` — une seule déclaration dans le
fichier, appels qualifiés depuis `Bootstrap`, puis le comportement réel par
réflexion : identifiants légitimes acceptés, `-Force`, `--source`, guillemets,
chaîne vide, caractère unique et identifiant de 200 caractères refusés, et les
93 applications du catalogue passées une à une.
## [4.0.0-beta.56] - 2026-08-30
### Lot 3 — appels natifs confinés à System32, analyse de sécurité activée
Les analyseurs Roslyn n'avaient jamais tourné sur l'hôte C#. Mis en analyse
complète, ils lèvent **plus de 1 250 avertissements** — dont 390 `catch`
génériques et 300 appels sans `IFormatProvider`. Passer tout cela en erreur
bloquerait le build sans rien apprendre d'utile. La **catégorie sécurité**, elle,
est courte et actionnable : c'est elle qui est désormais traitée en erreur.
**Le vrai correctif : 15 P/Invoke confinés à System32.**
Sans l'attribut `DefaultDllImportSearchPaths`, un appel natif suit l'ordre de
recherche par défaut de Windows, qui inclut **le dossier de l'application et le
répertoire courant**. Une DLL déposée à côté de l'exécutable peut alors être
chargée à la place de celle du système.
Le risque n'était pas théorique partout, mais il ne l'était pas nulle part non
plus : `kernel32` et `advapi32` sont des **KnownDLLs**, que Windows protège
déjà de ce détournement — mais **`userenv.dll`, `wscapi.dll` et `dwmapi.dll` ne
le sont pas**. Ce sont précisément celles utilisées pour créer un bloc
d'environnement lors d'une opération élevée, lire l'état du Centre de sécurité,
et dessiner la barre de titre. L'attribut est appliqué aux 15 déclarations,
plutôt que de maintenir à la main la liste des DLL protégées par Windows.
**Deux règles écartées, après examen et non par confort** — la justification est
écrite dans le `.csproj` :
- **CA5386** conseille `SecurityProtocolType.SystemDefault` plutôt qu'un
protocole codé en dur. Sur .NET Framework 4.6.2, le défaut système peut encore
autoriser TLS 1.0 : forcer TLS 1.2 est ici le choix **le plus sûr**. Le nombre
magique `3072` devient toutefois `SecurityProtocolType.Tls12`.
- **CA2322** ne vaut que pour un `JavaScriptSerializer` construit avec un
`JavaScriptTypeResolver`. Ici il est toujours construit sans, donc il ne peut
produire que des dictionnaires, des tableaux et des primitives.
Le certificat de signature est également libéré (`using`) : il détenait des
ressources non managées jusqu'au passage du ramasse-miettes.
Garde : `tests/Test-NativeInteropHardening.ps1` — chaque P/Invoke porte
l'attribut, aucune DLL inattendue n'est importée, le forçage TLS explicite
subsiste, et la catégorie sécurité reste en erreur dans le projet.
## [4.0.0-beta.55] - 2026-08-30
### Lot 2 — où est vraiment la masse d'`app.js`, et un filet avant d'y toucher
Première tranche du découpage du front-end. Avant de couper, j'ai mesuré — et
la masse n'est pas là où le plan la supposait.
Sur les **236 fonctions** de premier niveau de `legacy.js`,
**`handleInstallMessage` en fait 827 à elle seule, soit 18 % du fichier**. C'est
un routeur plat de **97 branches** `message.type === "…"`, et le **seul** point
d'entrée des messages envoyés par l'hôte C#.
Deuxième constat : seules **34 fonctions (279 lignes)** sont exemptes de DOM et
d'état global, et les plus grosses sont déjà extraites. Le reste du fichier
demandera un découpage **par couches**, pas une simple récolte de fonctions
pures — c'est une correction utile de la trajectoire du lot.
**Un filet avant de découper ce routeur.**
`beta/test/ipc-contract.test.js` tient ensemble les deux côtés de l'IPC : tout
type émis par `OwlSetupWebView.cs` doit avoir une branche, et toute branche doit
correspondre à un type émis. Un message non traité ne provoque aucune erreur —
il est simplement ignoré, et la fonctionnalité ne fait rien. C'est le genre de
panne silencieuse qu'un découpage de 827 lignes peut introduire sans bruit.
Le test lit **les deux formes** d'émission côté C# : l'initialiseur d'objet
`new { type="x" }` et l'affectation `snapshot["type"]="x"`. N'en chercher
qu'une faisait passer un handler bien vivant pour du code mort. Résultat
actuel : **96 types, aucun orphelin des deux côtés**.
**Première extraction.** `operation-summary.js` porte les libellés d'issue de
quarantaine, que le routeur construisait **deux fois** — une pour la
désinstallation simple, une pour la groupée — avec des titres et des détails
rigoureusement identiques et seule la phrase du panneau qui changeait.
Le test de parité recopie **les chaînes de l'ancien code** comme valeurs
attendues, et vérifie en plus qu'elles ont toujours une entrée dans `i18n.js` :
en modifier une ferait retomber ce texte en français dans l'interface anglaise.
## [4.0.0-beta.54] - 2026-08-30
### Lot 4 — les quatre scripts d'opération sont couverts, `winget` simulé
`Nettoyer-residus-applications.ps1` et `Mettre-a-jour-mon-PC.ps1` rejoignent les
deux premiers : leur logique passe en **fonctions importables**, chargées par
`-AsModule` sans rien exécuter. La suite Pester passe de **15 à 37 tests**.
**`winget` est désormais simulé.** L'appel réel est isolé dans une fonction
d'enveloppe que les tests remplacent par `Mock` : on vérifie que la branche
« winget absent » ne l'appelle **jamais**, qu'un code 0 donne « réussi » et
qu'un code non nul demande une vérification — sans rien installer sur la
machine.
**Un test tient ensemble deux côtés du code.** Quand un dossier part en
quarantaine, son nom porte l'emplacement d'origine en préfixe (`Local-`,
`Roaming-`, `ProgramData-`). C'est ce préfixe que `RestoreQuarantine`
(`OwlSetupWebView.cs`) relit pour savoir où remettre le dossier. Si les deux
divergent, **la restauration échoue en silence** : le test compare maintenant
ce que le script produit à ce que l'hôte C# sait relire.
La sélection des résidus était jusqu'ici enfouie dans un `Where-Object` en
chaîne. Elle devient une fonction, `Test-OwlSetupResidueCandidate`, avec ses
règles nommées et testées : âge supérieur à 90 jours, pas un lien symbolique,
nom d'au moins 4 caractères, hors liste protégée, sans point initial, et aucune
application installée dont le nom se rapproche — **dans les deux sens**, car
« vlcmedia » contient « vlc » comme l'inverse.
Le comportement de la normalisation est documenté au lieu d'être subi : les
caractères accentués disparaissent (« Café » → « caf »), le rapprochement étant
volontairement grossier.
**Vérifié en cassant le code** : changer le préfixe de quarantaine produit 4
échecs, dont celui qui compare au C#.
## [4.0.0-beta.53] - 2026-08-30
### Lot 4 — premiers tests de comportement, et une regex de sécurité oubliée
Les ~45 tests PowerShell du dépôt vérifient surtout la **présence de chaînes**
dans le source. Ils n'exécutent presque rien : un fichier peut contenir le bon
texte et se comporter mal.
`Installer-selection.ps1` et `Liberer-espace-disque.ps1` exposent désormais
leur logique en **fonctions importables**. Le drapeau `-AsModule` charge le
fichier sans rien exécuter, ce qui permet de tester les fonctions directement.
**Un seul fichier par script, volontairement.** Sortir la logique dans un
`.psm1` aurait ajouté une ressource à embarquer, à extraire, et surtout à
recopier dans le dossier d'exécution élevé à ACL stricte — un fichier de plus
dont l'intégrité conditionne une exécution administrateur. Le drapeau évite
tout cela.
**Une regex de sécurité avait été oubliée.** Le durcissement de la beta.2 —
« un identifiant de paquet doit commencer par un caractère alphanumérique » —
avait été appliqué à `app.js`, `OwlSetupWebView.cs`, `tools/check-catalog.mjs`
et `beta/`, mais **pas à `Installer-selection.ps1`**, resté sur
`^[A-Za-z0-9.+_-]+$`. Cette regex accepte `-Force`, `--source` ou `-h`, que
winget lirait comme des drapeaux et non comme des noms de paquet. Le script est
alimenté par OwlSetup, qui valide déjà — c'était donc de la défense en
profondeur manquante, sur un fichier extrait sur disque et lançable seul.
**15 tests Pester** (`tests/Pester/`, lancés par
`tests/Test-OperationScripts.ps1`) couvrent la validation des identifiants, la
lecture des sélections (liste, JSON, vide), le filtrage des zones de nettoyage,
et la suppression de contenu — dont le refus de vider un dossier qui est
lui-même une jonction.
Pester 3.4 est livré avec Windows : aucune dépendance ajoutée.
**La suite a été validée en cassant volontairement le code** : le retour de
l'ancienne regex produit 5 échecs, le retrait du garde-fou des jonctions 1
échec. Un test qui ne peut pas échouer ne prouve rien.
Cette vérification a d'ailleurs montré qu'un des tests **ne discriminait pas** :
retirer le filtre sur les points d'analyse des enfants ne le fait pas échouer,
`Remove-Item -Recurse` ne traversant pas une jonction sur PowerShell 5.1. Le
filtre reste (ce comportement a varié selon les versions de Windows), mais le
test dit maintenant clairement qu'il vérifie le contrat, pas l'implémentation.
## [4.0.0-beta.52] - 2026-08-30
### Lot 7 — auto-élévation du mode CLI, avec relais de sortie
Le mode ligne de commande détectait l'absence de droits administrateur, mais ne
savait qu'avertir : « relancez depuis une invite Administrateur ». Pour un
déploiement en parc, cela veut dire deux exécutions et un opérateur devant
l'écran.
`--elevate` relance OwlSetup en administrateur, puis **rejoue la sortie et le
code de sortie** de l'exécution élevée vers l'appelant : un script voit
exactement ce qu'il aurait vu sans élévation.
**Pourquoi un fichier de relais.** Une élévation passe forcément par
`ShellExecute` + `runas`, qui **interdit la redirection des flux** : le
processus élevé ne peut pas écrire dans la console de l'appelant. Il écrit donc
dans un fichier que le parent recopie sur sa propre sortie, puis supprime.
**L'élévation est opt-in, à dessein.** Sans le drapeau, aucune invite UAC
n'apparaît et le comportement ne change pas : les actions qui exigent des
droits sont signalées puis ignorées. Un script ou un MDM ne doit jamais se
bloquer sur une invite qu'il n'a pas demandée. `--elevate` ne s'applique qu'à
`--install`, `--uninstall`, `--apply` et `--update`, et reste sans effet avec
`--dry-run` — demander l'UAC pour une simulation n'aurait aucun sens.
Trois garde-fous côté processus élevé :
- il **ne s'élève jamais lui-même** : invoqué sans droits, il renvoie 740 ;
- il **valide son fichier de relais** — confiné au dossier des journaux, nom
conforme à un motif généré ; une remontée de chemin est refusée ;
- `--elevate` est **retiré des arguments relayés**, pour qu'aucune boucle
d'élévation ne soit possible.
La ligne de commande de l'enfant est construite à la convention Windows, avec
les antislashs précédant un guillemet doublés : sans cela, un dossier terminé
par `\` échapperait le guillemet fermant et décalerait tout le reste.
Garde : `tests/Test-CliElevation.ps1` — câblage, puis comportement réel par
réflexion (mise entre guillemets, refus des chemins de relais illégitimes,
refus hors élévation) et exécution réelle de `--elevate --dry-run`, qui doit
rendre 0 sans aucune invite.
**Le trajet UAC complet reste à essayer sur le PC de test** : une invite
interactive ne peut pas être automatisée ici.
## [4.0.0-beta.51] - 2026-08-30
### Lot 3 — trace des opérations élevées et dernier trou de validation
Deux points de sécurité du plan restaient décochés.
**Les opérations élevées ne laissaient pas de trace fiable.**
`RunElevatedProcess` n'écrivait que dans le rapport de son appelant : rien ne
garantissait qu'il soit persisté, et un nouvel appelant pouvait l'oublier. La
trace est désormais écrite **par `RunElevatedProcess` lui-même**, dans
`PC-Setup-Elevations.log` :
- la **demande** est tracée **avant** le lancement — si l'application est
interrompue pendant l'opération, l'historique garde ce qui a été demandé ;
- puis l'issue : code de sortie, refus de l'invite UAC, ou échec.
Le fichier suit la convention de nommage des journaux, il apparaît donc dans
l'historique local et s'ouvre depuis l'interface. Il est tronqué à 512 Ko en
gardant les entrées **récentes**, pour rester sous la limite d'affichage
d'`OpenLog` (2 Mo). Une trace d'audit ne doit jamais faire échouer l'opération
qu'elle observe : toute erreur d'écriture est ignorée.
**Un seul trou de validation de chemin restait.** L'audit des handlers montre
que `OpenLog`, `OpenReport`, `GetQuarantineItem` et `GetAuthorizedDiskTarget`
étaient déjà confinés — nom de fichier seul, racine autorisée, liste blanche
issue d'une analyse préalable, refus des points de jonction.
`ValidateInstallBasePath` faisait exception : il ne travaillait que sur le
chemin **textuel** (pas la racine du disque, pas le dossier Windows, pas de
guillemet). Un dossier d'installation existant pouvait donc être une
**jonction** redirigeant vers une zone protégée, invisible pour ces contrôles.
Il vérifie maintenant chaque composant depuis la racine du disque, comme le
faisait déjà `GetAuthorizedDiskTarget`.
Garde : `tests/Test-ElevationAudit.ps1` — câblage dans le source, puis
comportement réel des helpers par réflexion sur l'exécutable compilé
(aplatissement des sauts de ligne, troncature des arguments longs, rotation qui
conserve bien la fin du journal et laisse un fichier sous le seuil intact).
## [4.0.0-beta.50] - 2026-08-30
### Lot 6 — les chaînes construites par interpolation passent en anglais
La beta.49 laissait **152 chaînes** en français : celles que le code assemble
avec une valeur, comme « 3 mises à jour disponibles » ou « Installer la
sélection (2) ». Aucune clé de dictionnaire ne peut les couvrir, puisque le
nombre change à chaque affichage.
**Écrire un motif par forme aurait demandé plus de 150 expressions
régulières**, dont beaucoup avec la marque du pluriel au milieu d'un mot
(`mise${s} à jour`) — autant d'occasions de fautes d'accord.
`i18n.js` **décompose** désormais la chaîne autour de ses parties variables :
- compteur en tête (« 3 mises à jour disponibles ») ;
- compteur final entre parenthèses (« Installer la sélection (2) ») ;
- segments séparés par « · » (« 4 installée(s) · 1 à vérifier »).
Le texte fixe passe alors par le dictionnaire — des **clés exactes**, sans
risque d'accord. La décomposition n'est retenue que si **toutes** les parties
se traduisent : une phrase à moitié anglaise serait pire que la version
française. Un segment sans texte (« 4,2 Go », « 45 % ») passe tel quel plutôt
que de faire échouer la phrase entière.
**98 fragments** ajoutés au dictionnaire, **58 motifs** pour les cas où la
valeur est au milieu de la phrase (« Désinstallation de X », « Réparation
incomplète (code N) »). Les groupes capturés passent eux aussi par le
dictionnaire, en correspondance exacte : « Étape 1/3 préparée : **Nettoyage** »
devient « Step 1/3 prepared: **Cleanup** », tandis qu'un nom d'application
reste intact.
**Trois défauts de l'audit corrigés au passage**, tous découverts parce que le
compte refusait de descendre :
- les **sondes d'instanciation** substituaient un nombre à la marque du
pluriel, produisant « 1 mise1 à jour » — une chaîne qui n'existe nulle part.
L'audit reconnaît maintenant un accord (`? "s" : ""`, mais aussi
`? "nt" : ""` pour « résiste » / « résistent ») et rend le gabarit au
singulier **puis** au pluriel ;
- le **parseur de motifs** exigeait `[/` collés, donc ignorait toute entrée
formatée sur plusieurs lignes — l'audit signalait des trous déjà couverts ;
- la liste des fragments manquants était filtrée par la détection du français,
ce qui **masquait** « libres » ou « introuvable(s) », sans accent.
La porte `--check` **inclut désormais les interpolations** : elles sont toutes
couvertes, elle protège donc l'acquis.
Vérifié à l'écran en anglais, toutes vues et fenêtres affichées : **0 texte** et
**0 attribut** en français, hors le badge BÊTA et les noms de langues.
## [4.0.0-beta.49] - 2026-08-30
### Lot 6 — traduction anglaise complète de l'interface
En mode anglais, **491 chaînes restaient en français** : tout ce que rend
`app.js` (résultats d'opération, messages d'erreur, états), plus des libellés
d'`index.html` qui échappaient à l'audit.
**L'outil d'audit était la vraie cause.** Il annonçait « `index.html` : 100 % »
alors que 16 chaînes de ce fichier n'étaient même pas comptées. Trois défauts :
- **La détection du français exigeait un accent** ou un mot-outil. « Espace
disque », « Langue », « Validation avant action » passaient donc pour de
l'anglais. La liste s'étend à des mots français sans accent — en excluant
ceux qui s'écrivent pareil en anglais (`installation`, `version`, `guide`).
- **Le filtre « identifiant de paquet »** (`^[A-Za-z0-9._+-]+$`) rejetait
« Analyse... » à cause de ses points de suspension. Il exige maintenant un
séparateur **entre** deux groupes alphanumériques (`Microsoft.Edge`).
- **Les littéraux de `app.js` étaient extraits à l'expression régulière.** Une
apostrophe française dans une chaîne à guillemets doubles (`"n'a pas ...
l'application"`) faisait croire à un littéral simple quote « a pas ... l ».
Un vrai scanner JavaScript remplace les regex : il suit les commentaires, les
trois types de guillemets, les échappements, l'imbrication `${}` et les
littéraux réguliers.
L'extraction gagne aussi en précision : le HTML contenu dans un littéral passe
par le tokeniseur (les nœuds de texte sont comptés un par un, plus le bloc
entier), et les scripts PowerShell comme les sorties console sont écartés — ils
n'atteignent jamais le DOM.
**Résultat : 1 227 chaînes, 100 %.** 489 traductions ajoutées, plus un motif
pour l'attribut `title` des cartes du catalogue, qui couvre à lui seul les
**95 boutons « Ouvrir le site officiel de… »**.
La porte `--check` **bloque désormais sur `app.js` aussi**, et plus seulement
sur `index.html` : l'extraction est assez fiable pour cela.
Vérifié à l'écran, toutes vues et fenêtres affichées : **0 attribut** et **0
texte** en français, hors le badge BÊTA et les noms de langues (`Français`,
`Demnächst`, `Português`), laissés dans leur langue à dessein.
**Ce qui reste.** 152 chaînes sont **construites par interpolation** (« 3 mises
à jour disponibles ») : le nœud rendu mêle texte et valeurs, aucune clé exacte
ne peut correspondre. Elles relèvent d'un motif dans `englishPatterns`, souvent
avec la marque du pluriel au milieu d'un mot (`mise${s} à jour`) — c'est une
passe à part, avec ses propres tests. L'audit les compte et les liste
séparément, hors de la porte.
## [4.0.0-beta.48] - 2026-08-30
### Lot 6 — `styles.css` découpé en partiels
`styles.css` était un fichier unique de 1 445 lignes, dont **20 dépassaient
2 000 caractères** (la plus longue : 7 326). Les règles s'y étaient accumulées
par ajouts successifs — « Beta 3.7 », « Audit beta.37 », « 4.0.0-beta.5 » — sans
jamais être regroupées.
La feuille est désormais **formatée**, puis découpée en **10 partiels** dans
`beta/src/styles/`, réassemblés par `beta/scripts/build-css.mjs` que `build.ps1`
appelle avant la compilation — même mécanique que `app.js`.
**L'ordre des partiels est significatif** et le préfixe numérique le rend
explicite : la feuille s'est construite par accumulation, et les surcharges du
thème clair, de l'accessibilité et des contrastes s'appuient sur le fait
d'arriver **après** les règles de base, à spécificité parfois égale. Les coupes
ont donc été faites à l'endroit exact où elles tombent dans l'ordre d'origine,
sans jamais réordonner.
Pas de minification, contrairement à ce que prévoyait le plan : la feuille est
chargée depuis l'hôte virtuel local par WebView2, **jamais sur le réseau**.
Minifier ne ferait gagner aucun temps de chargement et compliquerait le
débogage — c'est déjà le raisonnement retenu pour `build-js.mjs`.
**Aucun changement de rendu.** Deux vérifications :
- la concaténation des partiels reproduit le fichier formaté **octet pour
octet** ;
- après normalisation des écarts purement cosmétiques du formateur (zéros de
tête, espaces dans les parenthèses, `!important`, `@media (`, opérateurs de
`calc()`, guillemets d'attribut), le résultat est **identique au caractère
près** à l'ancien `styles.css` — 251 099 caractères de part et d'autre.
Contrôlé aussi à l'écran : 2 490 règles chargées, 0 échec de contraste sur les
4 combinaisons de thème.
Garde : `beta/test/styles-bundle.test.js` — déterminisme, correspondance entre
le fichier généré et ses partiels, ordre de concaténation, accolades
équilibrées.
**Tests PowerShell rendus insensibles à la mise en forme.** 18 tests
inspectaient `styles.css` en cherchant des motifs compacts
(`#catalog .catalog-tools{`, `button,input,select,textarea,`) ; 10 se sont
cassés au formatage. Plutôt que de les recoller à la sortie exacte du
formateur — ce qui les aurait rendus fragiles à chaque reformatage — un helper
partagé `tests/lib/CssText.ps1` ramène CSS et motif à une forme comparable.
Les tests vérifient désormais le **contenu** des règles, pas leur présentation.
## [4.0.0-beta.47] - 2026-08-30
### Lot 6 — contrastes WCAG AA
L'interface a été mesurée à l'écran sur les **quatre combinaisons de thème**
(clair et sombre, chacun avec et sans contraste renforcé), au seuil WCAG AA de
4,5:1 (3:1 pour le grand texte). Sur 3 947 éléments porteurs de texte,
**132 couleurs distinctes passaient sous le seuil**.
La cause n'était pas une couleur mal choisie ici ou là : la feuille de style
improvisait **une nuance de gris par règle** — 90 gris différents pour dire
« texte secondaire » — au lieu du token `--muted` déjà prévu pour ça. Chaque
nuance devait donc être corrigée séparément, dans chaque thème.
Le correctif rebranche ces textes sur des **tokens** :
- `--muted` pour les textes secondaires ;
- `--text-blue`, `--text-cyan`, `--text-green`, `--text-danger` et
`--text-warn` pour les textes de couleur. Ils sont **distincts** de
`--blue`, `--cyan` et `--green`, qui servent aussi aux fonds et aux bordures
et ne peuvent donc pas être assombris librement.
Chaque token est redéfini par thème avec une valeur garantie au-dessus de
4,5:1 sur toutes les surfaces de ce thème. **216 déclarations** de couleur ont
été converties.
Trois défauts n'étaient pas des problèmes de couleur mais de support :
- **`.btn.ghost`** (le bouton « Parcourir… » du sélecteur de dossier) n'était
déclaré nulle part : le navigateur lui appliquait sa face de bouton native
(`#f0f0f0`), donc un texte clair sur fond clair en thème sombre. Il a
désormais son propre fond, tiré des tokens.
- Le **« / 100 »** des jauges se superposait au remplissage : selon le
pourcentage, son support était le panneau, le bleu ou le vert. Il reçoit un
fond opaque pour que son contraste ne dépende plus du remplissage.
- La **barre de sélection** et le **badge d'étape** héritaient de `--text` sur
un bleu plein — texte sombre sur fond bleu en thème clair.
Un test verrouille l'invariant (`beta/test/contrast.test.js`) : il recalcule le
contraste de chaque token de texte sur chaque surface des quatre thèmes, et
refuse le retour des 132 littéraux connus comme non conformes dans une
déclaration `color:`.
## [4.0.0-beta.46] - 2026-08-29
### Lot 6 — accessibilité au clavier
Les 19 fenêtres de l'application déclaraient `aria-modal="true"`, mais **seules
deux piégeaient le focus** : dans les 17 autres, la tabulation sortait derrière
la fenêtre et continuait dans la page masquée. Au clavier, on perdait le fil
sans pouvoir revenir.
Un **mécanisme unique** remplace les deux pièges écrits à la main. Il observe
l'affichage des boîtes (classe `hidden`) et se charge de tout :
- à l'ouverture, il mémorise l'élément déclencheur et place le focus sur le
premier contrôle utile — pas sur la croix de fermeture ;
- pendant, il retient Tab et Maj+Tab, et
ramène le focus dans la boîte s'il en est sorti ;
- **Échap** ferme la boîte, mais **uniquement si elle expose un bouton de
fermeture** : les trois boîtes obligatoires (langue, premier démarrage,
guide) n'en ont pas et restent non annulables ;
- à la fermeture, il **rend le focus à l'élément déclencheur**.
Vérifié à l'écran sur les **19 boîtes** : focus déplacé dedans, tabulation
bouclée dans les deux sens, retour dans la boîte depuis l'extérieur — aucun
échec. Le cycle complet déclencheur → boîte → Échap → déclencheur a été
contrôlé de bout en bout.
### Animations réduites
`prefers-reduced-motion` n'était honoré que par le parcours d'accueil :
**21 animations et 39 transitions** restaient actives. La règle couvre
désormais toute l'interface, défilement doux compris.
### Anneau de focus visible
Plusieurs contrôles s'appuyaient sur l'anneau par défaut du navigateur,
invisible sur les fonds sombres de l'application. Un `:focus-visible` explicite
est appliqué partout, décliné pour le thème clair.
### Tests
Nouveau `tests/Test-Accessibility.ps1` : mécanisme générique, règle de
fermeture par Échap, neutralisation des animations, anneau de focus, et
étiquette accessible sur chacune des 19 boîtes. Il vérifie aussi que les boîtes
obligatoires **ne gagnent pas** de bouton de fermeture — sinon Échap se
mettrait à les fermer sans que personne ne le remarque.
Vérifié : `tests/Test-ReleaseCandidateReadiness.ps1` vert, intégrité SHA-256
OK, l'application démarre.
## [4.0.0-beta.45] - 2026-08-29
### CodeQL : 5 alertes « high » dans l'outil d'audit de la bêta 44
L'analyse de sécurité a signalé cinq alertes de gravité haute, toutes dans le
nouveau `beta/scripts/audit-i18n.mjs` : j'y découpais du HTML à coups
d'expressions régulières.
- `js/bad-tag-filter` : `/`.
- `js/incomplete-multi-character-sanitization` (×3) : la chaîne de `replace()`
pouvait laisser passer `