feat: Ctrl+Shift+Q quick-search-fill + Edge browser directive

Ctrl+Shift+Q quick-search + autofill
- New global hotkey: capture the foreground HWND, restore the window
  if hidden, pop the quick-search modal in "fill mode". On pick, the
  password is SendInput'd into the saved HWND — no clipboard touch.
- hide_after flag added to cmd://autofill/execute: when set (tray-mode
  hotkey), Delphi MinimizeToTray's *after* SendInput completes. Hiding
  before SendInput would trip Win10/11 anti-focus-stealing rules and
  block focus handoff to the target.
- Quick-search modal hint text adapts to fill vs copy mode.
- Esc / close in fill mode sends cmd://autofill/cancel so a stale
  HWND doesn't get reused by an unrelated Ctrl+Shift+L later.

Compile-time browser engine switch
- {.$DEFINE USE_EDGE_BROWSER} in UMainForm.pas selects between
  TTMSFNCWebBrowser (default, cross-platform abstraction) and
  TTMSFNCEdgeWebBrowser (Windows-only WebView2 wrapper). Both
  inherit from TTMSFNCCustomWebBrowser so the bridge cmd:// glue is
  unchanged; the field type is a conditional alias TWebBrowserClass.
- WebBrowser is created dynamically in FormCreate so neither variant
  needs a second .fmx. Events are wired BEFORE Parent assignment so
  OnInitialized doesn't race the WebView2 async init on fast/pre-warmed
  Edge installs (was silently missing the disable-context-menu /
  disable-accelerator-keys calls).
- Native context menu disabled by assigning an empty PopupMenu1 (works
  for both backends, unlike OnGetContextMenu which is publish-gated
  via {$IFNDEF FNCLIB} on TTMSFNCWebBrowser).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 14:31:12 +01:00
