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-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;
|
||||||
|
|||||||
+105
-47
@@ -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
|
begin
|
||||||
var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
|
LSB := TStringBuilder.Create;
|
||||||
var LDlg := TSaveDialog.Create(nil);
|
FFileSaveChunks.Add(LReqId, LSB);
|
||||||
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;
|
|
||||||
end;
|
end;
|
||||||
|
LSB.Append(LData);
|
||||||
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.onFileChunkAck)' +
|
||||||
'Bridge.onFileSaveResult("' + LEscReq + '",' +
|
'Bridge.onFileChunkAck("' + LEscReq + '")');
|
||||||
BoolToStr(LOk, True).ToLower + ',"' + LEscPath + '","' + LEscErr + '")');
|
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
|
end
|
||||||
|
|
||||||
// ---- Auto-backup: folder picker (modal Win32 dialog) -----------------
|
// ---- 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">
|
<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 -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
|
|||||||
@@ -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,25 +208,75 @@ 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);
|
||||||
return new Promise(resolve => {
|
// cmd:// goes through window.location.href, which WebView2 caps at
|
||||||
fileSaveResolvers[reqId] = resolve;
|
// roughly a couple MB of URL. A big attachment base64'd blows past
|
||||||
cmd('cmd://file/save?name=' + encodeURIComponent(name) +
|
// that → the navigation blanks the document (black screen). So for
|
||||||
'&data=' + encodeURIComponent(b64) +
|
// anything large we stream the data in chunks small enough to fit
|
||||||
'&reqId=' + encodeURIComponent(reqId));
|
// a URL, each acknowledged before the next is sent (sequential —
|
||||||
setTimeout(() => {
|
// otherwise repeated location.href assignments coalesce and only
|
||||||
if (fileSaveResolvers[reqId]) {
|
// the last lands). Small payloads keep the fast single-shot path.
|
||||||
delete fileSaveResolvers[reqId];
|
// ~1 MB base64 per chunk — comfortably under WebView2's ~2 MB
|
||||||
resolve({ ok: false, error: 'timeout' });
|
// URL cap even after percent-encoding, while keeping the number
|
||||||
}
|
// of round-trips (and total time) low on big exports.
|
||||||
}, 120000);
|
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) {
|
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
|
||||||
const container = await encryptExportPayload(payload, exportPwd);
|
// attachments) take a few seconds — show a spinner so the app
|
||||||
const json = JSON.stringify(container, null, 2);
|
// doesn't look frozen while the Save dialog is being prepared.
|
||||||
const fname = 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json';
|
showBusy('Encrypting export…');
|
||||||
const res = await Bridge.saveFile(fname, json);
|
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) {
|
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user