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:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user