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:
+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) -----------------
|
||||
|
||||
Reference in New Issue
Block a user