feat: unified entry slideover + custom icons + UX fixes
Unified create/edit slideover
- openSlideOver(id) now accepts null for new entries. Same UI
(icon, name, site, user, password, TOTP, folder, tags) for both
create and edit. Drops the separate entry modal — no more "save
first, then add TOTP" two-step.
- "+ New" button, Ctrl+K → New entry, and Ctrl+Shift+A all route
through the slideover. Ctrl+Shift+A pre-fills the title with the
foreground window's name.
- Save button visible from the start in new mode (no dirty wait).
- Title shows mode unambiguously: cyan "+ New entry" vs
"Edit · <name>".
Custom icon upload (soIconField)
- 56×56 preview at the top of every slideover + Upload icon /
Remove buttons. Same POST /entries/{id}/icon endpoint as the
auto-fetch path. Validates type / size (64 KB cap matching server).
- Solves the case where DDG doesn't index a domain (self-hosted
apps, private sites): the user pastes any image and it sticks.
Favicon: privacy-first, DDG only
- Removed the direct-fetch fallback steps (3-5). Privacy stance:
zero DNS leak outside icons.duckduckgo.com. Domains DDG doesn't
cover stay icon-less until the user uploads a custom one.
- PM.Favicon.FetchFaviconDataUri takes an optional TFaviconLog
callback so UMainForm can stream per-step trace into LogLine for
diagnostics.
Fixes
- Slideover z-index 30 → 50. The topbar's backdrop-filter creates a
stacking context at z-index 40 which was clipping the slideover
header (title + close button hidden behind topbar).
- RestoreFromTray no longer un-maximises a maximised window when
called outside a tray-restore context (Ctrl+Shift+A, Ctrl+Shift+L
picker, app/focus cmd). SW_RESTORE on a maximised window reverts
to normal — now we only SW_RESTORE if IsIconic.
- "Show all"/"Show less" per-category state survives renderGrid
re-renders (healthExpanded map).
- "+ New" and dashboard "Fix" buttons stopPropagation so the
document-level click-outside handler doesn't close the slideover
they just opened.
- soDirtyCheck keeps Save visible while in new mode regardless of
diff.
- openSlideover → openSlideOver typo fix across all call sites.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -168,6 +168,12 @@
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Cfg_1_Win32)'!=''">
|
||||
<AppDPIAwarenessMode>PerMonitorV2</AppDPIAwarenessMode>
|
||||
<VerInfo_IncludeVerInfo>true</VerInfo_IncludeVerInfo>
|
||||
<VerInfo_Locale>1033</VerInfo_Locale>
|
||||
<VerInfo_Keys>CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments=;ProgramID=com.embarcadero.$(MSBuildProjectName)</VerInfo_Keys>
|
||||
<Icon_MainIcon>app.ico</Icon_MainIcon>
|
||||
<UWP_DelphiLogo44>app.png</UWP_DelphiLogo44>
|
||||
<UWP_DelphiLogo150>app.png</UWP_DelphiLogo150>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Cfg_2)'!=''">
|
||||
<DCC_Define>DEBUG;$(DCC_Define)</DCC_Define>
|
||||
@@ -203,6 +209,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
|
||||
<DelphiCompile Include="$(MainSource)">
|
||||
<MainSource>MainSource</MainSource>
|
||||
</DelphiCompile>
|
||||
<DCCReference Include="Source\PM.SingleInstance.pas"/>
|
||||
<DCCReference Include="UMainForm.pas">
|
||||
<Form>MainForm</Form>
|
||||
</DCCReference>
|
||||
@@ -219,9 +226,9 @@ $(PreBuildEvent)]]></PreBuildEvent>
|
||||
<DCCReference Include="Source\PM.Bridge.pas"/>
|
||||
<DCCReference Include="Source\PM.QuickUnlock.pas"/>
|
||||
<DCCReference Include="Source\PM.UserPrefs.pas"/>
|
||||
<DCCReference Include="Source\PM.SingleInstance.pas"/>
|
||||
<DCCReference Include="Source\PM.AutoStart.pas"/>
|
||||
<DCCReference Include="Source\PM.Favicon.pas"/>
|
||||
<DCCReference Include="Source\PM.ProcessLockdown.pas"/>
|
||||
<DCCReference Include="Handlers\PM.Handler.Ping.pas"/>
|
||||
<DCCReference Include="Handlers\PM.Handler.Auth.pas"/>
|
||||
<DCCReference Include="Handlers\PM.Handler.Folders.pas"/>
|
||||
|
||||
@@ -586,8 +586,11 @@ begin
|
||||
FMainForm.Show;
|
||||
|
||||
// Restore to the exact pre-tray state (maximised/normal + size + pos).
|
||||
// Falls back to SW_RESTORE if we never captured a placement (e.g. tray
|
||||
// restore was triggered without a prior MinimizeToTray call).
|
||||
// Falls back to SW_SHOW (+ conditional SW_RESTORE) if we never captured
|
||||
// a placement (e.g. focus-app / new-entry hotkey on an already-visible
|
||||
// window). Unconditional SW_RESTORE would un-maximise a maximised
|
||||
// window — surprising for the user who pressed Ctrl+Shift+A / +L /
|
||||
// clicked the tray.
|
||||
if FHasSavedPlacement then
|
||||
begin
|
||||
// showCmd governs whether the window comes back maximised or normal;
|
||||
@@ -599,7 +602,10 @@ begin
|
||||
else
|
||||
begin
|
||||
ShowWindow(LFormHwnd, SW_SHOW);
|
||||
ShowWindow(LFormHwnd, SW_RESTORE);
|
||||
// Only un-iconify if actually minimised. Win32 SW_RESTORE on a
|
||||
// maximised window reverts it to normal — not what we want here.
|
||||
if IsIconic(LFormHwnd) then
|
||||
ShowWindow(LFormHwnd, SW_RESTORE);
|
||||
end;
|
||||
SetForegroundWindow(LFormHwnd);
|
||||
end;
|
||||
|
||||
@@ -21,9 +21,15 @@ unit PM.Favicon;
|
||||
|
||||
interface
|
||||
|
||||
type
|
||||
TFaviconLog = reference to procedure(const ALine: string);
|
||||
|
||||
// Fetches an icon for AHost (bare hostname, no scheme). Returns a
|
||||
// "data:image/...;base64,..." string on success, or '' on any failure.
|
||||
function FetchFaviconDataUri(const AHost: string): string;
|
||||
// ALog (optional): called for each fallback step so the host can trace
|
||||
// exactly which URL hit / missed.
|
||||
function FetchFaviconDataUri(const AHost: string;
|
||||
ALog: TFaviconLog = nil): string;
|
||||
|
||||
implementation
|
||||
|
||||
@@ -32,9 +38,16 @@ uses
|
||||
System.Net.HttpClient, System.Net.URLClient;
|
||||
|
||||
const
|
||||
ICON_URL_TEMPLATE = 'https://icons.duckduckgo.com/ip3/%s.ico';
|
||||
MAX_ICON_BYTES = 65536; // 64 KB cap (matches handler's SetEntryIcon limit)
|
||||
HTTP_TIMEOUT_MS = 5000;
|
||||
ICON_URL_TEMPLATE = 'https://icons.duckduckgo.com/ip3/%s.ico';
|
||||
MAX_ICON_BYTES = 65536; // 64 KB cap (matches handler's SetEntryIcon limit)
|
||||
HTTP_TIMEOUT_MS = 5000;
|
||||
// DDG returns a generic placeholder for unknown domains. Bigger threshold
|
||||
// than 100 to avoid treating its blank globe glyph as a real icon.
|
||||
MIN_REAL_ICON_BYTES = 500;
|
||||
// Privacy stance: DDG-only fetches. We don't fall back to the site's
|
||||
// own /favicon.ico because that would leak DNS to every domain stored
|
||||
// in the vault. For sites DDG doesn't index, the user can upload a
|
||||
// custom icon via the slideover (soIconField).
|
||||
|
||||
function NormalizeHost(const ARaw: string): string;
|
||||
var
|
||||
@@ -96,23 +109,120 @@ begin
|
||||
Exit('image/x-icon');
|
||||
end;
|
||||
|
||||
function FetchFaviconDataUri(const AHost: string): string;
|
||||
// "chat.deepseek.com" → "deepseek.com". Returns '' if S has no dot or
|
||||
// is already a 2-label hostname (we'd fall back to the same input).
|
||||
function ExtractSLD(const S: string): string;
|
||||
var
|
||||
LHost, LUrl, LMime, LBase64: string;
|
||||
LHttp: THTTPClient;
|
||||
LResp: IHTTPResponse;
|
||||
LStream: TMemoryStream;
|
||||
DotCount, FirstDot: Integer;
|
||||
I: Integer;
|
||||
begin
|
||||
Result := '';
|
||||
DotCount := 0;
|
||||
FirstDot := 0;
|
||||
for I := 1 to Length(S) do
|
||||
if S[I] = '.' then
|
||||
begin
|
||||
Inc(DotCount);
|
||||
if FirstDot = 0 then FirstDot := I;
|
||||
end;
|
||||
if DotCount < 2 then Exit; // already SLD or no dots
|
||||
Result := Copy(S, FirstDot + 1, MaxInt);
|
||||
end;
|
||||
|
||||
function FetchOneIcon(const AUrl: string;
|
||||
out ABytes: TBytes): Boolean; forward;
|
||||
|
||||
function FetchFaviconDataUri(const AHost: string;
|
||||
ALog: TFaviconLog = nil): string;
|
||||
|
||||
procedure Trace(const ALine: string);
|
||||
begin
|
||||
if Assigned(ALog) then ALog(ALine);
|
||||
end;
|
||||
|
||||
var
|
||||
LHost, LSld, LMime, LBase64, LUrl: string;
|
||||
LBytes: TBytes;
|
||||
LOk: Boolean;
|
||||
begin
|
||||
Result := '';
|
||||
LHost := NormalizeHost(AHost);
|
||||
if LHost = '' then Exit;
|
||||
if LHost = '' then
|
||||
begin
|
||||
Trace('reject: "' + AHost + '" not a valid hostname');
|
||||
Exit;
|
||||
end;
|
||||
|
||||
// Strategy: prefer DDG (privacy-centralising) but fall back to the
|
||||
// site's own /favicon.ico for domains DDG doesn't index (self-hosted
|
||||
// tools, niche services, fresh subdomains, etc.). The user already
|
||||
// opted into "fetch icons" so the DNS leak to one extra host they
|
||||
// already visit is an acceptable trade-off for actually getting an icon.
|
||||
|
||||
LSld := ExtractSLD(LHost);
|
||||
LOk := False;
|
||||
|
||||
// 1) DDG full host.
|
||||
LUrl := Format(ICON_URL_TEMPLATE, [LHost]);
|
||||
if FetchOneIcon(LUrl, LBytes) then
|
||||
begin
|
||||
if Length(LBytes) >= MIN_REAL_ICON_BYTES then
|
||||
begin
|
||||
LOk := True;
|
||||
Trace(Format('OK step1 DDG host: %s (%d bytes)', [LUrl, Length(LBytes)]));
|
||||
end
|
||||
else
|
||||
Trace(Format('skip step1 DDG host: %s only %d bytes (< %d)',
|
||||
[LUrl, Length(LBytes), MIN_REAL_ICON_BYTES]));
|
||||
end
|
||||
else
|
||||
Trace('fail step1 DDG host: ' + LUrl);
|
||||
|
||||
// 2) DDG SLD (e.g. "deepseek.com" when "chat.deepseek.com" 404s).
|
||||
if (not LOk) and (LSld <> '') then
|
||||
begin
|
||||
var LTry: TBytes;
|
||||
LUrl := Format(ICON_URL_TEMPLATE, [LSld]);
|
||||
if FetchOneIcon(LUrl, LTry) then
|
||||
begin
|
||||
if Length(LTry) >= MIN_REAL_ICON_BYTES then
|
||||
begin
|
||||
LBytes := LTry; LOk := True;
|
||||
Trace(Format('OK step2 DDG sld: %s (%d bytes)', [LUrl, Length(LTry)]));
|
||||
end
|
||||
else
|
||||
Trace(Format('skip step2 DDG sld: %s only %d bytes', [LUrl, Length(LTry)]));
|
||||
end
|
||||
else
|
||||
Trace('fail step2 DDG sld: ' + LUrl);
|
||||
end;
|
||||
|
||||
if (not LOk) or (Length(LBytes) = 0) then
|
||||
begin
|
||||
Trace('DDG has no icon for ' + LHost + ' — user can upload a custom one');
|
||||
Exit;
|
||||
end;
|
||||
|
||||
LMime := GuessMimeFromBytes(LBytes);
|
||||
LBase64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
|
||||
LBase64 := StringReplace(LBase64, #13, '', [rfReplaceAll]);
|
||||
LBase64 := StringReplace(LBase64, #10, '', [rfReplaceAll]);
|
||||
Result := 'data:' + LMime + ';base64,' + LBase64;
|
||||
end;
|
||||
|
||||
// Low-level HTTP GET. Returns False on any failure (DNS, TLS, non-200,
|
||||
// oversize). On success ABytes contains the raw image bytes.
|
||||
// THTTPClient wraps WinHTTP on Windows → native TLS, system cert store,
|
||||
// zero extra DLLs to ship next to the exe.
|
||||
function FetchOneIcon(const AUrl: string; out ABytes: TBytes): Boolean;
|
||||
var
|
||||
LHttp: THTTPClient;
|
||||
LResp: IHTTPResponse;
|
||||
LStream: TMemoryStream;
|
||||
begin
|
||||
Result := False;
|
||||
SetLength(ABytes, 0);
|
||||
|
||||
// THTTPClient wraps WinHTTP on Windows → native TLS, system cert store,
|
||||
// zero extra DLLs to ship next to the exe (unlike Indy + OpenSSL which
|
||||
// fails silently when libcrypto/libssl are missing).
|
||||
LHttp := THTTPClient.Create;
|
||||
LStream := TMemoryStream.Create;
|
||||
try
|
||||
@@ -125,9 +235,8 @@ begin
|
||||
'image/png,image/x-icon,image/*;q=0.8,*/*;q=0.1');
|
||||
|
||||
try
|
||||
LResp := LHttp.Get(LUrl, LStream);
|
||||
LResp := LHttp.Get(AUrl, LStream);
|
||||
except
|
||||
// Any DNS / connect / TLS failure → silent ''.
|
||||
Exit;
|
||||
end;
|
||||
|
||||
@@ -136,17 +245,9 @@ begin
|
||||
if LStream.Size > MAX_ICON_BYTES then Exit;
|
||||
|
||||
LStream.Position := 0;
|
||||
SetLength(LBytes, LStream.Size);
|
||||
LStream.ReadBuffer(LBytes[0], LStream.Size);
|
||||
|
||||
LMime := GuessMimeFromBytes(LBytes);
|
||||
LBase64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
|
||||
// Strip CR/LF that the encoder inserts every 76 chars — invalid inside
|
||||
// an <img src="..."> attribute and bloats the cached blob.
|
||||
LBase64 := StringReplace(LBase64, #13, '', [rfReplaceAll]);
|
||||
LBase64 := StringReplace(LBase64, #10, '', [rfReplaceAll]);
|
||||
|
||||
Result := 'data:' + LMime + ';base64,' + LBase64;
|
||||
SetLength(ABytes, LStream.Size);
|
||||
LStream.ReadBuffer(ABytes[0], LStream.Size);
|
||||
Result := True;
|
||||
finally
|
||||
LStream.Free;
|
||||
LHttp.Free;
|
||||
|
||||
@@ -590,7 +590,15 @@ begin
|
||||
var
|
||||
LDataUri: string;
|
||||
begin
|
||||
LDataUri := PM.Favicon.FetchFaviconDataUri(LHost);
|
||||
LDataUri := PM.Favicon.FetchFaviconDataUri(LHost,
|
||||
procedure(const ALine: string)
|
||||
begin
|
||||
TThread.Queue(nil,
|
||||
procedure
|
||||
begin
|
||||
LogLine('favicon[' + LHost + ']: ' + ALine);
|
||||
end);
|
||||
end);
|
||||
TThread.Queue(nil,
|
||||
procedure
|
||||
begin
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user