Tabbing into an input already selects its whole content, so the second clear was redundant. Where Tab lands on something that is not editable - a link, a reveal button, the document, as on the Reddit login form - Ctrl+A selected the entire page instead and the password went nowhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
39 KiB
Scénarios de test — écriture concurrente & fermeture de l'éditeur
Sessions du 2026-07-22. Tous les tests sont manuels (ils portent sur des
interactions UI que le harness node:test ne couvre pas). Rebuild obligatoire
avant de commencer : delphi-backend\assets\BuildAssets.cmd puis build Delphi.
Convention : PRÉ = état de départ, ACTION, ATTENDU. Un test qui échoue = régression, noter le commit courant.
Les 4 classes de bug
| # | Classe | Cause racine | Corrigé par |
|---|---|---|---|
| A | Écriture extérieure écrasée au Save | L'éditeur garde le payload chargé à l'ouverture et le re-poste entier ; toute écriture faite ailleurs entre-temps est perdue | editorAbsorbChange (d146fbe) |
| B | Éditeur fermé sans garde-fou | closeSlideOver() appelé sans condition + click-outside non filtré |
closeEditorForDeleted (3dbce78), whitelist (e1ddc65) |
| C | PUT /entries sans withEncryptedMeta |
Les colonnes en clair sont droppées côté serveur → le body ne contient rien de stockable et blanchit les *_enc |
d146fbe |
| D | UI construite sans regarder la vue | Le menu kebab est partagé entre vues ; la corbeille n'était pas testée | d146fbe |
A — Éditeur ouvert vs écriture extérieure
Préparer : 3 entrées A, B, C, dont A dans le dossier Work avec le tag
old.
A1 · batch Add tag sur l'entrée éditée
- PRÉ :
Aouverte dans l'éditeur, taper du texte dans NOTE/PASSWORD (ne pas sauver) - ACTION : cocher
A+B→ barre de sélection → Add tag →shared→ puis Save dans l'éditeur - ATTENDU : le chip
sharedapparaît immédiatement dans l'éditeur ; après Save,Aporteold, sharedet la modif tapée. Le bouton Save reste visible (l'entrée était déjà dirty) mais le tag seul ne rend pas dirty.✅
A2 · batch Add tag sur une autre entrée
- PRÉ :
Aouverte + dirty - ACTION : cocher
B+Cseulement → Add tag →x→ Save dans l'éditeur - ATTENDU : l'éditeur ne bouge pas (aucun chip ajouté),
Aconserve ses tags,B/Ctaguées.✅
A3 · batch Move to folder sur l'entrée éditée
- ACTION :
Aouverte + dirty → cocherA→ Move to folder… →Perso→ Save - ATTENDU : le select Folder de l'éditeur bascule sur
Persotout seul ; après Save l'entrée est bien dansPerso.✅
A4 · dossier choisi dans l'éditeur puis move extérieur
- ACTION :
Aouverte, changer Folder →Archive(ne pas sauver) → cocherA→ Move to folder… →Perso→ Save - ATTENDU :
Persogagne (action extérieure = intention la plus récente). Comportement documenté, pas un bug. ✅
A5 · glisser une entrée sur un dossier (moveEntryToFolder)
- ACTION :
Aouverte + dirty → glisser la ligneAsurPersodans la sidebar → Save - ATTENDU : identique à A3.✅
A6 · glisser une multi-sélection sur un dossier (moveEntriesToFolder) ✅
- ACTION : cocher
A+B→ glisser surPerso→ recharger (F5 impossible : relancer l'app) - ATTENDU : dossier changé et nom / username / site / tags intacts.
- Historique : ce chemin n'appelait pas
withEncryptedMeta→ il blanchissait les métadonnées chiffrées. Si des entrées ont été déplacées ainsi avant le fix, elles sont peut-être déjà vides — vérifier.
A7 · suppression du dossier de l'entrée éditée
- ACTION :
A(dossierWork) ouverte + dirty → sidebar → supprimer le dossierWork→ Save - ATTENDU : le select de l'éditeur passe à
All; après Save l'entrée est dansAll, le dossier supprimé ne réapparaît pas.✅
A8 · tags édités des deux côtés
- ACTION :
Aouverte, ajouter le chipminedans l'éditeur (ne pas sauver) → batch Add tagsharedsurA→ Save - ATTENDU :
old, mine, shared— aucun des trois perdu.✅
A9 · favori / épingle pendant l'édition (non-régression)
- ACTION :
Aouverte + dirty → cliquer ★ et 📌 sur la ligneA→ Save - ATTENDU : l'entrée reste favorite et épinglée (ces colonnes ne sont pas dans le PUT de l'éditeur).✅
A10 · icône / pièce jointe pendant l'édition (non-régression)
- ACTION :
Aouverte + dirty → Settings → Fetch missing favicons (ou upload d'icône) → Save - ATTENDU : l'icône survit au Save (endpoint dédié, hors payload éditeur).✅
A10b · icône rafraîchie dans l'éditeur ouvert
- ACTION :
Aouverte → Settings → Fetch missing / Re-fetch all favicons - ATTENDU : l'aperçu d'icône dans l'éditeur se met à jour tout seul (pas seulement la carte). Idem après un import en mode overwrite qui apporte une icône.✅
A11 · sync / import pendant que l'éditeur est ouvert
- ACTION :
Aouverte + dirty → lancer Sync now (ou importer un JSON en mode overwrite) qui metAà jour → Save - ATTENDU : modale « Entry changed elsewhere ». Annuler ⇒ rien n'est écrit (la version du sync/import survit, l'éditeur reste ouvert le temps de recopier). Overwrite ⇒ la version de l'éditeur gagne.✅
A12 · pas de fausse alerte (non-régression d'A11)
Aucun de ces enchaînements ne doit afficher « Entry changed elsewhere » :
- A12a :
Aouverte + dirty → Save → modifier encore → Save (le rechargement post-save re-cale la référence) - A12b :
Aouverte + dirty → batch Add tag / Move to folder surA→ Save (l'éditeur a absorbé le changement) - A12c :
Aouverte + dirty → supprimer son dossier → Save (cf. A7) - A12d :
Aouverte + dirty → Fetch missing favicons → Save (l'icône est hors payload éditeur) - A12e :
Aouverte + dirty → sync sans changement distant surA→ Save
A13 · entrée supprimée ailleurs pendant l'édition
- ACTION :
Aouverte + dirty → un sync distant supprimeA(tombstone) → Save - ATTENDU : pas de plantage. L'entrée n'est plus en mémoire → le contrôle A11 passe la main ; le PUT échoue proprement avec un toast d'erreur.
B — Fermeture de l'éditeur & message de discard
Le message n'apparaît que si confirmOnUnsaved est actif et que l'entrée
est dirty. Tester avec askBeforeDelete ON puis OFF (Settings → Security).
B1 · ✕ sur une autre ligne
- PRÉ :
Aouverte + dirty - ACTION : cliquer ✕ (Move to trash) sur la ligne
B - ATTENDU :
Bpart à la corbeille, l'éditeur deAreste ouvert avec les modifs. Vrai dans les deux réglages deaskBeforeDelete.
B2 · ✕ sur la ligne éditée, askBeforeDelete = OFF
- ACTION :
Aouverte + dirty → ✕ surA - ATTENDU : modale « Discard unsaved changes? ». Annuler ⇒ rien n'est supprimé et l'éditeur reste ouvert. Confirmer ⇒
Aà la corbeille + éditeur fermé.
B3 · ✕ sur la ligne éditée, askBeforeDelete = ON
- ATTENDU : une seule modale (« Move to trash »). Pas de double prompt.
B4 · batch Delete incluant l'entrée éditée
- ACTION :
Aouverte + dirty → cocherA+B→ Delete - ATTENDU : une seule modale (« 2 entries will be moved to trash »), puis l'éditeur se ferme.
B5 · batch Delete excluant l'entrée éditée
- ATTENDU : l'éditeur reste ouvert, modifs intactes.
B6 · corbeille
- ACTION : ouvrir une entrée de la corbeille via Trash actions, puis : (a) Delete forever dessus, (b) batch purge la contenant, (c) Empty trash
- ATTENDU : dans les trois cas l'éditeur se ferme s'il portait une entrée purgée, sinon il reste.
B7 · boutons de la barre de sélection
- ACTION :
Aouverte + dirty → cocherB→ cliquer successivement Move to folder…, Add tag, Clear - ATTENDU : aucun de ces clics ne ferme l'éditeur ni n'affiche de discard.
B8 · bouton Empty trash (barre de titre)
- ATTENDU : idem, pas de fermeture parasite de l'éditeur.
B9 · chemins de fermeture légitimes (non-régression)
- Esc, ✕ de l'éditeur, clic sur le fond de la grille, ouverture d'une autre entrée
- ATTENDU : message de discard à chaque fois si dirty ; annuler garde l'éditeur ouvert sur la bonne entrée.
B10 · verrouillage (comportement assumé)
- ACTION :
Aouverte + dirty → auto-lock ou Lock vault - ATTENDU : fermeture sans message, modifs perdues. Volontaire : la sécurité prime.
B11 · Esc au-dessus d'un modal ouvert par-dessus l'éditeur
- ACTION :
Aouverte + dirty → ouvrir un modal → Esc → Esc - ATTENDU : 1ᵉʳ Esc ferme le modal seul, l'éditeur reste ouvert ; 2ᵉ Esc traite l'éditeur (discard).
- Modals à passer un par un : + New folder (sidebar), Customize folder, Change master password, générateur de mot de passe, cheatsheet (
?), historique de mot de passe, palette (Ctrl+K), outil TOTP, quick search (Ctrl+Shift+Q), reauth (déclenché par un export), picker d'autofill (Ctrl+Shift+Lavec 2 entrées qui matchent). - Historique :
#folderModal(dynamique) n'était dans aucune liste → Esc fermait l'éditeur derrière.#reauthModal,#quickSearchModal,#autofillPickerModal(statiques) non plus.
B12 · le modal reste fonctionnel après un Esc
- ACTION : ouvrir reauth (export) → Esc → ré-ouvrir (relancer l'export). Idem quick search et picker d'autofill.
- ATTENDU : le modal se ré-affiche normalement. Un Esc ne doit jamais supprimer un modal statique du DOM (sinon la fonctionnalité est morte jusqu'au redémarrage).
B13 · clic sur un toast / l'overlay d'attente
- ACTION :
Aouverte + dirty → lancer une action longue (export, sync) → cliquer sur le toast ou sur le spinner - ATTENDU : l'éditeur ne se ferme pas, pas de discard.
B14 · dialogues natifs ouverts par un clic programmatique
- ACTION :
Aouverte + dirty → (a) Settings → Import vault ; (b) télécharger une pièce jointe (fallback web) ; (c) Upload icon ; (d) joindre un fichier. - ATTENDU : seul le dialogue natif s'ouvre. Pas de « Discard unsaved changes? » par-dessus, l'éditeur reste ouvert et modifié.
- Historique : le
<input type=file>(et le<a download>) est attaché à<body>puis.click()→ le clic synthétique remontait au handler click-outside qui le prenait pour un clic dehors. Gardé pare.isTrusted.
F — Round-trip export / import (drapeaux & provenance)
F1 · favori + épingle survivent au restore
- F1a — entrée supprimée puis réimportée ✅ : épingler et mettre en favori une entrée → Export vault → supprimer l'entrée (corbeille + purge) → Import vault. ATTENDU : elle revient épinglée et favorite (chemin INSERT / bulk-import).
- F1b — import en mode écrasement ✅ : même export, mais sans supprimer l'entrée. Retirer l'épingle et le favori localement, réimporter, choisir Overwrite.✅ ATTENDU : les deux drapeaux du fichier sont réappliqués.
- Historique :
pinnedn'était dans aucun container ;favoritey était écrit mais jamais relu (ni parser, ni body, ni INSERT) → les deux perdus au restore. Puis F1a a marché sans F1b : le mode écrasement passe parPUT, qui ignore volontairementfavorite/pinnedpour qu'un sync ne puisse jamais écraser les drapeaux locaux (device-local par design). L'import les applique donc via les endpoints de bascule dédiés, uniquement quand ils diffèrent.
F2 · même chose depuis une auto-backup
- ACTION : Backup now → supprimer l'entrée → importer le fichier
vault-autobackup-*.json. - ATTENDU : identique à F1.
F3 · CSV étranger → drapeaux à 0
- ACTION : importer un CSV sans colonnes favorite/pinned.
- ATTENDU : entrées importées ni épinglées ni favorites (défaut 0), pas d'erreur.
F4 · le sync ne propage PAS les drapeaux (device-local, décidé 2026-07-11)
- ACTION : épingler/favoriser une entrée sur le device A → Sync now → Sync now sur le device B.
- ATTENDU : sur B, l'entrée n'est pas épinglée/favorite. Et un sync ne doit jamais retirer un drapeau posé localement sur B (le PUT serveur ne touche pas ces colonnes).
F5 · icône custom : provenance préservée ✅
- ACTION : upload d'une icône custom → export → supprimer l'entrée → import → puis Re-fetch all.
- ATTENDU : l'icône custom revient et survit au Re-fetch all (le flag
icon_customa fait le round-trip).
C — withEncryptedMeta sur tout write /entries
Test générique, à rejouer après toute modif touchant un body POST/PUT
/entries : faire l'action, relancer l'app (pour relire depuis SQLite),
vérifier que nom / site / username / tags s'affichent encore.
Chemins à couvrir : Save éditeur (neuf + existant), duplicate, import
(bulk + overwrite), sync (applyRemoteSnapshot), addTagToEntry,
batchAddTag, moveEntryToFolder, moveEntriesToFolder, batchMoveToFolder,
migrateMetadataAtRest (au unlock), rotation du master password.
Symptôme du bug : après relance, lignes au nom vide / (no name).
D — Vue corbeille
D1 · menu kebab en table view
- ACTION : corbeille + vue table → kebab d'une ligne
- ATTENDU :
Restore/Copy…/Delete foreveruniquement. Pas de Pin / Favorite / Edit / Duplicate / Move to trash.
D2 · vues cartes et liste
- ATTENDU : boutons restore + purge (déjà le cas avant), pas de drag possible.
D3 · clic sur une ligne de corbeille
- ATTENDU : ouvre Trash actions, jamais l'éditeur (une entrée supprimée ne doit pas pouvoir être sauvegardée).
E — Auth / recovery / mémoire (série 2026-07-25)
Ces flux touchent la dérivation de clé et l'anti-lockout : tester sur un compte argon2id-v2 (défaut courant). Rebuild Delphi requis (fixes serveur) + BuildAssets pour le JS.
E1 · reauth — mauvais master pw reste inline (les 4 flux) ✅
- ACTION : sur chacun de Set/Change PIN, Enable Quick Unlock, Generate recovery code, Export → taper un mauvais master pw.
- ATTENDU : le modal reste ouvert avec « Wrong master password. Attempt N / 5. » ; au 5ᵉ échec ou 429 → toast lockout + fermeture. Annuler à tout moment = action abandonnée, rien fait.
- Historique : PIN / Quick Unlock / recovery fermaient + toast au 1ᵉʳ essai.
E2 · recovery setup sur compte argon2id-v2 ✅
- ACTION : Generate recovery code → bon master pw.
- ATTENDU : code généré, pas de « invalid password ».
- Historique :
/recovery-key/setuprejetait tout compte -v2 / argon2id (vérif serveur ne connaissait que pbkdf2 / pbkdf2-sha256).
E3 · recovery generate → annuler n'écrit rien ✅
- PRÉ : un code de recovery existe déjà.
- ACTION : Regenerate code → bon master pw → à l'affichage du code, Cancel (ou Esc).
- ATTENDU : toast « Cancelled — no recovery code was set » ; l'ancien code fonctionne toujours au redeem. Refaire avec I saved it → nouveau code actif, ancien invalidé.
- Historique : le setup était POSTé avant l'affichage → annuler écrasait quand même l'ancien code (chemin silencieux vers le lockout).
E4 · recovery redeem sur compte argon2id-v2 ✅
- ACTION : générer un code, se déconnecter, Use a recovery code → username + code.
- ATTENDU : déverrouillage OK, redirection vers Change master password. Le coffre se déchiffre (clé dérivée avec les bons params Argon2 renvoyés par le serveur).
- Historique : la réponse redeem n'echoait pas
argon2→ params par défaut (correct aujourd'hui, lockout latent si params non-défaut).
E5 · la clé du coffre n'est plus en clair dans le web storage
- ACTION : coffre déverrouillé → console WebView2 :
sessionStorage.getItem('cryptoKey'). - ATTENDU :
null. (Avant : une string base64 de 44 car = la clé AES.) - Bonus : dump mémoire du renderer →
this_is_passwordpeut persister (strings V8, non corrigeable) ; c'est la clé qu'on a retirée du storage.
E6 · non-régression — les 4 exporteurs de clé marchent toujours
Après le retrait du cache sessionStorage, re-tester que ces flux (qui
exportKey la clé) fonctionnent : login, Quick Unlock, PIN,
recovery redeem, change master password.
E7 · non-régression — éditeur slideover après suppression du modal mort
Le générateur, les tags et la barre de force étaient partagés. Vérifier dans l'éditeur slideover : créer + éditer une entrée, ajouter/retirer des tags, Generate → Use insère le mot de passe, la barre de force réagit.
G — Vault health (série 2026-07-25)
G1 · bouton Fix quand le changement d'entrée est annulé
- ACTION :
Aouverte + dirty → aller sur Vault health → cliquer Fix sur une autre entréeB→ Cancel au message discard. - ATTENDU : rien ne bouge.
Areste ouverte, son mot de passe reste masqué, aucun clignotement du bouton Generate. - Puis refaire → Discard :
Bs'ouvre, c'est le mot de passe de B qui est démasqué + sélectionné, et c'est le Generate deBqui pulse. - Le focus RESTE sur le mot de passe (~1 s après le clic, vérifier que le curseur n'a pas sauté sur le titre) :
openSlideOverprogramme un focus différé de 80 ms sur#soTitle, neutralisé ici par{ noFocus: true }. - Historique :
openSlideOverne renvoyait rien, doncopenEntryForFixcontinuait après une annulation et démasquait en clair le mot de passe de l'entrée restée ouverte.
G2 · Fix sur une entrée supprimée entre-temps
- ACTION : ouvrir Vault health → dans un autre onglet de l'app, mettre à la corbeille une entrée listée → revenir sur health (sans Recompute) → Fix sur cette entrée.
- ATTENDU : rien ne casse (l'éditeur ne s'ouvre pas, pas d'erreur JS). Avec G3 la liste devrait de toute façon être à jour.
G3 · le tableau de bord reflète les mutations
- ACTION : ouvrir Vault health (noter le score) → revenir à la liste → faire une mutation : import, sync, suppression, batch delete, duplicate, restore, ou changement de master pw → rouvrir Vault health.
- ATTENDU : score et listes recalculés (les entrées supprimées ont disparu, les importées apparaissent) sans cliquer Recompute.
- Historique : le cache n'était vidé qu'au lock / au save / via Recompute — après un import ou une suppression le dashboard restait figé sur l'état précédent.
H — Hotkey 2FA (Ctrl+Shift+T, série 2026-07-25)
H1 · remplissage simple ✅
- ACTION : entrée avec TOTP → ouvrir son site → cliquer le champ 2FA →
Ctrl+Shift+T. - ATTENDU : le code à 6 chiffres est tapé dans le champ focalisé, toast « 2FA code filled: … ».
H2 · entrée sans TOTP
- ACTION : fenêtre dont le titre matche une entrée sans secret TOTP →
Ctrl+Shift+T. - ATTENDU : « No 2FA code stored for "…" » (et pas le « no match » générique, ni un remplissage du mot de passe).
H3 · picker multi-match
- PRÉ : 2 entrées matchent le titre, une seule a un TOTP.
- ATTENDU : le picker s'ouvre uniquement s'il reste ≥ 2 candidats avec TOTP ; ici il ne doit pas s'ouvrir (1 seul candidat éligible → remplissage direct). En-tête du picker : « Pick entry (2FA code) — ».
H4 · combo configurable
- ACTION : Settings → Autofill → cliquer le bouton
Ctrl+Shift+T→ capturer un autre combo. - ATTENDU : accepté et actif immédiatement ; refusé s'il duplique un des 4 autres hotkeys ; Reset to defaults le remet à
Ctrl+Shift+T; le réglage survit à un redémarrage (synced viasettings_json).
H5 · coffre verrouillé
- ACTION : verrouiller →
Ctrl+Shift+T. - ATTENDU : l'app revient au premier plan avec le focus sur le master password (même comportement que les autres hotkeys), pas de no-op silencieux.
H9 · retour au tray après un pick dans le picker ✅
- ACTION : app au tray →
Ctrl+Shift+Ldepuis une autre app sur un titre à plusieurs candidats → le picker sort la fenêtre du tray → choisir une entrée. - ATTENDU : le remplissage se fait puis l'app repart au tray, comme elle est venue.
- Variantes : match unique (pas de picker) → reste au tray ; app déjà visible → reste visible ; app minimisée dans la barre des tâches → n'est pas envoyée au tray (
not Self.Visibleest faux pour une fenêtre iconique) ; picker fermé par Esc sans choisir → l'app repart au tray elle aussi (symétrique de quick-search, qui se cache sur Esc) et le hotkey suivant n'hérite pas de l'état capturé. - Historique : seul le chemin quick-search capturait « était cachée » (
LWasHidden) et le faisait remonter viahide_after; JS n'envoie jamais ce paramètre depuis le picker d'autofill, donc l'app restait sur le bureau. Capturé désormais côté Delphi dansBridgeAutofillRequest, au moment du hotkey — après, le picker a déjà détruit l'information.
H10 · le focus revient à l'application précédente ✅
Cacher une fenêtre ne transfère le premier plan à personne (contrairement à une
minimisation). On restait donc au premier plan en étant invisible : le
hotkey suivant capturait notre propre fenêtre comme cible et tapait le mot
de passe dedans, et Ctrl+Shift+A ouvrait un « New entry » au titre vide (le
garde refusait, à raison, de pré-remplir avec « Password Manager »).
- ACTION : depuis une application X → ouvrir l'app (icône du tray, hotkey, ou lancement à froid) → fermer par la croix.
- ATTENDU : X reprend le focus. Puis un hotkey d'autofill depuis X doit cibler X, et
Ctrl+Shift+Apré-remplir son titre. - ACTION 2 : enchaîner plusieurs allers-retours en changeant d'application entre deux — c'est toujours la dernière réellement utilisée qui récupère le focus.
- Non-régression : après un autofill (pick ou Esc), le focus reste sur la fenêtre remplie — ces deux chemins passent
AReturnForeground = False, ayant déjà placé le premier plan eux-mêmes. - Historique — quatre tentatives, toutes tranchées par le log : (1) rendre le focus après avoir caché la fenêtre — Windows n'autorise que le propriétaire courant du premier plan à le céder, l'appel était silencieusement refusé ; (2) échantillonner le premier plan au moment du besoin — il vaut alors notre fenêtre, la capture se rejetait elle-même et n'apprenait jamais rien (
prev=<none>à chaque fois) ; (3) capturer au clic sur l'icône du tray — on obtient une fenêtre du shell (NotifyIconOverflowWindow, « Fenêtre de dépassement de capacité de la barre d'état système ») ; (4) consommer la valeur après usage — le premier masquage marchait, les suivants plus. Solution : un hookEVENT_SYSTEM_FOREGROUND(WINEVENT_OUTOFCONTEXT, aucune injection) note chaque changement et garde le dernier qui n'est ni à nous ni au shell ; la restitution se fait avant de cacher.
H13 · pas de Ctrl+A après le Tab (formulaires type Reddit)
- ACTION :
Ctrl+Shift+Lsur un formulaire où Tab depuis le username ne mène pas au champ mot de passe (page de connexion Reddit : le Tab tombe à côté). - ATTENDU : plus de sélection de toute la page. Le mot de passe peut malgré tout ne pas être saisi si le focus n'a pas atterri dans un champ éditable — c'est la limite du Tab, pas un bug de plus.
- Contournement pour ces formulaires : cliquer le champ mot de passe puis
Ctrl+Shift+P(remplissage password seul, aucun Tab). - Historique : la séquence envoyait
Ctrl+A + Delavant chaque champ. Après le Tab c'était doublement mauvais : redondant (arriver par Tab sélectionne déjà tout le contenu du champ, cf. K1) et destructeur quand le Tab tombe sur un lien, un bouton ou le document —Ctrl+Asélectionne alors la page entière et la frappe se perd. Retiré ; celui d'avant le username reste (l'utilisateur y a placé le curseur par un clic, rien n'est sélectionné).
H11 · l'application précédente réduite reste réduite
- ACTION : depuis X, réduire X dans la barre des tâches → ouvrir le coffre (icône du tray) → le fermer par la croix.
- ATTENDU : X reste réduite. Le focus part où Windows décide ; on ne rouvre pas une fenêtre que l'utilisateur a rangée.
- Pourquoi c'est fragile :
ForceForegroundWindowfaitShowWindow(SW_RESTORE)sur une cible iconique — il est écrit pour l'autofill, où sortir la fenêtre cible est justement le but. Réutilisé tel quel pour la restitution du focus, il changeait l'état de la fenêtre d'une autre application. Gardenot IsIconicposé côtéMinimizeToTray, pas dansForceForegroundWindow(l'autofill en a toujours besoin).
H12 · démarrage -tray — aucune activation parasite
- ACTION : ouvrir une application X, puis lancer
PMServer.exe -tray(ou redémarrer avec « Start with Windows »). - ATTENDU : rien ne passe au premier plan. Le coffre démarre caché sans toucher au focus de qui que ce soit — il n'a rien pris à personne, il n'a rien à rendre.
- Les quatre réglages de
MinimizeToTray(AReturnForeground) : fermeture par la croix = rend ; quick-search copie-puis-cache = rend (c'est ce qui permet le Ctrl+V dans l'app d'origine) ; autofill et annulation du picker = ne rendent pas (le focus est déjà sur la fenêtre remplie) ; démarrage-tray= ne rend pas.
H7 · combo déjà pris par une autre application ✅
- ACTION : assigner à un hotkey un combo qu'une autre app tient déjà en global (ex.
Ctrl+Shift+Qsi Quick Assist / un lanceur l'occupe ; sinon, tester en assignant le même combo dans deux apps). - ATTENDU : toast nommant le combo refusé — « Already used by another app: 2FA code — pick another combo » — et un ⚠ ambre persistant sur cette ligne de Settings, et une pastille ambre sur l'entrée Settings de la sidebar (seul indice hors du panneau). Le toast d'avertissement reste ~10 s et se ferme au clic. Les autres hotkeys restent fonctionnels (enregistrement best-effort, pas de tout-ou-rien).
- ACTION 2 : fermer l'app concurrente → Reset to defaults (ou re-capturer le combo) → le hotkey redevient actif et le ⚠ disparaît.
- Piège de test : une app qui capture via un hook bas niveau (et non
RegisterHotKey) avale la frappe sans faire échouer notre enregistrement → hotkey inerte sans ⚠. Utiliser AutoHotkey (^+t::MsgBox) ou Lightshot pour un vrai conflit. - Historique : le message disait « one or both hotkeys » (faux depuis qu'il y en a 3) sans dire lequel ; et un échec sur Quick search ou New entry n'était écrit que dans le panel debug — invisible en prod, le hotkey semblait juste cassé.
H8 · capturer un combo déjà pris par une autre app ✅
- ACTION : Settings → cliquer le bouton du hotkey 2FA → presser un combo qu'une autre app détient (ex.
Ctrl+Shift+T). - ATTENDU : au relâchement (pas après un délai), toast « No key registered after Ctrl+Shift. If you did press one, another app already owns that combo — pick a different one. » et le bouton reprend son libellé précédent.
- ACTION 2 : presser un combo libre (ex.
Ctrl+Win+T) → accepté, actif immédiatement, pas de ⚠. - ACTION 3 : cliquer le bouton puis ne rien presser pendant 10 s → « Hotkey capture cancelled — no key was pressed. » et le bouton se restaure (sinon la capture avalerait toutes les frappes jusqu'à Échap).
- ACTION 4 : cliquer le bouton puis presser un combo déjà assigné à un autre de nos slots (ex.
Ctrl+Shift+L) → « That combo is already used by another hotkey ». Doit fonctionner sans déclencher l'autofill : nos 5 hotkeys sont libérés pendant la capture. - Historique : un combo tenu par
RegisterHotKey(le nôtre ou celui d'une autre app) n'atteint jamais WebView2 → la capture restait figée sur « Press combo… » sans message, et la vérif de collision interne était du code mort.
H6 · code en fin de période
- ACTION : presser le hotkey juste avant la fin du compte à rebours de 30 s.
- ATTENDU : le code tapé est celui de la période courante au moment de la frappe (généré à la demande, pas mis en cache).
I — État de la fenêtre (maximisé, série 2026-07-25)
L'app démarre maximisée (WindowState = wsMaximized dans la .fmx).
I1 · maximisé survit à Show desktop ✅
- ACTION : fenêtre maximisée →
Win+D(ou Show desktop) → revenir à l'app (barre des tâches, tray ou Alt+Tab). - ATTENDU : elle revient maximisée, sans passer par une frame en taille normale.
- Historique : FMX oublie l'état maximisé quand Windows minimise la fenêtre de l'extérieur, et aucun de nos chemins de restore ne tourne pour un clic sur la barre des tâches.
- Historique (flash) : une fois l'état final correct, il restait un scintillement — le cadre apparaissait en taille normale puis grandissait. C'est un bug FMX, reproduit sur une application FMX vide : FMX perd le maximisé quand Windows minimise le formulaire de l'extérieur. Windows choisit alors
rcNormalPositioncomme cible avant d'animer, donc aucune correction réactive ne peut arriver à temps — trois ont été essayées et rejetées par la mesure :ForceQueue(un tour de boucle trop tard), appel inline dansMINIMIZEEND(le hook est délivré après leWM_SIZE), correction dans leWM_SIZElui-même (l'état devient parfait, le flash reste — preuve que le problème n'était plus l'état). Écarté aussi : poserWPF_RESTORETOMAXIMIZED, queGetWindowPlacementrapporte déjà à 2 sur une fenêtre maximisée (no-op). Statut : non corrigé, assumé. Une cinquième piste a été tentée puis annulée : garerrcNormalPositionet le remplacer parrcWorkdu moniteur pendant que la fenêtre est en bas, pour que « restaurer en normal » vaille déjà plein écran. Ça supprimait bien le flash, mais toutSetWindowPlacementsur une fenêtre en cours de minimisation — ou déjà iconique — laissait un moignon de fenêtre dans un coin de l'écran, y compris en différant l'écriture jusqu'àIsIconic. Un artefact visible permanent est pire que l'animation. Reverté. Ce qui reste : l'état final est correct (la fenêtre revient bien maximisée), seule l'animation de restauration est laide. Ne pas rouvrir sans une vraie raison — la seule route restante est de sous-classer leWndProcde la fenêtre FMX pour corriger le rectangle dansWM_WINDOWPOSCHANGING, ce qui insère du code dans la boucle de messages d'une fenêtre qu'on ne contrôle pas, avec le tray, l'autofill et le démarrage-trayen aval.
I2 · normal survit aussi ✅
- ACTION : dé-maximiser la fenêtre →
Win+D→ revenir. - ATTENDU : elle reste en taille normale — le correctif ne doit jamais forcer le maximise.
I3 · maximiser / dé-maximiser reste enregistré ✅
- ACTION : dé-maximiser →
Win+D→ retour (normal) → maximiser →Win+D→ retour. - ATTENDU : chaque retour respecte le dernier geste de l'utilisateur.
I4 · retour depuis le tray ✅
- ACTION : fenêtre maximisée → fermer (part au tray) → clic sur l'icône du tray.
- ATTENDU : revient maximisée (chemin distinct :
RestoreFromTrayrejoue son propre placement).
I5 · le garde de minimisation ne reste pas bloqué (autofill → tray) ✅
Le chemin le plus subtil : ExecuteAutofill minimise réellement la fenêtre
(SW_MINIMIZE) avant de taper, ce qui lève le garde ; si elle est ensuite
cachée dans le tray au lieu d'être restaurée, MINIMIZEEND peut ne jamais
arriver.
- ACTION : depuis le tray,
Ctrl+Shift+Q→ choisir une entrée → laisser le remplissage se faire (l'app repart au tray) → rouvrir l'app par l'icône du tray → dé-maximiser (ou maximiser) →Win+D→ revenir. - ATTENDU : le dernier geste est respecté. S'il ne l'est pas, le garde est resté levé et le suivi de l'état est gelé pour toute la session.
- Variante : même chose avec
Ctrl+Shift+Ldepuis une autre application.
I6 · le hook n'affecte pas la corbeille ni le tray (non-régression)
- ACTION : minimiser dans le tray (fermeture), restaurer ;
app/focusvia le picker d'autofill multi-match ; « Lock vault » depuis le menu du tray. - ATTENDU : comportements inchangés. Le hook n'écoute que notre process
et
MinimizeToTrayutiliseSW_HIDE, qui n'émet aucun événement de minimisation.
Diagnostic
Avec config.txt contenant debug=true, Ctrl+Shift+D affiche le journal.
Lignes utiles : [winstate] minimise hook installed=True au démarrage (un
False signifierait que le hook Win32 ne s'installe pas → préservation
inactive) et [winstate] re-maximising after restore quand la correction agit.
J — Quick search depuis le tray (série 2026-07-25)
Le flux le plus fragile : la fenêtre revient du tray, un modal s'ouvre, et la
fermeture doit renvoyer l'app au tray. Deux cmd:// s'y enchaînent, ce qui
est précisément le piège de coalescence documenté dans CLAUDE.md.
J1 · Esc dès l'ouverture, sans cliquer ✅
- PRÉ : app dans le tray, coffre déverrouillé.
- ACTION :
Ctrl+Shift+Q→ ne rien cliquer →Escimmédiatement. - ATTENDU : le modal se ferme du premier coup, et l'app repart au tray.
- Historique : deux bugs distincts ici. (1) WebView2 n'avait pas le focus
clavier de l'OS après la restauration → aucune frappe n'atteignait la page,
Esc ne faisait rien tant qu'on n'avait pas cliqué dans le champ. (2) En mode
fill,
cancelAutofillpuisminimizeToTray= deuxwindow.location.hrefconsécutifs → coalescés, la minimisation était perdue.
J2 · remplissage puis retour au tray ✅ ✅
- ACTION : app dans le tray →
Ctrl+Shift+Q→ choisir une entrée (Enter). - ATTENDU : le mot de passe est tapé dans la fenêtre cible et l'app
repart au tray — une seule fois (c'est Delphi qui minimise ici, via
hide_after, pas le JS : vérifier qu'il n'y a pas de double minimisation). - Variantes :
Shift+Enter(user+pwd),Ctrl+Enter(username seul).
J3 · copie depuis le menu du tray ✅
- ACTION : menu du tray → Quick search… → choisir une entrée (Enter).
- ATTENDU : mot de passe copié, app de retour au tray, et le presse-papiers
conserve la valeur (
keepclip=1— c'est le rôle du paramètre).
J4 · app déjà visible (non-régression) ✅
- ACTION : app ouverte à l'écran →
Ctrl+Shift+Q→Esc. - ATTENDU : le modal se ferme et l'app reste à l'écran — elle ne doit pas partir au tray (elle n'en venait pas). Idem si elle était seulement minimisée dans la barre des tâches.
K — Autofill : comportements intentionnels (à ne pas « corriger »)
K1 · « Clear the field before typing » désactivé → le password est quand même remplacé
- ACTION : Settings → Autofill → décocher Clear the field before typing.
Puis remplissage complet (
Ctrl+Shift+L, ou clic-droit dans la quick search) sur un formulaire dont les deux champs contiennent déjà quelque chose. - OBSERVÉ : le username n'est pas effacé (la frappe s'insère), mais l'ancien password disparaît et est remplacé.
- C'EST NORMAL — vérifié de bout en bout : le JS envoie
&clear=0, Delphi metFAutofillPendingClear := False. Notre code n'efface rien. (Depuis le correctif H13 il n'y a de toute façon plus qu'un seulClearFieldIfWanted, avant le username : celui d'avant le password a été retiré.) La cause est le Tab du pipelineusername → Tab → password: arriver dans un champ par Tab sélectionne tout son contenu (comportement standard Windows / navigateur), et la frappe remplace la sélection. Le username, lui, a été focalisé par un clic de l'utilisateur → curseur sans sélection → insertion. - Ce que le réglage fait réellement : empêcher l'envoi de
Ctrl+A. Il existe pour les terminaux / bureaux distants oùCtrl+Asignifie « début de ligne » ou déclenche le préfixe tmux, ce qui casserait le remplissage. Il ne promet pas de préserver le contenu des champs. - Ne pas « corriger » : désélectionner après le Tab ferait concaténer le nouveau mot de passe à l'ancien — jamais souhaitable.
- Test qui isole la cause :
Ctrl+Shift+P(password seul) en cliquant directement dans le champ → pas de Tab → l'ancienne valeur reste.
L — Démarrage avec Windows (-tray, série 2026-07-26)
Le flag -tray est ce que « Start with Windows » écrit dans HKCU\…\Run
("<exe>" -tray). Pour tester sans redémarrer : PMServer.exe -tray depuis le
dossier de sortie. Ne pas laisser -tray dans Run → Parameters de l'IDE,
sinon chaque F9 démarre caché.
L1 · démarrage caché, sans bouton de barre des tâches ✅
- ACTION : lancer avec
-tray(l'app n'étant pas déjà lancée — sinon le mutex single-instance fait sortir la nouvelle instance immédiatement). - ATTENDU : aucune fenêtre, aucun bouton dans la barre des tâches, icône présente dans le tray, pas de balloon.
- Historique : le bouton restait 1-2 s. La fenêtre du formulaire n'était en
fait jamais visible — c'est le proxy taskbar FMX (
ApplicationHWND) qui s'affichait, et avant même queFormCreatesoit atteint, ce qui rendait inopérant tout correctif posé depuis le formulaire. Le masquer ne suffit pas non plus : il faut retirerWS_EX_APPWINDOWet poserWS_EX_TOOLWINDOW, depuis le.dpr, juste aprèsApplication.Initialize.
L2 · le bouton revient à la restauration ✅
- ACTION : après L1, clic sur l'icône du tray.
- ATTENDU : la fenêtre s'ouvre et le bouton réapparaît dans la barre des tâches.
- Historique :
WS_EX_TOOLWINDOWest permanent — sans le rétablissement (TrayProxyShowInTaskbardansRestoreWindowFromTray) l'app tournait ensuite sans aucune entrée de barre des tâches.
L3 · lancement normal (non-régression) ✅
- ACTION : lancer sans
-tray. - ATTENDU : fenêtre visible et bouton de barre des tâches présents, comme avant. Le correctif est entièrement gardé par le flag.
Reste à traiter (constaté pendant l'audit, non corrigé)
- Attachments hors garde A11. Le contrôle « changed elsewhere » couvre le payload de l'entrée, pas ses pièces jointes (table séparée, écrites par leurs propres endpoints). Un sync qui remplace une pièce jointe pendant l'édition n'est pas signalé. Faible risque : l'éditeur ne re-ship jamais les pièces jointes existantes, donc il n'y a rien à écraser.