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
+105 -47
View File
@@ -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.