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:
@@ -18,6 +18,7 @@ interface
|
||||
uses
|
||||
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
|
||||
System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON,
|
||||
System.Net.HttpClient, System.Net.URLClient,
|
||||
Winapi.Windows, Winapi.ShellAPI,
|
||||
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
|
||||
FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types,
|
||||
@@ -370,6 +371,18 @@ begin
|
||||
// so we bypass the minimize-to-tray intercept in that case.
|
||||
if FQuitting then Exit;
|
||||
|
||||
// Windows shutdown / logoff / restart: WM_QUERYENDSESSION flipped the
|
||||
// bridge's flag. Let the form close normally so FServer.Free and the
|
||||
// FireDAC connection get a chance to checkpoint the WAL — otherwise
|
||||
// Windows force-kills us at the shutdown timeout and we leave
|
||||
// -shm / -wal files next to vault.db.
|
||||
if Assigned(FBridge) and FBridge.ShutdownPending then
|
||||
begin
|
||||
FQuitting := True;
|
||||
LogLine('System shutdown detected — closing normally.');
|
||||
Exit;
|
||||
end;
|
||||
|
||||
// Otherwise: minimize to tray on close instead of quitting, so the vault
|
||||
// stays available without the dev-panel being visible.
|
||||
// When the server is stopped, allow normal close — there's no vault to
|
||||
@@ -932,6 +945,119 @@ begin
|
||||
BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')');
|
||||
end
|
||||
|
||||
// ---- WebDAV remote sync (THTTPClient → WinHTTP, async) --------------
|
||||
// cmd://webdav/get | put | test ?reqId=&url=&user=&pwd=[&data=]
|
||||
// Callback: Bridge.onWebdavResult(reqId, status, bodyOrError)
|
||||
// - GET ok → status=200, body=base64 of response bytes
|
||||
// - GET 404 → status=404, body='' (caller treats as "no remote yet")
|
||||
// - PUT ok → status=200/201/204, body=''
|
||||
// - test → status=200..399 means reachable, body=''
|
||||
// Network errors → status=0, body=exception message.
|
||||
else if (ACmd = 'webdav/get') or (ACmd = 'webdav/put') or (ACmd = 'webdav/test') then
|
||||
begin
|
||||
var LMethod := ACmd;
|
||||
var LReqId := GetParam('reqId');
|
||||
var LUrl := GetParam('url');
|
||||
var LUser := GetParam('user');
|
||||
var LPwd := GetParam('pwd');
|
||||
var LData := GetParam('data');
|
||||
TThread.CreateAnonymousThread(
|
||||
procedure
|
||||
var
|
||||
LHttp: System.Net.HttpClient.THTTPClient;
|
||||
LResp: System.Net.HttpClient.IHTTPResponse;
|
||||
LBodyStream: TBytesStream;
|
||||
LReqStream: TBytesStream;
|
||||
LBytes: TBytes;
|
||||
LBodyB64: string;
|
||||
LStatus: Integer;
|
||||
LErr: string;
|
||||
begin
|
||||
LStatus := 0;
|
||||
LBodyB64 := '';
|
||||
LErr := '';
|
||||
try
|
||||
LHttp := System.Net.HttpClient.THTTPClient.Create;
|
||||
try
|
||||
LHttp.ConnectionTimeout := 10000;
|
||||
LHttp.ResponseTimeout := 30000;
|
||||
if (LUser <> '') then
|
||||
begin
|
||||
LHttp.CredentialsStorage.AddCredential(
|
||||
System.Net.URLClient.TCredentialsStorage.TCredential.Create(
|
||||
System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd));
|
||||
end;
|
||||
if LMethod = 'webdav/get' then
|
||||
begin
|
||||
LBodyStream := TBytesStream.Create;
|
||||
try
|
||||
LResp := LHttp.Get(LUrl, LBodyStream);
|
||||
LStatus := LResp.StatusCode;
|
||||
if (LStatus >= 200) and (LStatus < 300) and (LBodyStream.Size > 0) then
|
||||
begin
|
||||
SetLength(LBytes, LBodyStream.Size);
|
||||
Move(LBodyStream.Bytes[0], LBytes[0], LBodyStream.Size);
|
||||
LBodyB64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
|
||||
LBodyB64 := StringReplace(LBodyB64, #13, '', [rfReplaceAll]);
|
||||
LBodyB64 := StringReplace(LBodyB64, #10, '', [rfReplaceAll]);
|
||||
end;
|
||||
finally
|
||||
LBodyStream.Free;
|
||||
end;
|
||||
end
|
||||
else if LMethod = 'webdav/put' then
|
||||
begin
|
||||
LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
|
||||
LReqStream := TBytesStream.Create(LBytes);
|
||||
try
|
||||
LResp := LHttp.Put(LUrl, LReqStream);
|
||||
LStatus := LResp.StatusCode;
|
||||
finally
|
||||
LReqStream.Free;
|
||||
end;
|
||||
end
|
||||
else // webdav/test — HEAD is widely supported even when PROPFIND isn't
|
||||
begin
|
||||
LResp := LHttp.Head(LUrl);
|
||||
LStatus := LResp.StatusCode;
|
||||
end;
|
||||
finally
|
||||
LHttp.Free;
|
||||
end;
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
LStatus := 0;
|
||||
LErr := E.Message;
|
||||
end;
|
||||
end;
|
||||
TThread.Queue(nil,
|
||||
procedure
|
||||
var
|
||||
LEscReq, LEscPayload: string;
|
||||
begin
|
||||
LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
|
||||
// GET success path → ship body base64. Otherwise the field
|
||||
// carries either the empty string or the exception message
|
||||
// (for status=0 network errors).
|
||||
if (LMethod = 'webdav/get') and (LStatus >= 200) and (LStatus < 300) then
|
||||
LEscPayload := LBodyB64
|
||||
else
|
||||
LEscPayload := LErr;
|
||||
LEscPayload := StringReplace(LEscPayload, '\', '\\', [rfReplaceAll]);
|
||||
LEscPayload := StringReplace(LEscPayload, '"', '\"', [rfReplaceAll]);
|
||||
LEscPayload := StringReplace(LEscPayload, #13, '', [rfReplaceAll]);
|
||||
LEscPayload := StringReplace(LEscPayload, #10, '\n', [rfReplaceAll]);
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onWebdavResult)' +
|
||||
'Bridge.onWebdavResult("' + LEscReq + '",' +
|
||||
IntToStr(LStatus) + ',"' + LEscPayload + '")');
|
||||
LogLine(Format('%s %s → %d (%d bytes payload)',
|
||||
[LMethod, LUrl, LStatus, Length(LEscPayload)]));
|
||||
end);
|
||||
end).Start;
|
||||
end
|
||||
|
||||
// ---- Native file save (bypasses WebView2's browser download UI) ------
|
||||
// JS sends: cmd://file/save?name=<filename>&data=<base64>&reqId=<id>
|
||||
// Delphi opens GetSaveFileName, writes the decoded bytes, then calls
|
||||
|
||||
Reference in New Issue
Block a user