Commit Graph

261 Commits

Author SHA1 Message Date
r-zakarya f198eaf6f3 docs: K1 — the clear-field toggle vs Tab-selects-all, intentional
Records why disabling "Clear the field before typing" still leaves the
password replaced: Tab selects the target field's content, so the keystrokes
overwrite it. Our code clears nothing — verified from the JS clear=0 through
both ClearFieldIfWanted guards. The toggle exists to avoid sending Ctrl+A
(terminals, remote desktops), not to preserve field content.

Filed as intentional so it isn't "fixed" later into appending a new password
onto the old one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:05:49 +01:00
r-zakarya e68536420f docs: J2-J4 validated — fill, copy and already-visible paths
Section J fully green: no double minimise on the fill path, the clipboard
survives the copy path, and an app already on screen is not sent to the tray.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:58:10 +01:00
r-zakarya 59090dcf67 docs: section J — quick search from the tray
J1 covers both bugs just fixed (lost keyboard focus after the tray restore,
and the coalesced cmd:// that swallowed the minimise). J2/J3 check the fill and
copy paths still return to the tray exactly once, J4 that an app which was
already on screen is not sent to the tray.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:39:56 +01:00
r-zakarya e88dbf3ec8 fix(quick-search): Esc from the tray now closes AND returns to the tray
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>
2026-07-25 23:25:28 +01:00
r-zakarya 610755e8e6 docs: I5/I6 — minimise-guard interaction with autofill and the tray
I5 exercises the path the guard fix addresses (autofill minimises, then the
window is hidden to the tray, so MINIMIZEEND may never arrive). I6 is the
non-regression check on the tray and focus paths the hook could have touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:08:09 +01:00
r-zakarya 2773cc444b fix(window): route every tray restore through one guard-clearing helper
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>
2026-07-25 23:04:37 +01:00
r-zakarya 7ae20aac43 docs: section I — window maximised-state scenarios
I1/I2 validated (both directions survive Show desktop), plus the tray path and
how to read the [winstate] diagnostics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:55:55 +01:00
r-zakarya 63dd7a296a chore(window): trim the winstate diagnostics now the fix is confirmed
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>
2026-07-25 22:54:30 +01:00
r-zakarya 72fe01f513 fix(window): don't filter the hook on the form HWND
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>
2026-07-25 22:50:26 +01:00
r-zakarya 7a19bdc13b fix(window): observe the minimise itself via a WinEvent hook
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>
2026-07-25 22:47:52 +01:00
r-zakarya ca54c39c75 fix(window): use foreground ownership to tell a user resize from a restore
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>
2026-07-25 22:42:05 +01:00
r-zakarya 03c638b2e1 fix(window): ask Windows whether a resize is part of a restore
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>
2026-07-25 20:22:09 +01:00
r-zakarya 28f0d1c5bc fix(window): let an activate cancel the restore's own resize
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>
2026-07-25 20:16:47 +01:00
r-zakarya ab12f6bf66 fix(window): apply the restore correction after FMX finishes, with tracing
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>
2026-07-25 20:13:30 +01:00
r-zakarya 8204ccb316 fix(window): stop forcing maximised on a window the user left normal
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>
2026-07-25 20:06:34 +01:00
r-zakarya 03a8a6f8a6 fix(window): re-apply maximised after an external minimise (Win+D)
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>
2026-07-25 19:58:27 +01:00
r-zakarya 6bed75f49e fix(window): a maximised window comes back maximised after Show desktop
Win+D / Show desktop minimises the window without any tray transition, so the
restore took the non-tray branch. FMainForm.Show runs first there and
re-applies FMX's own WindowState (normal), discarding the
WPF_RESTORETOMAXIMIZED flag Windows had recorded — the app returned in normal
size.

Read the placement flag BEFORE showing, and re-apply SW_SHOWMAXIMIZED when it
was set. The tray path is untouched: it already replays its own captured
placement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:50:24 +01:00
r-zakarya f45a17d876 fix(hotkeys): name the full combo when its key-up reaches us
The message could only cite the modifiers ("after Ctrl+Shift") because
Windows consumes the KEYDOWN of a combo it routed to another app. Its KEYUP
often still arrives though — when it does, report the exact combo the user
tried: "Ctrl+Shift+T is already used by another app".

Falls back to the modifiers-only wording when the key-up is swallowed too.
Can't misfire: an accepted combo ends the capture on keydown, so a non-modifier
key-up during capture means that key was swallowed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:40:08 +01:00
r-zakarya 6518b59860 docs: H8 — capturing a combo owned by another app (validated)
Covers the swallowed-combo detection, the abandoned-capture timeout, and the
internal collision check that only works now that the capture releases our
own hotkeys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:38:26 +01:00
r-zakarya 1bcb74b0bd fix(hotkeys): detect a swallowed combo on key release, not after 10 s
"No combo captured" was both slow and wrong: the user HAD pressed a combo.
We actually receive the Ctrl/Shift/Alt keydowns — only the final key of a
combo owned by another app is swallowed by Windows. So "modifiers held, then
all released, no key ever arrived" pinpoints the situation exactly.

Reported on keyup, instantly, naming what was held: "No key registered after
Ctrl+Shift. If you did press one, another app already owns that combo — pick
a different one." The wording also holds if the user just tapped a modifier
and let go.

The 10 s timer stays as the fallback for a capture armed and abandoned with
no key at all (it would otherwise swallow every keystroke until Esc), with a
message that no longer contradicts the user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:28:31 +01:00
r-zakarya c818f12c24 fix(hotkeys): release our combos while capturing a new one
A RegisterHotKey combo is delivered to the owning window, never to the
WebView. So a combo one of OUR slots holds could not be captured at all —
pressing it fired that hotkey instead (Ctrl+Shift+L ran an autofill in the
middle of the capture), and the "already used by another hotkey" collision
check was unreachable dead code. Swapping two combos was impossible.

The capture now suspends all five hotkeys (enabled=0 + vk=0 for quick-search
and new-entry, which unregisters without re-registering and is excluded from
the failure report) and restores them on finish — silently when cancelled or
timed out, with the usual toast on a successful change.

Combos held by ANOTHER app still can't be captured — that one is out of our
hands, and the 10 s timeout explains it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:22:36 +01:00
r-zakarya dd69747cb8 fix(hotkeys): capture times out instead of waiting forever
A combo another app already registered is consumed by Windows and delivered
to that app — our keydown listener never fires. The capture therefore sat on
"Press combo…" indefinitely with no feedback, which is exactly what a user
sees when they try to re-assign a taken combo.

Worse, while armed the capture preventDefaults every keystroke, so an
abandoned capture froze the app's keyboard until Esc.

10 s timeout that restores the button and explains the likely cause: the
combo never reached us because another app owns it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:15:51 +01:00
r-zakarya 3513e7b6e5 fix(ui): actionable toasts linger, and a dead hotkey shows in the sidebar
Every toast auto-dismissed after 2.8 s — fine for a success receipt, too
short to read AND act on an error. Errors and warnings now stay 10 s and can
be clicked away; success is unchanged.

The hotkey-conflict warning also needed a trace outside the toast: the ⚠ in
Settings only helps someone who opens the panel. A dot on the sidebar Settings
entry (plus a tooltip) is now the cue in the main UI that a combo is dead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:10:17 +01:00
r-zakarya feb956001e docs: H7 validated — persistent conflict marker confirmed
Also records the test pitfall: hook-based apps swallow the key without
failing RegisterHotKey, so they produce a dead hotkey with no marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:06:46 +01:00
r-zakarya 4965ff2518 feat(hotkeys): persistent conflict marker in Settings
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>
2026-07-25 17:52:05 +01:00
r-zakarya 3d3ab3b02b fix(hotkeys): name the combo another app already owns
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>
2026-07-25 17:46:33 +01:00
r-zakarya 4c745d2dd9 docs: section H — 2FA hotkey scenarios
H1 fill (confirmed working), H2 entry without TOTP, H3 picker eligibility,
H4 configurable combo + collision + reset, H5 locked vault, H6 code freshness
at period boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:42:27 +01:00
r-zakarya 7c86f35fce feat(autofill): Ctrl+Shift+T types the 2FA code
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>
2026-07-25 16:53:00 +01:00
r-zakarya 2d6ba98073 docs: G1 also covers the deferred-focus steal on Fix
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:44:06 +01:00
r-zakarya 47689f693c fix(health): Fix keeps focus on the password field
openSlideOver defers a focus+select on the title (setTimeout 80ms), which
landed after openEntryForFix's own focus and yanked the caret out of the
password field a moment after Fix was clicked.

Pass the existing { noFocus: true } option — added for the post-save re-open,
exactly the same need: the caller owns the focus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:40:30 +01:00
r-zakarya 0184fc7cc2 docs: section G — Vault health scenarios (Fix button, cache freshness)
G1 (cancelled switch must not act on the still-open entry), G2 (Fix on an
entry deleted meanwhile), G3 (dashboard reflects import/sync/delete without
a manual Recompute).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:32:05 +01:00
r-zakarya d40fd57133 fix(health): invalidate the dashboard cache whenever entries change
healthCache was only dropped on lock, on save and by the Recompute button.
Import, sync, delete, batch ops, duplicate, restore and the master-password
rotation all left it in place, so Vault health kept showing a pre-mutation
score and listed entries that no longer exist.

Invalidate inside loadEntries() — the choke point every mutation path already
goes through — instead of one opt-in per caller. Recompute stays lazy: it only
runs when the dashboard is next rendered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:31:29 +01:00
r-zakarya 8b02a5650a fix(health): Fix button must not act when the switch was cancelled
Vault health → Fix on entry B while the editor is open and dirty on entry A:
the discard prompt appears, Cancel keeps A open — but openEntryForFix carried
on regardless. It unmasked A's password to cleartext, selected it, and pulsed
the Generate button (the blinking). Wrong entry, and it revealed a password
the user never asked to see.

openSlideOver was void, so callers couldn't tell a cancelled switch from a
successful one. It now returns false when it bails (discard cancelled, entry
not found) and true once the panel actually shows the requested entry;
openEntryForFix checks it before touching the password field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:22:50 +01:00
r-zakarya 660f495649 docs: scenarios for the programmatic-click and round-trip fixes
B14 (native dialogs must not stack a discard prompt) and a new section F
covering the export/import round-trip: favorite + pinned restored, foreign
CSV defaults, sync staying device-local, custom-icon provenance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:19:23 +01:00
r-zakarya 1638246a53 fix(editor): a programmatic click is not a click-outside
Import with an open, dirty editor showed "Discard unsaved changes?" stacked
on top of the native file dialog. doImport appends its hidden file input to
<body> and calls .click(); that synthetic click bubbles to the document
handler, which sees a target outside .slideover and fires
requestCloseSlideOver.

Same latent bug on the attachment download web-fallback (an <a download>
appended to <body>, then .click()ed).

Gate the handler on e.isTrusted: only a real user click can mean "clicked
away". Fixes both and any future programmatic click, at the shared choke
point. File inputs living inside the slideover (icon upload, attachment
picker) were already safe via closest('.slideover').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:10:19 +01:00
r-zakarya 30e37b3fbd fix(import): restore favorite + pinned on import/backup restore
A pinned entry came back unpinned after export→import. Wider than pinned:
`favorite` was WRITTEN into the export/backup/sync containers but nothing
ever read it back — not parseEntriesFromJSON, not encryptImportEntry, not
the server INSERTs. So it was a write-only field, and a restore silently
dropped both flags. `pinned` wasn't in any container at all.

Wired the full round-trip:
- containers: export + auto-backup now carry `pinned` (favorite was there).
- parseEntriesFromJSON reads both back (absent → 0, e.g. foreign CSV).
- encryptImportEntry passes them through to the body.
- server create + bulk-import INSERTs write favorite/pinned
  (GetValue<Boolean>(..., False) → 0 default). PUT still ignores them, so a
  sync update can't clobber local flags.

Sync stays device-local as decided 2026-07-11: buildSyncSnapshot now OMITS
favorite explicitly (it was dead weight before; with the passthrough live,
leaving it in would have started propagating it). Commented so the intent
isn't re-broken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:06:05 +01:00
r-zakarya a6ba0100ee feat(icons): propagate icon_custom through export / import / sync
Follow-up to 18fe13a: the custom-icon flag now survives round-trips instead
of resetting to fetched on a restore.

- Containers write icon_custom: manual export (doExport), auto-backup, sync
  snapshot; parseEntriesFromJSON carries it back (default 0 when absent, e.g.
  foreign CSV).
- encryptImportEntry ships it in the body (shared by bulk-import + sync
  create/update).
- Server create + bulk-import INSERTs read icon_custom
  (GetValue<Boolean>('icon_custom', False) → 0 default). An empty icon always
  forces icon_custom=0.

Now a custom icon stays custom after export→import and across sync — it won't
be silently reclassified as cache and clobbered by Re-fetch-all on the other
device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:21:04 +01:00
r-zakarya 18fe13a861 feat(icons): preserve user-uploaded icons from bulk fetch/clear
Auto-fetched favicons and user uploads shared one icon_b64 column, so
"Clear cache" wiped custom uploads and "Re-fetch all" overwrote them — data
loss the user can't undo (a custom icon exists precisely because DDG can't
fetch that site).

