From c5fe26ce94626e38b8d7fde802723162a99cde34 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:56:47 +0100 Subject: [PATCH] feat: chunked file save + export progress spinner + password-reveal + avatar size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- css/style.css | 41 +++++++-- delphi-backend/UMainForm.pas | 152 +++++++++++++++++++++---------- delphi-backend/assets/assets.res | Bin 657952 -> 664644 bytes index.html | 15 ++- js/app.js | 147 +++++++++++++++++++++++++----- 5 files changed, 279 insertions(+), 76 deletions(-) diff --git a/css/style.css b/css/style.css index 66a2db9..7642cb7 100644 --- a/css/style.css +++ b/css/style.css @@ -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; diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index 04a2128..d95b34e 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -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; 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.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) ----------------- diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index cbe7dd20649dc8666e480faaa37c4f16d845915c..ede4dc47cf0f3e5a3b98a059f13ce93702466cb1 100644 GIT binary patch delta 5108 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 H9%=XoDFo20 diff --git a/index.html b/index.html index 5b5ed65..938deb0 100644 --- a/index.html +++ b/index.html @@ -1130,8 +1130,13 @@

Are you sure?

-