feat: chunked file save + export progress spinner + password-reveal + avatar size

Large file save (fixes black screen on big attachment download / export)
- Bridge.saveFile streams anything over ~1MB base64 in chunks through the
  cmd:// channel instead of stuffing the whole payload in one URL — a
  multi-MB base64 URL blew past WebView2's ~2MB navigation cap and blanked
  the document (black screen). Small payloads keep the single-shot path.
- Chunks are sent sequentially (each acked via Bridge.onFileChunkAck
  before the next) so repeated location.href assignments don't coalesce.
- Delphi accumulates chunks per reqId in a TStringBuilder
  (FFileSaveChunks), commits on file/save-commit, and shares the
  decode+dialog+write logic with the single-shot path via SaveDecodedFile.
- Chunk size 1MB → far fewer round-trips (a 20MB export dropped from ~67
  to ~27 hops).

Busy overlay + progress
- Global spinner overlay (showBusy/updateBusy/hideBusy). doExport shows it
  immediately on click — BEFORE the entry-decrypt + attachment-fetch loop
  that is the real cost — with a 0ms yield so it paints before the thread
  blocks (was appearing 3-5s late). Phases: "Reading vault… N/total" →
  "Encrypting export…" → "Preparing file… N%" (real chunk progress).
  Attachment download shows the same for files > 512KB.
- Spinner ring used an undefined --bg-elev-3 (invalid border → invisible);
  switched to --border. Fixed a second stale --bg-elev-3 use on the
  settings-search clear button hover.

Password reveal
- promptDialog gets an eye toggle in password mode, so every encrypted
  prompt (export, import, backup password, recovery code, sync password)
  can show/hide the typed value.

Avatar
- Top-right chip avatar enlarged 22px → 30px with the chip padding
  rebalanced.

