fix: chunk auto-backup writes + spinner on manual "Backup now"
Bridge.writeFile had the same URL-length trap saveFile did: a large
auto-backup base64'd into a single cmd://file/write URL blew past
WebView2's ~2MB navigation cap, so backups of big vaults failed
silently (or blanked the page) — for BOTH the manual "Backup now"
button and the silent scheduled run.
- writeFile now streams payloads over ~1MB in chunks (reusing the same
file/chunk transport as saveFile), committed via a new
file/write-commit. Delphi shares the decode+write logic through a new
WriteDecodedFile helper and the existing FFileSaveChunks buffer.
- runAutoBackupNow(silent): the manual run shows the busy overlay
("Reading vault… N/total" → "Encrypting backup…" → "Writing file… N%")
since a 20MB backup takes ~30s; the scheduled on-unlock run passes
silent=true (no overlay, but still chunked so it no longer fails on
large vaults).
- Fixed the "Backup now" click handler passing the click Event as the
silent arg (truthy → would have suppressed the spinner and swallowed
errors); wrapped in () => runAutoBackupNow().
- Pre-sync backup benefits from the chunked writeFile automatically.
Rebuild: BuildAssets + F9 (UMainForm.pas changed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -115,6 +115,9 @@ type
|
|||||||
// (chunked): decode the base64, show the Save dialog, write, and fire
|
// (chunked): decode the base64, show the Save dialog, write, and fire
|
||||||
// Bridge.onFileSaveResult back to JS.
|
// Bridge.onFileSaveResult back to JS.
|
||||||
procedure SaveDecodedFile(const AName, AB64, AReqId: string);
|
procedure SaveDecodedFile(const AName, AB64, AReqId: string);
|
||||||
|
// Silent write to a fixed path (auto-backup / sync pre-backup). Shared
|
||||||
|
// by cmd://file/write (single-shot) and file/write-commit (chunked).
|
||||||
|
procedure WriteDecodedFile(const APath, AB64, AReqId: string);
|
||||||
procedure LogLine(const AMsg: string);
|
procedure LogLine(const AMsg: string);
|
||||||
procedure UpdateButtons;
|
procedure UpdateButtons;
|
||||||
procedure NavigateToVault;
|
procedure NavigateToVault;
|
||||||
@@ -508,6 +511,40 @@ begin
|
|||||||
BoolToStr(LOk, True).ToLower + ',"' + LEscPath + '","' + LEscErr + '")');
|
BoolToStr(LOk, True).ToLower + ',"' + LEscPath + '","' + LEscErr + '")');
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
procedure TMainForm.WriteDecodedFile(const APath, AB64, AReqId: string);
|
||||||
|
var
|
||||||
|
LOk: Boolean;
|
||||||
|
LErr: string;
|
||||||
|
begin
|
||||||
|
LOk := False;
|
||||||
|
LErr := '';
|
||||||
|
try
|
||||||
|
var LBytes := TNetEncoding.Base64.DecodeStringToBytes(AB64);
|
||||||
|
var LStream := TFileStream.Create(APath, fmCreate);
|
||||||
|
try
|
||||||
|
if Length(LBytes) > 0 then
|
||||||
|
LStream.WriteBuffer(LBytes[0], Length(LBytes));
|
||||||
|
finally
|
||||||
|
LStream.Free;
|
||||||
|
end;
|
||||||
|
LOk := True;
|
||||||
|
LogLine(Format('File written: %s (%d bytes)', [APath, Length(LBytes)]));
|
||||||
|
except
|
||||||
|
on E: Exception do
|
||||||
|
begin
|
||||||
|
LErr := E.Message;
|
||||||
|
LogLine('File write FAILED for "' + APath + '": ' + LErr);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
var LEscReq := StringReplace(AReqId, '"', '\"', [rfReplaceAll]);
|
||||||
|
var LEscErr := StringReplace(LErr, '\', '\\', [rfReplaceAll]);
|
||||||
|
LEscErr := StringReplace(LEscErr, '"', '\"', [rfReplaceAll]);
|
||||||
|
WebBrowser.ExecuteJavaScript(
|
||||||
|
'if(window.Bridge&&Bridge.onFileWriteResult)' +
|
||||||
|
'Bridge.onFileWriteResult("' + LEscReq + '",' +
|
||||||
|
BoolToStr(LOk, True).ToLower + ',"' + LEscErr + '")');
|
||||||
|
end;
|
||||||
|
|
||||||
procedure TMainForm.NavigateToVault;
|
procedure TMainForm.NavigateToVault;
|
||||||
begin
|
begin
|
||||||
FPendingURL := 'http://127.0.0.1:' + FServer.BoundPort.ToString + '/index.html';
|
FPendingURL := 'http://127.0.0.1:' + FServer.BoundPort.ToString + '/index.html';
|
||||||
@@ -1240,37 +1277,29 @@ begin
|
|||||||
// cmd://file/write?path=<full>&data=<base64>&reqId=<id>
|
// cmd://file/write?path=<full>&data=<base64>&reqId=<id>
|
||||||
// Callback: Bridge.onFileWriteResult(reqId, ok, error)
|
// Callback: Bridge.onFileWriteResult(reqId, ok, error)
|
||||||
else if ACmd = 'file/write' then
|
else if ACmd = 'file/write' then
|
||||||
|
// Single-shot silent write (small payload).
|
||||||
|
WriteDecodedFile(GetParam('path'), GetParam('data'), GetParam('reqId'))
|
||||||
|
|
||||||
|
// Chunked silent write: chunks accumulated via file/chunk, committed
|
||||||
|
// here (large auto-backups exceed a single cmd:// URL otherwise).
|
||||||
|
else if ACmd = 'file/write-commit' then
|
||||||
begin
|
begin
|
||||||
var LPath := GetParam('path');
|
|
||||||
var LData := GetParam('data');
|
|
||||||
var LReqId := GetParam('reqId');
|
var LReqId := GetParam('reqId');
|
||||||
var LOk := False;
|
var LSB: TStringBuilder;
|
||||||
var LErr := '';
|
if FFileSaveChunks.TryGetValue(LReqId, LSB) then
|
||||||
try
|
begin
|
||||||
var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
|
var LFull := LSB.ToString;
|
||||||
var LStream := TFileStream.Create(LPath, fmCreate);
|
LSB.Free;
|
||||||
try
|
FFileSaveChunks.Remove(LReqId);
|
||||||
if Length(LBytes) > 0 then
|
WriteDecodedFile(GetParam('path'), LFull, LReqId);
|
||||||
LStream.WriteBuffer(LBytes[0], Length(LBytes));
|
end
|
||||||
finally
|
else
|
||||||
LStream.Free;
|
begin
|
||||||
end;
|
var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
|
||||||
LOk := True;
|
WebBrowser.ExecuteJavaScript(
|
||||||
LogLine(Format('File written: %s (%d bytes)', [LPath, Length(LBytes)]));
|
'if(window.Bridge&&Bridge.onFileWriteResult)' +
|
||||||
except
|
'Bridge.onFileWriteResult("' + LEscReq + '",false,"no chunks buffered")');
|
||||||
on E: Exception do
|
|
||||||
begin
|
|
||||||
LErr := E.Message;
|
|
||||||
LogLine('File write FAILED for "' + LPath + '": ' + LErr);
|
|
||||||
end;
|
|
||||||
end;
|
end;
|
||||||
var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
|
|
||||||
var LEscErr := StringReplace(LErr, '\', '\\', [rfReplaceAll]);
|
|
||||||
LEscErr := StringReplace(LEscErr, '"', '\"', [rfReplaceAll]);
|
|
||||||
WebBrowser.ExecuteJavaScript(
|
|
||||||
'if(window.Bridge&&Bridge.onFileWriteResult)' +
|
|
||||||
'Bridge.onFileWriteResult("' + LEscReq + '",' +
|
|
||||||
BoolToStr(LOk, True).ToLower + ',"' + LEscErr + '")');
|
|
||||||
end
|
end
|
||||||
|
|
||||||
// ---- Auto-backup: list files in dir matching name prefix --------------
|
// ---- Auto-backup: list files in dir matching name prefix --------------
|
||||||
|
|||||||
Binary file not shown.
@@ -306,25 +306,64 @@ const Bridge = (() => {
|
|||||||
if (r) { delete folderPickResolvers[reqId]; r(path || ''); }
|
if (r) { delete folderPickResolvers[reqId]; r(path || ''); }
|
||||||
},
|
},
|
||||||
|
|
||||||
writeFile(path, content) {
|
writeFile(path, content, onProgress) {
|
||||||
if (!active) return Promise.resolve({ ok: false, error: 'bridge offline' });
|
if (!active) return Promise.resolve({ ok: false, error: 'bridge offline' });
|
||||||
let bytes;
|
let bytes;
|
||||||
if (typeof content === 'string') bytes = new TextEncoder().encode(content);
|
if (typeof content === 'string') bytes = new TextEncoder().encode(content);
|
||||||
else bytes = content;
|
else bytes = content;
|
||||||
const b64 = bytesToBase64(bytes);
|
const b64 = bytesToBase64(bytes);
|
||||||
const reqId = 'fw_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
const reqId = 'fw_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
||||||
return new Promise(resolve => {
|
// Same URL-length trap as saveFile: a big auto-backup base64'd
|
||||||
fileWriteResolvers[reqId] = resolve;
|
// into a single cmd:// URL blows past WebView2's cap and the
|
||||||
cmd('cmd://file/write?path=' + encodeURIComponent(path) +
|
// write fails (silently, or blanks the page). Chunk it.
|
||||||
'&data=' + encodeURIComponent(b64) +
|
const CHUNK = 1000000;
|
||||||
'&reqId=' + encodeURIComponent(reqId));
|
if (b64.length <= CHUNK) {
|
||||||
setTimeout(() => {
|
return new Promise(resolve => {
|
||||||
if (fileWriteResolvers[reqId]) {
|
fileWriteResolvers[reqId] = resolve;
|
||||||
delete fileWriteResolvers[reqId];
|
cmd('cmd://file/write?path=' + encodeURIComponent(path) +
|
||||||
resolve({ ok: false, error: 'timeout' });
|
'&data=' + encodeURIComponent(b64) +
|
||||||
}
|
'&reqId=' + encodeURIComponent(reqId));
|
||||||
}, 30000);
|
setTimeout(() => {
|
||||||
});
|
if (fileWriteResolvers[reqId]) {
|
||||||
|
delete fileWriteResolvers[reqId];
|
||||||
|
resolve({ ok: false, error: 'timeout' });
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (async () => {
|
||||||
|
const totalChunks = 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 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 => {
|
||||||
|
fileWriteResolvers[reqId] = resolve;
|
||||||
|
cmd('cmd://file/write-commit?reqId=' + encodeURIComponent(reqId) +
|
||||||
|
'&path=' + encodeURIComponent(path));
|
||||||
|
setTimeout(() => {
|
||||||
|
if (fileWriteResolvers[reqId]) {
|
||||||
|
delete fileWriteResolvers[reqId];
|
||||||
|
resolve({ ok: false, error: 'timeout' });
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
})();
|
||||||
},
|
},
|
||||||
onFileWriteResult(reqId, ok, err) {
|
onFileWriteResult(reqId, ok, err) {
|
||||||
const r = fileWriteResolvers[reqId];
|
const r = fileWriteResolvers[reqId];
|
||||||
@@ -10432,14 +10471,19 @@ async function onToggleAutoBackup(ev) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAutoBackupNow() {
|
async function runAutoBackupNow(silent) {
|
||||||
const cfg = await loadAutoBackupConfig();
|
const cfg = await loadAutoBackupConfig();
|
||||||
if (!cfg) return toast('Bridge not available', 'error');
|
if (!cfg) return silent || toast('Bridge not available', 'error');
|
||||||
if (!cfg.dir) return toast('Choose a backup folder first', 'warning');
|
if (!cfg.dir) return silent || toast('Choose a backup folder first', 'warning');
|
||||||
if (!cfg.hasPwd) return toast('Backup password not set', 'warning');
|
if (!cfg.hasPwd) return silent || toast('Backup password not set', 'warning');
|
||||||
if (!state.cryptoKey) return toast('Vault is locked', 'warning');
|
if (!state.cryptoKey) return silent || toast('Vault is locked', 'warning');
|
||||||
|
|
||||||
toast('Encrypting backup…');
|
// Manual "Backup now" shows a spinner (big vaults take ~30s). The
|
||||||
|
// scheduled on-unlock run stays silent (no overlay stealing focus).
|
||||||
|
if (!silent) {
|
||||||
|
showBusy('Reading vault…');
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -10454,7 +10498,12 @@ async function runAutoBackupNow() {
|
|||||||
})),
|
})),
|
||||||
entries: [],
|
entries: [],
|
||||||
};
|
};
|
||||||
|
let _bkDone = 0;
|
||||||
|
const _bkTotal = state.entries.length;
|
||||||
for (const e of state.entries) {
|
for (const e of state.entries) {
|
||||||
|
_bkDone++;
|
||||||
|
if (!silent && _bkTotal > 10 && (_bkDone % 5 === 0 || _bkDone === _bkTotal))
|
||||||
|
updateBusy('Reading vault… ' + _bkDone + '/' + _bkTotal);
|
||||||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
||||||
let plainTotp = '';
|
let plainTotp = '';
|
||||||
if (e.totp_secret && e.totp_iv) {
|
if (e.totp_secret && e.totp_iv) {
|
||||||
@@ -10497,6 +10546,7 @@ async function runAutoBackupNow() {
|
|||||||
created_at: e.created_at, updated_at: e.updated_at,
|
created_at: e.created_at, updated_at: e.updated_at,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!silent) updateBusy('Encrypting backup…');
|
||||||
const container = await encryptExportPayload(payload, cfg.pwd);
|
const container = await encryptExportPayload(payload, cfg.pwd);
|
||||||
const json = JSON.stringify(container, null, 2);
|
const json = JSON.stringify(container, null, 2);
|
||||||
// Filename: yyyymmdd-HHMMSS for filesystem-sort-friendliness.
|
// Filename: yyyymmdd-HHMMSS for filesystem-sort-friendliness.
|
||||||
@@ -10504,18 +10554,23 @@ async function runAutoBackupNow() {
|
|||||||
.replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
|
.replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
|
||||||
const fname = AUTO_BACKUP_PREFIX + ts + '.json';
|
const fname = AUTO_BACKUP_PREFIX + ts + '.json';
|
||||||
const path = cfg.dir.replace(/[\\/]+$/, '') + '\\' + fname;
|
const path = cfg.dir.replace(/[\\/]+$/, '') + '\\' + fname;
|
||||||
const res = await Bridge.writeFile(path, json);
|
if (!silent) updateBusy('Writing file…');
|
||||||
|
const res = await Bridge.writeFile(path, json, pct => {
|
||||||
|
if (!silent) updateBusy('Writing file… ' + pct + '%');
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast('Backup failed: ' + (res.error || 'unknown'), 'error');
|
if (!silent) toast('Backup failed: ' + (res.error || 'unknown'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
Bridge.setPref(ABK.last, now);
|
Bridge.setPref(ABK.last, now);
|
||||||
$('#autoBackupLast').textContent = 'Last run: ' + now.replace('T', ' ').slice(0, 16);
|
$('#autoBackupLast').textContent = 'Last run: ' + now.replace('T', ' ').slice(0, 16);
|
||||||
toast(payload.entries.length + ' entries backed up');
|
if (!silent) toast(payload.entries.length + ' entries backed up');
|
||||||
applyAutoBackupRetention(cfg.dir, cfg.keep);
|
applyAutoBackupRetention(cfg.dir, cfg.keep);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast('Backup failed: ' + (err && err.message ? err.message : err), 'error');
|
if (!silent) toast('Backup failed: ' + (err && err.message ? err.message : err), 'error');
|
||||||
|
} finally {
|
||||||
|
if (!silent) hideBusy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10543,7 +10598,7 @@ async function runAutoBackupIfDue() {
|
|||||||
const intervalMs = cfg.interval * 24 * 3600 * 1000;
|
const intervalMs = cfg.interval * 24 * 3600 * 1000;
|
||||||
const last = cfg.last ? Date.parse(cfg.last) : 0;
|
const last = cfg.last ? Date.parse(cfg.last) : 0;
|
||||||
if (last && (Date.now() - last) < intervalMs) return;
|
if (last && (Date.now() - last) < intervalMs) return;
|
||||||
await runAutoBackupNow();
|
await runAutoBackupNow(true); // silent — no spinner on the scheduled run
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -11430,7 +11485,7 @@ async function init() {
|
|||||||
e.target.value = n;
|
e.target.value = n;
|
||||||
Bridge.setPref(ABK.keep, String(n));
|
Bridge.setPref(ABK.keep, String(n));
|
||||||
});
|
});
|
||||||
$('#autoBackupNowBtn').addEventListener('click', runAutoBackupNow);
|
$('#autoBackupNowBtn').addEventListener('click', () => runAutoBackupNow());
|
||||||
|
|
||||||
$('#settingFavicons').addEventListener('change', e => {
|
$('#settingFavicons').addEventListener('change', e => {
|
||||||
state.faviconsEnabled = e.target.checked;
|
state.faviconsEnabled = e.target.checked;
|
||||||
|
|||||||
Reference in New Issue
Block a user