parent f047fba9a3
commit 39406d712e
5 changed files with 252 additions and 22 deletions
+33
View File
@@ -109,6 +109,12 @@ type
FOnDebugHotkey: TProc;
FNewEntryHotkeyRegistered: Boolean;
FOnNewEntryHotkey: TNewEntryHotkeyEvent;
// Ctrl+Shift+Q — "Quick search + autofill": open the JS quick-search
// modal, the user picks an entry, the password is SendInput'd into the
// window that had focus at hotkey time (captured into FAutofillTargetHWND
// by the host).
FQuickSearchHotkeyRegistered: Boolean;
FOnQuickSearchHotkey: TAutofillRequestEvent;
procedure MsgWindowHandler(var AMsg: TMessage);
procedure PrepareNid;
procedure ShowTrayMenu;
@@ -183,6 +189,11 @@ type
// browser suffix by the JS layer before pre-fill).
property OnNewEntryHotkey: TNewEntryHotkeyEvent
read FOnNewEntryHotkey write FOnNewEntryHotkey;
// Fires on Ctrl+Shift+Q — quick-search-and-fill. Args mirror the
// regular autofill hotkey so the host can save the target HWND and
// pop the JS modal. Kind is always akPasswordOnly (no Tab).
property OnQuickSearchHotkey: TAutofillRequestEvent
read FOnQuickSearchHotkey write FOnQuickSearchHotkey;
end;
implementation
@@ -227,6 +238,7 @@ const
AUTOFILL_HOTKEY_ID_PWDONLY = 43; // Ctrl+Shift+P → password only
DEBUG_HOTKEY_ID = 44; // Ctrl+Shift+D → toggle debug panel
NEW_ENTRY_HOTKEY_ID = 45; // Ctrl+Shift+A → quick-add from window title
QUICK_SEARCH_HOTKEY_ID = 46; // Ctrl+Shift+Q → quick-search-and-fill
AF_MOD_CONTROL = $0002; // same value as MOD_CONTROL
AF_MOD_SHIFT = $0004; // same value as MOD_SHIFT
@@ -422,6 +434,8 @@ begin
AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('D'));
FNewEntryHotkeyRegistered := RegisterHotKey(FMsgWindow, NEW_ENTRY_HOTKEY_ID,
AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('A'));
FQuickSearchHotkeyRegistered := RegisterHotKey(FMsgWindow,
QUICK_SEARCH_HOTKEY_ID, AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('Q'));
end;
destructor TPMBridge.Destroy;
@@ -432,6 +446,9 @@ begin
if FNewEntryHotkeyRegistered then
UnregisterHotKey(FMsgWindow, NEW_ENTRY_HOTKEY_ID);
if FQuickSearchHotkeyRegistered then
UnregisterHotKey(FMsgWindow, QUICK_SEARCH_HOTKEY_ID);
if (FPowerNotify <> 0) and Assigned(_PowerUnregister) then
_PowerUnregister(FPowerNotify);
@@ -739,6 +756,22 @@ begin
end;
end
else if (AMsg.Msg = WM_HOTKEY) and (AMsg.WParam = QUICK_SEARCH_HOTKEY_ID) then
begin
// Quick-search + autofill: capture the foreground HWND BEFORE the JS
// modal steals focus, hand it to the host so it can stash it in
// FAutofillTargetHWND (consumed by cmd://autofill/execute later).
if Assigned(FOnQuickSearchHotkey) then
begin
var LTarget := GetForegroundWindow;
var LTitle: string;
SetLength(LTitle, 512);
var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512);
SetLength(LTitle, LLen);
FOnQuickSearchHotkey(akPasswordOnly, LTarget, LTitle);
end;
end
else if (AMsg.Msg = WM_HOTKEY) and Assigned(FOnAutofillRequest) and
((AMsg.WParam = AUTOFILL_HOTKEY_ID_FULL) or
(AMsg.WParam = AUTOFILL_HOTKEY_ID_PWDONLY)) then
+3 -7
View File
@@ -123,12 +123,8 @@ object MainForm: TMainForm
Size.Height = 6.000000000000000000
Size.PlatformDefault = False
end
object WebBrowser: TTMSFNCWebBrowser
Align = Client
Size.Width = 1100.000000000000000000
Size.Height = 502.000000000000000000
Size.PlatformDefault = False
TabOrder = 3
DesigntimeEnabled = False
object PopupMenu1: TPopupMenu
Left = 424
Top = 240
end
end
+160 -8
View File
@@ -1,19 +1,48 @@
unit UMainForm;
// ----------------------------------------------------------------------
// WebBrowser engine switch — compile-time directive.
//
// Uncomment USE_EDGE_BROWSER to build against TTMSFNCEdgeWebBrowser
// (Windows-only, direct WebView2 wrapper). Default = TTMSFNCWebBrowser
// (cross-platform abstraction also on WebView2 under Windows). Both
// expose the same TTMSFNCCustomWebBrowser API for OnBeforeNavigate /
// ExecuteJavaScript / Navigate, so the bridge cmd:// glue is unchanged.
// ----------------------------------------------------------------------
{$DEFINE USE_EDGE_BROWSER}
interface
uses
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
System.StrUtils,
System.StrUtils, System.Generics.Collections,
Winapi.Windows,
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types,
FMX.Dialogs, FMX.DialogService,
FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes,
FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser,
// ICoreWebView2 / ICoreWebView2Settings3 COM interface declarations —
// used to disable browser accelerator keys (Ctrl+P, Ctrl+J, F12, etc.)
// via the AreBrowserAcceleratorKeysEnabled property that Settings.* doesn't
// expose at the TMS wrapper level.
FMX.TMSFNCWebBrowser.Win,
{$IFDEF USE_EDGE_BROWSER}
FMX.TMSFNCEdgeWebBrowser,
{$ENDIF}
PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs, PM.AutoStart,
PM.Favicon,
FMX.Platform.Win; // WindowHandleToPlatform → HWND for visibility check
FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check
type
// Concrete class chosen at compile time. Both inherit from
// TTMSFNCCustomWebBrowser so we use that as the field type — events
// and ExecuteJavaScript live on the base class.
{$IFDEF USE_EDGE_BROWSER}
TWebBrowserClass = TTMSFNCEdgeWebBrowser;
{$ELSE}
TWebBrowserClass = TTMSFNCWebBrowser;
{$ENDIF}
type
TMainForm = class(TForm)
@@ -28,7 +57,7 @@ type
PanelLog: TPanel;
Memo: TMemo;
Splitter: TSplitter;
WebBrowser: TTMSFNCWebBrowser;
PopupMenu1: TPopupMenu;
procedure FormCreate(Sender: TObject);
procedure FormDestroy(Sender: TObject);
procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean);
@@ -51,6 +80,17 @@ type
FAutofillPendingHWND: HWND;
FAutofillPendingUser: string;
FAutofillPendingPass: string;
// True when the JS layer asked us to hide back to the tray AFTER the
// autofill SendInput completes. Used by the Ctrl+Shift+Q hotkey path
// when invoked while the app was in the tray — we can't hide before
// SendInput because Win10/11 anti-focus-stealing rules then refuse to
// hand focus to the target window.
FAutofillPendingHide: Boolean;
// Created dynamically in FormCreate so the directive can pick either
// TTMSFNCWebBrowser or TTMSFNCEdgeWebBrowser at compile time without
// needing two .fmx variants. Aligned to Client to fill the remaining
// space between PanelTop (top) and PanelLog/Splitter (bottom).
WebBrowser: TWebBrowserClass;
procedure AutofillTimerTick(Sender: TObject);
procedure LogLine(const AMsg: string);
procedure UpdateButtons;
@@ -59,6 +99,9 @@ type
// JS↔Delphi bridge
procedure WebBrowserBeforeNavigate(Sender: TObject;
var Params: TTMSFNCCustomWebBrowserBeforeNavigateParams);
procedure WebBrowserGetContextMenu(Sender: TObject;
ATarget: TTMSFNCWebBrowserTargetItem;
AContextMenu: TObjectList<TTMSFNCWebBrowserContextMenuItem>);
procedure HandleBridgeCommand(const ACmd, AParams: string);
procedure BridgeSystemLock;
procedure BridgeTrayRestore;
@@ -69,6 +112,8 @@ type
procedure BridgeDebugHotkey;
procedure BridgeNewEntryHotkey(const AWindowTitle: string);
procedure BridgeQuickSearchRequest;
procedure BridgeQuickSearchHotkey(AKind: TAutofillKind;
ATargetHWND: HWND; const ATitle: string);
procedure WebBrowserInitialized(Sender: TObject);
end;
@@ -85,6 +130,30 @@ function MaskAccessToken(const AUrl: string): string; forward;
procedure TMainForm.FormCreate(Sender: TObject);
begin
// Create the WebBrowser control programmatically so the {$IFDEF
// USE_EDGE_BROWSER} directive can swap the concrete class without a
// second .fmx variant. Align Client to fill the area below PanelTop
// and above the (collapsible) PanelLog + Splitter.
WebBrowser := TWebBrowserClass.Create(Self);
// Wire events BEFORE Parent assignment: setting Parent triggers the
// TMS browser's async WebView2 init, which fires OnInitialized when
// Edge Chromium is ready. If we assigned the handlers afterwards we'd
// miss the event on fast / pre-warmed Edge installs (and the
// Settings.EnableContextMenu / SetAcceleratorKeys calls inside would
// never run, leaving the native context menu and Ctrl+P/J live).
WebBrowser.OnBeforeNavigate := WebBrowserBeforeNavigate;
WebBrowser.OnInitialized := WebBrowserInitialized;
{$IFDEF USE_EDGE_BROWSER}
// On TTMSFNCEdgeWebBrowser the event is published.
// On TTMSFNCWebBrowser the same property is only re-published inside a
// conditional ($IFNDEF FNCLIB) block — depending on the FNC build, it
// may not be accessible. Restricting the assignment to the Edge build
// keeps the unconditional path TTMSFNCWebBrowser-safe.
WebBrowser.OnGetContextMenu := WebBrowserGetContextMenu;
{$ENDIF}
WebBrowser.Parent := Self;
WebBrowser.Align := TAlignLayout.Client;
FServer := TPMHTTPServer.Create;
FServer.OnLog := LogLine;
@@ -97,13 +166,10 @@ begin
FBridge.OnDebugHotkey := BridgeDebugHotkey;
FBridge.OnNewEntryHotkey := BridgeNewEntryHotkey;
FBridge.OnQuickSearchRequest := BridgeQuickSearchRequest;
FBridge.OnQuickSearchHotkey := BridgeQuickSearchHotkey;
FBridge.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup
FBridge.ApplyTitleBarTheme(True); // dark by default, JS may toggle later
FAutofillTargetHWND := 0;
WebBrowser.OnBeforeNavigate := WebBrowserBeforeNavigate;
WebBrowser.OnInitialized := WebBrowserInitialized;
// Delayed-Navigate timer: TTMSFNCWebBrowser (WebView2 backend) ignores
// Navigate() calls until Edge Chromium finishes its async init (~1-2s).
// We wait 1.5 s after Start, then issue a SINGLE Navigate — no retry loop
@@ -115,7 +181,7 @@ begin
FNavTimer.OnTimer := NavTimerTick;
UpdateButtons;
LogLine('Password Manager - Delphi backend ready.');
LogLine('Password Manager - d backend ready.');
LogLine('Click Start to launch server + embedded web vault.');
PanelTop.Visible := False;
FRequireAccessToken := True;
@@ -162,9 +228,47 @@ begin
end;
procedure TMainForm.WebBrowserInitialized(Sender: TObject);
var
LUnk: IUnknown;
LCtrl: ICoreWebView2Controller;
LWv2: ICoreWebView2;
LSettings: ICoreWebView2Settings;
LSettings3: ICoreWebView2Settings3;
LPtr: Pointer;
begin
// Disable native Edge context menu + DevTools via the TMS-wrapped settings.
// Disable browser-level accelerator keys (Ctrl+P / Ctrl+J / Ctrl+H /
// Ctrl+S / F12 / etc.). TMS's NativeBrowser returns the
// ICoreWebView2Controller, not the ICoreWebView2 — so we walk the chain:
// Controller → get_CoreWebView2 → get_Settings → QI(Settings3) → set_.
// Pointer→Interface cast must go through an explicit AddRef to keep the
// refcount balanced (otherwise the auto-Release at scope exit frees a
// reference TMS is still holding → AV at next use).
LPtr := WebBrowser.NativeBrowser;
if LPtr <> nil then
begin
Pointer(LUnk) := LPtr;
LUnk._AddRef;
if Supports(LUnk, ICoreWebView2Controller, LCtrl) then
begin
LCtrl.get_CoreWebView2(LWv2);
if Assigned(LWv2) then
begin
LWv2.get_Settings(LSettings);
if Assigned(LSettings) and
Supports(LSettings, ICoreWebView2Settings3, LSettings3) then
LSettings3.set_AreBrowserAcceleratorKeysEnabled(False);
end;
end;
end;
{$IFDEF USE_EDGE_BROWSER}
WebBrowser.Settings.EnableContextMenu := False;
WebBrowser.Settings.EnableShowDebugConsole := False;
{$ELSE}
WebBrowser.EnableContextMenu := False;
WebBrowser.EnableShowDebugConsole := False;
WebBrowser.PopupMenu := PopupMenu1;
{$ENDIF}
// Race-safe navigation fallback: the 1.5 s timer in NavigateToVault
// assumes WebView2 finishes its async init within that window. On slow
@@ -183,6 +287,29 @@ begin
end;
end;
procedure TMainForm.BridgeQuickSearchHotkey(AKind: TAutofillKind;
ATargetHWND: HWND; const ATitle: string);
var
LWasHidden: Boolean;
LWasHiddenJs: string;
begin
// Ctrl+Shift+Q anywhere — save the foreground HWND so the JS layer's
// cmd://autofill/execute (fired after the user picks an entry) sends
// the password into it. Then pop the modal in fill mode.
if not FServer.Active then Exit;
FAutofillTargetHWND := ATargetHWND;
LWasHidden := (not Self.Visible) or
IsIconic(WindowHandleToPlatform(Self.Handle).Wnd);
FBridge.RestoreFromTray;
LWasHiddenJs := BoolToStr(LWasHidden, True).ToLower;
// Second arg forFill=true tells JS to SendInput on pick instead of copy.
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&typeof Bridge.openQuickSearch==="function")' +
'Bridge.openQuickSearch(' + LWasHiddenJs + ',true)');
LogLine(Format('Quick-search hotkey — target=%s, title="%s"',
[IntToHex(ATargetHWND, 8), ATitle]));
end;
procedure TMainForm.BridgeQuickSearchRequest;
var
LWasHidden: Boolean;
@@ -303,6 +430,8 @@ procedure TMainForm.btnStartClick(Sender: TObject);
var
LPort: Integer;
begin
// WebBrowser.Navigate('about:blank');
// exit;
LPort := StrToIntDef(edtPort.Text, 8765);
try
FServer.Start(LPort, True, FRequireAccessToken, FRequireProcessCheck);
@@ -347,6 +476,16 @@ end;
// JS↔Delphi bridge
// ---------------------------------------------------------------------------
procedure TMainForm.WebBrowserGetContextMenu(Sender: TObject;
ATarget: TTMSFNCWebBrowserTargetItem;
AContextMenu: TObjectList<TTMSFNCWebBrowserContextMenuItem>);
begin
// Clearing the items leaves WebView2 with nothing to show → the native
// right-click menu is suppressed entirely. The JS layer renders a
// custom Cut/Copy/Paste menu on text inputs via installCustomContextMenu().
AContextMenu.Clear;
end;
procedure TMainForm.WebBrowserBeforeNavigate(Sender: TObject;
var Params: TTMSFNCCustomWebBrowserBeforeNavigateParams);
var
@@ -544,6 +683,7 @@ begin
FAutofillPendingUser := GetParam('username');
FAutofillPendingPass := GetParam('password');
FAutofillPendingHWND := FAutofillTargetHWND;
FAutofillPendingHide := GetParam('hide_after') = '1';
FAutofillTargetHWND := 0;
// Small timer so SetForegroundWindow has time to take effect before
@@ -756,14 +896,17 @@ procedure TMainForm.AutofillTimerTick(Sender: TObject);
var
TargetHwnd: HWND;
PendingUser, PendingPass: string;
HideAfter: Boolean;
ForegroundAfter: HWND;
begin
TargetHwnd := FAutofillPendingHWND;
PendingUser := FAutofillPendingUser;
PendingPass := FAutofillPendingPass;
HideAfter := FAutofillPendingHide;
FAutofillPendingHWND := 0;
FAutofillPendingUser := '';
FAutofillPendingPass := '';
FAutofillPendingHide := False;
TTimer(Sender).Enabled := False;
TTimer(Sender).Free;
@@ -773,6 +916,15 @@ begin
LogLine(Format('Autofill executed — target=%s, foreground_after=%s, match=%s',
[IntToHex(TargetHwnd, 8), IntToHex(ForegroundAfter, 8),
BoolToStr(ForegroundAfter = TargetHwnd, True)]));
// Hide-after (Ctrl+Shift+Q from tray): SendInput is done, the target
// already has focus — now we can safely tray ourselves without
// confusing the Win10/11 foreground-stealing watchdog.
if HideAfter then
begin
FBridge.MinimizeToTray;
LogLine('Hidden back to tray (post-autofill).');
end;
end;
procedure TMainForm.BridgeAutofillRequest(AKind: TAutofillKind;
Binary file not shown.