Adopts the approach that actually works: ApplicationHWND (FMX.Platform.Win) IS
the taskbar proxy, so there is nothing to hunt for and it is valid before the
form exists. And hiding it was never enough — the taskbar had already drawn
the button; clearing WS_EX_APPWINDOW and setting WS_EX_TOOLWINDOW is what
removes it, since a tool window is never listed.
That style is permanent, which is why the button never came back after a
restore. TrayProxyShowInTaskbar puts it back, called from
RestoreWindowFromTray — the single choke point every tray restore already goes
through — so it lands before RestoreFromTray shows the windows and the style
takes effect on that show.
Removes the scaffolding this chase accumulated and that the real fix makes
pointless: the EVENT_OBJECT_SHOW hook, the polling worker, FindOurFMXAppWindow,
TrayHideNow with its four probes, the one-shot OnActivate hide, and the [tray]
tracing. ~4.8 KB of dead code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Decisive observation: the taskbar button is already up before FormCreate is
entered. Everything I had tried lived inside the form, so all of it ran after
the button was drawn — the watcher included, which is why moving it around
never helped.
StartTrayProxyHider is now exported from UMainForm and called from the .dpr
right after Application.Initialize, before CreateForm. It polls every 5 ms and
hides our TFMAppClass proxy unconditionally (the button can be up before
WS_VISIBLE reads as set, which the previous IsWindowVisible check would have
skipped), stopping as soon as the tray startup completes so a user restoring
from the tray is never fought.
Raw Win32 only — FindWindowEx + ShowWindow, no FMX object touched at that
point, which is what made the earlier .dpr attempt crash.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Why every previous attempt could not work: the main thread is BUSY inside
FormCreate and pumps no message, and that is exactly when FMX creates and
shows its taskbar proxy. A WinEvent hook, a timer, a queued call — none of
them can run in that window. Deferring the server start shortened the window
but did not remove it, because the proxy is created regardless.
A worker thread is the one thing still running while the main thread blocks.
It polls for our TFMAppClass window (matched on our own process id) and hides
it the instant it appears; ShowWindow is safe cross-thread. It stops as soon
as the tray startup completes, with a 10 s ceiling so it can never linger.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The visible thing is only the taskbar button — the form window is never shown
(form_visible_was=False throughout). That button belongs to the FMX proxy,
created partway through FormCreate, and FormCreate runs for ~1 s because it
opens the DB, binds the Indy server and kicks off WebView2. The thread pumps
no message during that second, so nothing could hide the proxy until it ended:
hooks and hide points alike were powerless by construction.
So stop trying to react faster and remove the wait instead: on a --tray launch
btnStartClick now runs inside the deferred block, after MinimizeToTray.
FormCreate returns in milliseconds, the proxy is hidden immediately, and the
server starts once nothing is on screen. FServer is constructed earlier
(line 295), so FServer.Active stays safe meanwhile. Normal launches keep the
original order untouched.
The EVENT_OBJECT_SHOW hook stays armed across the deferred start, so a proxy
re-shown during it is hidden again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The instrumented run pinned it down: the FORM is never visible
(form_visible_was=False at all four hide points). What shows is the FMX
taskbar proxy — app=0 at 20.145, app=854682 at 20.974 — so it is created
partway through FormCreate and stayed on screen for the best part of a second
until the first hide that could see it.
A WinEvent hook on EVENT_OBJECT_SHOW, active only for the --tray startup,
hides any top-level window of ours the moment Windows shows it, and is torn
down once MinimizeToTray has run.
Two details that would each have broken it:
- WINEVENT_INCONTEXT, not OUTOFCONTEXT: the latter queues the callback until
the thread pumps messages, which does not happen during FormCreate — exactly
when the proxy appears.
- The pending flag is a unit-level var, not a form field: CreateForm assigns
the MainForm global only after the constructor returns, so a callback firing
during FormCreate could not have reached the form.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The timeline says FormCreate itself runs for 1.02 s (45.509 → 46.532) while
the hide only landed at 46.663 — so if the window is visible during that
stretch, it accounts for exactly the second being reported.
TrayHideNow() hides the form AND the FMX taskbar proxy, and is now called at
three points: top of FormCreate, right after the browser is parented (which
forces the handle to exist, the likely moment FMX shows it), and at the end of
FormCreate — plus the existing on-activate one-shot.
It also logs whether the window WAS visible each time, which pins down exactly
where FMX turns it on instead of another guess.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The window still showed, taskbar entry included. Two things.
The taskbar button belongs to the FMX proxy window (TFMAppClass), a separate
top-level window from the form — hiding the form alone never removed it.
FindFMXAppWindow moves from private to public so the activate-hide can drop
both. (Guarded on Assigned(FBridge): the handler is installed at line 222,
the bridge is created at 268.)
And rather than guess a fourth time at FMX's startup order — the same trap as
the window-state saga, which only fell to instrumentation — this logs a
millisecond timeline: FormCreate begin/end, the activate-hide, and the
deferred MinimizeToTray. Whichever gap holds the second will be obvious.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The .dpr version crashed with an EAccessViolation writing to 000000CE —
touching MainForm.Visible/Handle between CreateForm and Run is evidently too
early for FMX. Reverted; the app builds and runs as before.
Replaced with a one-shot OnActivate handler installed in FormCreate only for
--tray: it fires the instant FMX shows the form, hides it (FMX + Win32) and
unhooks itself, so it can never fight the user later. That runs far earlier
than the deferred MinimizeToTray, which waits for the message loop and hence
for the DB open, server start and WebView2 init — the second the window used
to spend on screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With "Start with Windows" the window sat on screen for a second or two before
dropping to the tray. The hide was deferred with ForceQueue, which only runs
once the message loop is free — i.e. after the DB open, the HTTP server start
and the WebView2 init, all of which happen first.
Set Visible := False at the very top of FormCreate instead, before any of that
work, so there is nothing to show in the first place. The deferred
MinimizeToTray stays: it still adds the tray icon and drops the taskbar entry,
and doubles as the safety net if FMX shows the main form regardless — worst
case the behaviour is exactly what it is today.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two bugs in the same flow, both on the tray-originated path.
1. Esc did nothing until the search box was clicked: after the tray restore
the WebView2 child does not hold the OS keyboard focus, so no keystroke
reached the page at all — a DOM .focus() cannot fix that. Both quick-search
entry points now call WebBrowser.SetFocus after restoring.
2. Esc closed the modal but left the app on screen. In fill mode
closeQuickSearchModal fires cancelAutofill and then minimizeToTray, and two
back-to-back window.location.href assignments COALESCE — the minimise was
dropped. The copy path was unaffected because it never calls cancelAutofill,
which is why this only showed up here. Deferred by a setTimeout(0), the same
remedy CLAUDE.md already documents for quickunlock/store + pin/clear.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Checking the hook's blast radius surfaced a real interaction: ExecuteAutofill
genuinely minimises the window (SW_MINIMIZE) before typing, which raises the
minimise guard. If the window is then HIDDEN to the tray rather than
un-minimised — the quick-search hide-after flow does exactly that —
MINIMIZEEND may never fire and the guard would stay raised for the rest of the
session, silently freezing the tracked window state.
All six FBridge.RestoreFromTray call sites now go through
RestoreWindowFromTray, which clears the guard first. RestoreFromTray replays
its own saved placement, so nothing is left for the guard to protect there.
MinimizeToTray itself is unaffected: it uses SW_HIDE, which raises no minimise
event at all.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The traces did their job: hwnd=657868 vs form=657842 proved Win+D minimises
the FMX taskbar proxy, not the form — which is why the earlier HWND filter
swallowed every event. Two full cycles then showed minimise start/end firing
and the re-maximise landing, with no stray "user resize" during the restore.
Keeps the two lines worth having: the hook install result (a silent failure
would be baffling) and the correction itself, which is rare.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hook installed but never fired: an FMX app owns TWO top-level windows —
the form and the taskbar proxy (the one FindFMXAppWindow digs out in
PM.Bridge) — and Win+D minimises the proxy. Comparing the event HWND against
the form handle rejected every event.
Dropped the comparison; the hook is already scoped to our process and WebView2
lives in separate ones, so any window event arriving here is ours. Logs the
install result and each event's HWND so a silent failure is visible next time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Third discriminator measured and rejected: the log shows the restore's resize
arrives with fg=True (22:44:08), so foreground ownership no more separates it
from a user gesture than the placement flags did.
FMX simply exposes nothing usable — no resize while iconic, and the restore's
resize is indistinguishable from the user's own. So stop inferring and observe
the transition at the OS level: SetWinEventHook on EVENT_SYSTEM_MINIMIZESTART
/ MINIMIZEEND, scoped to our process, WINEVENT_OUTOFCONTEXT (no injection,
callback on our thread).
Minimise start raises a guard that makes the resize handler ignore everything
until the restore settles; minimise end re-applies the state the user actually
left, in both directions. Hook failure is non-fatal — only the preservation is
lost. Unhooked in FormDestroy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The log settled the previous attempt: WPF_RESTORETOMAXIMIZED (flags=2) is set
for ANY maximised window, not just one on its way back from minimised, and
showCmd is already 1/3 by the time the resize fires. Testing those classified
the user's own Maximize click as a restore, so the intent was never recorded
(want stayed False) and nothing could be re-applied afterwards.
Windows exposes no restore marker here. What does separate the two: a user
gesture happens while the window OWNS THE FOREGROUND; FMX's re-layout during a
restore (and during the startup burst) runs before it does. Resizes that
arrive without foreground ownership are no longer recorded as intent, and
re-apply a maximised state the user had left.
Placement is still read, for the log only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The traces killed the previous discriminator: OnActivate fires constantly
(WebView2 focus churn) and even right after the user clicks Maximize, so
"resize followed by activate" wrongly classified a user maximise as a restore
and dropped the intent.
Windows itself knows: WPF_RESTORETOMAXIMIZED (or showCmd still
SW_SHOWMINIMIZED) marks the resize of a window on its way back from minimised.
That resize is now never recorded as intent, and triggers the re-maximise
directly — no dependency on activate ordering. Activate is demoted to a
guarded safety net.
Log line now dumps zoomed/showCmd/flags so the next run confirms the flag is
actually set on this machine.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The traces settled it: on a restore FMX fires its resize BEFORE the activate,
already reporting "not maximised" — so any snapshot taken at activate time was
too late. A genuine user resize, by contrast, is never followed by an activate
(the window is already active).
That is the discriminator. A resize no longer commits immediately: it queues,
and an activate arriving first cancels it, leaving the intent intact for the
correction to re-apply. Verified against every sequence in the log, including
the startup burst (three resizes then an activate), which must NOT be mistaken
for the user un-maximising.
Skips resizes that don't change the maximised state, so dragging the window
edge no longer floods the queue or the log.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The FWasMinimized gate never armed — FMX evidently fires no resize while the
window is iconic — so activate bailed out and the maximised window still came
back normal.
Stop depending on that signal or on event ordering: snapshot the intent at
activate (before a restore-time resize can overwrite it) and apply the
correction from a queued call, which necessarily runs after FMX has finished
sizing the window. FRestoring keeps that resize out of the tracking, and the
intent is re-asserted afterwards since our own ShowWindow fires one too.
Adds [winstate] LogLine traces so the next iteration works from observed
event order instead of guesses (needs config.txt with debug=true).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous version over-corrected: a normal window came back maximised
after Win+D. Two causes, both fixed.
- It read FMX's WindowState, which does not reliably follow a native
un-maximise, so FWantMaximized never cleared. Now reads Win32 IsZoomed /
IsIconic, which is the actual truth.
- The restore fires its own resize, reporting whatever FMX decided — recording
that erased the very intent we were preserving. FWasMinimized now gates the
restore's resize out of the tracking.
Activate then corrects both directions: re-maximise when FMX dropped a
maximised window, restore when Windows brought a normal one back maximised.
Degrades safely — if the minimise resize never fires, it simply does nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The earlier fix only covered the tray restore path, but Show desktop /
taskbar restore never runs our code at all — it is pure Windows + FMX, and
FMX brings the window back "normal" after forgetting it was maximised.
Track the state the user actually settled on (OnResize, ignoring wsMinimized
since that is the state we must survive) and re-apply it on OnActivate, which
fires however the user returns: taskbar, tray or Alt+Tab. Gated on
FWantMaximized so a window the user deliberately un-maximised is left alone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A toast is gone by the time the user opens Settings, so a hotkey Windows
refused looked perfectly configured while doing nothing. The offending row now
carries a ⚠ marker (amber border + tooltip explaining the combo is taken)
until a re-registration clears it.
Delphi reports slot ids instead of labels; HOTKEY_SLOTS maps id → button +
label in one place, so JS owns the wording and can flag the exact row. The
markers are re-applied by openSettings, surviving panel re-renders.
Also stop toasting success on the startup push (it fires on every unlock) —
autofillPushHotkeys(true). A clash still warns: that one is actionable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two gaps when Windows refuses a RegisterHotKey:
- the toast said "one or both hotkeys" — wrong since there are three autofill
combos, and it never said which one lost.
- Quick search and New entry failures were only written to the debug panel,
which is off in production: the hotkey silently did nothing with no clue why.
Delphi now exposes the per-slot registration outcome and the host accumulates
the failed combo names across all five hotkeys, emitting one notification for
the batch. A combo the user deliberately cleared (vk=0) is never listed.
JS names them: "Already used by another app: 2FA code — pick another combo in
Settings".
Registration stays best-effort per slot: one clash doesn't disarm the others.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same workflow as the password-only hotkey: click the 2FA field, press the
combo, the current 6-digit code is typed in.
Reuses the whole autofill pipeline — no new native typing path. The code is
sent in the password slot with an empty username, so Delphi's existing
password-only branch types just the code.
- Bridge: akTotp kind, hotkey id 47, registered/torn down alongside the other
two autofill combos; an unconfigured combo (vk=0) doesn't count as a
registration failure.
- Matching filters on totp_secret BEFORE scoring, so the multi-match picker
never offers an entry that has no code.
- Configurable + synced like the others (autofillHotkeyTotp), collision-checked
against the other four slots, covered by Reset to defaults and the cheatsheet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Part A — Ctrl+Shift+A (new entry from window title) is now editable like the
other 3 global hotkeys, following the same pattern end to end: state.newEntryHotkey
(synced), a capture button in Settings > Autofill, dup cross-check, reset-to-
defaults, autofillPushHotkeys, and Bridge.setAutofillHotkeys carrying ne_mods/
ne_vk. Delphi gets SetNewEntryHotkey + stores the live combo (FNewEntryHotkeyMods/
Vk) so ExecuteAutofill's post-fill re-register uses the user's combo instead of
resetting to Ctrl+Shift+A. UMainForm parses ne_* unconditionally (works with
autofill off).
Part B — new-entry from the tray pre-filled the title with "Password Manager":
GetForegroundWindow resolved to our own hidden window. Only capture the title
when the foreground window belongs to another process; otherwise pass '' (blank).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The qsInput Esc handler didn't stopPropagation, so the SAME keystroke fell
through to the document-level Esc handlers which, seeing the modal now
closed, also closed (or discard-prompted) the dirty slideover behind it.
- autofill/cancel now hands the foreground back to the saved target HWND:
Esc after a fill-mode hotkey returns the user to the window they came
from instead of leaving our app focused. Locked-vault path still ends
focused on us (cancelAutofill runs before focusApp).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wasHidden counted IsIconic too, so a window merely minimised to the taskbar
("visible" to the user) was treated as tray-origin: Esc/pick sent it to the
TRAY, vanishing from the taskbar it came from. wasHidden is now strictly
"came from the tray" (Visible=false); an iconic window gets restored by the
hotkey and simply stays open when the modal closes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Residual new-entry trigger (1 in 6): password chars go out as
KEYEVENTF_UNICODE (VK_PACKET, can't match a hotkey) — the real chord risk is
the Ctrl+A clear-field, which sends a real VK_A. If the user re-presses
Ctrl+Shift mid-sequence, that VK_A becomes physical Ctrl+Shift+A = our own
new-entry hotkey. ForceReleaseModifiers now runs inside
SendSelectAllAndDelete, at the risky instant, not just once up front.
Balloon: was gated by "Show tray notifications" (OFF for this user) — now
gated by its own synced setting "Tray alert when autofill is blocked"
(autofillFailBalloon, Settings > Autofill, default ON), carried as notify=0
on cmd://autofill/execute. ShowBalloon no longer gates internally; each
caller applies its own setting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of "Ctrl+Shift+P opened the new-entry modal": if the user still
holds Ctrl+Shift when WaitForModifierRelease times out (1s), every password
letter is typed as a Ctrl+Shift+<letter> chord — garbage in the field AND it
fires our own global hotkeys (a password containing 'a' triggers Ctrl+Shift+A
= new entry). ForceReleaseModifiers now injects KEYUP for any still-held
modifier before typing.
Also: when the fill is blocked (elevated target) while the window is hidden
in the tray, the in-app toast is invisible — show a tray balloon instead.
ShowFirstTimeBalloon generalized into ShowBalloon(title, text, warning),
gated by the existing "Show tray notifications" setting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Ctrl+A + Del sent before each field misbehaves on targets where Ctrl+A
isn't select-all (terminals, some remote desktops). New synced setting
(autofillClearField, Settings > Autofill) gates it: JS appends clear=0 to
cmd://autofill/execute when off; ExecuteAutofill wraps the three
SendSelectAllAndDelete calls behind AClearFirst. Absent param = ON, so
existing behavior is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug 1: a maximized window trayed via the quick-search fill flow came back
"normal" on the next restore. ExecuteAutofill minimizes the window BEFORE
MinimizeToTray snapshots the placement, so the snapshot said SHOWMINIMIZED
and the never-restore-minimized guard forced SHOWNORMAL. Now honours
WPF_RESTORETOMAXIMIZED (Windows keeps the pre-minimize state in flags).
Bug 2: filling into an elevated app (admin Notepad) showed "password sent"
while UIPI silently discarded the keystrokes (SendInput even reports
success). ExecuteAutofill is now a function: it checks the target process
elevation up front (can't-open counts as elevated) and returns False without
typing. UMainForm feeds the result to JS via Bridge.onAutofillResult; the
quick-search success toast is deferred until Delphi confirms, and a failure
shows "Autofill blocked - the target window runs as administrator".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two bugs when Ctrl+Shift+Q fires while the app window is open beside the
target app:
- RestoreFromTray re-applied FSavedPlacement (captured at the LAST
MinimizeToTray) to an already-visible window -> it jumped to a stale
position. Now: visible and not iconic -> just SetForegroundWindow.
- ExecuteAutofill minimizes our window when it is foreground (the user just
clicked the entry) and never brought it back when hide_after was false.
New ARestoreAfter param (= not HideAfter): restore with SW_SHOWNOACTIVATE
after the fill, so the window returns to its position without stealing
focus from the freshly-filled target.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add --disable-renderer-backgrounding / -backgrounding-occluded-windows /
-background-timer-throttling to the WebView2 args. Without them Chromium
freezes a hidden renderer, so the first autofill hotkey after a tray-only
start (Start with Windows) waited 3-5s for onAutofillRequest to run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sync optimistic concurrency (ETag/If-Match)
- webdav GET captures the response ETag; PUT sends it back as If-Match so
the server rejects (412) our write when another device changed the file
between our pull and push. A 412 re-runs the whole pull→merge→push
(bounded to 3) so the other device's changes are folded in instead of
clobbered. Servers without ETags → empty etag → no If-Match → falls
back to last-write-wins (no regression). onWebdavResult gained a 4th
etag arg.
Chunked webdav PUT (big vaults no longer black-screen on sync)
- The whole encrypted snapshot base64'd into a single cmd://webdav/put URL
blew past WebView2's cap → black screen once the vault grew (20MB of
attachments). PUT bodies now stream through the file/chunk transport and
commit via a new webdav/put-commit (reads the accumulated buffer).
Chunk-transfer hang fix (root cause of the stuck "Preparing…" sync)
- All chunked transfers (saveFile/writeFile/webdav PUT) share one
reqId-keyed resolver. A resolved chunk's stale 30s timeout would later
delete the CURRENT chunk's resolver and fire the wrong res(), leaving
that chunk's await pending forever. Extracted a single _streamChunks()
helper whose ack CLEARS the pending timeout, so resolvers stay strictly
one-at-a-time. Also fixed _webdavCall referencing the Bridge-local cmd()
from module scope (latent ReferenceError).
Sync busy overlay
- syncStatus() now drives the global busy overlay too, so a running sync
blocks stray clicks (e.g. the auto-backup "Choose…" picker) and reads
like the manual backup. The account-mismatch confirm hideBusy()s first
so it's visible above the overlay.
Auto-VACUUM (reclaim space after deleting large attachments)
- SQLite never shrinks the file on DELETE, so deleting big attachments
left vault.db bloated (35MB for 11 tiny entries). DB.CompactIfBloated
VACUUMs when >20% of pages are free AND >~2MB is reclaimable — called on
startup and after each attachment delete. A healthy small vault pays
nothing. (Verified: 35MB → 695KB after the deletes.)
Rebuild: BuildAssets + F9 (UMainForm + PM.Database + PM.Handler.Attachments).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bridge.writeFile had the same URL-length trap saveFile did: a large
auto-backup base64'd into a single cmd://file/write URL blew past
WebView2's ~2MB navigation cap, so backups of big vaults failed
silently (or blanked the page) — for BOTH the manual "Backup now"
button and the silent scheduled run.
- writeFile now streams payloads over ~1MB in chunks (reusing the same
file/chunk transport as saveFile), committed via a new
file/write-commit. Delphi shares the decode+write logic through a new
WriteDecodedFile helper and the existing FFileSaveChunks buffer.
- runAutoBackupNow(silent): the manual run shows the busy overlay
("Reading vault… N/total" → "Encrypting backup…" → "Writing file… N%")
since a 20MB backup takes ~30s; the scheduled on-unlock run passes
silent=true (no overlay, but still chunked so it no longer fails on
large vaults).
- Fixed the "Backup now" click handler passing the click Event as the
silent arg (truthy → would have suppressed the spinner and swallowed
errors); wrapped in () => runAutoBackupNow().
- Pre-sync backup benefits from the chunked writeFile automatically.
Rebuild: BuildAssets + F9 (UMainForm.pas changed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Large file save (fixes black screen on big attachment download / export)
- Bridge.saveFile streams anything over ~1MB base64 in chunks through the
cmd:// channel instead of stuffing the whole payload in one URL — a
multi-MB base64 URL blew past WebView2's ~2MB navigation cap and blanked
the document (black screen). Small payloads keep the single-shot path.
- Chunks are sent sequentially (each acked via Bridge.onFileChunkAck
before the next) so repeated location.href assignments don't coalesce.
- Delphi accumulates chunks per reqId in a TStringBuilder
(FFileSaveChunks), commits on file/save-commit, and shares the
decode+dialog+write logic with the single-shot path via SaveDecodedFile.
- Chunk size 1MB → far fewer round-trips (a 20MB export dropped from ~67
to ~27 hops).
Busy overlay + progress
- Global spinner overlay (showBusy/updateBusy/hideBusy). doExport shows it
immediately on click — BEFORE the entry-decrypt + attachment-fetch loop
that is the real cost — with a 0ms yield so it paints before the thread
blocks (was appearing 3-5s late). Phases: "Reading vault… N/total" →
"Encrypting export…" → "Preparing file… N%" (real chunk progress).
Attachment download shows the same for files > 512KB.
- Spinner ring used an undefined --bg-elev-3 (invalid border → invisible);
switched to --border. Fixed a second stale --bg-elev-3 use on the
settings-search clear button hover.
Password reveal
- promptDialog gets an eye toggle in password mode, so every encrypted
prompt (export, import, backup password, recovery code, sync password)
can show/hide the typed value.
Avatar
- Top-right chip avatar enlarged 22px → 30px with the chip padding
rebalanced.
Rebuild: BuildAssets + F9 (UMainForm.pas changed for the chunk handlers).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Quick search (Ctrl+Shift+Q fill mode)
- Enter / left-click → full autofill (username + Tab + password), like
Ctrl+Shift+L.
- Shift+Enter / right-click → username only (new Delphi username-only
SendInput path via field=user; ExecuteAutofill AUsernameOnly param).
- Ctrl+Enter / Ctrl+click → password only.
- Copy mode (tray / palette) unchanged: Enter/left = password,
Shift+Enter/right = username.
- Clipboard fix: copy-then-minimise no longer wipes the just-copied
password — MinimizeToTray takes an AClearClipboard flag (False on the
quick-search copy path, driven by app/minimize?keepclip=1). The 30s
auto-clear still guards it.
- Right-click on a result row suppresses the native/custom context menu
(preventDefault + stopPropagation).
Editable custom-field combobox
- Option-backed custom fields (card brand, expiry year/month, etc.) now
render a custom editable combobox instead of a locked <select>: an
arrow drops a menu of ALL options (a native <datalist> filtered to the
typed text, which confused users), while the input stays freely
typeable for values not in the list. Storage shape unchanged.
- Outside-click closes the menu via the existing slideover mousedown
handler; item mousedown + preventDefault so blur doesn't race the pick.
Build safety
- BuildAssets.ps1 runs `node --check` on every embedded .js before
generating assets.res. A syntax error now aborts the asset build
(exit 1, file + line logged) instead of shipping a dead bundle that
only surfaces after a full Delphi rebuild. Node is optional: absent →
warn and continue.
Docs
- CODE_AUDIT.md: full static-analysis report (security, latent bugs,
maintainability, future features, prioritized action plan).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Profile picture / avatar
- users.avatar_b64 column (nullable, cosmetic, not encrypted) + GET/POST
/avatar endpoints mirroring the settings handler pattern.
- Top-right chip + Settings→Account show a round avatar: custom picture
if set, otherwise the username's initial on a deterministic
hash-picked colour (stable across renders).
- Upload downscales + center-crops to a 128px JPEG via FileReader →
data: URI (NOT blob:, which the CSP's `img-src 'self' data:` blocks)
before POSTing. Remove button clears it.
- Carried in the encrypted JSON export; restored on import only when the
current account has no picture (never clobbers a local one).
Tombstone restore-then-sync fix
- POST /entries and POST /entries/bulk-import now DELETE any tombstone
matching an inserted uuid (same transaction) so a restored backup
isn't re-killed on the next sync by its own stale tombstone.
- applyRemoteSnapshot arbitrates remote tombstones by timestamp: a
tombstone is skipped when the local entry with that uuid is newer than
deleted_at (resurrection wins). Ties / unparseable timestamps favour
KEEP. loadEntries() up front so updated_at reflects the live rows.
WebView2 navigation race
- Black-window-on-cold-start fix: the 1.5s nav timer no longer consumes
FPendingURL when WebView2 isn't initialised yet (it re-arms, bounded
to ~10 retries). FBrowserInitialized flag set in OnInitialized; after
the retry budget we Navigate best-effort rather than loop forever.
Sync UX
- Bidirectional toast: "pulled X new · Y updated · Z deleted · pushed N
entries" so a 0/0/0 pull still shows the vault was uploaded.
- FolderPOST/PUT: pre-declare ftString on color/icon params (fixes the
earlier [SQLite]-335 on NULL bind, already in play for CSV import).
Docs
- CLAUDE.md sync section documents tombstone purge-on-insert +
resurrection arbitration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Bitwarden CSV import: folders auto-created server-side; notes column on
login rows surfaces as a "Notes" custom field instead of polluting tags;
type=card / type=identity rows now mapped to kind=note with the
credit-card / identity template + card_* / identity_* columns
pulled into custom_fields; `fields` column parsed (Bitwarden's
"label: value\nlabel: value" lines + our own JSON shape).
- Settings panel search: live filter at top of the panel, matches each
.setting-row individually, hides whole section when no row matches,
shows a "No matches" banner. Esc clears query (without closing
Settings); Esc with empty query closes the panel.
- Quick-search hotkey customizable: SetQuickSearchHotkey added to
PM.Bridge; cmd://autofill/hotkeys extended with qs_mods/qs_vk
(independent of the autofill enabled flag — quick-search stays
armed even when autofill is off); state.quickSearchHotkey synced
via settings_json; new "Quick search picker" row in Settings.
- FireDAC SQLite folder POST/PUT: pre-declare ftString on color/icon
params so .Clear (NULL) doesn't trip "[FireDAC][Phys][SQLite]-335
type unknown" at Prepare — was crashing the CSV-import folder
auto-creation path.
- Edge form-data autocomplete suppressed on slideover inputs (title,
site, username, password, TOTP, note body, custom fields):
autocomplete=off (new-password on secrets) + spellcheck=false. Fixes
the "Informations enregistrées" dropdown popping over data after a
field was edited.
- closeSlideOver blurs any focused descendant before removing .is-open
so an invisible focused field can't react to arrow-down / backspace
after dismissal.
- Slideover Esc handler upgraded to capture phase so it fires before
the input's own keydown or browser-level Esc swallow on the active
autocomplete popup.
- Settings panel Esc closes the panel when search input is empty;
search keeps the keystroke when it has a query to clear.
- Discard-fantome on note open: customFields working copy and
originalCustomJson now share the SAME normalized array — comparing
raw plainCustom against the .map()'d working copy made notes look
dirty on open.
- Delete / Backspace global shortcut: batch-trash on normal views,
batch perm-delete on trash view, gated on selection + no input
focused + no modal up.
- Toggle thumb vertical centering via top:50% + translateY(-50%);
state checked uses translate(16px, -50%) to keep the centring.
- Batch bar disappears after per-card restore/perm-delete/trash:
state.checked.delete(id) before render for the relevant flows;
state.checked.clear() before render in emptyTrash and the new
moveEntriesToFolder helper.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Sync (WebDAV, auto-merge): UUID + tombstones foundations (server +
JS), THTTPClient bridge cmds (get/put/test), runSyncNow engine with
pull/merge/push flow, Settings UI, pre-sync backup option. Test
connection now treats 404 as OK (snapshot not yet created) and 401/
403 as auth failure with dedicated toast.
- Batch drag-drop: cards + table rows carry checked-set ids (CSV) when
dragged from an active selection; folder + trash drop handlers parse
and apply in batch via new moveEntriesToFolder helper that preserves
TOTP / custom_fields / kind in the full PUT payload.
- Clean shutdown: WM_QUERYENDSESSION / WM_ENDSESSION captured in the
bridge message-only window; FormCloseQuery bypasses the tray-minimize
intercept on system shutdown / restart / logoff so FireDAC closes the
SQLite WAL cleanly instead of leaving -shm / -wal residue after a
force-kill.
- Center-mode modal: blur+dim backdrop via body::before pseudo-element
in editor-position=center, swallows clicks below the panel so the
existing outside-click handlers reliably dismiss the slideover /
settings panel.
- Batch bar state fixes: state.checked cleared before render in
moveEntriesToFolder, emptyTrash, and per-card restoreEntry /
permanentDelete / deleteEntry so the action bar disappears once the
selection is fully processed.
- Save-then-discard duplicate fix: soState reset to null before
openSlideOver re-opens the freshly saved entry, otherwise the dirty
check fired on the soState.id=null → newId switch and a Cancel left
the form in new-entry mode (second Save → POST duplicate).
- TEST_SYNC.md: end-to-end checklist for validating the WebDAV sync
with 2 real instances.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- PIN unlock: device-local 4-12 digit shortcut, DPAPI-wrapped vault
key. Three modes (state.unlockMode): pw / pin / pw+pin. PIN
derives a wrap key via PBKDF2(pin, salt, 100k) and unwraps the
stored vault key (mirrors the Quick Unlock blob shape).
Anti-brute-force: 5 wrong attempts wipes the blob. Setup gated by
master-pw reauth so an unattended unlocked laptop can't be
backdoored. Master pw rotation clears the PIN blob (key drift).
loadServerSettings post-sync demotes pin/both -> pw when the local
blob is missing, so a wiped device re-syncs the correct mode up.
New unit PM.PinUnlock.pas + cmd://pin/{store,get,clear,status}.
- Table column picker: ⚙ in topbar (table view only), checkbox menu
for Site/Username/Folder/Updated. Site also drives showSiteOnCards
so the existing "Show site / URL" toggle in Settings stays in
sync. NAME column auto-widths (180px min, content max, +32px
right padding) so column hugs the next one without truncating.
- Editor position chooser (Appearance setting): Slide-over right /
left / Centered modal. Scoped to #slideover + #settingsPanel so
the click-outside / pointer-events logic doesn't accidentally
trap the modal-style empty viewport.
- Confirm before discarding unsaved edits: state.confirmOnUnsaved
setting (default ON), prompts on X / Esc / click-outside / switch-
to-other-entry. Also gates Lock vault / Sign out actions when the
editor is dirty; auto-lock and system-lock paths bypass to avoid
blocking on an unattended machine.
- Open-in-browser button added to the actions cell of the table
view (was card-only).
- Entry templates pass folder customization + template id through
duplicate / export / import / auto-backup roundtrips.
- Folder color + icon now persisted across export/import: payload.
folders carries name/color/icon; import creates missing folders
additively (existing local customisation kept).
- Bulk move-to-folder, batch add-tag, single add-tag now re-ship
the full entry payload so partial PUTs don't silently wipe
TOTP / custom_fields / kind / template.
- FireDAC: switched ftString -> ftMemo for icon_b64 / custom_fields
/ TOTP / template params and replaced .AsString with .Value so a
large (~200 KB) DeepSeek favicon no longer gets truncated at the
default ANSI 4000-char cap.
- Unicode filenames: attachment INSERT now uses ftWideString +
.AsWideString so non-ANSI filenames round-trip instead of being
mangled to "?".
- HandleSetEntryIcon cap raised 256 KB -> 512 KB chars to accept
base64 data URIs produced by max-raw favicon fetches.
- promptDialog + askReauth support inline `error` line + retry-
with-count loops on doExport reauth and auto-backup password
setup (5 attempts cap before bailing).
- Recently used moved from Tools to Vault section in the sidebar.
- Auth screen passkey button hidden (Delphi backend stubs WebAuthn).
- Sensitive cmd://favicon/refresh-style buttons in Settings now
stopPropagation so the document-level "close panel" handler
doesn't dismiss Settings mid-async during DOM reparenting.
- TEST_PLAN.md: +PIN unlock section.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Entry templates: new vault_entries.template column drives a typed
sub-kind ('credit-card', 'ssh-key', 'server', 'recovery-codes'). Card
+ table label off the template, badge reads "credit card" instead of
"note". Templates seed kind=note (no site/password required), use
custom_fields with optional dropdown options (brand, month/year,
protocol). Round-tripped across export/import/duplicate/master-pw
rotation, preserved by partial PUTs via a HasTemplate flag.
- Custom fields: support per-field `options[]` rendering as <select>
(card brand, expiry MM/YYYY, SSH/server protocol).
- Tags: existing-tag autocomplete dropdown under the chip input,
filtered against what's already selected.
- Search history: per-query X for individual delete + 1s debounced
commit (no Enter required).
- Slideover: clicking outside closes again (drag-selection respected
via mousedown origin tracker), Esc closes, X closes. App shell is
pushed left by 420px when the panel is open so the table / pagination
/ sort / search stay visible and interactive.
- Export/import: JSON now round-trips custom_fields, attachments
(decrypted to base64, re-encrypted under current key on restore),
icon_b64, and template. CSV warning lists what's not included.
- Auto-backup: same payload shape as user-driven export.
- Notes: import (JSON + CSV) accepts kind=note with empty site,
preserves title/template/custom_fields. CSV parser detects kind/
template columns.
- Bulk-import response returns `ids[]` parallel to input so the
client can map back to new entry IDs (drives attachment restore).
- Move-to-folder bugs fixed: moveEntryToFolder, batchMoveToFolder,
addTag, batchAddTag were all silently wiping TOTP / custom_fields
/ kind / template via partial PUT. Now re-ship full payload.
- Master-pw rotation: server mints a fresh session token + csrf so
the very next request after rotation no longer ESessionRejects.
Client adopts the new pair. Attachments are re-encrypted client-side
during rotation (GET old → decrypt with old key → encrypt with new
→ PUT). New endpoints: GET /attachments/all, PUT /attachments/:id.
- Duplicate: carries icon_b64 + template + attachments to the copy.
- HandleCreateEntry: accepts icon_b64.
- FireDAC param fix: all blob/icon/custom_fields params use ftMemo +
.Value assignment so SQLite TEXT no longer truncates to 4000 chars
(deepseek's 200+ KB favicon was being wiped on lock/unlock).
- HandleSetEntryIcon cap: 262144 → 524288 chars (base64 of a 256 KB
raw fetch overflows the old cap, fails silently in saveEntryIcon).
- Native save dialog: surfaces server errors instead of swallowing.
- Modals: reauth (export) + backup-password prompt support inline
error display, retry up to 5 attempts, then hard-stop.
- Keyboard cursor (j/k): bootstraps to current page, auto-paginates
when the cursor crosses a page boundary, Enter opens slideover.
- Slideover focuses Title on edit-open so j/k → Enter → type Just
Works.
- TOTP tool: Esc closes the modal.
- App version + launch mode (auto/manual): exposed via bridge,
surfaced in Settings → Account. Autostart launches suppress the
first-time tray balloon.
- Passkey button hidden (Delphi backend stubs WebAuthn at 501).
- TEST_PLAN.md captured for regression coverage.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Ctrl+Shift+Q quick-search + autofill
- New global hotkey: capture the foreground HWND, restore the window
if hidden, pop the quick-search modal in "fill mode". On pick, the
password is SendInput'd into the saved HWND — no clipboard touch.
- hide_after flag added to cmd://autofill/execute: when set (tray-mode
hotkey), Delphi MinimizeToTray's *after* SendInput completes. Hiding
before SendInput would trip Win10/11 anti-focus-stealing rules and
block focus handoff to the target.
- Quick-search modal hint text adapts to fill vs copy mode.
- Esc / close in fill mode sends cmd://autofill/cancel so a stale
HWND doesn't get reused by an unrelated Ctrl+Shift+L later.
Compile-time browser engine switch
- {.$DEFINE USE_EDGE_BROWSER} in UMainForm.pas selects between
TTMSFNCWebBrowser (default, cross-platform abstraction) and
TTMSFNCEdgeWebBrowser (Windows-only WebView2 wrapper). Both
inherit from TTMSFNCCustomWebBrowser so the bridge cmd:// glue is
unchanged; the field type is a conditional alias TWebBrowserClass.
- WebBrowser is created dynamically in FormCreate so neither variant
needs a second .fmx. Events are wired BEFORE Parent assignment so
OnInitialized doesn't race the WebView2 async init on fast/pre-warmed
Edge installs (was silently missing the disable-context-menu /
disable-accelerator-keys calls).
- Native context menu disabled by assigning an empty PopupMenu1 (works
for both backends, unlike OnGetContextMenu which is publish-gated
via {$IFNDEF FNCLIB} on TTMSFNCWebBrowser).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Quick search from tray
- New "Quick search…" entry in the tray context menu (between Open
and Lock vault).
- Compact modal with live-filtered top-8 entries, arrow keys / Enter
to copy the password (Shift+Enter copies the username instead),
Esc to dismiss. Each row shows the favicon when cached.
- Locked vault → focus the master password input instead of opening
the modal (same pattern as the locked-autofill-hotkey path).
- Window-state restore: Delphi remembers whether the window was
hidden before the menu was opened and tells JS via the
Bridge.openQuickSearch(wasHidden) arg. After the copy (or cancel)
we hide back to the tray so the previously-foreground app comes
back and Ctrl+V drops the password in.
Tray notifications toggle
- New Settings → Security "Show tray notifications" toggle. Gates
Shell_NotifyIcon NIF_INFO balloons (currently only the "still
running in the tray" first-time popup). Default ON, synced via
settings_json so it follows the user across devices.
- PM.Bridge.ShowNotifications exposed as a public property; JS
pushes the value on every settings sync.
Privacy: WebView2 phone-home killed
- WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS set in the unit
initialization section (before the TMS WebBrowser instantiates
its CoreWebView2Environment). Disables: background networking,
sync, component updates, breakpad/crashpad, domain reliability,
client-side phishing detection, experiments, UMA upload,
MediaRouter, OptimizationHints, SafeBrowsing enhanced, autofill
server, privacy sandbox APIs. Verified via Resource Monitor: only
127.0.0.1 connections remain (plus DDG when favicons are on).
Fixes
- Blank-window-on-launch race: the 1.5 s navigation timer assumes
WebView2 finishes init in time, but on slow machines Edge
Chromium needs 2-3 s and the Navigate() call is silently
dropped. WebBrowserInitialized now also navigates if a URL is
still pending — first to run wins.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Unified create/edit slideover
- openSlideOver(id) now accepts null for new entries. Same UI
(icon, name, site, user, password, TOTP, folder, tags) for both
create and edit. Drops the separate entry modal — no more "save
first, then add TOTP" two-step.
- "+ New" button, Ctrl+K → New entry, and Ctrl+Shift+A all route
through the slideover. Ctrl+Shift+A pre-fills the title with the
foreground window's name.
- Save button visible from the start in new mode (no dirty wait).
- Title shows mode unambiguously: cyan "+ New entry" vs
"Edit · <name>".
Custom icon upload (soIconField)
- 56×56 preview at the top of every slideover + Upload icon /
Remove buttons. Same POST /entries/{id}/icon endpoint as the
auto-fetch path. Validates type / size (64 KB cap matching server).
- Solves the case where DDG doesn't index a domain (self-hosted
apps, private sites): the user pastes any image and it sticks.
Favicon: privacy-first, DDG only
- Removed the direct-fetch fallback steps (3-5). Privacy stance:
zero DNS leak outside icons.duckduckgo.com. Domains DDG doesn't
cover stay icon-less until the user uploads a custom one.
- PM.Favicon.FetchFaviconDataUri takes an optional TFaviconLog
callback so UMainForm can stream per-step trace into LogLine for
diagnostics.
Fixes
- Slideover z-index 30 → 50. The topbar's backdrop-filter creates a
stacking context at z-index 40 which was clipping the slideover
header (title + close button hidden behind topbar).
- RestoreFromTray no longer un-maximises a maximised window when
called outside a tray-restore context (Ctrl+Shift+A, Ctrl+Shift+L
picker, app/focus cmd). SW_RESTORE on a maximised window reverts
to normal — now we only SW_RESTORE if IsIconic.
- "Show all"/"Show less" per-category state survives renderGrid
re-renders (healthExpanded map).
- "+ New" and dashboard "Fix" buttons stopPropagation so the
document-level click-outside handler doesn't close the slideover
they just opened.
- soDirtyCheck keeps Save visible while in new mode regardless of
diff.
- openSlideover → openSlideOver typo fix across all call sites.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Favicons
- PM.Favicon (new): THTTPClient/WinHTTP proxy to icons.duckduckgo.com.
Native Windows TLS — no OpenSSL DLLs to ship (Indy would fail
silently without them). 5 s timeout, max 3 redirects, 64 KB cap,
magic-byte MIME sniffing.
- DB: vault_entries.icon_b64 TEXT (idempotent migration).
- Endpoints: POST /entries/{id}/icon stores a cached data URI without
forcing a full PUT (which would re-encrypt the password). DELETE
/entries/icons/all purges the cache.
- Bridge cmd://favicon/fetch?host=X&reqId=Y runs in an anonymous thread
so the up-to-5 s HTTP GET doesn't block the main thread; result
shipped back via Bridge.onFaviconResult(reqId, host, dataUri).
- Hostname validated on both sides (JS faviconHost + Delphi
NormalizeHost) so brand labels like "Gitea" never leak upstream.
- Settings: opt-in "Fetch website icons" toggle (synced), three explicit
actions (Fetch missing / Re-fetch all / Clear cache) that bypass the
toggle — manual user actions always work.
- Entry card avatar shows <img> when cached, falls back to initials.
onerror handler recovers silently from a corrupt data URI.
Vault health
- New sidebar Tools → "Vault health" view. Four category cards:
Weak (strength < 50), Reused (same plaintext on ≥ 2 entries), Old
(updated_at > 365d), Pwned (HIBP cache).
- Score 0-100 with colour band (Good/Fair/At risk/Critical).
- One-shot computation cached per session (healthCache), invalidated
on lockVault, entry save, and the explicit "Recompute" button.
- "Fix" button on each item opens the slideover for the affected
entry, unmasks the password, focuses it, and pulses the dice button
— full context preserved, user decides how to fix.
- Click handler stopPropagation prevents the document-level
"click outside slideover" listener from closing the panel that
we just opened in the same click event.
Fixes
- openSlideover typo (lowercase O) → openSlideOver across all call
sites. Was silently breaking the Authenticator card click and the
Vault health Fix button.
- W1050 WideChar warning in PM.Favicon — replaced set-membership
with explicit Ord-style range comparisons.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- PM.AutoStart wraps HKCU\Software\Microsoft\Windows\CurrentVersion\Run.
Value "PMServer" = "<exe>" -tray. Per-user, no admin required, shows
up in Task Manager → Startup so the user can override from there.
- UMainForm honours the -tray CLI flag (set by the registry entry):
after the server starts, MinimizeToTray via TThread.ForceQueue so the
app comes up directly in the tray with no visible window flash.
- Bridge cmd://autostart/{get,set} + Bridge.getAutoStart() /
setAutoStart() / onAutoStartStatus(). Settings exposes a toggle in
the Security section, visible only when Bridge.active (the PHP
frontend can't touch the registry).
- Toggle reads "on" only when the registered command matches the
current exe path, so a stale entry from a moved exe lets the user
re-enable to refresh.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
User-controlled opt-in to skip the master-password prompt on subsequent
app starts. The vault state (raw AES key + salt + username + session
token) is bundled and handed to the Delphi side, which DPAPI-encrypts
it with CRYPTPROTECT_CURRENT_USER and stashes the blob at
%LOCALAPPDATA%\PMServer\quickunlock.bin.
Honest threat model
===================
This is NOT biometric authentication. The DPAPI scope is the Windows
USER ACCOUNT — any process running as the same user can decrypt the
blob via the same DPAPI call. The security perimeter is the Windows
account itself. The Settings UI label is "Quick unlock" with an
explainer:
"convenient on a personal machine, not safe on a shared one"
If the user has Windows Hello / fingerprint / PIN configured at the
OS level, then Windows login is biometric-gated and that gating
transitively applies to DPAPI access — but the cryptographic strength
of the encryption isn't tied to the biometric, it's tied to the
Windows account secret. Honest framing matters here, so the feature
isn't sold as "biometric".
Backend
=======
New unit Source/PM.QuickUnlock.pas:
- StoreQuickUnlock(bytes) → DPAPI-encrypt and persist to
%LOCALAPPDATA%\PMServer\quickunlock.bin
- LoadQuickUnlock(out bytes) → read file, DPAPI-decrypt
- ClearQuickUnlock → forget-me
- HasQuickUnlock → file existence probe
DPAPI declarations are local (CryptProtectData / CryptUnprotectData
from crypt32.dll) — Winapi.WinCrypt's signatures drift across Delphi
versions and we don't want to fight that.
Bridge commands (UMainForm.HandleBridgeCommand):
cmd://quickunlock/store?data=<base64> payload opaque to Delphi
cmd://quickunlock/get → ExecuteJavaScript callback
Bridge.onQuickUnlockResult(b64|null)
cmd://quickunlock/clear forget-me
cmd://quickunlock/status → Bridge.onQuickUnlockStatus(bool)
The get / status results are returned via ExecuteJavaScript rather than
HTTP (the bridge is request-only) — JS resolves a Promise that the
caller awaited.
Client
======
state.quickUnlockEnabled mirrors localStorage flag, lazy-cleared if the
backing DPAPI blob has gone missing (e.g., user reset Windows profile).
enableQuickUnlock():
1. askReauth + /reauth to verify it's actually the user.
2. exportKey('raw', state.cryptoKey) — extractable already.
3. JSON-bundle { v, username, salt, token, csrf, key } → base64.
4. cmd://quickunlock/store sends the blob to Delphi.
tryQuickUnlock() (called from init):
1. Probe localStorage flag.
2. cmd://quickunlock/get, await Bridge.onQuickUnlockResult.
3. Decode JSON, importKey, restore state.* + sessionStorage.
4. Return true on success, false to fall through to master-pw login.
Two restore scenarios both covered:
A. Same app session (sessionStorage still populated, only cryptoKey
was wiped by lock). tryQuickUnlock just restores the key.
B. Cold start (sessionStorage empty). tryQuickUnlock restores
EVERYTHING from the DPAPI blob, including the session token.
UI
==
Settings panel → new "Quick unlock" section above Recovery key.
Single toggle button: "Enable on this device" / "Disable" with status
line above. Opens settings → bridgeQuickUnlockStatus() reconciles the
JS-side flag with the actual file (drift detection).
Stale-blob protection
=====================
The stored blob holds the AES key BYTES, which would become useless
if the vault were re-encrypted under a different key. Three paths
that re-encrypt the vault now also wipe the DPAPI blob:
- Explicit doLogout (user said "I'm done")
- Master password change (new key, old blob can't decrypt anything)
- (Recovery redeem already forces master pw change → covered.)
The blob persists across the passive lockVault() flow on purpose —
that's the whole point: lock without losing convenience.
Init wiring
===========
On app start, the existing "restore session from sessionStorage" path
now falls through to tryQuickUnlock if either sessionStorage is empty
OR the cryptoKey is gone. Auth screen shows up only after both
attempts fail.