feat: WebDAV sync + batch DnD + clean shutdown + center-modal UX bundle

- 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>
This commit is contained in:
r-zakarya
2026-06-30 00:32:12 +01:00
parent b00da43ab0
commit 6869b7c692
10 changed files with 1132 additions and 25 deletions
+21
View File
@@ -92,6 +92,12 @@ type
FSavedPlacement: TWindowPlacement;
FHasSavedPlacement: Boolean;
FOnSystemLock: TProc;
// Set TRUE the moment Windows tells us the session is ending
// (WM_QUERYENDSESSION / WM_ENDSESSION). FormCloseQuery checks this
// to bypass the "minimize to tray" intercept so the form closes
// normally and the DB connection is checkpointed instead of being
// force-killed (which leaves -shm / -wal files behind).
FShutdownPending: Boolean;
FOnTrayRestore: TProc;
FOnLockRequest: TProc;
FOnQuit: TProc;
@@ -158,6 +164,9 @@ type
property AutofillRegistered: Boolean read FAutofillRegistered;
// Fired on main thread when Windows locks the session (WTS_SESSION_LOCK).
property OnSystemLock: TProc read FOnSystemLock write FOnSystemLock;
// True once WM_QUERYENDSESSION (or WM_ENDSESSION) has been received.
// FormCloseQuery uses this to allow normal close during shutdown.
property ShutdownPending: Boolean read FShutdownPending;
// Fired on main thread when the user clicks the tray icon.
property OnTrayRestore: TProc read FOnTrayRestore write FOnTrayRestore;
// Fired when the user picks "Lock vault" from the tray menu. Handler
@@ -735,6 +744,18 @@ begin
if Assigned(FOnSystemLock) then FOnSystemLock();
end
else if (AMsg.Msg = WM_QUERYENDSESSION) or (AMsg.Msg = WM_ENDSESSION) then
begin
// Windows is logging off / shutting down / restarting. Flip the flag
// so FormCloseQuery lets the form actually close instead of
// minimizing to tray — otherwise Windows force-kills us after the
// shutdown timeout and SQLite's WAL/SHM never get checkpointed.
// Return TRUE (do not block shutdown). DefWindowProc returns TRUE
// by default for WM_QUERYENDSESSION, so we just don't assign Result.
FShutdownPending := True;
AMsg.Result := 1;
end
else if (AMsg.Msg <> 0) and (AMsg.Msg = WM_PMShowMessage) then
begin
// A second instance was launched and PostMessage'd HWND_BROADCAST.
+36
View File
@@ -314,6 +314,42 @@ begin
// Drives the card/table label so notes-with-fields read as "Credit card"
// instead of the generic "Encrypted note" placeholder.
AddColumnIfMissing('vault_entries', 'template', 'TEXT');
// Stable identity that survives export/import + cross-device sync.
// SQLite `id` is autoincrement local-only — useless to match the same
// logical entry across two installs. Populate existing rows with a
// fresh UUID v4 below so the migration is non-destructive.
AddColumnIfMissing('vault_entries', 'uuid', 'TEXT');
FConn.ExecSQL(
'CREATE INDEX IF NOT EXISTS idx_entries_uuid ' +
' ON vault_entries(uuid)');
// Backfill UUIDs for legacy rows that landed before the column existed.
// SQLite has no native uuid() — emit one via hex(randomblob) + manual
// dashes (RFC 4122 v4 = 8-4-4-4-12 hex, version nibble forced to 4,
// variant nibble high bits 10).
FConn.ExecSQL(
'UPDATE vault_entries SET uuid = ' +
' lower(hex(randomblob(4))) || ''-'' || ' +
' lower(hex(randomblob(2))) || ''-4'' || ' +
' substr(lower(hex(randomblob(2))), 2) || ''-'' || ' +
' substr(''89ab'', 1 + (abs(random()) % 4), 1) || ' +
' substr(lower(hex(randomblob(2))), 2) || ''-'' || ' +
' lower(hex(randomblob(6))) ' +
'WHERE uuid IS NULL OR uuid = ''''');
// Tombstones: every hard-delete inserts a row here so the sync engine
// can propagate deletes to other devices without leaving deleted
// entries to silently reappear at next pull.
FConn.ExecSQL(
'CREATE TABLE IF NOT EXISTS entry_tombstones (' +
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
' user_id INTEGER NOT NULL,' +
' uuid TEXT NOT NULL,' +
' deleted_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,' +
' UNIQUE(user_id, uuid)' +
')');
FConn.ExecSQL(
'CREATE INDEX IF NOT EXISTS idx_tombstones_user ' +
' ON entry_tombstones(user_id, deleted_at DESC)');
// Per-folder customisation. NULL = no override → JS uses the default
// accent + i-folder symbol.
AddColumnIfMissing('folders', 'color', 'TEXT');