New icon_custom flag (0=fetched cache, 1=user upload). Invariant: an
automatic action never touches a custom icon; only the user changes/removes
it per entry.

- Schema: icon_custom INTEGER on vault_entries.
- POST /entries/{id}/icon takes {custom}; GET /entries echoes icon_custom;
  DELETE /entries/icons/all filters COALESCE(icon_custom,0)=0.
- saveEntryIcon(id, uri, isCustom): upload→true, fetch/remove→false.
- backfillFavicons skips icon_custom even on force (Re-fetch all).
- clearAllFavicons clears only non-custom locally + refreshes the open editor
  (the icon preview didn't update on clear before).
- duplicate re-stamps a copied custom icon; import-overwrite carries the flag.

Not propagated across bulk-import / sync (the bulk INSERT doesn't read it) —
a restored icon counts as fetched. Documented; custom icons are typically
self-hosted sites DDG won't re-fetch anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:11:25 +01:00
r-zakarya f45b96d147 docs: test scenarios for the auth / recovery / memory series
Section E (E1-E7) covers the fixes that had none: reauth inline retry,
recovery setup + redeem on argon2id-v2, recovery commit-after-save, the
sessionStorage key removal, and a slideover regression guard after deleting
the dead entry modal. Dropped the now-done "legacy entry modal" item from the
remaining list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:38:16 +01:00
r-zakarya 0434eec85e refactor: remove the dead legacy entry modal
openEntryModal had zero callers — the slideover editor replaced it — but the
whole cluster still shipped: the #entryModal markup, saveEntry (wired to the
form submit), closeEntryModal, populateFolderSelect, updateEntryStrength, and
the entire tag-chip subsystem (editingTags, renderChips, addTag, the suggest
dropdown) plus their init wirings and the Esc-chain branch.

saveEntry was a latent hazard: still bound to #entryForm submit, it did a
PARTIAL PUT (no kind / totp_secret / custom_fields / template) — the exact
silent-data-loss bug moveEntryToFolder documents. Unreachable today, but one
stray call away from wiping fields.

Removed JS + HTML + the orphaned .chip-suggestion CSS. The generator is shared
with the slideover — kept; its dead 'entry' target branch collapsed to
slideover-only. 322 lines gone, tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:35:39 +01:00
r-zakarya d101192e22 fix(recovery): don't commit the code until the user saves it
Generating a recovery code POSTed /recovery-key/setup — which deletes any
existing recovery row and inserts the new one — BEFORE showing the code. So
cancelling the "save this code" modal still erased the old code and activated
a new one the user never saw: a silent path to lockout.

Generate + wrap locally, show the code, and only POST setup after "I saved
it". Cancel now writes nothing — the account is left exactly as it was, old
code still valid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:26:32 +01:00
r-zakarya e7dca84f40 fix(recovery): echo Argon2 params in the redeem response
The redeem client already reads `state.argon2Params = r.argon2 || null`, but
the server never sent `argon2`, so it always fell back to null → the default
params. Correct today (every account uses ARGON2_DEFAULT_PARAMS), but a latent
wrong-key derivation the day per-account params diverge: same class as the
setup verifier bug — an Argon2 account derived under the wrong params.

Echo m/t/p from the users row like /login/challenge does; omitted for
non-Argon2 accounts (m=0). No client change — it already consumes the field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:19:16 +01:00
r-zakarya 665d8ea4e6 fix(recovery): accept -v2 / argon2id verifiers in setup
/recovery-key/setup verified the master password with a local copy of the
verifier check that only knew pbkdf2 (legacy) and pbkdf2-sha256. Any account
on a decoupled scheme — pbkdf2-sha256-v2 or argon2id-v2, the current default
after a master-pw rotation — fell through both branches and was rejected as
"invalid password" even when correct, so recovery-key setup was impossible.

Match the canonical VerifierToStoredHash (PM.Handler.Auth): legacy stores the
verifier raw, every other algo is SHA256-wrapped identically. One branch, plus
a keep-in-sync note so the copy doesn't drift again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:00:40 +01:00
r-zakarya 9b7d04ee46 fix(reauth): wrong master pw re-prompts inline instead of closing
Setting a PIN, enabling Quick Unlock, and generating a recovery key all
closed the reauth modal and showed a toast on the first wrong master
password — no retry. Export already looped inline; the others didn't.

Extracted that loop into reauthLoop(message): re-prompts with an inline
error + attempt count (5 tries), honours the 429 lockout, returns the
verified password or null. All four sensitive-action flows now share it, so
a mistyped master password keeps the modal up to try again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:52:05 +01:00
r-zakarya b542106c76 security: stop caching the raw vault key in sessionStorage
A memory dump of the WebView2 renderer (vault unlocked) showed the AES vault
key sitting in sessionStorage as a clean base64 string — a plaintext copy of
the master secret, trivially grep-able, present the whole session.

persistCryptoKey/restoreCryptoKey existed only to survive a page reload, but
the shell has no reload path (F5/Ctrl+R blocked, every navigation is a cmd://
bridge call), so the branch that consumed it was unreachable. Removed both
functions, their 7 call sites, and collapsed the init restore branch into the
plain cold-start path (Quick Unlock → PIN → master-pw, all already there).

Also scrub the derived key bytes from the heap (keyBytes.fill(0)) once the
CryptoKey and verifier are built — the CryptoKey keeps its own copy.

Not fixable here, documented: the key stays extractable:true (Quick Unlock,
PIN, recovery and master-pw change all exportKey it), and decrypted password
strings still linger in the V8/Blink heap after lock — immutable strings the
GC never zeroes, inherent to a JS/WebView2 password manager.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:40:56 +01:00
r-zakarya de50a5f71f docs: A11 fixed, plus its false-positive and deleted-entry scenarios
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:45:10 +01:00
r-zakarya 5f807bcf23 fix(editor): warn before overwriting an entry changed elsewhere (A11)
The editor re-ships the payload it loaded on open, so a sync or import that
rewrote the same entry meanwhile was silently clobbered by Save — the last
hole editorAbsorbChange doesn't cover, since those paths rewrite every field
including the ciphertext.

Save now compares the live entry against the baseline soState already keeps
and asks before overwriting. Ciphertext comparison, so no decryption; changes
the editor absorbed read as equal (editorAbsorbChange updates `original`
too), so local tag/folder actions never trip it; the post-save reload
re-baselines, so a second save is clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:44:31 +01:00
r-zakarya 4ceb7ab872 docs: scenarios for the Esc-chain and icon-refresh fixes
A10b (editor icon preview), B11 (Esc closes the top modal, not the editor,
across all 11 modals), B12 (a static modal survives its Esc), B13 (toast /
busy-overlay clicks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:19:49 +01:00
r-zakarya 04bc809c71 fix(editor): refresh the icon preview on outside icon writes
A10: the editor's icon preview is painted by a closure nothing outside can
reach, so a favicon fetched from Settings (or an import overwrite) updated
the cards and left the open editor showing the old initials. soIconField
publishes its painter; saveEntryIcon — the choke point for every icon write
— routes through editorAbsorbChange like tag/folder already do.

Also whitelists .toast and #busyOverlay in the editor's click-outside: both
can sit under the cursor during a long operation, and clicking one counted
as clicking away.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:19:21 +01:00
r-zakarya 47c29bd04e fix(esc): reauth / picker / quick-search were missing from the Esc chain
Three static modals had no entry in the fallback Esc handler, so Esc over
them fell through and closed the editor behind (or did nothing when no
editor was open — reauth and the autofill picker could only be dismissed by
mouse).

Also scopes the stray-modal catch added in a95becd to [data-dynamic]. As
written it matched ANY open .modal, so an Esc over reauth / quick-search /
the autofill picker would have removed those static nodes from the DOM for
good, breaking the feature until restart. Only the runtime-built folder
modal carries the marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:17:53 +01:00