feat: native save + auto-backup + folder customization + attachments + UX bundle
- File: native Save As dialog via Bridge.saveFile (replaces WebView2
browser download popup) for encrypted JSON + CSV exports.
- Auto-backup: silent periodic encrypted JSON to a chosen folder,
user-set interval + retention, separate DPAPI-stored password, runs
5s after unlock if due. New file/* bridge cmds (folder/pick,
file/write, file/listMatch, file/delete).
- Folders: per-folder color + icon (8-swatch palette, 8 icon presets),
drag-reorder via HTML5 DnD with insert-line indicators, edit pencil
on hover. New POST /folders/reorder + PUT /folders/{name}. Folder
chip on cards inherits custom icon + color.
- Recently used: vault_entries.accessed_at + POST /entries/{id}/touch
(debounced 2s), sidebar Tools entry showing top-10 by accessed_at.
- Encrypted attachments: per-entry file storage (5MB cap), AES-GCM
with vault key, native Save As download, paperclip upload in
slideover. New entry_attachments table + PM.Handler.Attachments.
- Password expiry: vault_entries.password_changed_at (conditional bump
via SQL CASE only when ciphertext differs), passwordExpiryDays
setting, "Aged" badge on cards + matching Filters chip.
- Recovery: Print button on generated code modal (A4 printable sheet
via @media print, code in 32px monospace + instructions).
- Audit log viewer (sidebar Tools, GET /audit with pagination cursor).
- Plaintext CSV export + Filters dropdown with 9 predicates.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ interface
|
||||
|
||||
uses
|
||||
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
|
||||
System.StrUtils, System.Generics.Collections,
|
||||
System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON,
|
||||
Winapi.Windows, Winapi.ShellAPI,
|
||||
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
|
||||
FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types,
|
||||
@@ -860,6 +860,182 @@ begin
|
||||
BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')');
|
||||
end
|
||||
|
||||
// ---- Native file save (bypasses WebView2's browser download UI) ------
|
||||
// JS sends: cmd://file/save?name=<filename>&data=<base64>&reqId=<id>
|
||||
// Delphi opens GetSaveFileName, writes the decoded bytes, then calls
|
||||
// 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
|
||||
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;
|
||||
end;
|
||||
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 + '")');
|
||||
end
|
||||
|
||||
// ---- Auto-backup: folder picker (modal Win32 dialog) -----------------
|
||||
// cmd://folder/pick?reqId=<id>
|
||||
// Callback: Bridge.onFolderPickResult(reqId, path) (path = '' on cancel)
|
||||
else if ACmd = 'folder/pick' then
|
||||
begin
|
||||
var LReqId := GetParam('reqId');
|
||||
var LDir := '';
|
||||
SelectDirectory('Choose backup folder', '', LDir);
|
||||
var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
|
||||
var LEscDir := StringReplace(LDir, '\', '\\', [rfReplaceAll]);
|
||||
LEscDir := StringReplace(LEscDir, '"', '\"', [rfReplaceAll]);
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onFolderPickResult)' +
|
||||
'Bridge.onFolderPickResult("' + LEscReq + '","' + LEscDir + '")');
|
||||
if LDir <> '' then LogLine('Folder picked: ' + LDir);
|
||||
end
|
||||
|
||||
// ---- Auto-backup: silent file write (no dialog) ----------------------
|
||||
// cmd://file/write?path=<full>&data=<base64>&reqId=<id>
|
||||
// Callback: Bridge.onFileWriteResult(reqId, ok, error)
|
||||
else if ACmd = 'file/write' then
|
||||
begin
|
||||
var LPath := GetParam('path');
|
||||
var LData := GetParam('data');
|
||||
var LReqId := GetParam('reqId');
|
||||
var LOk := False;
|
||||
var LErr := '';
|
||||
try
|
||||
var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
|
||||
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 written: %s (%d bytes)', [LPath, Length(LBytes)]));
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
LErr := E.Message;
|
||||
LogLine('File write FAILED for "' + LPath + '": ' + LErr);
|
||||
end;
|
||||
end;
|
||||
var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
|
||||
var LEscErr := StringReplace(LErr, '\', '\\', [rfReplaceAll]);
|
||||
LEscErr := StringReplace(LEscErr, '"', '\"', [rfReplaceAll]);
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onFileWriteResult)' +
|
||||
'Bridge.onFileWriteResult("' + LEscReq + '",' +
|
||||
BoolToStr(LOk, True).ToLower + ',"' + LEscErr + '")');
|
||||
end
|
||||
|
||||
// ---- Auto-backup: list files in dir matching name prefix --------------
|
||||
// cmd://file/listMatch?dir=<full>&prefix=<str>&reqId=<id>
|
||||
// Callback: Bridge.onFileListResult(reqId, jsonArr)
|
||||
// Each item: {name, size, mtime} (mtime = ISO).
|
||||
else if ACmd = 'file/listMatch' then
|
||||
begin
|
||||
var LDir := GetParam('dir');
|
||||
var LPrefix := GetParam('prefix');
|
||||
var LReqId := GetParam('reqId');
|
||||
var LArr := TJSONArray.Create;
|
||||
try
|
||||
if (LDir <> '') and TDirectory.Exists(LDir) then
|
||||
begin
|
||||
var LFiles := TDirectory.GetFiles(LDir, LPrefix + '*');
|
||||
for var F in LFiles do
|
||||
begin
|
||||
var LObj := TJSONObject.Create;
|
||||
LObj.AddPair('name', ExtractFileName(F));
|
||||
LObj.AddPair('size', TJSONNumber.Create(TFile.GetSize(F)));
|
||||
LObj.AddPair('mtime',
|
||||
FormatDateTime('yyyy-mm-dd"T"hh:nn:ss', TFile.GetLastWriteTime(F)));
|
||||
LArr.Add(LObj);
|
||||
end;
|
||||
end;
|
||||
var LJSON := LArr.ToString;
|
||||
var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
|
||||
var LEscJson := StringReplace(LJSON, '\', '\\', [rfReplaceAll]);
|
||||
LEscJson := StringReplace(LEscJson, '"', '\"', [rfReplaceAll]);
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onFileListResult)' +
|
||||
'Bridge.onFileListResult("' + LEscReq + '","' + LEscJson + '")');
|
||||
finally
|
||||
LArr.Free;
|
||||
end;
|
||||
end
|
||||
|
||||
// ---- Auto-backup: delete a single file (for retention pruning) -------
|
||||
// cmd://file/delete?path=<full>&reqId=<id>
|
||||
// Callback: Bridge.onFileDeleteResult(reqId, ok)
|
||||
else if ACmd = 'file/delete' then
|
||||
begin
|
||||
var LPath := GetParam('path');
|
||||
var LReqId := GetParam('reqId');
|
||||
var LOk := False;
|
||||
try
|
||||
if TFile.Exists(LPath) then
|
||||
begin
|
||||
TFile.Delete(LPath);
|
||||
LOk := True;
|
||||
LogLine('File deleted: ' + LPath);
|
||||
end;
|
||||
except
|
||||
on E: Exception do
|
||||
LogLine('File delete FAILED for "' + LPath + '": ' + E.Message);
|
||||
end;
|
||||
var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onFileDeleteResult)' +
|
||||
'Bridge.onFileDeleteResult("' + LEscReq + '",' +
|
||||
BoolToStr(LOk, True).ToLower + ')');
|
||||
end
|
||||
|
||||
else
|
||||
LogLine('Bridge: unknown command "' + ACmd + '"');
|
||||
end;
|
||||
|
||||
Reference in New Issue
Block a user