feat: MFA tools, single-instance, tray polish, prefs persistence
Session highlights:
- feat(prefs): DPAPI-backed key/value store (PM.UserPrefs) — fixes
rememberedUsername being lost across reboots due to the random
ephemeral HTTP port changing the localStorage origin every launch.
Bridge cmd://prefs/{get,set} round-trips through Delphi.
- feat(tray): icon visible from startup (NIM_ADD at constructor, not
at first minimize). Tray context menu themed via uxtheme!135
SetPreferredAppMode so it follows the app's dark/light setting.
- feat(single-instance): named mutex + RegisterWindowMessage broadcast.
Second launch posts WM_PMSHOW to HWND_BROADCAST and exits; the
running bridge restores the window from tray. Mutex lives in Local\
namespace so distinct Windows users can still each run one.
- feat(mfa): Authenticator sidebar view (live TOTP codes for every
entry with a secret) + standalone TOTP generator modal (paste
base32 / otpauth:// URI, or generate a random 20-byte secret).
- feat(sidebar): Folders / Tags / Tools sections collapsible with
chevron toggle. Badge counts stay visible when collapsed. State
persisted in settings_json (synced across devices).
- feat(autofill): hotkey when vault is locked now restores the app
and focuses the master password input instead of no-op'ing
silently. Cleaner UX for the common "I hit Ctrl+Shift+L but the
vault was locked" path.
- feat(quick-unlock): when enabled, skip lockVault on Windows lock /
sleep. Rationale: the DPAPI blob already gates access via the
Windows account, so re-locking on top of the OS lock is redundant.
Idle auto-lock still fires (separate opt-in).
- fix(quick-unlock): re-sync state.quickUnlockEnabled from DPAPI
source-of-truth at boot, instead of trusting (now-volatile)
localStorage.
- docs: CLAUDE.md updated with all new modules, bridge commands,
and the port-ephemeral pitfall.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
# 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` |
|
||||
| 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
|
||||
`WebBrowserBeforeNavigate` → `HandleBridgeCommand`.
|
||||
|
||||
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)
|
||||
- `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)`
|
||||
où `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 `FOnTrayRestore` →
|
||||
`RestoreFromTray`. `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).
|
||||
|
||||
## 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 (`BridgeSystemLock` → `Bridge.onSystemLock`) :
|
||||
- Si `state.quickUnlockEnabled` → **pas 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`).
|
||||
|
||||
## 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 := False` ← **NE 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.
|
||||
|
||||
## 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
|
||||
Reference in New Issue
Block a user