Files
Password-Manager/CLAUDE.md
T
Zaki 63fac5b3b7 feat: secure notes + password history + custom fields + quick-win bundle
Big feature trio
- Secure notes (kind='login'|'note') reusing the encrypted_password+iv
  pipeline for the body. New sidebar entry, slideover variant (title +
  multiline body), distinct card / table-view rendering, badge in name
  column, copy-content button replacing the password copy on note rows.
- Password history: entries_password_history table keeps up to 20 prior
  ciphertexts per entry. HandleUpdateEntry pushes the pre-update
  encrypted_password into history ONLY when it actually differs from
  the incoming one (JS reuses originalEncrypted bit-for-bit when the
  plaintext is unchanged — avoids spamming history on title/folder edits).
  GET /entries/{id}/history endpoint. Slideover modal lists versions
  with mask/reveal/copy/revert. Master-pw rotation wipes history (old
  ciphertext can't be decrypted with the new key).
- Custom fields: per-entry encrypted JSON array of {label, value,
  is_secret}. Same crypto pipeline as the password. Slideover row UI
  with label/value inputs, secret toggle (eye), copy, delete. Re-
  encryption flows through bulk-import, change-master-password, and
  duplicate.

Quick wins
- Cheatsheet overlay (press '?' or topbar button or Ctrl+K). Lists all
  hotkeys + global / tray / card actions. SVG icons inline so the
  cheatsheet matches the actual app glyphs (no emoji mismatch).
- Open URL button on entry cards: ShellExecute via cmd://app/open-url,
  http(s) only, validates entry.site looks like a real hostname.
- Trash auto-purge: setting "Empty trash after N days" (never/7/30/90).
  DELETE /entries/trash/old?days=N called at every unlock.

Favicon strategy
- Subdomains (chat.deepseek.com, app.X.com…) now try the SLD first
  (deepseek.com.ico) before the full host. DDG often returns a generic
  placeholder for subdomains that passes the byte threshold; the SLD-first
  switch surfaces the real brand icon.
- Cap bumped 64 KB → 256 KB on all three sides (Delphi fetch, server
  endpoint, JS upload). DDG sometimes serves the full-res asset.

UX polish
- Click-outside-slideover: stopPropagation everywhere it bites. Custom
  fields buttons (add / delete / secret toggle / copy / eye) all stop
  the click bubble so the document-level "close on outside click" handler
  doesn't fire when rerender() detaches the target from the DOM.
- Native search-cancel button restyled: cyan accent X via mask-image,
  cursor: pointer, breathing room before the Ctrl+K kbd chip.
- Password history modal: scrollable body, multiline wrapped passwords,
  hover border highlight.
- Cheatsheet panel widened (560 → 720 px) so the descriptions no longer
  ellipsis-clip.
- "+ New" topbar splits into a small dropdown: New login / New note.
- Notes show a "note" badge in table-view name column, italic
  "Encrypted note" placeholder in the username column.

Internals
- duplicateEntry copies kind + custom_fields too (one-line forgotten
  earlier).
- entries_password_history dropped on master-pw rotation — the old
  ciphertexts are unrecoverable with the new key.
- bulk-import re-encryption path includes custom_fields.

CLAUDE.md
- "Entry payload — call sites à toucher ensemble" lists the 6 spots
  to update when adding a new (en)crypted field. Notes the historical
  miss of kind in duplicateEntry and custom_fields in the rotation +
  duplicate.

Repo hygiene
- .gitattributes forces CRLF on Delphi sources (RAD Studio refuses LF).
  text=auto for web frontend / docs, binary for .res / .exe / images.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 20:17:19 +01:00

26 KiB

Password Manager — Architecture éclair

Desktop password vault Windows. Exe Delphi FMX embarquant WebView2 qui charge un frontend HTML/JS via Indy HTTP loopback (127.0.0.1:8765). 100 % offline, multi-utilisateur, chiffrement client-side (AES-GCM, clé dérivée PBKDF2-SHA256 600 k iter).

Stack

  • Backend : Delphi 12, FMX, TMS FNC WebBrowser (Edge WebView2)
  • HTTP : Indy TIdHTTPServer bindé 127.0.0.1 only
  • DB : SQLite via FireDAC (vault.db, gitignored)
  • Frontend : vanilla JS + CSS, pas de framework (js/app.js monofile ~4000 lignes)

Build pipeline (CRITIQUE)

Toute modif index.html / js/ / css/ nécessite :

  1. delphi-backend\assets\BuildAssets.cmd → régénère assets.res
  2. Build Delphi (F9 ou MSBuild PMServer.dproj)

Sans étape 1, l'exe embarque l'ancienne version des assets — le bug le plus courant après modif frontend.

Carte des fichiers

Rôle Path
Form principale, wire bridge, dispatch cmd:// delphi-backend/UMainForm.pas
Bridge JS↔Delphi (clipboard, tray, hotkeys, autofill, focus, debug) delphi-backend/Source/PM.Bridge.pas
HTTP server (ordre dispatch : router → embedded → static → 404) delphi-backend/Source/PM.HTTPServer.pas
Routes API (regex match) delphi-backend/Source/PM.Router.pas
Auth + sessions + CSRF delphi-backend/Source/PM.Session.pas
Migrations DB idempotentes (AddColumnIfMissing) delphi-backend/Source/PM.Database.pas
Static files disque (mort en prod, embedded gagne) delphi-backend/Source/PM.StaticFiles.pas
Assets embedded dans .res delphi-backend/Source/PM.EmbeddedAssets.pas
Quick unlock DPAPI (device-bound) delphi-backend/Source/PM.QuickUnlock.pas
Prefs key/value DPAPI (prefs.bin) delphi-backend/Source/PM.UserPrefs.pas
Single-instance mutex + broadcast delphi-backend/Source/PM.SingleInstance.pas
Start with Windows (HKCU Run) delphi-backend/Source/PM.AutoStart.pas
Favicon proxy (DuckDuckGo, async THTTPClient/WinHTTP) delphi-backend/Source/PM.Favicon.pas
Handlers REST delphi-backend/Handlers/PM.Handler.*.pas
Frontend complet js/app.js
HTML racine index.html
Styles css/style.css

Bridge cmd:// (JS → Delphi)

JS navigue vers cmd://action/sub?params → intercepté par WebBrowserBeforeNavigateHandleBridgeCommand.

Commandes connues :

  • clipboard/copy, clipboard/clear, clipboard/read (Paste custom menu)
  • quickunlock/{store,get,clear,status}
  • prefs/{get,set}?key=... (device-bound DPAPI key/value, voir plus bas)
  • autostart/{get,set}?enabled=1|0 (HKCU Run registry, "Start with Windows")
  • favicon/fetch?host=X&reqId=Y (async via anonymous thread, callback Bridge.onFaviconResult(reqId, host, dataUri))
  • autofill/{configure,hotkeys,execute,cancel}
  • app/focus (ramène la fenêtre au premier plan, pour le picker)
  • app/ready (page chargée → SetFocus WebBrowser + DOM focus auth input)
  • app/theme?mode=dark|light (sync title bar + popup menus via SetPreferredAppMode)
  • audit (POST log d'action user-visible)
  • entry/new-from-title (Ctrl+Shift+A → modal pré-rempli)

Retour Delphi → JS via WebBrowser.ExecuteJavaScript('Bridge.onX(...)').

Hotkeys globaux Win32

  • Ctrl+Shift+L : autofill complet (user + Tab + password)
  • Ctrl+Shift+P : autofill password seul (forms step-2, unlock screens)
  • Ctrl+Shift+A : new entry pré-rempli avec le titre de la fenêtre foreground
  • Ctrl+Shift+D : toggle debug panel (uniquement si config.txt présent)

Combos autofill configurables depuis Settings (synced en DB via settings_json).

Debug mode

Fichier config.txt à côté de l'exe (tous champs optionnels) :

port=8765                    # 0 ou absent = port aléatoire ephemeral (49152-65535)
debug=true                   # affiche PanelTop (Start/Stop, log memo)
require_token=false          # désactive le token validation (curl/Postman direct)
require_process_check=false  # désactive le PID-on-socket check

Sans config.txt, Ctrl+Shift+D ne fait rien (sécurité, panel jamais exposé en prod).

HTTP server lockdown (anti-browser-direct)

Le serveur Indy n'accepte une requête que si elle a :

  • Query param ?pmt=<token> (sur la 1ʳᵉ navigation) → Set-Cookie
  • OU cookie pm_token=<token>; HttpOnly; SameSite=Strict; Path=/

Au start :

  • Port aléatoire dans [49152, 65535] si pas configuré
  • Token random 32-chars hex généré
  • WebView2 navigate vers http://127.0.0.1:{port}/index.html?pmt={token}
  • JS strip le ?pmt=... du URL bar via history.replaceState

Requête sans token valide → 404 (pas 403 pour ne pas révéler que le serveur existe). Override via require_token=false.

Couche 2 : PID-on-socket process check

Sur chaque request, PM.ProcessLockdown :

  • GetExtendedTcpTable (winapi iphlpapi.dll) lookup le PID qui possède la connexion entrante (match sur Chrome's localPort + remotePort = our port)
  • IsDescendantOfCurrentProcess walk parent tree via Toolhelp32 → notre exe est-il ancêtre ?
  • Si non (curl externe, autre browser, etc.) → 404

Defense in depth : si quelqu'un connaît le token (leaked logs, mémoire), il faut AUSSI exécuter le code dans notre lignée de process pour passer. Override via require_process_check=false. Notre WebView2 (children msedgewebview2.exe) passent naturellement.

Token masqué dans les logs

MaskAccessToken(url) dans UMainForm remplace ?pmt=<32 chars> par ?pmt=*** avant LogLine. Évite le leak via copie/upload du log.

Title bar + popup menus dark mode

PM.Bridge.ApplyTitleBarTheme(ADark: Boolean) fait 2 choses :

  1. DwmSetWindowAttribute(DWMWA_USE_IMMERSIVE_DARK_MODE=20) → title bar. No-op sur Win < 19044.
  2. uxtheme!SetPreferredAppMode (ordinal #135) + FlushMenuThemes (#136) — API privée stable depuis Win10 1809 utilisée par Explorer/Edge/Office. Themea automatiquement les popup menus (tray context menu inclus), scrollbars, tooltips. Valeurs : ForceDark=2, ForceLight=3.

Appelé au FormCreate + via cmd://app/theme?mode=dark|light quand JS toggle.

Single instance

Au démarrage, PM.SingleInstance.AcquireOrSignal :

  • Crée un mutex nommé Local\PMServer.SingleInstance.Mutex (namespace Local\ = per-session, donc deux users Windows distincts peuvent chacun lancer le leur)
  • Si ERROR_ALREADY_EXISTS : PostMessage(HWND_BROADCAST, WM_PMSHOW, 0, 0)WM_PMSHOW = RegisterWindowMessage('PMServer_ShowExisting') est un ID system-unique. Puis exit immédiat avant Application.Initialize.

L'instance existante reçoit WM_PMSHOW dans son message-only window (PM.Bridge.MsgWindowHandler) → déclenche FOnTrayRestoreRestoreFromTray. AllocateHWnd crée une fenêtre top-level (pas HWND_MESSAGE) donc elle reçoit bien le broadcast.

Tray icon

Shell_NotifyIcon(NIM_ADD) au constructor de TPMBridge, pas au premier minimize → ic ône visible dès le démarrage, même si la fenêtre est ouverte. NIM_DELETE uniquement au destructor.

MinimizeToTray ne touche plus à l'icône, juste à la visibilité de la fenêtre + clipboard clear + balloon first-time.

Menu : Open / Lock vault / Quit (via TrackPopupMenu, themé par SetPreferredAppMode ci-dessus).

Favicons (PM.Favicon)

Opt-in (state.faviconsEnabled, default OFF, synced via settings_json). La SEULE feature qui sort sur le réseau côté Delphi (HIBP est côté JS).

  • Source : https://icons.duckduckgo.com/ip3/<host>.ico — proxy DDG, pas de tracking, retourne PNG 16-32 px. DDG-only par design : pas de fallback vers /favicon.ico direct (qui leakerait DNS à chaque domaine stocké). 2 essais : host complet puis SLD (chat.qwen.aiqwen.ai). Pour les sites privés/self-hosted que DDG ne couvre pas, l'user upload une icône custom via soIconField dans le slideover
  • THTTPClient (System.Net.HttpClient) qui wrappe WinHTTP sur Windows → TLS natif via le store de certificats Windows. Pas de DLLs OpenSSL à shipper (Indy aurait silencieusement fail sans libcrypto-3.dll/libssl-3.dll). 5 s timeout, max 3 redirects, cap 64 KB. Toute erreur → return ''
  • Magic-byte sniffing pour le MIME (PNG/JPEG/GIF/SVG/ICO)
  • Cmd async : TThread.CreateAnonymousThread car un GET HTTP bloquerait le main thread jusqu'à 5 s. Callback via TThread.QueueBridge.onFaviconResult(reqId, host, dataUri)
  • Stockage : colonne vault_entries.icon_b64 TEXT (nullable). Endpoint dédié POST /entries/{id}/icon pour ne pas forcer un PUT complet (qui re-PUT le password chiffré)
  • Bulk : DELETE /entries/icons/all purge tout (bouton "Clear cache")
  • Render : entry-avatar contient <img class="entry-avatar-img"> si icon_b64, sinon fallback aux initials. onerror repasse aux initials si la data URI est corrompue
  • Auto-fetch après save d'entry, backfill via "Fetch missing" / "Re-fetch all" boutons dans Settings

Start with Windows (PM.AutoStart)

  • Toggle dans Settings → "Start with Windows" (visible seulement quand Bridge.active)
  • Mécanisme : HKCU\Software\Microsoft\Windows\CurrentVersion\Run, valeur "PMServer" = "<exe-path>" -tray (single dash — --tray ne match pas FindCmdLineSwitch)
  • Per-user, pas d'admin requis. Visible dans Task Manager → Startup tab
  • Toggle "on" = registered AND la valeur pointe vers notre exe courant (un old entry d'un exe déplacé lit comme "off" → user peut re-enable pour rafraîchir le path)
  • CLI flag -tray (FindCmdLineSwitch('tray', True)) dans FormCreate → FBridge.MinimizeToTray via TThread.ForceQueue (defer après la show initiale FMX pour minimiser le flash)

Device-bound prefs (PM.UserPrefs)

Problème résolu : le serveur HTTP bind un port éphémère aléatoire (49152-65535) sur chaque start. localStorage est keyed par origin (scheme+host+port) → reboot = nouvelle origine = localStorage wipé. Pour les prefs qui doivent survivre (rememberedUsername, etc.), on persiste via DPAPI.

  • %LOCALAPPDATA%\PMServer\prefs.bin — DPAPI-encrypted UTF-8 JSON {"key":"value",...}
  • PM.UserPrefs.GetPref(key) / SetPref(key, value) Delphi-side
  • Bridge JS : await Bridge.getPref(key) (Promise) / Bridge.setPref(key, value)
  • Callback : Bridge.onPrefResult(key, value) posé par Delphi via ExecuteJavaScript. Resolvers stockés dans prefResolvers[key] avec timeout 2 s.

LoadAll retourne toujours un TJSONObject valide (jamais nil) — défault = objet vide, remplacé seulement sur parse success.

Quick unlock — interactions

  • Au boot, init() call bridgeQuickUnlockStatus() et synchronise state.quickUnlockEnabled + localStorage depuis la source of truth DPAPI (corrige le décalage localStorage quand le port change).
  • Sur Windows lock / sleep (BridgeSystemLockBridge.onSystemLock) :
    • Si state.quickUnlockEnabledpas de lockVault. Le blob DPAPI gate déjà l'accès via le compte Windows, re-locker est redondant. Toast informatif affiché.
    • Sinon → comportement d'origine (lockVault()).
  • L'auto-lock par inactivité (autoLockTimer) reste indépendant — il ignore le flag QU (opt-in utilisateur explicite via Settings).

Authenticator + TOTP tool

Sidebar Tools expose 2 items MFA :

  • Authenticator (state.view = 'authenticator') : grille de cards (chaque entry avec TOTP secret) — code 6 chiffres en gros + barre countdown. renderAuthenticatorGrid() décrypte tous les secrets upfront puis tick 1 s. authTickTimer clear sur lock / view change. Snap instantané à 100% sur reset de période (sinon CSS transition anime le saut backward → effet "freeze").
  • TOTP generator (openTotpTool()) : modal standalone. Paste base32 ou otpauth:// URI → code live. Bouton "Generate" génère un secret base32 aléatoire 20 bytes (randomBase32Secret()). Rien n'est sauvegardé.

Code TOTP retourne { code, period, secondsLeft } — attention au nom, pas remaining (utiliser t.secondsLeft).

Vault health dashboard

Sidebar Tools → "Vault health" (state.view = 'health'). Vue dédiée avec un score 0-100 et 4 cards de catégorie :

  • Weak : computeStrength(plain) < 50
  • Reused : groupes d'entries partageant le même plaintext (≥ 2)
  • Old : updated_at > 365 days
  • Pwned : depuis state.hibpResults (n'apparaît que si HIBP actif)

computeHealthCache() décrypte chaque entry une fois et stocke dans healthCache (module-level let, pas dans state pour ne pas polluer). Invalidé sur lock, save d'entry, et bouton "Recompute".

Score : start 100 → -5/weak (cap -40), -10/reused (cap -30), -2/old (cap -20), -15/pwned (cap -50). Bandes : ≥80 Good (vert), ≥50 Fair (cyan), ≥25 At risk (orange), <25 Critical (rouge).

Bouton "Fix" par item → openEntryForFix(id) = openSlideover puis click sur le bouton edit-password (fallback : juste ouvre le slideover).

Liste tronquée à 20 items par catégorie + count "+N more".

Sidebar sections collapsibles

Sections Folders, Tags, Tools ont chacune un .section-toggle button (chevron + label + badge count) qui toggle .is-collapsed sur la section parent. Body masqué via display: none sous .is-collapsed.

État persisté dans state.sidebarCollapsed = { folders, tags, tools }, synced via SYNCED_SETTING_KEYS (settings_json). Badges (#countFolders, #countTags) restent visibles quand replié.

Hotkey verrouillé (UX)

autofillHandleRequest : si state.locked ou pas de cryptoKey → Bridge.cancelAutofill() + Bridge.focusApp() puis focus le master password input via DOM. Remplace l'ancien no-op silencieux qui laissait l'utilisateur perplexe.

WebView2 / TMS settings — pièges majeurs

TTMSFNCWebBrowser expose des props qui semblent settables au FormCreate mais ne sont effectivement appliquées qu'après l'init async du WebView2 sous-jacent. Set ces props dans WebBrowser.OnInitialized, pas FormCreate :

  • EnableContextMenu := False → kill le menu Edge natif (Inspecter, Importer mots de passe…)
  • EnableShowDebugConsole := False → kill DevTools
  • EnableAcceleratorKeys := FalseNE PAS METTRE : casse aussi Ctrl+C/V/X/Z/A/K dans les inputs

Le blocking F12 / Ctrl+Shift+I/J / Ctrl+U se fait côté JS (keydown preventDefault) en complément.

Custom right-click menu

Le menu natif est désactivé via TMS. On gère un mini-menu JS dédié sur right-click dans les inputs : installCustomContextMenu() → Cut / Copy / Paste / Select All. Paste utilise Bridge.readClipboard() (sans prompt WebView2 contrairement à navigator.clipboard.readText()). stopPropagation sur les click items sinon le slideover se ferme (chip retiré du DOM avant que le doc click handler check .closest('.slideover')).

Browser shortcuts bloqués (keydown JS)

Bloqués via preventDefault :

  • F12, Ctrl+Shift+I/J, Ctrl+U — DevTools / View source
  • Ctrl+J — Downloads overlay (le moins évident, déclenche un popup Edge)
  • Ctrl+H/S/P/T/N/R, F5 — History / Save / Print / New tab+win / Reload
  • Ctrl+Shift+N/W/Delete — Incognito / Close window / Clear data

Conservés : Ctrl+C/V/X/A/Z (édition), Ctrl+F (find in page), Ctrl+0/+/- (zoom accessibility), Ctrl+K (notre command palette), Ctrl+Shift+L/P/A/D (nos hotkeys).

Pagination

State : pageSize (10/25/50/100, default 25, synced via settings_json)

  • currentPage runtime-only. Active sur les 3 view modes (cards/list/ table) si > 10 items. Position top (au-dessus du grid/table).

CSS : .pagination { grid-column: 1 / -1 } pour span full width en cards view (sinon ça occupe une slot de card).

Reset currentPage = 1 sur : search, sort, view (folder/fav/tag), viewMode change, pageSize change. Render via renderPagination(total, totalPages)

  • computePageList(current, total) avec ellipsis intelligente (1 … 4 5 6 … 12).

Flash animation (nouvelle entry)

flashEntry(id) appelée après saveEntry success + duplicateEntry : setTimeout(0)querySelector('.entry-card[data-id],.entry-row[data-id]')scrollIntoView({block:'center'}) + classe is-flash 2.5s (@keyframes entryFlash pulse cyan).

Résout le cas "j'ai ajouté un mot de passe avec tri A-Z, où se loge-t-il ?" — scroll + pulse trouvent la nouvelle entry dans la grille triée.

Entry payload — call sites à toucher ensemble

Une vault_entries row porte plusieurs blobs chiffrés indépendants : encrypted_password/iv, totp_secret/totp_iv, custom_fields/custom_fields_iv, plus le champ-icône icon_b64 et les méta non chiffrées (site, title, username, folder, tags, kind).

Quand tu ajoutes un nouveau champ (chiffré ou non), il faut toujours mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement sur certaines actions :

  1. DB schema : PM.Database.CreateSchema AddColumnIfMissing(...)
  2. GET /entries : PM.Handler.Entries.HandleGetEntries — ajouter au LObj.AddPair(...) (NULL → TJSONNull)
  3. POST + PUT /entries : HandleCreateEntry + HandleUpdateEntry — lire du body, binder le param, gérer Clear pour NULL
  4. Master pw rotation : PM.Handler.Auth.HandleChangeMasterPassword — UPDATE doit inclure la colonne, sinon la rotation l'écrase à NULL
  5. Master pw rotation JS : doChangeMasterPassword → la boucle for (const e of state.entries) re-chiffre chaque blob et push dans encrypted[]. Manquer un champ chiffré = donnée perdue.
  6. duplicateEntry (js/app.js) — copier le blob chiffré tel quel (même vault key, pas besoin de re-chiffrer)

Bonus utile (pas critique) : soDirtyCheck doit comparer le nouveau champ, et openSlideOver doit le déchiffrer et l'exposer via soState.

Historiquement on a oublié kind dans duplicateEntry (bug "Site required" sur duplique-note), et custom_fields dans la rotation + duplicate. Cette liste évite de répéter ces erreurs.

Settings sync

Per-user blob JSON dans users.settings_json, exposé via GET/PUT /settings. Synced : theme, autoLock, viewMode, sortBy/sortDir, hibp, hotkeys autofill, etc. Device-only (localStorage seulement) : quickUnlockEnabled (DPAPI lié au compte Windows), autofillEnabled (toggle hotkey Win32), rememberedUsername (auth screen autofill local).

Quick Unlock

DPAPI blob à %LOCALAPPDATA%\PMServer\quickunlock.bin (tied to Windows user, pas à l'emplacement de l'exe — survit au déplacement).

Cold-start restore : tryQuickUnlock ne check pas localStorage comme source of truth (WebView2 user-data folder est relatif à l'exe → move = localStorage perdu). Le fichier DPAPI seul gouverne. Si présent :

  • Décrypte blob → restore key
  • Re-login fresh via /login avec verifier = bytesToHex(rawKey) (le token stocké dans le blob peut être expiré ; on en récupère un nouveau à chaque cold-start)
  • Resync localStorage à '1' pour que Settings affiche "Disable"

Recovery code (refactor 2026-05)

  • 5 uses par code (compteur remaining_uses dans recovery_keys)
  • Décrémenté à chaque redeem. Si tombe à 0 → row deletée
  • Row deletée aussi sur successful change-master-password
  • Workflow attendu : recover → set new master pw (consomme le code) → regenerate un nouveau code

State justRecovered : flag JS posé après redeem, cleared sur

  • successful master pw change (le code est consumed)
  • lockVault (lock = perte de contexte recovery)
  • doLogin success (login normal avec pw = sortie du mode recovery)

En mode recovery, le modal "Change master password" masque le champ "Current master password" ET retire son required (sinon HTML5 form validation bloque silencieusement le submit). Le verifier "current" est dérivé du state.cryptoKey en mémoire (qu'on vient de recover).

Autofill pipeline

Workflow utilisateur (important)

L'utilisateur doit cliquer le champ cible AVANT de presser le hotkey. Le code ne fait plus de "click au centre de la fenêtre" pour deviner le champ — ça détruisait le focus sur les forms qui n'étaient pas centrés (Gitea login, etc.).

  • Ctrl+Shift+L (full) : click sur username field → hotkey → user+Tab+pwd
  • Ctrl+Shift+P (password only) : click sur password field → hotkey → pwd seul

Pipeline Delphi

  1. RegisterHotKey Ctrl+Shift+L/P sur message-only window
  2. WM_HOTKEY → capture GetForegroundWindow + titre, fire callback
  3. JS reçoit Bridge.onAutofillRequest(title, kind) → strip browser suffix (- Google Chrome, etc.) → matching state.entries.site
  4. Single match → Bridge.executeAutofill(user, pwd) direct Multi-match → Bridge.focusApp() + picker modal → user pick → execute Note : closeAutofillPicker(false) quand user pick (skip cancel cmd qui clearrait FAutofillTargetHWND)
  5. Delphi ExecuteAutofill :
    • Si app foreground → SW_MINIMIZE (laisse la place au target)
    • ForceForegroundWindow(target) avec AttachThreadInput + poll jusqu'à GetForegroundWindow == target (max 600ms)
    • WaitForModifierRelease (sinon Ctrl+Shift restent down → toutes les frappes deviennent Ctrl+Shift+X)
    • Sleep(120) settle
    • Pas de click parasite — le user a déjà focusé le bon champ
    • Ctrl+A + Del avant chaque champ (clear contenu existant)
    • SendInput : Ctrl+A+Del → username → 200ms → Tab → 200ms → Ctrl+A+Del → password

Matching titre → entry

  • Strip suffixe browser via BROWSER_SUFFIX_RE (Chrome, Firefox, Edge, Brave, Opera, Vivaldi, Safari, Tor, Arc) avant toute comparaison
  • Score = max sur 3 champs : entry.title (display name, score 1500+), entry.site hostname full (1000+), SLD (500+)
  • Pour SLD courts (x, qq), match avec word boundaries
  • entry.title priorisé → si utilisateur stocke site = "git.example.com" (hostname), mettre aussi title = "Gitea" (brand) couvre le browser title sans ambiguïté

Contraintes connues autofill

  • UIPI : SendInput vers process élevé bloqué. Notepad++ admin, regedit → autofill silencieusement no-op. Tester avec apps non-élevées.
  • Chrome password manager : si Chrome a des credentials sauvegardés pour le domaine, il peut racer avec notre SendInput et écraser notre username pendant le Tab. Workaround : user désactive Chrome PM par site (clic-droit password field → "Never save passwords for this site")
  • Gitea-like forms : tabindex explicite + autocomplete="current-password" → Chrome PM s'active. Désactiver pour ce domaine ou utiliser Ctrl+Shift+P
  • Title-based matching : si l'utilisateur stocke site = "git.ai-agents4you.com" (hostname) mais le titre browser n'a que la marque ("Gitea"), pas de match. Convention : préférer site = "Gitea" (brand) pour les sites brandés. URL hostname-only fonctionne pour les sites dont le titre contient le hostname (rare).

Contraintes / pièges connus

  • UIPI : process non-élevé ne peut pas SendInput vers process élevé (Notepad++ admin, regedit, …). Solution future : manifest uiAccess=true + exe signé EV + installé dans C:\Program Files\.
  • Assets embedded > static dans le dispatch HTTP : modifier JS sans rebuild assets.res n'a aucun effet sur l'exe.
  • WebView2 mange tous les keydown : ne pas utiliser FormKeyDown pour des raccourcis globaux → toujours RegisterHotKey.
  • FMX FormCloseQuery : minimize-to-tray sauf si FQuitting=True (set par le tray menu "Quit" uniquement).
  • SetForegroundWindow Win10/11 : règles strictes anti-stealing → nécessite AttachThreadInput pour cross-process.
  • updated_at est bumpé à chaque PUT entry → sort par défaut = name asc pour que modifier une entry ne change pas sa position.
  • TMS render-time vs DOM-attachment : pattern courant — fonctions qui rendent un widget (soTagsField, soTotpField) appellent un helper (renderSoChips, startTotpTick) qui fait $('#id') sur un élément pas encore dans le DOM. Solution : render inline OU setTimeout(fn, 0) pour deferrer jusqu'au tick suivant.
  • HTML5 required + display:none : un input required caché bloque silencieusement le form submit. Toggle aussi .required = false quand on cache (cf. modal recovery).
  • WebView2 user-data folder est relatif à l'exe : déplacer l'exe = nouveau folder = localStorage/sessionStorage perdu. Pour ce qui doit survivre au move, stocker via Delphi (DPAPI, fichiers user).
  • Port éphémère = nouvelle origin = localStorage wipé chaque launch : cause distincte du point précédent (l'exe ne bouge pas, mais le port change). Mêmes conséquences : pour tout pref qui doit survivre au reboot, passer par PM.UserPrefs via Bridge.getPref/setPref. Le fix port-fixe est possible (port=N dans config.txt) mais on perd l'aléatoire qui complique le scan externe.
  • Modifier les Enable* props TMS en OnInitialized, pas FormCreate.

Conventions code (héritage sessions précédentes)

  • Pas de commentaires multi-lignes pour chaque fix : préférer noms de variables/fonctions descriptifs (OwnFormHwnd, EnsureTargetThreadHasKeyboardFocus, FocusSettleDelayMs)
  • Migrations DB : toujours via AddColumnIfMissing (idempotent)
  • Nouveau setting UI : ajouter à SYNCED_SETTING_KEYS + handler dans loadServerSettings switch case + listener change qui appelle saveServerSettings()
  • Tout new bridge cmd : handler dans UMainForm.HandleBridgeCommand, log via LogLine pour traçabilité

Audit / docs

  • security-issues.md à la racine : audit sécu legacy (api.php)
  • Ce CLAUDE.md = source de vérité pour l'architecture courante

Git

  • Pas de remote configuré (dev local)
  • vault.db, *.db-shm, *.db-wal, delphi-backend/Win32/, *.dcu, assets.res gitignored
  • Messages commit : type(scope): description style conventional