Rebuild: BuildAssets + F9 (UMainForm.pas changed for the chunk handlers).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-03 16:56:47 +01:00
parent 3f8ecde571
commit c5fe26ce94
5 changed files with 279 additions and 76 deletions
+126 -21
View File
@@ -15,6 +15,7 @@ const API = (location.pathname.indexOf('/password-manager/') === 0)
// Falls back to navigator.clipboard for the standalone PHP frontend.
const prefResolvers = {};
const fileSaveResolvers = {};
const fileChunkResolvers = {};
let versionResolver = null;
let launchModeResolver = null;
const folderPickResolvers = {};
@@ -207,25 +208,75 @@ const Bridge = (() => {
// (which Edge wraps with "Téléchargements" popup + Open File prompt).
// The caller passes raw bytes as Uint8Array OR a string; we base64
// it and ship to Delphi which writes the file after the user picks.
saveFile(name, content) {
saveFile(name, 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 = 'fs_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
return new Promise(resolve => {
fileSaveResolvers[reqId] = resolve;
cmd('cmd://file/save?name=' + encodeURIComponent(name) +
'&data=' + encodeURIComponent(b64) +
'&reqId=' + encodeURIComponent(reqId));
setTimeout(() => {
if (fileSaveResolvers[reqId]) {
delete fileSaveResolvers[reqId];
resolve({ ok: false, error: 'timeout' });
}
}, 120000);
});
// cmd:// goes through window.location.href, which WebView2 caps at
// roughly a couple MB of URL. A big attachment base64'd blows past
// that → the navigation blanks the document (black screen). So for
// anything large we stream the data in chunks small enough to fit
// a URL, each acknowledged before the next is sent (sequential —
// otherwise repeated location.href assignments coalesce and only
// the last lands). Small payloads keep the fast single-shot path.
// ~1 MB base64 per chunk — comfortably under WebView2's ~2 MB
// URL cap even after percent-encoding, while keeping the number
// of round-trips (and total time) low on big exports.
const CHUNK = 1000000;
if (b64.length <= CHUNK) {
return new Promise(resolve => {
fileSaveResolvers[reqId] = resolve;
cmd('cmd://file/save?name=' + encodeURIComponent(name) +
'&data=' + encodeURIComponent(b64) +
'&reqId=' + encodeURIComponent(reqId));
setTimeout(() => {
if (fileSaveResolvers[reqId]) {
delete fileSaveResolvers[reqId];
resolve({ ok: false, error: 'timeout' });
}
}, 120000);
});
}
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 => {
fileSaveResolvers[reqId] = resolve;
cmd('cmd://file/save-commit?reqId=' + encodeURIComponent(reqId) +
'&name=' + encodeURIComponent(name));
setTimeout(() => {
if (fileSaveResolvers[reqId]) {
delete fileSaveResolvers[reqId];
resolve({ ok: false, error: 'timeout' });
}
}, 120000);
});
})();
},
onFileChunkAck(reqId) {
const r = fileChunkResolvers[reqId];
if (r) { delete fileChunkResolvers[reqId]; r(true); }
},
onFileSaveResult(reqId, ok, path, err) {
const r = fileSaveResolvers[reqId];
@@ -6231,14 +6282,22 @@ async function uploadAttachment(entryId, file) {
}
async function downloadAttachment(att) {
// Spinner for large attachments — fetch + decrypt + chunked transfer
// of a multi-MB file takes a moment before the Save dialog appears.
const big = (att.size_bytes || 0) > 512 * 1024;
try {
if (big) showBusy('Preparing download…');
const full = await api('/attachments/' + att.id, { headers: authHeaders() });
const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv);
if (Bridge.active && typeof Bridge.saveFile === 'function') {
const res = await Bridge.saveFile(att.filename, bytes);
const res = await Bridge.saveFile(att.filename, bytes, pct => {
if (big) updateBusy('Preparing download… ' + pct + '%');
});
if (big) hideBusy();
if (res.ok) toast('Saved to ' + res.path);
else if (res.error) toast('Save failed: ' + res.error, 'error');
} else {
if (big) hideBusy();
// Web fallback: trigger a browser download.
const blob = new Blob([bytes], { type: att.mime || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
@@ -6248,6 +6307,7 @@ async function downloadAttachment(att) {
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
}
} catch (e) {
if (big) hideBusy();
toast('Download failed: ' + (e && e.message ? e.message : e), 'error');
}
}
@@ -6800,12 +6860,36 @@ function promptDialog(opts) {
$('#confirmInput').value = opts.value || '';
$('#confirmInput').placeholder = opts.placeholder || '';
// Allow password-style masking (used by encrypted import/export).
$('#confirmInput').type = opts.password ? 'password' : 'text';
const inp = $('#confirmInput');
const eye = $('#confirmInputEye');
inp.type = opts.password ? 'password' : 'text';
if (eye) {
// Eye toggle only for password prompts. Reset to masked + leave
// room on the right so text doesn't run under the button.
eye.style.display = opts.password ? '' : 'none';
inp.style.paddingRight = opts.password ? '34px' : '';
eye.onclick = () => {
inp.type = inp.type === 'password' ? 'text' : 'password';
inp.focus();
};
}
$('#confirmModal').classList.remove('is-hidden');
setTimeout(() => $('#confirmInput').focus(), 50);
return new Promise(res => { confirmResolver = res; });
}
// Global busy overlay for long operations (export, big file saves).
function showBusy(text) {
const t = $('#busyText'); if (t) t.textContent = text || 'Working…';
const o = $('#busyOverlay'); if (o) o.classList.remove('is-hidden');
}
function updateBusy(text) {
const t = $('#busyText'); if (t) t.textContent = text || '';
}
function hideBusy() {
const o = $('#busyOverlay'); if (o) o.classList.add('is-hidden');
}
function closeConfirm(value) {
$('#confirmModal').classList.add('is-hidden');
if (confirmResolver) {
@@ -8871,7 +8955,12 @@ async function doExport() {
}
}
toast('Encrypting backup… (PBKDF2 600k iterations)');
// Show the spinner BEFORE the heavy work — the entry-decrypt +
// attachment-fetch loop below is the real cost on big vaults, not
// just the final encrypt/save. A 0ms yield lets the overlay paint
// before we block the thread.
showBusy('Reading vault…');
await new Promise(r => setTimeout(r, 0));
try {
// Step 3: assemble the plaintext payload (same shape as the legacy
// plaintext exporter — round-trips with the existing JSON importer
@@ -8896,7 +8985,12 @@ async function doExport() {
})),
entries: [],
};
let _expDone = 0;
const _expTotal = state.entries.length;
for (const e of state.entries) {
_expDone++;
if (_expTotal > 10 && (_expDone % 5 === 0 || _expDone === _expTotal))
updateBusy('Reading vault… ' + _expDone + '/' + _expTotal);
const plain = await decryptPwd(e.encrypted_password, e.iv);
let plainTotp = '';
if (e.totp_secret && e.totp_iv) {
@@ -8953,11 +9047,21 @@ async function doExport() {
const attachTotal = payload.entries.reduce(
(n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0);
// Step 4: encrypt + save via native dialog
const container = await encryptExportPayload(payload, exportPwd);
const json = JSON.stringify(container, null, 2);
const fname = 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json';
const res = await Bridge.saveFile(fname, json);
// Step 4: encrypt + save via native dialog. Big vaults (many
// attachments) take a few seconds — show a spinner so the app
// doesn't look frozen while the Save dialog is being prepared.
showBusy('Encrypting export');
let res;
try {
const container = await encryptExportPayload(payload, exportPwd);
const json = JSON.stringify(container, null, 2);
const fname = 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json';
updateBusy('Preparing file…');
res = await Bridge.saveFile(fname, json, pct =>
updateBusy('Preparing file… ' + pct + '%'));
} finally {
hideBusy();
}
if (res.ok) {
const tail = attachTotal > 0
? ' + ' + attachTotal + ' attachment(s)' : '';
@@ -8966,6 +9070,7 @@ async function doExport() {
}
else if (res.error) toast('Export failed: ' + res.error, 'error');
} catch (err) {
hideBusy();
toast('Export failed: ' + (err && err.message ? err.message : err), 'error');
}
}