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:
r-zakarya
2026-07-03 17:38:10 +01:00
parent c5fe26ce94
commit 22bec64348
3 changed files with 137 additions and 53 deletions
+80 -25
View File
@@ -306,25 +306,64 @@ const Bridge = (() => {
if (r) { delete folderPickResolvers[reqId]; r(path || ''); }
},
writeFile(path, content) {
writeFile(path, content, onProgress) {
if (!active) return Promise.resolve({ ok: false, error: 'bridge offline' });
let bytes;
if (typeof content === 'string') bytes = new TextEncoder().encode(content);
else bytes = content;
const b64 = bytesToBase64(bytes);
const reqId = 'fw_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
return new Promise(resolve => {
fileWriteResolvers[reqId] = resolve;
cmd('cmd://file/write?path=' + encodeURIComponent(path) +
'&data=' + encodeURIComponent(b64) +
'&reqId=' + encodeURIComponent(reqId));
setTimeout(() => {
if (fileWriteResolvers[reqId]) {
delete fileWriteResolvers[reqId];
resolve({ ok: false, error: 'timeout' });
}
}, 30000);
});
// Same URL-length trap as saveFile: a big auto-backup base64'd
// into a single cmd:// URL blows past WebView2's cap and the
// write fails (silently, or blanks the page). Chunk it.
const CHUNK = 1000000;
if (b64.length <= CHUNK) {
return new Promise(resolve => {
fileWriteResolvers[reqId] = resolve;
cmd('cmd://file/write?path=' + encodeURIComponent(path) +
'&data=' + encodeURIComponent(b64) +
'&reqId=' + encodeURIComponent(reqId));
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) {
const r = fileWriteResolvers[reqId];
@@ -10432,14 +10471,19 @@ async function onToggleAutoBackup(ev) {
}
}
async function runAutoBackupNow() {
async function runAutoBackupNow(silent) {
const cfg = await loadAutoBackupConfig();
if (!cfg) return toast('Bridge not available', 'error');
if (!cfg.dir) return toast('Choose a backup folder first', 'warning');
if (!cfg.hasPwd) return toast('Backup password not set', 'warning');
if (!state.cryptoKey) return toast('Vault is locked', 'warning');
if (!cfg) return silent || toast('Bridge not available', 'error');
if (!cfg.dir) return silent || toast('Choose a backup folder first', 'warning');
if (!cfg.hasPwd) return silent || toast('Backup password not set', '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 {
const payload = {
version: 1,
@@ -10454,7 +10498,12 @@ async function runAutoBackupNow() {
})),
entries: [],
};
let _bkDone = 0;
const _bkTotal = state.entries.length;
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);
let plainTotp = '';
if (e.totp_secret && e.totp_iv) {
@@ -10497,6 +10546,7 @@ async function runAutoBackupNow() {
created_at: e.created_at, updated_at: e.updated_at,
});
}
if (!silent) updateBusy('Encrypting backup…');
const container = await encryptExportPayload(payload, cfg.pwd);
const json = JSON.stringify(container, null, 2);
// Filename: yyyymmdd-HHMMSS for filesystem-sort-friendliness.
@@ -10504,18 +10554,23 @@ async function runAutoBackupNow() {
.replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
const fname = AUTO_BACKUP_PREFIX + ts + '.json';
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) {
toast('Backup failed: ' + (res.error || 'unknown'), 'error');
if (!silent) toast('Backup failed: ' + (res.error || 'unknown'), 'error');
return;
}
const now = new Date().toISOString();
Bridge.setPref(ABK.last, now);
$('#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);
} 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 last = cfg.last ? Date.parse(cfg.last) : 0;
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;
Bridge.setPref(ABK.keep, String(n));
});
$('#autoBackupNowBtn').addEventListener('click', runAutoBackupNow);
$('#autoBackupNowBtn').addEventListener('click', () => runAutoBackupNow());
$('#settingFavicons').addEventListener('change', e => {
state.faviconsEnabled = e.target.checked;