feat: sync ETag concurrency + fix chunk-transfer hang + sync overlay + auto-VACUUM
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>
This commit is contained in:
@@ -499,8 +499,14 @@ device, never transmitted.
|
|||||||
restore attachments ; both sides have it = compare `updated_at`,
|
restore attachments ; both sides have it = compare `updated_at`,
|
||||||
PUT if remote newer.
|
PUT if remote newer.
|
||||||
7. `loadEntries()` + `buildSyncSnapshot()` for the post-merge state.
|
7. `loadEntries()` + `buildSyncSnapshot()` for the post-merge state.
|
||||||
8. `webdav/put` push the merged snapshot.
|
8. `webdav/put` push the merged snapshot, with `If-Match: <etag>` where
|
||||||
9. Toast `X added · Y updated · Z deleted`.
|
the etag was captured from the step-2 pull (optimistic concurrency).
|
||||||
|
A **412** means another device changed the file between our pull and
|
||||||
|
push → re-run the whole pull→merge→push (`runSyncNow(_attempt+1)`,
|
||||||
|
bounded to 3) so their changes are folded in instead of clobbered.
|
||||||
|
Servers without ETag support return an empty etag → no If-Match sent
|
||||||
|
→ falls back to last-write-wins (same as before).
|
||||||
|
9. Toast `pulled X new · Y updated · Z deleted · pushed N entries`.
|
||||||
|
|
||||||
Sensitive actions (export, change master pw, recovery code…) still
|
Sensitive actions (export, change master pw, recovery code…) still
|
||||||
require master pw via `askReauth` — sync never substitutes.
|
require master pw via `askReauth` — sync never substitutes.
|
||||||
|
|||||||
@@ -431,6 +431,11 @@ begin
|
|||||||
DB.Unlock;
|
DB.Unlock;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
// Attachments are the big rows; deleting one leaves the file bloated
|
||||||
|
// until VACUUM'd. Compact now (no-op unless the free ratio is high) so
|
||||||
|
// disk space is reclaimed immediately, not just on the next launch.
|
||||||
|
DB.CompactIfBloated;
|
||||||
|
|
||||||
LogAudit(LUserId, 'delete_attachment', GetClientIP(ARequest));
|
LogAudit(LUserId, 'delete_attachment', GetClientIP(ARequest));
|
||||||
TJSONHelper.SendOK(AResponse, 'Deleted');
|
TJSONHelper.SendOK(AResponse, 'Deleted');
|
||||||
end;
|
end;
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ type
|
|||||||
destructor Destroy; override;
|
destructor Destroy; override;
|
||||||
procedure Lock;
|
procedure Lock;
|
||||||
procedure Unlock;
|
procedure Unlock;
|
||||||
|
// VACUUM the file when a large fraction of its pages are free (SQLite
|
||||||
|
// never shrinks on DELETE — deleting big attachments leaves the file
|
||||||
|
// bloated). No-op when the free ratio is low, so the common case pays
|
||||||
|
// nothing. Caller must NOT hold the lock or be in a transaction.
|
||||||
|
procedure CompactIfBloated;
|
||||||
property Connection: TFDConnection read FConn;
|
property Connection: TFDConnection read FConn;
|
||||||
property DBPath: string read FDBPath;
|
property DBPath: string read FDBPath;
|
||||||
end;
|
end;
|
||||||
@@ -61,6 +66,10 @@ begin
|
|||||||
CreateSchema;
|
CreateSchema;
|
||||||
ApplyMigrations;
|
ApplyMigrations;
|
||||||
CleanupExpired;
|
CleanupExpired;
|
||||||
|
// Reclaim space left behind by previously-deleted large rows
|
||||||
|
// (attachments especially). Only actually rewrites the file when it's
|
||||||
|
// meaningfully bloated, so a healthy small vault opens instantly.
|
||||||
|
CompactIfBloated;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
destructor TPMDatabase.Destroy;
|
destructor TPMDatabase.Destroy;
|
||||||
@@ -70,6 +79,36 @@ begin
|
|||||||
inherited;
|
inherited;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
procedure TPMDatabase.CompactIfBloated;
|
||||||
|
var
|
||||||
|
LQ: TFDQuery;
|
||||||
|
LFree, LTotal: Int64;
|
||||||
|
begin
|
||||||
|
FLock.Enter;
|
||||||
|
try
|
||||||
|
LFree := 0; LTotal := 0;
|
||||||
|
LQ := TFDQuery.Create(nil);
|
||||||
|
try
|
||||||
|
LQ.Connection := FConn;
|
||||||
|
LQ.SQL.Text := 'PRAGMA freelist_count';
|
||||||
|
LQ.Open; if not LQ.IsEmpty then LFree := LQ.Fields[0].AsLargeInt;
|
||||||
|
LQ.Close;
|
||||||
|
LQ.SQL.Text := 'PRAGMA page_count';
|
||||||
|
LQ.Open; if not LQ.IsEmpty then LTotal := LQ.Fields[0].AsLargeInt;
|
||||||
|
LQ.Close;
|
||||||
|
finally
|
||||||
|
LQ.Free;
|
||||||
|
end;
|
||||||
|
// Rewrite only when >20% of the pages are free AND there's at least a
|
||||||
|
// few MB to reclaim (avoids churning a tiny vault). VACUUM can't run
|
||||||
|
// inside a transaction, so this must be called outside one.
|
||||||
|
if (LTotal > 0) and (LFree * 5 > LTotal) and (LFree > 512) then
|
||||||
|
FConn.ExecSQL('VACUUM');
|
||||||
|
finally
|
||||||
|
FLock.Leave;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
procedure TPMDatabase.Lock;
|
procedure TPMDatabase.Lock;
|
||||||
begin
|
begin
|
||||||
FLock.Enter;
|
FLock.Enter;
|
||||||
|
|||||||
@@ -1101,7 +1101,8 @@ begin
|
|||||||
// - PUT ok → status=200/201/204, body=''
|
// - PUT ok → status=200/201/204, body=''
|
||||||
// - test → status=200..399 means reachable, body=''
|
// - test → status=200..399 means reachable, body=''
|
||||||
// Network errors → status=0, body=exception message.
|
// Network errors → status=0, body=exception message.
|
||||||
else if (ACmd = 'webdav/get') or (ACmd = 'webdav/put') or (ACmd = 'webdav/test') then
|
else if (ACmd = 'webdav/get') or (ACmd = 'webdav/put') or (ACmd = 'webdav/test')
|
||||||
|
or (ACmd = 'webdav/put-commit') then
|
||||||
begin
|
begin
|
||||||
var LMethod := ACmd;
|
var LMethod := ACmd;
|
||||||
var LReqId := GetParam('reqId');
|
var LReqId := GetParam('reqId');
|
||||||
@@ -1109,6 +1110,26 @@ begin
|
|||||||
var LUser := GetParam('user');
|
var LUser := GetParam('user');
|
||||||
var LPwd := GetParam('pwd');
|
var LPwd := GetParam('pwd');
|
||||||
var LData := GetParam('data');
|
var LData := GetParam('data');
|
||||||
|
// put-commit: the (large) body arrived in chunks via file/chunk,
|
||||||
|
// accumulated in FFileSaveChunks keyed by reqId. Pull it out and treat
|
||||||
|
// the rest as a normal PUT.
|
||||||
|
if ACmd = 'webdav/put-commit' then
|
||||||
|
begin
|
||||||
|
var LSB: TStringBuilder;
|
||||||
|
if FFileSaveChunks.TryGetValue(LReqId, LSB) then
|
||||||
|
begin
|
||||||
|
LData := LSB.ToString;
|
||||||
|
LSB.Free;
|
||||||
|
FFileSaveChunks.Remove(LReqId);
|
||||||
|
end
|
||||||
|
else
|
||||||
|
LData := '';
|
||||||
|
LMethod := 'webdav/put';
|
||||||
|
end;
|
||||||
|
// Optimistic concurrency: JS passes the ETag it saw at pull time; we
|
||||||
|
// send it as If-Match on the push so the server rejects (412) the
|
||||||
|
// write when another device changed the file in between.
|
||||||
|
var LIfMatch := GetParam('ifmatch');
|
||||||
TThread.CreateAnonymousThread(
|
TThread.CreateAnonymousThread(
|
||||||
procedure
|
procedure
|
||||||
var
|
var
|
||||||
@@ -1120,10 +1141,12 @@ begin
|
|||||||
LBodyB64: string;
|
LBodyB64: string;
|
||||||
LStatus: Integer;
|
LStatus: Integer;
|
||||||
LErr: string;
|
LErr: string;
|
||||||
|
LEtag: string;
|
||||||
begin
|
begin
|
||||||
LStatus := 0;
|
LStatus := 0;
|
||||||
LBodyB64 := '';
|
LBodyB64 := '';
|
||||||
LErr := '';
|
LErr := '';
|
||||||
|
LEtag := '';
|
||||||
try
|
try
|
||||||
LHttp := System.Net.HttpClient.THTTPClient.Create;
|
LHttp := System.Net.HttpClient.THTTPClient.Create;
|
||||||
try
|
try
|
||||||
@@ -1141,6 +1164,7 @@ begin
|
|||||||
try
|
try
|
||||||
LResp := LHttp.Get(LUrl, LBodyStream);
|
LResp := LHttp.Get(LUrl, LBodyStream);
|
||||||
LStatus := LResp.StatusCode;
|
LStatus := LResp.StatusCode;
|
||||||
|
LEtag := LResp.HeaderValue['ETag'];
|
||||||
if (LStatus >= 200) and (LStatus < 300) and (LBodyStream.Size > 0) then
|
if (LStatus >= 200) and (LStatus < 300) and (LBodyStream.Size > 0) then
|
||||||
begin
|
begin
|
||||||
SetLength(LBytes, LBodyStream.Size);
|
SetLength(LBytes, LBodyStream.Size);
|
||||||
@@ -1158,8 +1182,13 @@ begin
|
|||||||
LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
|
LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
|
||||||
LReqStream := TBytesStream.Create(LBytes);
|
LReqStream := TBytesStream.Create(LBytes);
|
||||||
try
|
try
|
||||||
LResp := LHttp.Put(LUrl, LReqStream);
|
if LIfMatch <> '' then
|
||||||
|
LResp := LHttp.Put(LUrl, LReqStream, nil,
|
||||||
|
[System.Net.URLClient.TNetHeader.Create('If-Match', LIfMatch)])
|
||||||
|
else
|
||||||
|
LResp := LHttp.Put(LUrl, LReqStream);
|
||||||
LStatus := LResp.StatusCode;
|
LStatus := LResp.StatusCode;
|
||||||
|
LEtag := LResp.HeaderValue['ETag'];
|
||||||
finally
|
finally
|
||||||
LReqStream.Free;
|
LReqStream.Free;
|
||||||
end;
|
end;
|
||||||
@@ -1182,7 +1211,7 @@ begin
|
|||||||
TThread.Queue(nil,
|
TThread.Queue(nil,
|
||||||
procedure
|
procedure
|
||||||
var
|
var
|
||||||
LEscReq, LEscPayload: string;
|
LEscReq, LEscPayload, LEscEtag: string;
|
||||||
begin
|
begin
|
||||||
LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
|
LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
|
||||||
// GET success path → ship body base64. Otherwise the field
|
// GET success path → ship body base64. Otherwise the field
|
||||||
@@ -1196,12 +1225,16 @@ begin
|
|||||||
LEscPayload := StringReplace(LEscPayload, '"', '\"', [rfReplaceAll]);
|
LEscPayload := StringReplace(LEscPayload, '"', '\"', [rfReplaceAll]);
|
||||||
LEscPayload := StringReplace(LEscPayload, #13, '', [rfReplaceAll]);
|
LEscPayload := StringReplace(LEscPayload, #13, '', [rfReplaceAll]);
|
||||||
LEscPayload := StringReplace(LEscPayload, #10, '\n', [rfReplaceAll]);
|
LEscPayload := StringReplace(LEscPayload, #10, '\n', [rfReplaceAll]);
|
||||||
|
LEscEtag := StringReplace(LEtag, '\', '\\', [rfReplaceAll]);
|
||||||
|
LEscEtag := StringReplace(LEscEtag, '"', '\"', [rfReplaceAll]);
|
||||||
|
LEscEtag := StringReplace(LEscEtag, #13, '', [rfReplaceAll]);
|
||||||
|
LEscEtag := StringReplace(LEscEtag, #10, '', [rfReplaceAll]);
|
||||||
WebBrowser.ExecuteJavaScript(
|
WebBrowser.ExecuteJavaScript(
|
||||||
'if(window.Bridge&&Bridge.onWebdavResult)' +
|
'if(window.Bridge&&Bridge.onWebdavResult)' +
|
||||||
'Bridge.onWebdavResult("' + LEscReq + '",' +
|
'Bridge.onWebdavResult("' + LEscReq + '",' +
|
||||||
IntToStr(LStatus) + ',"' + LEscPayload + '")');
|
IntToStr(LStatus) + ',"' + LEscPayload + '","' + LEscEtag + '")');
|
||||||
LogLine(Format('%s %s → %d (%d bytes payload)',
|
LogLine(Format('%s %s → %d (%d bytes payload, etag=%s)',
|
||||||
[LMethod, LUrl, LStatus, Length(LEscPayload)]));
|
[LMethod, LUrl, LStatus, Length(LEscPayload), LEtag]));
|
||||||
end);
|
end);
|
||||||
end).Start;
|
end).Start;
|
||||||
end
|
end
|
||||||
|
|||||||
Binary file not shown.
@@ -16,6 +16,36 @@ const API = (location.pathname.indexOf('/password-manager/') === 0)
|
|||||||
const prefResolvers = {};
|
const prefResolvers = {};
|
||||||
const fileSaveResolvers = {};
|
const fileSaveResolvers = {};
|
||||||
const fileChunkResolvers = {};
|
const fileChunkResolvers = {};
|
||||||
|
// Stream a base64 string to Delphi in URL-sized pieces via cmd://file/chunk,
|
||||||
|
// each acked (onFileChunkAck) before the next is sent. Returns true when all
|
||||||
|
// pieces are buffered server-side, false on a stalled chunk. The ack CLEARS
|
||||||
|
// the pending timeout — without that, a resolved chunk's stale 30s timeout
|
||||||
|
// would later delete the CURRENT chunk's resolver and hang the transfer
|
||||||
|
// forever (only the last location.href navigation "wins" per event loop, so
|
||||||
|
// resolvers must be strictly 1-at-a-time and their timers torn down).
|
||||||
|
async function _streamChunks(reqId, b64, onProgress) {
|
||||||
|
const CHUNK = 1000000;
|
||||||
|
const total = Math.ceil(b64.length / CHUNK);
|
||||||
|
let done = 0;
|
||||||
|
for (let off = 0; off < b64.length; off += CHUNK) {
|
||||||
|
const piece = b64.slice(off, off + CHUNK);
|
||||||
|
const ok = await new Promise(res => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (fileChunkResolvers[reqId]) {
|
||||||
|
delete fileChunkResolvers[reqId];
|
||||||
|
res(false);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
fileChunkResolvers[reqId] = { resolve: res, timer };
|
||||||
|
window.location.href = 'cmd://file/chunk?reqId=' + encodeURIComponent(reqId) +
|
||||||
|
'&data=' + encodeURIComponent(piece);
|
||||||
|
});
|
||||||
|
if (!ok) return false;
|
||||||
|
done++;
|
||||||
|
if (typeof onProgress === 'function') onProgress(Math.round(done / total * 100));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
let versionResolver = null;
|
let versionResolver = null;
|
||||||
let launchModeResolver = null;
|
let launchModeResolver = null;
|
||||||
const folderPickResolvers = {};
|
const folderPickResolvers = {};
|
||||||
@@ -241,26 +271,8 @@ const Bridge = (() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return (async () => {
|
return (async () => {
|
||||||
const totalChunks = Math.ceil(b64.length / CHUNK);
|
const ok = await _streamChunks(reqId, b64, onProgress);
|
||||||
let done = 0;
|
if (!ok) return { ok: false, error: 'chunk transfer failed' };
|
||||||
for (let off = 0; off < b64.length; off += CHUNK) {
|
|
||||||
const piece = b64.slice(off, off + CHUNK);
|
|
||||||
const ackOk = await new Promise(res => {
|
|
||||||
fileChunkResolvers[reqId] = res;
|
|
||||||
cmd('cmd://file/chunk?reqId=' + encodeURIComponent(reqId) +
|
|
||||||
'&data=' + encodeURIComponent(piece));
|
|
||||||
setTimeout(() => {
|
|
||||||
if (fileChunkResolvers[reqId]) {
|
|
||||||
delete fileChunkResolvers[reqId];
|
|
||||||
res(false);
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
});
|
|
||||||
if (!ackOk) return { ok: false, error: 'chunk transfer failed' };
|
|
||||||
done++;
|
|
||||||
if (typeof onProgress === 'function')
|
|
||||||
onProgress(Math.round(done / totalChunks * 100));
|
|
||||||
}
|
|
||||||
return await new Promise(resolve => {
|
return await new Promise(resolve => {
|
||||||
fileSaveResolvers[reqId] = resolve;
|
fileSaveResolvers[reqId] = resolve;
|
||||||
cmd('cmd://file/save-commit?reqId=' + encodeURIComponent(reqId) +
|
cmd('cmd://file/save-commit?reqId=' + encodeURIComponent(reqId) +
|
||||||
@@ -276,7 +288,11 @@ const Bridge = (() => {
|
|||||||
},
|
},
|
||||||
onFileChunkAck(reqId) {
|
onFileChunkAck(reqId) {
|
||||||
const r = fileChunkResolvers[reqId];
|
const r = fileChunkResolvers[reqId];
|
||||||
if (r) { delete fileChunkResolvers[reqId]; r(true); }
|
if (r) {
|
||||||
|
delete fileChunkResolvers[reqId];
|
||||||
|
if (r.timer) clearTimeout(r.timer);
|
||||||
|
r.resolve(true);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onFileSaveResult(reqId, ok, path, err) {
|
onFileSaveResult(reqId, ok, path, err) {
|
||||||
const r = fileSaveResolvers[reqId];
|
const r = fileSaveResolvers[reqId];
|
||||||
@@ -332,26 +348,8 @@ const Bridge = (() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return (async () => {
|
return (async () => {
|
||||||
const totalChunks = Math.ceil(b64.length / CHUNK);
|
const ok = await _streamChunks(reqId, b64, onProgress);
|
||||||
let done = 0;
|
if (!ok) return { ok: false, error: 'chunk transfer failed' };
|
||||||
for (let off = 0; off < b64.length; off += CHUNK) {
|
|
||||||
const piece = b64.slice(off, off + CHUNK);
|
|
||||||
const ackOk = await new Promise(res => {
|
|
||||||
fileChunkResolvers[reqId] = res;
|
|
||||||
cmd('cmd://file/chunk?reqId=' + encodeURIComponent(reqId) +
|
|
||||||
'&data=' + encodeURIComponent(piece));
|
|
||||||
setTimeout(() => {
|
|
||||||
if (fileChunkResolvers[reqId]) {
|
|
||||||
delete fileChunkResolvers[reqId];
|
|
||||||
res(false);
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
});
|
|
||||||
if (!ackOk) return { ok: false, error: 'chunk transfer failed' };
|
|
||||||
done++;
|
|
||||||
if (typeof onProgress === 'function')
|
|
||||||
onProgress(Math.round(done / totalChunks * 100));
|
|
||||||
}
|
|
||||||
return await new Promise(resolve => {
|
return await new Promise(resolve => {
|
||||||
fileWriteResolvers[reqId] = resolve;
|
fileWriteResolvers[reqId] = resolve;
|
||||||
cmd('cmd://file/write-commit?reqId=' + encodeURIComponent(reqId) +
|
cmd('cmd://file/write-commit?reqId=' + encodeURIComponent(reqId) +
|
||||||
@@ -9889,15 +9887,45 @@ const SYNC_PREFS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let _webdavResolvers = {};
|
let _webdavResolvers = {};
|
||||||
Bridge.onWebdavResult = function(reqId, status, payload) {
|
Bridge.onWebdavResult = function(reqId, status, payload, etag) {
|
||||||
const r = _webdavResolvers[reqId];
|
const r = _webdavResolvers[reqId];
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
delete _webdavResolvers[reqId];
|
delete _webdavResolvers[reqId];
|
||||||
r({ status: status | 0, payload: payload || '' });
|
r({ status: status | 0, payload: payload || '', etag: etag || '' });
|
||||||
};
|
};
|
||||||
function _webdavCall(method, url, user, pwd, dataB64) {
|
// method: 'get'|'put'|'test'. opts.ifMatch → sent as If-Match on a put so
|
||||||
|
// the server rejects (412) a write when the remote changed since our pull.
|
||||||
|
function _webdavCall(method, url, user, pwd, dataB64, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
||||||
|
const CHUNK = 1000000;
|
||||||
|
// Large PUT bodies (a big encrypted vault) can't ride in a single
|
||||||
|
// cmd:// URL — WebView2 caps it and the navigation blanks the page.
|
||||||
|
// Stream the base64 through the file/chunk transport, then commit via
|
||||||
|
// webdav/put-commit (which reads the accumulated buffer server-side).
|
||||||
|
if (method === 'put' && dataB64 && dataB64.length > CHUNK) {
|
||||||
|
return (async () => {
|
||||||
|
const ok = await _streamChunks(reqId, dataB64);
|
||||||
|
if (!ok) return { status: 0, payload: 'chunk transfer failed', etag: '' };
|
||||||
|
return await new Promise(resolve => {
|
||||||
|
_webdavResolvers[reqId] = resolve;
|
||||||
|
let q = 'cmd://webdav/put-commit'
|
||||||
|
+ '?reqId=' + encodeURIComponent(reqId)
|
||||||
|
+ '&url=' + encodeURIComponent(url)
|
||||||
|
+ '&user=' + encodeURIComponent(user || '')
|
||||||
|
+ '&pwd=' + encodeURIComponent(pwd || '');
|
||||||
|
if (opts.ifMatch) q += '&ifmatch=' + encodeURIComponent(opts.ifMatch);
|
||||||
|
window.location.href = q;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (_webdavResolvers[reqId]) {
|
||||||
|
delete _webdavResolvers[reqId];
|
||||||
|
resolve({ status: 0, payload: 'timeout', etag: '' });
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
|
||||||
_webdavResolvers[reqId] = resolve;
|
_webdavResolvers[reqId] = resolve;
|
||||||
let q = 'cmd://webdav/' + method
|
let q = 'cmd://webdav/' + method
|
||||||
+ '?reqId=' + encodeURIComponent(reqId)
|
+ '?reqId=' + encodeURIComponent(reqId)
|
||||||
@@ -9905,11 +9933,12 @@ function _webdavCall(method, url, user, pwd, dataB64) {
|
|||||||
+ '&user=' + encodeURIComponent(user || '')
|
+ '&user=' + encodeURIComponent(user || '')
|
||||||
+ '&pwd=' + encodeURIComponent(pwd || '');
|
+ '&pwd=' + encodeURIComponent(pwd || '');
|
||||||
if (dataB64) q += '&data=' + encodeURIComponent(dataB64);
|
if (dataB64) q += '&data=' + encodeURIComponent(dataB64);
|
||||||
|
if (opts.ifMatch) q += '&ifmatch=' + encodeURIComponent(opts.ifMatch);
|
||||||
window.location.href = q;
|
window.location.href = q;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (_webdavResolvers[reqId]) {
|
if (_webdavResolvers[reqId]) {
|
||||||
delete _webdavResolvers[reqId];
|
delete _webdavResolvers[reqId];
|
||||||
resolve({ status: 0, payload: 'timeout' });
|
resolve({ status: 0, payload: 'timeout', etag: '' });
|
||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
});
|
});
|
||||||
@@ -10245,6 +10274,10 @@ function syncStatus(text) {
|
|||||||
if (text) { el.textContent = text; el.style.display = ''; }
|
if (text) { el.textContent = text; el.style.display = ''; }
|
||||||
else { el.textContent = ''; el.style.display = 'none'; }
|
else { el.textContent = ''; el.style.display = 'none'; }
|
||||||
}
|
}
|
||||||
|
// Also drive the global busy overlay so a running sync blocks stray
|
||||||
|
// clicks (e.g. the auto-backup "Choose…" picker) and reads the same
|
||||||
|
// as the manual backup. Empty text = clear.
|
||||||
|
if (text) showBusy(text); else hideBusy();
|
||||||
// Lock the Sync/Test buttons while a run is in flight so the user
|
// Lock the Sync/Test buttons while a run is in flight so the user
|
||||||
// can't double-click a second concurrent sync.
|
// can't double-click a second concurrent sync.
|
||||||
const active = !!text;
|
const active = !!text;
|
||||||
@@ -10254,14 +10287,15 @@ function syncStatus(text) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSyncNow() {
|
async function runSyncNow(_attempt) {
|
||||||
|
_attempt = _attempt || 0;
|
||||||
const cfg = await loadSyncConfig();
|
const cfg = await loadSyncConfig();
|
||||||
if (!cfg) return toast('Bridge not available', 'error');
|
if (!cfg) return toast('Bridge not available', 'error');
|
||||||
if (!cfg.url) return toast('Sync not configured', 'warning');
|
if (!cfg.url) return toast('Sync not configured', 'warning');
|
||||||
if (!cfg.encPwd) return toast('Set the sync password first', 'warning');
|
if (!cfg.encPwd) return toast('Set the sync password first', 'warning');
|
||||||
if (!state.cryptoKey) return toast('Vault is locked', 'warning');
|
if (!state.cryptoKey) return toast('Vault is locked', 'warning');
|
||||||
|
|
||||||
toast('Syncing…');
|
if (_attempt === 0) toast('Syncing…');
|
||||||
let merged = { added: 0, updated: 0, deleted: 0 };
|
let merged = { added: 0, updated: 0, deleted: 0 };
|
||||||
|
|
||||||
// Fail-fast connectivity: hit the remote FIRST so a dead server /
|
// Fail-fast connectivity: hit the remote FIRST so a dead server /
|
||||||
@@ -10285,6 +10319,10 @@ async function runSyncNow() {
|
|||||||
syncStatus('');
|
syncStatus('');
|
||||||
return toast('Pull failed: HTTP ' + pullResp.status, 'error');
|
return toast('Pull failed: HTTP ' + pullResp.status, 'error');
|
||||||
}
|
}
|
||||||
|
// ETag of the version we just pulled — sent back as If-Match on the
|
||||||
|
// push so the server rejects our write if another device changed the
|
||||||
|
// file in between (optimistic concurrency, avoids lost updates).
|
||||||
|
const remoteEtag = pullResp.etag || '';
|
||||||
|
|
||||||
// Decrypt + cross-account guard BEFORE touching local state, so a
|
// Decrypt + cross-account guard BEFORE touching local state, so a
|
||||||
// wrong sync password or a foreign account aborts cleanly.
|
// wrong sync password or a foreign account aborts cleanly.
|
||||||
@@ -10300,6 +10338,9 @@ async function runSyncNow() {
|
|||||||
}
|
}
|
||||||
if (remoteSnap && remoteSnap.username && state.username &&
|
if (remoteSnap && remoteSnap.username && state.username &&
|
||||||
remoteSnap.username !== state.username) {
|
remoteSnap.username !== state.username) {
|
||||||
|
// Drop the busy overlay so this confirm (z-index below it)
|
||||||
|
// is visible; a later syncStatus() re-shows it if we continue.
|
||||||
|
hideBusy();
|
||||||
const ok = await confirmDialog({
|
const ok = await confirmDialog({
|
||||||
title: 'Different account on remote',
|
title: 'Different account on remote',
|
||||||
message: 'The remote snapshot belongs to <b>' +
|
message: 'The remote snapshot belongs to <b>' +
|
||||||
@@ -10369,7 +10410,21 @@ async function runSyncNow() {
|
|||||||
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
|
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
|
||||||
syncStatus('Pushing…');
|
syncStatus('Pushing…');
|
||||||
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd,
|
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd,
|
||||||
bytesToBase64(bodyBytes));
|
bytesToBase64(bodyBytes),
|
||||||
|
{ ifMatch: remoteEtag });
|
||||||
|
// 412 Precondition Failed = the remote changed since our pull
|
||||||
|
// (another device pushed). Re-run the whole pull→merge→push so we
|
||||||
|
// fold in their changes instead of clobbering them. Bounded to a
|
||||||
|
// few tries to avoid a livelock against a device syncing in a hot
|
||||||
|
// loop.
|
||||||
|
if (r.status === 412) {
|
||||||
|
syncStatus('');
|
||||||
|
if (_attempt < 3) {
|
||||||
|
toast('Remote changed — re-syncing…');
|
||||||
|
return await runSyncNow(_attempt + 1);
|
||||||
|
}
|
||||||
|
return toast('Sync gave up after repeated remote changes — try again', 'error');
|
||||||
|
}
|
||||||
if (!(r.status >= 200 && r.status < 300)) {
|
if (!(r.status >= 200 && r.status < 300)) {
|
||||||
syncStatus('');
|
syncStatus('');
|
||||||
return toast('Push failed: HTTP ' + r.status, 'error');
|
return toast('Push failed: HTTP ' + r.status, 'error');
|
||||||
|
|||||||
Reference in New Issue
Block a user