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 ede4dc4..3735e69 100644 Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ 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;