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:
+34
-7
@@ -741,11 +741,11 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
|
||||
.user-menu { position: relative; }
|
||||
.user-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 5px 12px 5px 6px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
border-radius: 20px;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
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. */
|
||||
.user-avatar {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 22px; height: 22px;
|
||||
margin-left: -4px;
|
||||
width: 30px; height: 30px;
|
||||
margin-left: 0;
|
||||
border-radius: 50%;
|
||||
font-size: 11px; font-weight: 600;
|
||||
font-size: 13px; font-weight: 600;
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
background-size: cover; background-position: center;
|
||||
@@ -2178,7 +2178,7 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
|
||||
display: none;
|
||||
}
|
||||
.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; }
|
||||
/* Hidden section + per-row + no-results banner driven by JS. */
|
||||
.slideover-field.is-search-hidden,
|
||||
@@ -2191,6 +2191,33 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
|
||||
display: none;
|
||||
}
|
||||
.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-value {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
|
||||
+105
-47
@@ -74,6 +74,9 @@ type
|
||||
private
|
||||
FServer: TPMHTTPServer;
|
||||
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;
|
||||
FNavTimer: TTimer;
|
||||
// Set once WebView2 fires OnInitialized. The nav timer only consumes
|
||||
@@ -108,6 +111,10 @@ type
|
||||
// space between PanelTop (top) and PanelLog/Splitter (bottom).
|
||||
WebBrowser: TWebBrowserClass;
|
||||
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 UpdateButtons;
|
||||
procedure NavigateToVault;
|
||||
@@ -174,6 +181,7 @@ begin
|
||||
FServer.OnLog := LogLine;
|
||||
|
||||
FBridge := TPMBridge.Create(Self);
|
||||
FFileSaveChunks := TDictionary<string, TStringBuilder>.Create;
|
||||
FBridge.OnSystemLock := BridgeSystemLock;
|
||||
FBridge.OnTrayRestore := BridgeTrayRestore;
|
||||
FBridge.OnLockRequest := BridgeLockRequest;
|
||||
@@ -243,6 +251,11 @@ end;
|
||||
|
||||
procedure TMainForm.FormDestroy(Sender: TObject);
|
||||
begin
|
||||
if Assigned(FFileSaveChunks) then
|
||||
begin
|
||||
for var LSB in FFileSaveChunks.Values do LSB.Free;
|
||||
FFileSaveChunks.Free;
|
||||
end;
|
||||
FBridge.Free;
|
||||
FServer.Free;
|
||||
end;
|
||||
@@ -440,6 +453,61 @@ begin
|
||||
Result := Copy(Result, 1, TokenPos + 4) + '***';
|
||||
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;
|
||||
begin
|
||||
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
|
||||
// thread — payloads are small (a vault JSON export is well under 1 MB).
|
||||
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
|
||||
var LName := GetParam('name');
|
||||
var LData := GetParam('data');
|
||||
var LReqId := GetParam('reqId');
|
||||
var LOk := False;
|
||||
var LPath := '';
|
||||
var LErr := '';
|
||||
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
|
||||
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;
|
||||
var LData := GetParam('data');
|
||||
var LSB: TStringBuilder;
|
||||
if not FFileSaveChunks.TryGetValue(LReqId, LSB) then
|
||||
begin
|
||||
LSB := TStringBuilder.Create;
|
||||
FFileSaveChunks.Add(LReqId, LSB);
|
||||
end;
|
||||
LSB.Append(LData);
|
||||
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(
|
||||
'if(window.Bridge&&Bridge.onFileSaveResult)' +
|
||||
'Bridge.onFileSaveResult("' + LEscReq + '",' +
|
||||
BoolToStr(LOk, True).ToLower + ',"' + LEscPath + '","' + LEscErr + '")');
|
||||
'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
|
||||
else
|
||||
begin
|
||||
var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onFileSaveResult)' +
|
||||
'Bridge.onFileSaveResult("' + LEscReq + '",false,"","no chunks buffered")');
|
||||
end;
|
||||
end
|
||||
|
||||
// ---- Auto-backup: folder picker (modal Win32 dialog) -----------------
|
||||
|
||||
Binary file not shown.
+14
-1
@@ -1130,8 +1130,13 @@
|
||||
<p id="confirmMessage" style="margin:0 0 12px;color:var(--text-dim);font-size:13px;line-height:1.5">
|
||||
Are you sure?
|
||||
</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">
|
||||
<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>
|
||||
</form>
|
||||
<footer class="modal-footer">
|
||||
@@ -1143,6 +1148,14 @@
|
||||
</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 -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
@@ -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