Renaming the label from "Sync (WebDAV)" to "Sync (WebDAV or Gitea)" hid the
whole section from the Sync & Backup tab: applySettingsTab maps sections to
tabs BY THEIR VISIBLE LABEL TEXT, so the miss fell through to 'general'. The
section was not lost - it moved to the General tab, which is worse than
missing, because it is nowhere you would look for it.
The map is updated, and the coupling is now written down where the map lives:
these keys are the displayed strings, word for word, and rewording one means
touching both sides.
Checked every key against the labels in index.html; the only sections without
an entry are "Getting started" and "Startup & tray", which belong in General
anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same discriminant as the Android client - "/api/v1/repos/" in the URL - so one
address tells both ends which transport to speak, and nothing new appears in
Settings beyond a placeholder and a warning.
The bridge stays shared. Three things differ, and they are confined: the header
is "token <PAT>" rather than Basic (Gitea answers a silent 401 to the other),
a PUT carries a JSON envelope with the file base64-encoded inside it, and the
connection test uses GET because the contents API does not answer HEAD.
The sha does the ETag's whole job. It comes back from the pull, rides in the
PUT body instead of an If-Match, and a stale one earns a 409 - handled exactly
like WebDAV's 412, by re-running pull->merge->push so the other device's
changes are folded in rather than clobbered. On a first push `sha` is OMITTED
rather than sent empty: Gitea reads its presence as "replace that exact
version". `branch` is omitted too, so the repo's default is used instead of a
name we would be guessing.
A failing PUT now reads its response body: Gitea explains a 409 or a 422 there,
and without it the error was just a number.
The test button checks the SHAPE of what came back, not only the status. A URL
one path segment off answers 200 with valid JSON that is not a file - which
would otherwise surface much later as "wrong sync password" and send you
looking on the wrong side. Four tests cover that parsing, including the
line-wrapped base64 Gitea sends and the array an accidental directory URL
returns.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The map was built from state.entries only, so a container entry whose uuid existed just in the trash looked fresh and inserted a second row sharing that uuid - the identity the sync engine relies on. Same blind spot as the resurrection bug in applyRemoteSnapshot.
Also documents F9 and warns that emptying the trash is the wrong way to clean duplicate uuids: the tombstone would kill the live twin on the other device.
A backup that silently drops the trash is not a backup: export, empty the vault, import, and the trashed entries were gone for good with nothing said. Both containers now carry state.trashed with deleted/deleted_at, and deleted_at is preserved so a restore resumes the auto-purge clock instead of restarting it - hence its addition to the GET echo.
The sync snapshot deliberately does NOT: the trash is device-local, and re-publishing deleted rows is what produced the duplicate-uuid bug. The two identical export loops were folded into buildBackupEntry first, so the trash handling exists once.
The autofill hotkey already picked whichever field still needed filling; quick search forced the password, so a first unlock or one without a remembered username landed the cursor in the second field.
Both locked-vault paths cancelled the autofill with the default refocus, which returns the foreground to the captured target - right after Delphi had raised our window. Ctrl+Shift+Q flashed for a frame then Notepad took over; Ctrl+Shift+L moved the window twice because focusApp had to take it back.
The trash is a soft delete: no tombstone is written and the rows are absent from state.entries, so the merge read their uuids as missing locally and POSTed them back as fresh live entries. Trashing 30 entries then syncing produced 30 live duplicates alongside the trashed originals - two rows sharing one uuid.
applyRemoteSnapshot now loads the trash and skips any uuid found there. Emptying the trash is what writes the tombstone that propagates the delete.
Assigning .value fires nothing, and the code called soDirtyCheck by hand - so the strength bar, which listens to input, stayed frozen on the previous password. Dispatching the event covers every listener on the field instead of one named handler.
inert removes elements from focus and hit testing, but shortcuts are listened for on the document and never traverse an element. Ctrl+A still selected every entry under the overlay, Ctrl+K would open the palette behind it, j/k moved the cursor - and Delete ran a batch delete while a sync was writing to the same rows.
A renderer dump taken after a quick-unlock, with nothing opened, contained the auto-backup password in cleartext - inside an ExecuteJavaScript string and in the DevTools protocol buffers - and it survived the auto-lock that wipes the vault key.
New prefs/has answers set-or-not without moving the value. The settings panel uses that; only the paths that encrypt a backup, test the WebDAV connection or run a sync fetch the secrets themselves.
renderGrid replaces every card, so the scroll container jumped back to the top: ticking a card near the bottom of a long list threw the user back to the first row. Only the affected card and the selection bar are updated now.
content-header and pagination were exempt from click-away in full, so their large empty areas were dead: clicking beside the title or next to the page buttons left the editor open. Only the actual controls are exempt now.
They accepted any drag then looked for ids in text/plain: dragging text selected in a cell produced NaN, filtered out, so the target invited a drop that could do nothing. Entry drags now carry application/x-pm-entries, mirroring the folder MIME, and all four targets check it before accepting.
Checked ids belong to the view they were made in. Carried into the trash they were LIVE entries while the trash bar offers Delete forever, so a purge could destroy rows that had never been trashed. The checkbox-less views were the visible symptom of the same problem; one rule replaces the special case.
The batch bar survived into vault health, the authenticator grid and the audit log, none of which render checkboxes - it kept offering Delete, Move to folder and Add tag on entries the user could no longer see selected.
Free-floating it either covered the cards or the sidebar navigation. Inside .toast-container it shares the corner with the toasts, and column-reverse keeps it above them.
The pill was pinned to the left edge and covered the navigation, hiding Trash. It also kept counting after Breach check disabled, the workers carrying on with one network round-trip per entry.
Bottom-left, out of the way of the toasts, with a live count. Deliberately not the busy overlay: the scan is network-bound and can take a while, and there is no reason to lock the vault while it runs.
The dashboard reads its pwned column from state.hibpResults but its cache was computed before the scan finished, so enabling the breach check from the health view showed 0 pwned while the entry cards already carried the flag. Disabling had the mirror problem: results cleared, dashboard still counting them.
Each category is capped, so past the cap fixing one more password leaves the total unchanged and the work looks wasted. The breakdown lists count and points per category, marks a capped deduction with (max), and explains in a tooltip why the score is standing still.
inert removes the controls from focus but not the document scroll: Space, PageUp/Down, Home/End and the arrows scroll without any focused element, so the page still moved under the overlay.
The overlay covers the page, which stops the mouse but not Tab and Space. The inert attribute removes the subtree from focus, hit-testing and the accessibility tree in one go - applied to every body-level sibling except the overlay, and always cleared in hideBusy.
The busy guard cancelled through the path that hands the foreground back to the captured target. During an export that pushed the native Save dialog behind the other application, leaving the app disabled by a modal the user could no longer find. refocus=0 separates refusing a request from ending a user interaction.
Guarded in autofillHandleRequest, upstream of the single-match versus picker split, so it covers Ctrl+Shift+L/P/T and both outcomes: the picker would open behind the overlay, and a single-match fill would minimise our window mid-operation. Cancels the request so Delphi drops the captured target HWND.
Global hotkeys come from Win32, outside the window, so the busy overlay intercepts nothing: Ctrl+Shift+A opened the editor behind it, unreachable. Guarded in openSlideOver and openQuickSearchModal rather than at each trigger - every caller routes through them, and no save path raises the overlay.
There are two Escape handlers: a capture-phase one for the editor and the global one-surface-per-keystroke chain. Only the first was guarded, so export - which reaches the editor through the global chain - still popped the discard-confirm under the overlay.
The discard-confirm opened underneath the busy overlay, which sits at z-index 200 - blurred and half-clickable. Worse than the display: it let the user arbitrate closing the editor while a sync or a rotation was writing to the same entry.
A 404 on save meant the work had to be copied out by hand. The POST mints a fresh uuid, so this is a new entry rather than a resurrection and cannot fight the tombstone the delete left behind. Gated on status 404 AND the server message: the HTTP layer answers 404 to any request it refuses, which the status alone would misread.
Same placement as the TOTP field, which buys back a column on a dense row: label | value (eye inside) | copy | menu. Pinned top-right on a multiline field so it stays clear of the resize grip.
The inline copy button stays: copy is the frequent action and burying it costs a click every time. Unlike the old lock-versus-eye pair, two ways to copy are not confusable.
The lock button sat next to the eye and both masked the value, so they read as the same control. The eye now carries the state - cyan ring when the field is stored as secret - and flipping the flag moved into a row menu alongside Remove field, one deliberate step away from the everyday reveal. The row drops from six columns to five.
The menu reuses the card kebab classes, so closing it on an outside click is already handled.
A manual resize was lost on reopen: the row is rebuilt on every open, and remembering the height would not survive a restart either since localStorage is wiped when the ephemeral port changes the origin. Deriving it from the content removes the state entirely - the field is always the right size.
The note body of a note entry is the encrypted_password column, so logins had no way to carry notes. Rather than a dedicated encrypted column and the seven call sites that come with it, this rides on custom_fields, which rotation, duplicate, export/import and sync already carry.
Both the open-time normalisation and the save-time rebuild drop any key they do not name, so multiline is carried explicitly in each - otherwise the field silently reopens single-line.
unlockMode lived only in localStorage, wiped on every launch since the ephemeral port changes the origin, and in settings_json, which needs a session to read. The auth screen therefore always fell back to the master password on a cold start even though the PIN blob was there. Mirror it to a DPAPI pref and read it back in showAuth, like rememberedUsername.
F1 passed when the entry had been deleted first (INSERT path) but not in
overwrite mode: that path uses PUT, and PUT deliberately ignores
favorite/pinned so a SYNC update can never clobber the local flags — they are
device-local by design. A restore, however, must bring them back.
The overwrite loop now applies them through the dedicated toggle endpoints,
and only when they differ from the local entry. JS-only: PUT keeps ignoring
them, so sync behaviour is untouched.
Splits the F1 scenario into F1a (delete + reimport, already green) and F1b
(overwrite).
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>
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>
"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>
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>
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>
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>
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>
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>
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>