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
+34 -7
View File
@@ -741,11 +741,11 @@ input[type="range"]::-webkit-slider-thumb {
.user-menu { position: relative; } .user-menu { position: relative; }
.user-chip { .user-chip {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 8px;
padding: 6px 12px; padding: 5px 12px 5px 6px;
background: var(--bg-elev); background: var(--bg-elev);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 16px; border-radius: 20px;
color: var(--text); color: var(--text);
font-size: 12px; font-size: 12px;
transition: all var(--t-fast); transition: all var(--t-fast);
@@ -756,10 +756,10 @@ input[type="range"]::-webkit-slider-thumb {
Falls back to a background-image when a custom picture is set. */ Falls back to a background-image when a custom picture is set. */
.user-avatar { .user-avatar {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; width: 30px; height: 30px;
margin-left: -4px; margin-left: 0;
border-radius: 50%; border-radius: 50%;
font-size: 11px; font-weight: 600; font-size: 13px; font-weight: 600;
color: #fff; color: #fff;
text-transform: uppercase; text-transform: uppercase;
background-size: cover; background-position: center; background-size: cover; background-position: center;
@@ -2178,7 +2178,7 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
display: none; display: none;
} }
.settings-search-clear svg { width: 12px; height: 12px; } .settings-search-clear svg { width: 12px; height: 12px; }
.settings-search-clear:hover { color: var(--text); background: var(--bg-elev-3); } .settings-search-clear:hover { color: var(--text); background: var(--bg-elev-2); }
.settings-search-wrap.has-query .settings-search-clear { display: inline-flex; } .settings-search-wrap.has-query .settings-search-clear { display: inline-flex; }
/* Hidden section + per-row + no-results banner driven by JS. */ /* Hidden section + per-row + no-results banner driven by JS. */
.slideover-field.is-search-hidden, .slideover-field.is-search-hidden,
@@ -2191,6 +2191,33 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
display: none; display: none;
} }
.settings-no-results.is-visible { display: block; } .settings-no-results.is-visible { display: block; }
/* Global busy overlay — spinner + text for long ops (export, big saves). */
.busy-overlay {
position: fixed; inset: 0;
z-index: 200;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
}
.busy-overlay.is-hidden { display: none; }
.busy-box {
display: flex; flex-direction: column; align-items: center; gap: 14px;
padding: 24px 32px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.busy-spinner {
width: 34px; height: 34px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: busy-spin 0.7s linear infinite;
}
@keyframes busy-spin { to { transform: rotate(360deg); } }
.busy-text { font-size: 13px; color: var(--text-dim); text-align: center; }
.slideover-field-label { font-size: 11px; font-weight: 500; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; } .slideover-field-label { font-size: 11px; font-weight: 500; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.slideover-field-value { .slideover-field-value {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
+100 -42
View File
@@ -74,6 +74,9 @@ type
private private
FServer: TPMHTTPServer; FServer: TPMHTTPServer;
FBridge: TPMBridge; FBridge: TPMBridge;
// Accumulates base64 chunks for large file saves (attachments too big
// to fit a single cmd:// URL). Keyed by reqId, flushed on save-commit.
FFileSaveChunks: TDictionary<string, TStringBuilder>;
FPendingURL: string; FPendingURL: string;
FNavTimer: TTimer; FNavTimer: TTimer;
// Set once WebView2 fires OnInitialized. The nav timer only consumes // Set once WebView2 fires OnInitialized. The nav timer only consumes
@@ -108,6 +111,10 @@ type
// space between PanelTop (top) and PanelLog/Splitter (bottom). // space between PanelTop (top) and PanelLog/Splitter (bottom).
WebBrowser: TWebBrowserClass; WebBrowser: TWebBrowserClass;
procedure AutofillTimerTick(Sender: TObject); procedure AutofillTimerTick(Sender: TObject);
// Shared by cmd://file/save (single-shot) and file/save-commit
// (chunked): decode the base64, show the Save dialog, write, and fire
// Bridge.onFileSaveResult back to JS.
procedure SaveDecodedFile(const AName, AB64, AReqId: string);
procedure LogLine(const AMsg: string); procedure LogLine(const AMsg: string);
procedure UpdateButtons; procedure UpdateButtons;
procedure NavigateToVault; procedure NavigateToVault;
@@ -174,6 +181,7 @@ begin
FServer.OnLog := LogLine; FServer.OnLog := LogLine;
FBridge := TPMBridge.Create(Self); FBridge := TPMBridge.Create(Self);
FFileSaveChunks := TDictionary<string, TStringBuilder>.Create;
FBridge.OnSystemLock := BridgeSystemLock; FBridge.OnSystemLock := BridgeSystemLock;
FBridge.OnTrayRestore := BridgeTrayRestore; FBridge.OnTrayRestore := BridgeTrayRestore;
FBridge.OnLockRequest := BridgeLockRequest; FBridge.OnLockRequest := BridgeLockRequest;
@@ -243,6 +251,11 @@ end;
procedure TMainForm.FormDestroy(Sender: TObject); procedure TMainForm.FormDestroy(Sender: TObject);
begin begin
if Assigned(FFileSaveChunks) then
begin
for var LSB in FFileSaveChunks.Values do LSB.Free;
FFileSaveChunks.Free;
end;
FBridge.Free; FBridge.Free;
FServer.Free; FServer.Free;
end; end;
@@ -440,6 +453,61 @@ begin
Result := Copy(Result, 1, TokenPos + 4) + '***'; Result := Copy(Result, 1, TokenPos + 4) + '***';
end; end;
procedure TMainForm.SaveDecodedFile(const AName, AB64, AReqId: string);
var
LOk: Boolean;
LPath, LErr: string;
begin
LOk := False;
LPath := '';
LErr := '';
try
var LBytes := TNetEncoding.Base64.DecodeStringToBytes(AB64);
var LDlg := TSaveDialog.Create(nil);
try
LDlg.FileName := AName;
var LExt := ExtractFileExt(AName);
if LExt = '.json' then LDlg.Filter := 'JSON file (*.json)|*.json|All files (*.*)|*.*'
else if LExt = '.csv' then LDlg.Filter := 'CSV file (*.csv)|*.csv|All files (*.*)|*.*'
else LDlg.Filter := 'All files (*.*)|*.*';
LDlg.DefaultExt := LExt.TrimLeft(['.']);
LDlg.Options := LDlg.Options + [TOpenOption.ofOverwritePrompt];
if LDlg.Execute then
begin
LPath := LDlg.FileName;
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 saved: %s (%d bytes)', [LPath, Length(LBytes)]));
end
else
LogLine('File save cancelled by user');
finally
LDlg.Free;
end;
except
on E: Exception do
begin
LErr := E.Message;
LogLine('File save FAILED: ' + LErr);
end;
end;
var LEscReq := StringReplace(AReqId, '"', '\"', [rfReplaceAll]);
var LEscPath := StringReplace(LPath, '\', '\\', [rfReplaceAll]);
LEscPath := StringReplace(LEscPath, '"', '\"', [rfReplaceAll]);
var LEscErr := StringReplace(LErr, '\', '\\', [rfReplaceAll]);
LEscErr := StringReplace(LEscErr, '"', '\"', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onFileSaveResult)' +
'Bridge.onFileSaveResult("' + LEscReq + '",' +
BoolToStr(LOk, True).ToLower + ',"' + LEscPath + '","' + LEscErr + '")');
end;
procedure TMainForm.NavigateToVault; procedure TMainForm.NavigateToVault;
begin begin
FPendingURL := 'http://127.0.0.1:' + FServer.BoundPort.ToString + '/index.html'; FPendingURL := 'http://127.0.0.1:' + FServer.BoundPort.ToString + '/index.html';
@@ -1107,58 +1175,48 @@ begin
// Bridge.onFileSaveResult(reqId, ok, path). All synchronous on the UI // Bridge.onFileSaveResult(reqId, ok, path). All synchronous on the UI
// thread — payloads are small (a vault JSON export is well under 1 MB). // thread — payloads are small (a vault JSON export is well under 1 MB).
else if ACmd = 'file/save' then else if ACmd = 'file/save' then
// Single-shot: small payload fits in one cmd:// URL.
SaveDecodedFile(GetParam('name'), GetParam('data'), GetParam('reqId'))
// Chunked large-file transfer: accumulate base64 pieces keyed by reqId,
// ack each so JS can send the next (see Bridge.saveFile chunk path).
else if ACmd = 'file/chunk' then
begin begin
var LName := GetParam('name');
var LData := GetParam('data');
var LReqId := GetParam('reqId'); var LReqId := GetParam('reqId');
var LOk := False; var LData := GetParam('data');
var LPath := ''; var LSB: TStringBuilder;
var LErr := ''; if not FFileSaveChunks.TryGetValue(LReqId, LSB) then
try
var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
var LDlg := TSaveDialog.Create(nil);
try
LDlg.FileName := LName;
var LExt := ExtractFileExt(LName);
if LExt = '.json' then LDlg.Filter := 'JSON file (*.json)|*.json|All files (*.*)|*.*'
else if LExt = '.csv' then LDlg.Filter := 'CSV file (*.csv)|*.csv|All files (*.*)|*.*'
else LDlg.Filter := 'All files (*.*)|*.*';
LDlg.DefaultExt := LExt.TrimLeft(['.']);
LDlg.Options := LDlg.Options + [TOpenOption.ofOverwritePrompt];
if LDlg.Execute then
begin begin
LPath := LDlg.FileName; LSB := TStringBuilder.Create;
var LStream := TFileStream.Create(LPath, fmCreate); FFileSaveChunks.Add(LReqId, LSB);
try
if Length(LBytes) > 0 then
LStream.WriteBuffer(LBytes[0], Length(LBytes));
finally
LStream.Free;
end; end;
LOk := True; LSB.Append(LData);
LogLine(Format('File saved: %s (%d bytes)', [LPath, Length(LBytes)])); var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onFileChunkAck)' +
'Bridge.onFileChunkAck("' + LEscReq + '")');
end
// Commit the accumulated chunks: reconstruct the full base64, run the
// shared save routine, then discard the buffer.
else if ACmd = 'file/save-commit' then
begin
var LReqId := GetParam('reqId');
var LSB: TStringBuilder;
if FFileSaveChunks.TryGetValue(LReqId, LSB) then
begin
var LFull := LSB.ToString;
LSB.Free;
FFileSaveChunks.Remove(LReqId);
SaveDecodedFile(GetParam('name'), LFull, LReqId);
end end
else else
LogLine('File save cancelled by user');
finally
LDlg.Free;
end;
except
on E: Exception do
begin begin
LErr := E.Message;
LogLine('File save FAILED: ' + LErr);
end;
end;
var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]); var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
var LEscPath := StringReplace(LPath, '\', '\\', [rfReplaceAll]);
LEscPath := StringReplace(LEscPath, '"', '\"', [rfReplaceAll]);
var LEscErr := StringReplace(LErr, '\', '\\', [rfReplaceAll]);
LEscErr := StringReplace(LEscErr, '"', '\"', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript( WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onFileSaveResult)' + 'if(window.Bridge&&Bridge.onFileSaveResult)' +
'Bridge.onFileSaveResult("' + LEscReq + '",' + 'Bridge.onFileSaveResult("' + LEscReq + '",false,"","no chunks buffered")');
BoolToStr(LOk, True).ToLower + ',"' + LEscPath + '","' + LEscErr + '")'); end;
end end
// ---- Auto-backup: folder picker (modal Win32 dialog) ----------------- // ---- Auto-backup: folder picker (modal Win32 dialog) -----------------
Binary file not shown.
+14 -1
View File
@@ -1130,8 +1130,13 @@
<p id="confirmMessage" style="margin:0 0 12px;color:var(--text-dim);font-size:13px;line-height:1.5"> <p id="confirmMessage" style="margin:0 0 12px;color:var(--text-dim);font-size:13px;line-height:1.5">
Are you sure? Are you sure?
</p> </p>
<label class="field is-hidden" id="confirmInputField"> <label class="field is-hidden" id="confirmInputField" style="position:relative">
<input id="confirmInput" type="text" autocomplete="off"> <input id="confirmInput" type="text" autocomplete="off">
<button type="button" id="confirmInputEye" class="icon-btn icon-btn-sm"
title="Show / hide"
style="display:none;position:absolute;right:4px;top:50%;transform:translateY(-50%)">
<svg><use href="#i-eye"/></svg>
</button>
</label> </label>
</form> </form>
<footer class="modal-footer"> <footer class="modal-footer">
@@ -1143,6 +1148,14 @@
</div> </div>
</div> </div>
<!-- Global busy overlay: spinner + text for long operations (export…). -->
<div id="busyOverlay" class="busy-overlay is-hidden">
<div class="busy-box">
<div class="busy-spinner"></div>
<div class="busy-text" id="busyText">Working…</div>
</div>
</div>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- AUTOFILL PICKER — shown when multiple entries match hotkey --> <!-- AUTOFILL PICKER — shown when multiple entries match hotkey -->
<!-- ============================================================ --> <!-- ============================================================ -->
+111 -6
View File
@@ -15,6 +15,7 @@ const API = (location.pathname.indexOf('/password-manager/') === 0)
// Falls back to navigator.clipboard for the standalone PHP frontend. // Falls back to navigator.clipboard for the standalone PHP frontend.
const prefResolvers = {}; const prefResolvers = {};
const fileSaveResolvers = {}; const fileSaveResolvers = {};
const fileChunkResolvers = {};
let versionResolver = null; let versionResolver = null;
let launchModeResolver = null; let launchModeResolver = null;
const folderPickResolvers = {}; const folderPickResolvers = {};
@@ -207,13 +208,25 @@ const Bridge = (() => {
// (which Edge wraps with "Téléchargements" popup + Open File prompt). // (which Edge wraps with "Téléchargements" popup + Open File prompt).
// The caller passes raw bytes as Uint8Array OR a string; we base64 // 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. // 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' }); if (!active) return Promise.resolve({ ok: false, error: 'bridge offline' });
let bytes; let bytes;
if (typeof content === 'string') bytes = new TextEncoder().encode(content); if (typeof content === 'string') bytes = new TextEncoder().encode(content);
else bytes = content; else bytes = content;
const b64 = bytesToBase64(bytes); const b64 = bytesToBase64(bytes);
const reqId = 'fs_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); const reqId = 'fs_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
// 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 => { return new Promise(resolve => {
fileSaveResolvers[reqId] = resolve; fileSaveResolvers[reqId] = resolve;
cmd('cmd://file/save?name=' + encodeURIComponent(name) + cmd('cmd://file/save?name=' + encodeURIComponent(name) +
@@ -226,6 +239,44 @@ const Bridge = (() => {
} }
}, 120000); }, 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) { onFileSaveResult(reqId, ok, path, err) {
const r = fileSaveResolvers[reqId]; const r = fileSaveResolvers[reqId];
@@ -6231,14 +6282,22 @@ async function uploadAttachment(entryId, file) {
} }
async function downloadAttachment(att) { 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 { try {
if (big) showBusy('Preparing download…');
const full = await api('/attachments/' + att.id, { headers: authHeaders() }); const full = await api('/attachments/' + att.id, { headers: authHeaders() });
const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv); const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv);
if (Bridge.active && typeof Bridge.saveFile === 'function') { 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); if (res.ok) toast('Saved to ' + res.path);
else if (res.error) toast('Save failed: ' + res.error, 'error'); else if (res.error) toast('Save failed: ' + res.error, 'error');
} else { } else {
if (big) hideBusy();
// Web fallback: trigger a browser download. // Web fallback: trigger a browser download.
const blob = new Blob([bytes], { type: att.mime || 'application/octet-stream' }); const blob = new Blob([bytes], { type: att.mime || 'application/octet-stream' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -6248,6 +6307,7 @@ async function downloadAttachment(att) {
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
} }
} catch (e) { } catch (e) {
if (big) hideBusy();
toast('Download failed: ' + (e && e.message ? e.message : e), 'error'); toast('Download failed: ' + (e && e.message ? e.message : e), 'error');
} }
} }
@@ -6800,12 +6860,36 @@ function promptDialog(opts) {
$('#confirmInput').value = opts.value || ''; $('#confirmInput').value = opts.value || '';
$('#confirmInput').placeholder = opts.placeholder || ''; $('#confirmInput').placeholder = opts.placeholder || '';
// Allow password-style masking (used by encrypted import/export). // 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'); $('#confirmModal').classList.remove('is-hidden');
setTimeout(() => $('#confirmInput').focus(), 50); setTimeout(() => $('#confirmInput').focus(), 50);
return new Promise(res => { confirmResolver = res; }); 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) { function closeConfirm(value) {
$('#confirmModal').classList.add('is-hidden'); $('#confirmModal').classList.add('is-hidden');
if (confirmResolver) { 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 { try {
// Step 3: assemble the plaintext payload (same shape as the legacy // Step 3: assemble the plaintext payload (same shape as the legacy
// plaintext exporter — round-trips with the existing JSON importer // plaintext exporter — round-trips with the existing JSON importer
@@ -8896,7 +8985,12 @@ async function doExport() {
})), })),
entries: [], entries: [],
}; };
let _expDone = 0;
const _expTotal = state.entries.length;
for (const e of state.entries) { 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); const plain = await decryptPwd(e.encrypted_password, e.iv);
let plainTotp = ''; let plainTotp = '';
if (e.totp_secret && e.totp_iv) { if (e.totp_secret && e.totp_iv) {
@@ -8953,11 +9047,21 @@ async function doExport() {
const attachTotal = payload.entries.reduce( const attachTotal = payload.entries.reduce(
(n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0); (n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0);
// Step 4: encrypt + save via native dialog // 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 container = await encryptExportPayload(payload, exportPwd);
const json = JSON.stringify(container, null, 2); const json = JSON.stringify(container, null, 2);
const fname = 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json'; const fname = 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json';
const res = await Bridge.saveFile(fname, json); updateBusy('Preparing file…');
res = await Bridge.saveFile(fname, json, pct =>
updateBusy('Preparing file… ' + pct + '%'));
} finally {
hideBusy();
}
if (res.ok) { if (res.ok) {
const tail = attachTotal > 0 const tail = attachTotal > 0
? ' + ' + attachTotal + ' attachment(s)' : ''; ? ' + ' + attachTotal + ' attachment(s)' : '';
@@ -8966,6 +9070,7 @@ async function doExport() {
} }
else if (res.error) toast('Export failed: ' + res.error, 'error'); else if (res.error) toast('Export failed: ' + res.error, 'error');
} catch (err) { } catch (err) {
hideBusy();
toast('Export failed: ' + (err && err.message ? err.message : err), 'error'); toast('Export failed: ' + (err && err.message ? err.message : err), 'error');
} }
} }