From 22bec64348f7e39527d790766e59e0f88e307d80 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:38:10 +0100 Subject: [PATCH] fix: chunk auto-backup writes + spinner on manual "Backup now" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- delphi-backend/UMainForm.pas | 85 ++++++++++++++++--------- delphi-backend/assets/assets.res | Bin 664644 -> 667564 bytes js/app.js | 105 +++++++++++++++++++++++-------- 3 files changed, 137 insertions(+), 53 deletions(-) diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index d95b34e..6f4199b 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -115,6 +115,9 @@ type // (chunked): decode the base64, show the Save dialog, write, and fire // Bridge.onFileSaveResult back to JS. 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 UpdateButtons; procedure NavigateToVault; @@ -508,6 +511,40 @@ begin BoolToStr(LOk, True).ToLower + ',"' + LEscPath + '","' + LEscErr + '")'); 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; begin FPendingURL := 'http://127.0.0.1:' + FServer.BoundPort.ToString + '/index.html'; @@ -1240,37 +1277,29 @@ begin // cmd://file/write?path=&data=&reqId= // Callback: Bridge.onFileWriteResult(reqId, ok, error) 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 - var LPath := GetParam('path'); - var LData := GetParam('data'); var LReqId := GetParam('reqId'); - var LOk := False; - var LErr := ''; - try - var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData); - var LStream := TFileStream.Create(LPath, 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)', [LPath, Length(LBytes)])); - except - on E: Exception do - begin - LErr := E.Message; - LogLine('File write FAILED for "' + LPath + '": ' + LErr); - end; + var LSB: TStringBuilder; + if FFileSaveChunks.TryGetValue(LReqId, LSB) then + begin + var LFull := LSB.ToString; + LSB.Free; + FFileSaveChunks.Remove(LReqId); + WriteDecodedFile(GetParam('path'), LFull, LReqId); + end + else + begin + var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onFileWriteResult)' + + 'Bridge.onFileWriteResult("' + LEscReq + '",false,"no chunks buffered")'); 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 // ---- Auto-backup: list files in dir matching name prefix -------------- diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index ede4dc47cf0f3e5a3b98a059f13ce93702466cb1..3735e69e54b0c68253a3c4f5101deb3ff72441c8 100644 GIT binary patch delta 1390 zcmbu9T}&KR6vuOSm${2T37~9k1y9kkGubY?R7#a)ZJ{4P(o%L^#fK(1yL(|LJIt&* zGpxj>tTCn!8rw#b^JGGNwl7AV_+%3`AwD!E`q20Q)kKrhRzTWlqEzqBvame(zdhc8NV;|9v+I9WpB*Bp}au%ci)OhD!j-LstOq((@ zVCLl<$Oahl3_Go;tPf;JDpMd^rXEbnsjQWQq-?O>9)J2VsCq&nP&8BLazoLkR0gT( zbRQ4$+>liDf&n?%Fkze}Ur|`$u-|}Gsgjlk6F07TnvWFnipgM7R#XE-gO}D!b@mYG zdA?lMvIdwL#&b+DsS8eIEG-L)*@ZDbX~Ap9NfEyrBi$d|Bp!TphSVdCkwM2BiIEqa zm(>BHjak2E6_}sdrRYrimBN$4Rf_t*w(i7=FU-Me{V-kMM)g zj-v@e?aT@Cj%RntCf;($*+ejV8iFMb}I%KE?{_x4pzCZ zFVS;V?pM3rE0e^Bo-gR*b9}C6WzCXR=olzC?yO?C>;3D#Oy2%F>`~K}%EhR2K4<28Jomata*`K-6^5XINg9XZZ&v zKdW-YU{X(6Je!kAnFR5`_Ewz`e!n7g;=>i;F#hp|XP>Pv(_cu3%1oF@W>4xGgD8X} z+YG04jpNBb;muVckt|CBfgM4K^{O(^*;!I2bd#ULi_je|1;k3~gKz|*QC@%_@=iS- zs4S5r?AjFC@S`=M)wk780Q@081hzHUEz?#!9u@|$*w4Xk{9UL)?P*V=9a$H82;O*~ z_Boh(FVh~Jk8?@?Wm<>c4IyOT*bv&>c>X%sW3T;6M_sC+wCd@Jq$jvbS0e z871v(N|Yjab(KD858tQv-MwbsVp4?9uLN8fsh^7<0;j4%s;)WL%MOj4L40e-HDq60 or}{kRzIF@EJT}0u!7}lqdO?)z&8yyr$1Xis^x6*=YscdM0=BH*JOBUy delta 454 zcmW;HPe_w-9LI6L&%f_uPx+X0K6sc&hula~VcKevZYUAi8iSzLaMp?%!83WXG=u{XK{U8!NM0k=s2!aeHYIG?JU6xAp;Nf-n@HxF-touJK`ipO9#*&V` z6SEfOPN9^hpt#Ua3gL2`E@CW0-FP)b3O-(;9{d}j!x&0X6ps>g8k-Rcp_rvkEL^1^ zeh!m?bJwT^W}1#*AwP zRSuwKo_md(Stro$+{zam8xvK&gfCT&;m17pc!%wb^Kd+$wQslIxSqLN82?;+-htAt zkv4x-y9E9eIs(D$@1v9zc8WTY0U;q5PuNus>PuL_`8gwj(T2f>~n6$;}q;jA)kvLM8$Fs b0^vh2BW#J%GktA9s;eufD!NEj>f?$36cDLf diff --git a/js/app.js b/js/app.js index 0685976..8a7948f 100644 --- a/js/app.js +++ b/js/app.js @@ -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;