3051c3c392
Jeton "utilisateur:mot-de-passe" sur une URL ni Gitea ni GitHub = WebDAV, authentification Basic. Fichier lu et ecrit tel quel ; l'ETag fait le sha (If-None-Match, If-Match, 412 -> conflit). Clair autorise vers l'IP fixe du poste seulement. Eprouve contre le WsgiDAV local : 304, PUT 204, puis synchro du poste. Docs : CLAUDE.md, android-client/README.md (page eveillee comprise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2304 lines
95 KiB
ObjectPascal
2304 lines
95 KiB
ObjectPascal
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.Generics.Collections, System.IOUtils, System.JSON,
|
|
System.Diagnostics,
|
|
System.Net.HttpClient, System.Net.URLClient,
|
|
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,
|
|
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.PinUnlock, PM.UserPrefs, PM.Licence, PM.AutoStart,
|
|
PM.Favicon, DelphiZXingQRCode, PM.ScreenQr,
|
|
FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check
|
|
const
|
|
// Bump on each release. Surfaced to JS via cmd://app/version, displayed
|
|
// in Settings → Account so users can report bugs with the right build.
|
|
APP_VERSION = '1.0.0';
|
|
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)
|
|
PanelTop: TPanel;
|
|
btnStart: TButton;
|
|
btnStop: TButton;
|
|
lblStatus: TLabel;
|
|
edtPort: TEdit;
|
|
lblPort: TLabel;
|
|
btnToggleLog: TButton;
|
|
btnReload: TButton;
|
|
PanelLog: TPanel;
|
|
Memo: TMemo;
|
|
Splitter: TSplitter;
|
|
PopupMenu1: TPopupMenu;
|
|
procedure FormCreate(Sender: TObject);
|
|
procedure FormDestroy(Sender: TObject);
|
|
procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean);
|
|
procedure btnStartClick(Sender: TObject);
|
|
procedure btnStopClick(Sender: TObject);
|
|
procedure btnToggleLogClick(Sender: TObject);
|
|
procedure btnReloadClick(Sender: TObject);
|
|
private
|
|
// Windows minimising the window from the outside (Win+D / Show desktop /
|
|
// taskbar) makes FMX forget it was maximised: the window comes back
|
|
// "normal". None of our restore paths run in that case — the taskbar
|
|
// restore is pure Windows + FMX — so the state is re-applied on activate,
|
|
// which happens however the user comes back. FWantMaximized mirrors the
|
|
// user's LAST deliberate choice, so we never fight a normal-size window.
|
|
FWantMaximized: Boolean;
|
|
// Handle of the WinEvent hook that reports minimise start/end for our own
|
|
// window. FMX gives no usable signal (no resize while iconic, and the
|
|
// restore's resize is indistinguishable from a user gesture by placement
|
|
// flags or foreground ownership — both were measured and rejected).
|
|
FWinEventHook: THandle;
|
|
// True from "minimise starts" until just after the restore settles, so the
|
|
// resizes FMX fires while restoring are not recorded as user intent.
|
|
FIgnoreResizes: Boolean;
|
|
// Set from activate until the queued correction runs, so the restore's
|
|
// own resize is not mistaken for the user resizing the window.
|
|
FRestoring: Boolean;
|
|
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;
|
|
// REVEIL DE LA PAGE pendant que l'application dort dans le tray. Les
|
|
// drapeaux --disable-renderer-backgrounding & co sont bien appliques, et
|
|
// pourtant le premier autofill apres des heures cachee attendait 3 a 5 s
|
|
// la reponse de la page (mesure : 8 ms coffre tout juste ouvert, 4907 ms
|
|
// apres une veille). Windows laisse s'endormir un processus que personne
|
|
// ne sollicite ; une instruction JS triviale toutes les 5 min l'en empeche.
|
|
FKeepWarmTimer: TTimer;
|
|
// Set once WebView2 fires OnInitialized. The nav timer only consumes
|
|
// FPendingURL when this is True — otherwise a timer tick that lands
|
|
// before the engine is ready would Navigate() into the void AND clear
|
|
// FPendingURL, leaving OnInitialized nothing to do → permanent black
|
|
// window on slow cold starts.
|
|
FBrowserInitialized: Boolean;
|
|
FNavRetries: Integer; // bounded retry count for the deferred nav timer
|
|
FRequireAccessToken: Boolean;
|
|
FRequireProcessCheck: Boolean;
|
|
FQuitting: Boolean; // set when user picks "Quit" in tray menu — bypasses
|
|
// FormCloseQuery's minimize-to-tray intercept.
|
|
FAutofillTargetHWND: HWND; // saved at hotkey time, consumed on /execute
|
|
// CHRONOMETRE DU RACCOURCI, arme au moment ou l'on appelle la page.
|
|
//
|
|
// Un autofill lent apres une periode d'inactivite peut l'etre pour deux
|
|
// raisons sans rapport : le moteur WebView2 qui se reveille et met des
|
|
// secondes a repondre, ou la sequence de frappe elle-meme (prise du premier
|
|
// plan, attente des modificateurs). Les deux se ressemblent parfaitement de
|
|
// l'exterieur - l'utilisateur voit "ca a mis trois secondes" - et se
|
|
// corrigent a des endroits opposes. On mesure donc les deux segments
|
|
// separement plutot que de deviner lequel accuser.
|
|
FAutofillClock: TStopwatch;
|
|
// Pending payload for the 60ms delay timer (focus settle before SendInput).
|
|
// Cleared inside AutofillTimerTick.
|
|
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;
|
|
// Same idea for the Ctrl+Shift+L/P/T hotkeys: captured when the hotkey
|
|
// fires, because the multi-match picker pulls the window out of the tray
|
|
// before the user picks, so by execute time the state is already lost.
|
|
// JS never sends hide_after on this path — only quick-search does.
|
|
FAutofillHotkeyWasHidden: Boolean;
|
|
// True when the pending autofill should type ONLY the username (quick
|
|
// search "autofill username" — right-click / Shift+Enter in fill mode).
|
|
FAutofillPendingUserOnly: Boolean;
|
|
// Send Ctrl+A + Del before typing each field (user setting, default ON;
|
|
// OFF for targets where Ctrl+A isn't select-all — terminals, RDP).
|
|
FAutofillPendingClear: Boolean;
|
|
// Tray balloon when the fill fails while the window is hidden (user
|
|
// setting "Tray alert when autofill is blocked", default ON).
|
|
FAutofillPendingNotify: 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 FormResizeTrackState(Sender: TObject);
|
|
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);
|
|
// Silent write to a fixed path (auto-backup / sync pre-backup). Shared
|
|
// by cmd://file/write (single-shot) and file/write-commit (chunked).
|
|
procedure WriteDecodedFile(const APath, AB64, AReqId: string);
|
|
procedure LogLine(const AMsg: string);
|
|
procedure UpdateButtons;
|
|
procedure NavigateToVault;
|
|
procedure NavTimerTick(Sender: TObject);
|
|
procedure KeepWarmTick(Sender: TObject);
|
|
// 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;
|
|
procedure BridgeLockRequest;
|
|
procedure BridgeQuit;
|
|
procedure BridgeAutofillRequest(AKind: TAutofillKind;
|
|
ATargetHWND: HWND; const ATitle: string);
|
|
procedure BridgeDebugHotkey;
|
|
procedure BridgeNewEntryHotkey(const AWindowTitle: string);
|
|
procedure BridgeQuickSearchRequest;
|
|
procedure BridgeQuickSearchHotkey(AKind: TAutofillKind;
|
|
ATargetHWND: HWND; const ATitle: string);
|
|
procedure WebBrowserInitialized(Sender: TObject);
|
|
public
|
|
// Called from the WinEvent hook. WINEVENT_OUTOFCONTEXT delivers through
|
|
// our own message queue, so these already run on the main thread.
|
|
// Every tray restore goes through here so the minimise guard cannot get
|
|
// stuck raised (see the implementation).
|
|
// One-shot: hides the window the first time FMX shows it on a --tray
|
|
// launch, then unhooks itself.
|
|
// True when a native modal dialog owns us — see the implementation.
|
|
function NativeModalActive: Boolean;
|
|
procedure RestoreWindowFromTray;
|
|
procedure WinStateMinimizeStart;
|
|
procedure WinStateMinimizeEnd;
|
|
procedure ApplyWantedWindowState;
|
|
end;
|
|
|
|
var
|
|
MainForm: TMainForm;
|
|
|
|
// Call from the .dpr right after Application.Initialize, BEFORE CreateForm,
|
|
// for a --tray launch. FMX shows its taskbar proxy before FormCreate ever
|
|
// runs — measured — so anything installed from inside the form is already too
|
|
// late. Starts a worker that hides the proxy the instant it exists and keeps
|
|
// watching until the tray startup completes; the main thread is blocked
|
|
// loading the .fmx and starting the server, so only another thread can react.
|
|
procedure StartTrayProxyHider;
|
|
|
|
implementation
|
|
|
|
{$R *.fmx}
|
|
|
|
// Result slot for EnumOwnDialogProc — the enumeration is main-thread only and
|
|
// consumed immediately, so a unit variable is enough.
|
|
var
|
|
GFoundDialogHwnd: HWND = 0;
|
|
|
|
// Forward — used by WebBrowserInitialized (which sits above the actual
|
|
// definition lower in the unit).
|
|
function MaskAccessToken(const AUrl: string): string; forward;
|
|
|
|
// ---- WinEvent hook plumbing (see the maximised-state block further down) --
|
|
const
|
|
EVENT_SYSTEM_MINIMIZESTART = $0016;
|
|
EVENT_SYSTEM_MINIMIZEEND = $0017;
|
|
WINEVENT_OUTOFCONTEXT = $0000;
|
|
OBJID_WINDOW = 0;
|
|
|
|
function SetWinEventHook(eventMin, eventMax: DWORD; hmodWinEventProc: HMODULE;
|
|
lpfnWinEventProc: Pointer; idProcess, idThread, dwFlags: DWORD): THandle;
|
|
stdcall; external 'user32.dll' name 'SetWinEventHook';
|
|
function UnhookWinEvent(hWinEventHook: THandle): BOOL;
|
|
stdcall; external 'user32.dll' name 'UnhookWinEvent';
|
|
|
|
procedure WinStateEventProc(hWinEventHook: THandle; AEvent: DWORD; AWnd: HWND;
|
|
idObject, idChild: LongInt; idEventThread, dwmsEventTime: DWORD); stdcall; forward;
|
|
|
|
procedure StartTrayProxyHider;
|
|
begin
|
|
// ApplicationHWND (FMX.Platform.Win) IS the taskbar proxy — no hunting for a
|
|
// TFMAppClass window, and it is valid this early, before the form exists.
|
|
ShowWindow(ApplicationHWND, SW_HIDE);
|
|
// Hiding alone does not reliably drop the button: the taskbar had already
|
|
// drawn it. Clearing WS_EX_APPWINDOW and setting WS_EX_TOOLWINDOW does —
|
|
// a tool window is never listed. Undone by TrayProxyShowInTaskbar before any
|
|
// restore, otherwise the app would come back with no taskbar button at all.
|
|
SetWindowLong(ApplicationHWND, GWL_EXSTYLE,
|
|
(GetWindowLong(ApplicationHWND, GWL_EXSTYLE) and (not WS_EX_APPWINDOW))
|
|
or WS_EX_TOOLWINDOW);
|
|
end;
|
|
|
|
// Give the proxy its normal styles back. Takes effect on the next show, which
|
|
// is exactly what RestoreFromTray does right after.
|
|
procedure TrayProxyShowInTaskbar;
|
|
begin
|
|
SetWindowLong(ApplicationHWND, GWL_EXSTYLE,
|
|
(GetWindowLong(ApplicationHWND, GWL_EXSTYLE) and (not WS_EX_TOOLWINDOW))
|
|
or WS_EX_APPWINDOW);
|
|
end;
|
|
|
|
procedure TMainForm.FormCreate(Sender: TObject);
|
|
begin
|
|
// --tray ("Start with Windows"): the taskbar proxy was already dealt with in
|
|
// the .dpr (StartTrayProxyHider) — it is shown before FormCreate is even
|
|
// reached. This just keeps the form itself off screen.
|
|
if FindCmdLineSwitch('tray', True) then
|
|
Visible := False;
|
|
|
|
// The .fmx ships WindowState = wsMaximized, so that is the starting intent.
|
|
// Wired here (not in the .fmx) to keep the designer file untouched.
|
|
FWantMaximized := (WindowState = TWindowState.wsMaximized);
|
|
FIgnoreResizes := False;
|
|
// Scoped to our own process; WINEVENT_OUTOFCONTEXT means no DLL injection
|
|
// and the callback arrives on this thread. Failure is non-fatal — we just
|
|
// lose the maximised-state preservation.
|
|
FWinEventHook := SetWinEventHook(
|
|
EVENT_SYSTEM_MINIMIZESTART, EVENT_SYSTEM_MINIMIZEEND, 0,
|
|
@WinStateEventProc, GetCurrentProcessId, 0, WINEVENT_OUTOFCONTEXT);
|
|
LogLine('[winstate] minimise hook installed=' +
|
|
BoolToStr(FWinEventHook <> 0, True));
|
|
FRestoring := False;
|
|
OnResize := FormResizeTrackState;
|
|
|
|
// 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;
|
|
|
|
FBridge := TPMBridge.Create(Self);
|
|
FFileSaveChunks := TDictionary<string, TStringBuilder>.Create;
|
|
FBridge.OnSystemLock := BridgeSystemLock;
|
|
// Le reveil des 5 min ne tourne pas pendant la veille : on le donne tout de
|
|
// suite au retour, avant le premier autofill.
|
|
FBridge.OnSystemResume :=
|
|
procedure
|
|
begin
|
|
LogLine('Windows resumed / unlocked — waking the page');
|
|
KeepWarmTick(nil);
|
|
end;
|
|
FBridge.OnTrayRestore := BridgeTrayRestore;
|
|
// Changement fait sur la barrette elle-meme : l'onglet se relit (fillbar/get).
|
|
FBridge.OnFillBarChanged :=
|
|
procedure
|
|
begin
|
|
// Pas avant l'init de WebView2 : RestoreLazyBar passe ici au demarrage.
|
|
if FBrowserInitialized then
|
|
WebBrowser.ExecuteJavaScript('if(window.fillBarRequest)fillBarRequest()');
|
|
end;
|
|
FBridge.OnFillBarSettings :=
|
|
procedure
|
|
begin
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onOpenFillBarSettings)Bridge.onOpenFillBarSettings()');
|
|
end;
|
|
FBridge.OnLockRequest := BridgeLockRequest;
|
|
FBridge.OnQuit := BridgeQuit;
|
|
FBridge.OnAutofillRequest := BridgeAutofillRequest;
|
|
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
|
|
// Apres le theme et apres OnTrayRestore : la barrette se peint elle-meme, et
|
|
// son bouton Open passe par cet evenement.
|
|
FBridge.RestoreLazyBar;
|
|
FAutofillTargetHWND := 0;
|
|
// 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
|
|
// (retrying caused the loaded page to reload every interval, making icons
|
|
// flash). If Edge needed longer than 1.5 s, user clicks Reload.
|
|
FNavTimer := TTimer.Create(Self);
|
|
FNavTimer.Interval := 1500;
|
|
FNavTimer.Enabled := False;
|
|
FNavTimer.OnTimer := NavTimerTick;
|
|
|
|
FKeepWarmTimer := TTimer.Create(Self);
|
|
FKeepWarmTimer.Interval := 5 * 60 * 1000;
|
|
FKeepWarmTimer.OnTimer := KeepWarmTick;
|
|
|
|
UpdateButtons;
|
|
LogLine('Password Manager - d backend ready.');
|
|
LogLine('Click Start to launch server + embedded web vault.');
|
|
PanelTop.Visible := False;
|
|
FRequireAccessToken := True;
|
|
FRequireProcessCheck := True;
|
|
edtPort.Text := '0';
|
|
if FileExists('config.txt') then
|
|
begin
|
|
var configList := TStringList.Create;
|
|
configList.LoadFromFile('config.txt');
|
|
try
|
|
var defPort := StrToIntDef(configList.Values['port'], 0);
|
|
edtPort.Text := defPort.ToString;
|
|
PanelTop.Visible := configList.Values['debug'].ToLower.Equals('true');
|
|
if configList.Values['require_token'].ToLower.Equals('false') then
|
|
FRequireAccessToken := False;
|
|
if configList.Values['require_process_check'].ToLower.Equals('false') then
|
|
FRequireProcessCheck := False;
|
|
finally
|
|
FreeAndNil(configList);
|
|
end;
|
|
end;
|
|
// Starting the server (DB open + Indy bind + WebView2 navigation) is what
|
|
// makes FormCreate run for ~1 s. On a --tray launch it therefore runs AFTER
|
|
// the tray hide, in the deferred block below, so FormCreate returns in
|
|
// milliseconds and the app is tray-resident right away.
|
|
//
|
|
// NOTE: this was originally needed to shrink the window during which the FMX
|
|
// taskbar proxy was visible. That is no longer the reason — the proxy is now
|
|
// dealt with properly in the .dpr (StartTrayProxyHider, WS_EX_TOOLWINDOW).
|
|
// The ordering is kept because it works and reaching the tray sooner is
|
|
// desirable, NOT because anything depends on it. A normal launch keeps the
|
|
// original order. FServer is constructed earlier (see above), so FServer.Active
|
|
// reads False rather than faulting while the start is pending.
|
|
if not FindCmdLineSwitch('tray', True) then
|
|
btnStartClick(Nil);
|
|
btnToggleLogClick(nil);
|
|
|
|
// CLI flag --tray (set by the "Start with Windows" registry entry):
|
|
// launch directly into the tray instead of popping a window during
|
|
// the Windows login. The window was already made invisible at the top of
|
|
// FormCreate; this adds the tray icon and drops the taskbar entry. Still
|
|
// deferred: it is also the safety net if FMX shows the main form anyway.
|
|
if FindCmdLineSwitch('tray', True) then
|
|
begin
|
|
// Suppress the first-time tray balloon for autostart launches —
|
|
// it's noise when Windows itself put us in the tray (the user
|
|
// didn't actively minimise). Manual launches still see it once.
|
|
FBridge.BalloonShown := True;
|
|
TThread.ForceQueue(nil,
|
|
procedure
|
|
begin
|
|
// Hand the foreground back: a -tray launch DOES take it for a moment —
|
|
// FMX puts its taskbar proxy up before we hide it — so the application
|
|
// that launched us (a console, Explorer) would otherwise be left
|
|
// inactive. GStartupForeground caught it before any window of ours
|
|
// existed. Nothing to give back on a Windows-startup launch: the
|
|
// foreground is then a shell window, which the capture filters out.
|
|
FBridge.MinimizeToTray(True, True);
|
|
// Hidden now — safe to pay the startup cost.
|
|
btnStartClick(Nil);
|
|
LogLine('Launched with --tray, started hidden (balloon suppressed)');
|
|
end);
|
|
end;
|
|
end;
|
|
|
|
procedure TMainForm.FormDestroy(Sender: TObject);
|
|
begin
|
|
if FWinEventHook <> 0 then
|
|
begin
|
|
UnhookWinEvent(FWinEventHook);
|
|
FWinEventHook := 0;
|
|
end;
|
|
if Assigned(FFileSaveChunks) then
|
|
begin
|
|
for var LSB in FFileSaveChunks.Values do LSB.Free;
|
|
FFileSaveChunks.Free;
|
|
end;
|
|
FBridge.Free;
|
|
FServer.Free;
|
|
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
|
|
// boots / cold-start machines Edge Chromium can take 2-3 s, and the
|
|
// timer's Navigate() call lands while the browser is still uninitialised
|
|
// → silently dropped → blank window forever. OnInitialized fires once
|
|
// the engine is ready, so if a navigation is still pending here, do it
|
|
// now. The timer either already ran (FPendingURL == '') or runs later
|
|
// and no-ops on the empty string.
|
|
FBrowserInitialized := True;
|
|
FNavTimer.Enabled := False;
|
|
if FPendingURL <> '' then
|
|
begin
|
|
LogLine('OnInitialized fallback nav to: ' + MaskAccessToken(FPendingURL));
|
|
WebBrowser.Navigate(FPendingURL);
|
|
FPendingURL := '';
|
|
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;
|
|
if NativeModalActive then Exit;
|
|
FAutofillTargetHWND := ATargetHWND;
|
|
// Tray-hidden ONLY (Visible=false). A window merely minimised to the
|
|
// taskbar must NOT count: hide-after would send it to the TRAY on close —
|
|
// it vanishes from the taskbar it came from. Restored-from-iconic just
|
|
// stays open after the modal closes.
|
|
LWasHidden := not Self.Visible;
|
|
RestoreWindowFromTray;
|
|
// The window is back but the WebView2 child does NOT hold the keyboard
|
|
// focus, so nothing typed reaches the page — Esc did nothing until the user
|
|
// clicked the search box. DOM .focus() alone cannot fix that: the OS-level
|
|
// focus has to be on the browser control first.
|
|
WebBrowser.SetFocus;
|
|
LWasHiddenJs := BoolToStr(LWasHidden, True).ToLower;
|
|
// Second arg forFill tells JS to SendInput on pick instead of copy. The bridge
|
|
// reports HWND 0 when the foreground was our own hidden window: there is
|
|
// nothing to type into, so offer copy instead of a fill that goes nowhere.
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&typeof Bridge.openQuickSearch==="function")' +
|
|
'Bridge.openQuickSearch(' + LWasHiddenJs + ',' +
|
|
BoolToStr(ATargetHWND <> 0, True).ToLower + ')');
|
|
LogLine(Format('Quick-search hotkey — target=%s, title="%s"',
|
|
[IntToHex(ATargetHWND, 8), ATitle]));
|
|
end;
|
|
|
|
// A native modal dialog (Save As, folder picker) DISABLES its owner window for
|
|
// as long as it is up. Restoring / focusing that owner then strands the dialog
|
|
// behind everything: the app looks frozen, when it is merely disabled by a modal
|
|
// the user can no longer find. Every hotkey entry point checks this first — and
|
|
// rather than just refusing, it surfaces the dialog, which is what the user
|
|
// actually needs.
|
|
// The common Save/Open dialog is NOT an owned popup of the form —
|
|
// GetLastActivePopup returns the form itself — but it does live on our thread
|
|
// and carries the standard dialog class. Enumerating the thread's windows finds
|
|
// it whoever its owner turns out to be.
|
|
function EnumOwnDialogProc(AWnd: HWND; AParam: LPARAM): BOOL; stdcall;
|
|
var
|
|
LCls: array[0..63] of Char;
|
|
begin
|
|
Result := True; // keep enumerating
|
|
if (AWnd = HWND(AParam)) or (not IsWindowVisible(AWnd)) then Exit;
|
|
if GetClassName(AWnd, LCls, Length(LCls)) = 0 then Exit;
|
|
if SameText(LCls, '#32770') then
|
|
begin
|
|
GFoundDialogHwnd := AWnd;
|
|
Result := False; // stop
|
|
end;
|
|
end;
|
|
|
|
function TMainForm.NativeModalActive: Boolean;
|
|
var
|
|
LHwnd, LPopup: HWND;
|
|
begin
|
|
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
|
|
Result := (LHwnd <> 0) and (not IsWindowEnabled(LHwnd));
|
|
if not Result then Exit;
|
|
GFoundDialogHwnd := 0;
|
|
EnumThreadWindows(GetCurrentThreadId, @EnumOwnDialogProc, LPARAM(LHwnd));
|
|
LPopup := GFoundDialogHwnd;
|
|
if LPopup = 0 then LPopup := GetLastActivePopup(LHwnd); // fallback
|
|
if (LPopup <> 0) and (LPopup <> LHwnd) and IsWindow(LPopup) then
|
|
begin
|
|
ShowWindow(LPopup, SW_SHOW);
|
|
// Windows refuses SetForegroundWindow to a process that isn't already in
|
|
// front, and the hotkey comes from ANOTHER application. The way through is
|
|
// to attach our input queue to the CURRENT FOREGROUND thread — not to the
|
|
// dialog's, which is our own thread and would make the attach a no-op
|
|
// (that is why the autofill helper, written for cross-process targets,
|
|
// left the dialog raised but unfocused).
|
|
var LFgThread := GetWindowThreadProcessId(GetForegroundWindow, nil);
|
|
var LOurThread := GetCurrentThreadId;
|
|
var LAttached := (LFgThread <> 0) and (LFgThread <> LOurThread) and
|
|
AttachThreadInput(LOurThread, LFgThread, True);
|
|
try
|
|
BringWindowToTop(LPopup);
|
|
SetForegroundWindow(LPopup);
|
|
finally
|
|
if LAttached then AttachThreadInput(LOurThread, LFgThread, False);
|
|
end;
|
|
// A tray balloon, not a toast: our own window is disabled and sits behind
|
|
// the dialog, so nothing rendered in the page would be seen.
|
|
FBridge.ShowBalloon('Finish the open dialog',
|
|
'A Save or folder dialog is waiting for an answer. Shortcuts stay ' +
|
|
'disabled until you close it.', True);
|
|
LogLine('Hotkey ignored — a native dialog is open; brought it to front.');
|
|
end;
|
|
end;
|
|
|
|
procedure TMainForm.BridgeQuickSearchRequest;
|
|
var
|
|
LWasHidden: Boolean;
|
|
LWasHiddenJs: string;
|
|
begin
|
|
// Tray menu "Quick search…" — bring the window back so the user can
|
|
// see the modal, then ask JS to open it. JS handles the locked-vault
|
|
// case (shows the auth screen with master-pw focused instead).
|
|
if not FServer.Active then Exit;
|
|
if NativeModalActive then Exit;
|
|
// Remember whether the window was TRAY-hidden BEFORE we restore — after
|
|
// the user picks an entry the JS layer asks us to minimize back so they
|
|
// can paste into the target app without an extra alt-tab. Iconic-only
|
|
// (taskbar) does NOT count — see BridgeQuickSearchHotkey.
|
|
LWasHidden := not Self.Visible;
|
|
RestoreWindowFromTray;
|
|
WebBrowser.SetFocus; // see BridgeQuickSearchHotkey — keystrokes need it
|
|
LWasHiddenJs := BoolToStr(LWasHidden, True).ToLower;
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&typeof Bridge.openQuickSearch==="function")' +
|
|
'Bridge.openQuickSearch(' + LWasHiddenJs + ')');
|
|
LogLine('Quick search requested from tray menu (wasHidden=' + LWasHiddenJs + ')');
|
|
end;
|
|
|
|
procedure TMainForm.BridgeNewEntryHotkey(const AWindowTitle: string);
|
|
var
|
|
EscapedTitle: string;
|
|
begin
|
|
if not FServer.Active then Exit;
|
|
if NativeModalActive then Exit;
|
|
RestoreWindowFromTray;
|
|
EscapedTitle := StringReplace(AWindowTitle, '\', '\\', [rfReplaceAll]);
|
|
EscapedTitle := StringReplace(EscapedTitle, '"', '\"', [rfReplaceAll]);
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&typeof Bridge.onNewEntryFromTitle==="function")' +
|
|
'Bridge.onNewEntryFromTitle("' + EscapedTitle + '")');
|
|
LogLine('New entry hotkey — title: "' + AWindowTitle + '"');
|
|
end;
|
|
|
|
procedure TMainForm.BridgeDebugHotkey;
|
|
begin
|
|
if not FileExists('config.txt') then
|
|
Exit;
|
|
PanelTop.Visible := not PanelTop.Visible;
|
|
LogLine('Debug panel ' + IfThen(PanelTop.Visible, 'shown', 'hidden') +
|
|
' via Ctrl+Shift+D');
|
|
end;
|
|
|
|
Procedure TMainForm.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
|
|
begin
|
|
// The tray-menu "Quit" handler sets FQuitting before triggering close,
|
|
// so we bypass the minimize-to-tray intercept in that case.
|
|
if FQuitting then Exit;
|
|
|
|
// Windows shutdown / logoff / restart: WM_QUERYENDSESSION flipped the
|
|
// bridge's flag. Let the form close normally so FServer.Free and the
|
|
// FireDAC connection get a chance to checkpoint the WAL — otherwise
|
|
// Windows force-kills us at the shutdown timeout and we leave
|
|
// -shm / -wal files next to vault.db.
|
|
if Assigned(FBridge) and FBridge.ShutdownPending then
|
|
begin
|
|
FQuitting := True;
|
|
LogLine('System shutdown detected — closing normally.');
|
|
Exit;
|
|
end;
|
|
|
|
// Otherwise: minimize to tray on close instead of quitting, so the vault
|
|
// stays available without the dev-panel being visible.
|
|
// When the server is stopped, allow normal close — there's no vault to
|
|
// keep alive in the background.
|
|
if FServer.Active then
|
|
begin
|
|
CanClose := False;
|
|
FBridge.MinimizeToTray;
|
|
LogLine('Minimized to tray. Click the tray icon to restore.');
|
|
end;
|
|
end;
|
|
|
|
procedure TMainForm.LogLine(const AMsg: string);
|
|
begin
|
|
// Synchronize handles both cases: if already on main thread, runs inline;
|
|
// otherwise marshals. Avoids overload resolution issues with TThread.Queue.
|
|
TThread.Synchronize(nil,
|
|
procedure
|
|
begin
|
|
Memo.Lines.Add(FormatDateTime('hh:nn:ss', Now) + ' ' + AMsg);
|
|
Memo.GoToTextEnd;
|
|
end);
|
|
end;
|
|
|
|
procedure TMainForm.UpdateButtons;
|
|
begin
|
|
btnStart.Enabled := not FServer.Active;
|
|
btnStop.Enabled := FServer.Active;
|
|
btnReload.Enabled := FServer.Active;
|
|
edtPort.Enabled := not FServer.Active;
|
|
if FServer.Active then
|
|
lblStatus.Text := 'Running on http://127.0.0.1:' + edtPort.Text
|
|
else
|
|
lblStatus.Text := 'Stopped';
|
|
end;
|
|
|
|
function MaskAccessToken(const AUrl: string): string;
|
|
var
|
|
TokenPos: Integer;
|
|
begin
|
|
Result := AUrl;
|
|
TokenPos := Pos('?pmt=', Result);
|
|
if TokenPos > 0 then
|
|
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.WriteDecodedFile(const APath, AB64, AReqId: string);
|
|
var
|
|
LOk: Boolean;
|
|
LErr: string;
|
|
begin
|
|
LOk := False;
|
|
LErr := '';
|
|
try
|
|
var LBytes := TNetEncoding.Base64.DecodeStringToBytes(AB64);
|
|
var LStream := TFileStream.Create(APath, 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)', [APath, Length(LBytes)]));
|
|
except
|
|
on E: Exception do
|
|
begin
|
|
LErr := E.Message;
|
|
LogLine('File write FAILED for "' + APath + '": ' + LErr);
|
|
end;
|
|
end;
|
|
var LEscReq := StringReplace(AReqId, '"', '\"', [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;
|
|
|
|
procedure TMainForm.NavigateToVault;
|
|
begin
|
|
FPendingURL := 'http://127.0.0.1:' + FServer.BoundPort.ToString + '/index.html';
|
|
if FServer.RequireAccessToken then
|
|
FPendingURL := FPendingURL + '?pmt=' + FServer.AccessToken;
|
|
LogLine('Will navigate embedded browser in ~1.5s to: ' + MaskAccessToken(FPendingURL));
|
|
FNavRetries := 0;
|
|
FNavTimer.Enabled := False;
|
|
FNavTimer.Enabled := True;
|
|
end;
|
|
|
|
procedure TMainForm.KeepWarmTick(Sender: TObject);
|
|
begin
|
|
if FBrowserInitialized then
|
|
WebBrowser.ExecuteJavaScript('window.Bridge&&Bridge.active');
|
|
end;
|
|
|
|
procedure TMainForm.NavTimerTick(Sender: TObject);
|
|
begin
|
|
FNavTimer.Enabled := False;
|
|
if FPendingURL = '' then Exit;
|
|
// Engine not ready yet: Navigate() would be silently dropped. Leave
|
|
// FPendingURL intact and re-arm — either this timer catches the engine
|
|
// once it's up, or OnInitialized fires first and does the nav. Whoever
|
|
// wins clears FPendingURL so the other no-ops (no reload flash).
|
|
// Bounded to ~10 retries (15 s): if OnInitialized never fires (missing /
|
|
// broken WebView2 runtime), we stop deferring and attempt Navigate once
|
|
// anyway — best effort beats an eternal retry loop on a blank window.
|
|
if (not FBrowserInitialized) and (FNavRetries < 10) then
|
|
begin
|
|
Inc(FNavRetries);
|
|
LogLine(Format('Nav deferred — WebView2 not initialised (retry %d/10).',
|
|
[FNavRetries]));
|
|
FNavTimer.Enabled := True;
|
|
Exit;
|
|
end;
|
|
if not FBrowserInitialized then
|
|
LogLine('WebView2 still not initialised after retries — attempting nav anyway.');
|
|
LogLine('Navigating to: ' + MaskAccessToken(FPendingURL));
|
|
WebBrowser.Navigate(FPendingURL);
|
|
FPendingURL := '';
|
|
end;
|
|
|
|
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);
|
|
edtPort.Text := FServer.BoundPort.ToString;
|
|
UpdateButtons;
|
|
NavigateToVault;
|
|
except
|
|
on E: Exception do
|
|
begin
|
|
LogLine('ERROR starting server: ' + E.Message);
|
|
TDialogService.MessageDialog('Failed to start: ' + E.Message,
|
|
TMsgDlgType.mtError, [TMsgDlgBtn.mbOK], TMsgDlgBtn.mbOK, 0, nil);
|
|
end;
|
|
end;
|
|
end;
|
|
|
|
procedure TMainForm.btnStopClick(Sender: TObject);
|
|
begin
|
|
FServer.Stop;
|
|
UpdateButtons;
|
|
FPendingURL := '';
|
|
FNavTimer.Enabled := False;
|
|
WebBrowser.Navigate('about:blank');
|
|
end;
|
|
|
|
procedure TMainForm.btnReloadClick(Sender: TObject);
|
|
begin
|
|
if FServer.Active then NavigateToVault;
|
|
end;
|
|
|
|
procedure TMainForm.btnToggleLogClick(Sender: TObject);
|
|
begin
|
|
PanelLog.Visible := not PanelLog.Visible;
|
|
Splitter.Visible := PanelLog.Visible;
|
|
if PanelLog.Visible then
|
|
btnToggleLog.Text := 'Hide log'
|
|
else
|
|
btnToggleLog.Text := 'Show log';
|
|
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
|
|
URL, Cmd, ParamStr: string;
|
|
P: Integer;
|
|
begin
|
|
URL := Params.URL;
|
|
if not URL.StartsWith('cmd://') then Exit;
|
|
|
|
Params.Cancel := True;
|
|
URL := URL.Substring(6); // strip 'cmd://'
|
|
|
|
P := Pos('?', URL);
|
|
if P > 0 then
|
|
begin
|
|
Cmd := Copy(URL, 1, P - 1);
|
|
ParamStr := Copy(URL, P + 1, MaxInt);
|
|
end
|
|
else
|
|
begin
|
|
Cmd := URL;
|
|
ParamStr := '';
|
|
end;
|
|
|
|
// Defer to avoid WebView2 re-entrance issues.
|
|
TThread.ForceQueue(nil,
|
|
procedure
|
|
begin
|
|
HandleBridgeCommand(Cmd, ParamStr);
|
|
end);
|
|
end;
|
|
|
|
procedure TMainForm.HandleBridgeCommand(const ACmd, AParams: string);
|
|
|
|
function GetParam(const AKey: string): string;
|
|
var
|
|
Parts: TArray<string>;
|
|
Part, K, V: string;
|
|
EqPos: Integer;
|
|
begin
|
|
Result := '';
|
|
Parts := AParams.Split(['&']);
|
|
for Part in Parts do
|
|
begin
|
|
EqPos := Pos('=', Part);
|
|
if EqPos > 0 then
|
|
begin
|
|
K := Copy(Part, 1, EqPos - 1);
|
|
V := Copy(Part, EqPos + 1, MaxInt);
|
|
if SameText(K, AKey) then
|
|
begin
|
|
Result := TNetEncoding.URL.Decode(V);
|
|
Exit;
|
|
end;
|
|
end;
|
|
end;
|
|
end;
|
|
|
|
var
|
|
LText: string;
|
|
LClearMs: Integer;
|
|
begin
|
|
if ACmd = 'clipboard/copy' then
|
|
begin
|
|
LText := GetParam('text');
|
|
LClearMs := StrToIntDef(GetParam('clear'), 30000);
|
|
FBridge.SecureClipboard.SetText(LText, LClearMs);
|
|
LogLine(Format('Secure clipboard set (auto-clear in %ds)', [LClearMs div 1000]));
|
|
end
|
|
|
|
else if ACmd = 'clipboard/clear' then
|
|
begin
|
|
FBridge.SecureClipboard.Clear;
|
|
LogLine('Clipboard cleared by JS request');
|
|
end
|
|
|
|
else if ACmd = 'clipboard/read' then
|
|
begin
|
|
var ClipText := FBridge.SecureClipboard.ReadText;
|
|
var Escaped := StringReplace(ClipText, '\', '\\', [rfReplaceAll]);
|
|
Escaped := StringReplace(Escaped, '"', '\"', [rfReplaceAll]);
|
|
Escaped := StringReplace(Escaped, #13, '\r', [rfReplaceAll]);
|
|
Escaped := StringReplace(Escaped, #10, '\n', [rfReplaceAll]);
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onClipboardRead)Bridge.onClipboardRead("' + Escaped + '")');
|
|
end
|
|
|
|
// ---- Quick unlock (DPAPI persistence of the vault key) ----
|
|
// store: client provides a base64-encoded blob (UTF-8 JSON, content
|
|
// opaque to us). We DPAPI-encrypt and stash on disk.
|
|
// get: we DPAPI-decrypt, base64-encode, send back via ExecuteJavaScript.
|
|
// clear: forget-me.
|
|
else if ACmd = 'quickunlock/store' then
|
|
begin
|
|
LText := GetParam('data'); // base64 of UTF-8 JSON blob
|
|
if LText = '' then Exit;
|
|
var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LText);
|
|
if PM.QuickUnlock.StoreQuickUnlock(LBytes) then
|
|
LogLine(Format('Quick unlock stored (%d bytes)', [Length(LBytes)]))
|
|
else
|
|
LogLine('Quick unlock store FAILED (DPAPI error)');
|
|
end
|
|
|
|
else if ACmd = 'quickunlock/get' then
|
|
begin
|
|
var LBytes: TBytes;
|
|
if PM.QuickUnlock.LoadQuickUnlock(LBytes) and (Length(LBytes) > 0) then
|
|
begin
|
|
var LB64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
|
|
// Strip newlines that the Base64 encoder may insert (line-wrapping
|
|
// breaks the JS-side decoder) before injecting into a JS string.
|
|
LB64 := StringReplace(LB64, #13, '', [rfReplaceAll]);
|
|
LB64 := StringReplace(LB64, #10, '', [rfReplaceAll]);
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onQuickUnlockResult)' +
|
|
'Bridge.onQuickUnlockResult("' + LB64 + '")');
|
|
LogLine('Quick unlock served');
|
|
end
|
|
else
|
|
begin
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onQuickUnlockResult)' +
|
|
'Bridge.onQuickUnlockResult(null)');
|
|
end;
|
|
end
|
|
|
|
else if ACmd = 'quickunlock/clear' then
|
|
begin
|
|
PM.QuickUnlock.ClearQuickUnlock;
|
|
LogLine('Quick unlock cleared');
|
|
end
|
|
|
|
else if ACmd = 'quickunlock/status' then
|
|
begin
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onQuickUnlockStatus)' +
|
|
'Bridge.onQuickUnlockStatus(' +
|
|
BoolToStr(PM.QuickUnlock.HasQuickUnlock, True).ToLower + ')');
|
|
end
|
|
|
|
// ---- PIN unlock (DPAPI blob, PIN-derived wrap of the vault key) ----
|
|
// Same wire model as quickunlock — opaque base64 payload in / out.
|
|
else if ACmd = 'pin/store' then
|
|
begin
|
|
LText := GetParam('data');
|
|
if LText = '' then Exit;
|
|
var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LText);
|
|
if PM.PinUnlock.StorePinUnlock(LBytes) then
|
|
LogLine(Format('PIN blob stored (%d bytes)', [Length(LBytes)]))
|
|
else
|
|
LogLine('PIN blob store FAILED (DPAPI error)');
|
|
end
|
|
|
|
else if ACmd = 'pin/get' then
|
|
begin
|
|
var LBytes: TBytes;
|
|
if PM.PinUnlock.LoadPinUnlock(LBytes) and (Length(LBytes) > 0) then
|
|
begin
|
|
var LB64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
|
|
LB64 := StringReplace(LB64, #13, '', [rfReplaceAll]);
|
|
LB64 := StringReplace(LB64, #10, '', [rfReplaceAll]);
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onPinResult)' +
|
|
'Bridge.onPinResult("' + LB64 + '")');
|
|
LogLine('PIN blob served');
|
|
end
|
|
else
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onPinResult)Bridge.onPinResult(null)');
|
|
end
|
|
|
|
else if ACmd = 'pin/clear' then
|
|
begin
|
|
PM.PinUnlock.ClearPinUnlock;
|
|
LogLine('PIN blob cleared');
|
|
end
|
|
|
|
else if ACmd = 'pin/status' then
|
|
begin
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onPinStatus)' +
|
|
'Bridge.onPinStatus(' +
|
|
BoolToStr(PM.PinUnlock.HasPinUnlock, True).ToLower + ')');
|
|
end
|
|
|
|
// ---- Autofill --------------------------------------------------------
|
|
// configure: JS calls this on page load / settings change to sync the
|
|
// hotkey registration state with the user's localStorage preference.
|
|
// Uses the historical defaults (Ctrl+Shift+L / Ctrl+Shift+P) — for
|
|
// custom combos, JS sends cmd://autofill/hotkeys instead.
|
|
else if ACmd = 'autofill/configure' then
|
|
begin
|
|
if GetParam('enabled') = '1' then
|
|
begin
|
|
FBridge.RegisterAutofillHotkey;
|
|
LogLine('Autofill hotkeys registered (defaults)');
|
|
end
|
|
else
|
|
begin
|
|
FBridge.UnregisterAutofillHotkey;
|
|
LogLine('Autofill hotkeys unregistered');
|
|
end;
|
|
end
|
|
|
|
// hotkeys: JS pushes the user-configured combos. Params:
|
|
// enabled = '1' | '0'
|
|
// full_mods = MOD_x bitmask (decimal), full_vk = VK code (decimal)
|
|
// pwd_mods, pwd_vk = same for the password-only hotkey
|
|
// If enabled=0, we just unregister and ignore the rest. If enabled=1,
|
|
// we register both with the supplied combos (replacing any prior).
|
|
else if ACmd = 'autofill/hotkeys' then
|
|
begin
|
|
// Slot ids of the combos Windows refused (already held by another app).
|
|
// Ids, not labels: JS owns the wording AND uses them to flag the exact
|
|
// row in Settings. A combo the user cleared (vk=0) is never listed.
|
|
var LFailed := '';
|
|
// Quick-search combo is set unconditionally (independent of the
|
|
// autofill enabled flag) so the picker stays armed even when the
|
|
// autofill hotkeys are turned off.
|
|
if GetParam('qs_vk') <> '' then
|
|
begin
|
|
var LQsMods := Word(StrToIntDef(GetParam('qs_mods'), 6));
|
|
var LQsVk := Word(StrToIntDef(GetParam('qs_vk'), Ord('Q')));
|
|
var LQsOk := FBridge.SetQuickSearchHotkey(LQsMods, LQsVk);
|
|
if (not LQsOk) and (LQsVk <> 0) then LFailed := LFailed + 'quickSearch,';
|
|
LogLine(Format('Quick-search hotkey set — mods:%d vk:%d (ok=%s)',
|
|
[LQsMods, LQsVk, BoolToStr(LQsOk, True)]));
|
|
end;
|
|
// New-entry combo — also independent of the autofill enabled flag
|
|
// (Ctrl+Shift+A works even with autofill turned off).
|
|
if GetParam('ne_vk') <> '' then
|
|
begin
|
|
var LNeMods := Word(StrToIntDef(GetParam('ne_mods'), 6));
|
|
var LNeVk := Word(StrToIntDef(GetParam('ne_vk'), Ord('A')));
|
|
var LNeOk := FBridge.SetNewEntryHotkey(LNeMods, LNeVk);
|
|
if (not LNeOk) and (LNeVk <> 0) then LFailed := LFailed + 'newEntry,';
|
|
LogLine(Format('New-entry hotkey set — mods:%d vk:%d (ok=%s)',
|
|
[LNeMods, LNeVk, BoolToStr(LNeOk, True)]));
|
|
end;
|
|
if GetParam('enabled') <> '1' then
|
|
begin
|
|
FBridge.UnregisterAutofillHotkey;
|
|
LogLine('Autofill hotkeys unregistered (custom)');
|
|
end
|
|
else
|
|
begin
|
|
var LFullMods := Word(StrToIntDef(GetParam('full_mods'), 6)); // Ctrl+Shift
|
|
var LFullVk := Word(StrToIntDef(GetParam('full_vk'), Ord('L')));
|
|
var LPwdMods := Word(StrToIntDef(GetParam('pwd_mods'), 6));
|
|
var LPwdVk := Word(StrToIntDef(GetParam('pwd_vk'), Ord('P')));
|
|
// 2FA combo — vk 0 when the user cleared it, which skips registration.
|
|
var LTotpMods := Word(StrToIntDef(GetParam('totp_mods'), 6));
|
|
var LTotpVk := Word(StrToIntDef(GetParam('totp_vk'), Ord('T')));
|
|
var LAllOk := FBridge.SetAutofillHotkeys(LFullMods, LFullVk,
|
|
LPwdMods, LPwdVk,
|
|
LTotpMods, LTotpVk);
|
|
LogLine(Format('Autofill hotkeys set — full=mods:%d vk:%d pwd=mods:%d vk:%d totp=mods:%d vk:%d (all_ok=%s)',
|
|
[LFullMods, LFullVk, LPwdMods, LPwdVk, LTotpMods, LTotpVk, BoolToStr(LAllOk, True)]));
|
|
if (not FBridge.AutofillFullActive) and (LFullVk <> 0) then
|
|
LFailed := LFailed + 'full,';
|
|
if (not FBridge.AutofillPwdActive) and (LPwdVk <> 0) then
|
|
LFailed := LFailed + 'password,';
|
|
if (not FBridge.AutofillTotpActive) and (LTotpVk <> 0) then
|
|
LFailed := LFailed + 'totp,';
|
|
end;
|
|
// One notification for the whole batch, naming the combos that failed —
|
|
// empty string = everything registered.
|
|
LFailed := LFailed.TrimRight([',', ' ']);
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onAutofillHotkeysResult)' +
|
|
'Bridge.onAutofillHotkeysResult("' + LFailed.Replace('"', '') + '")');
|
|
end
|
|
|
|
// execute: JS has matched an entry, decrypted the password, and is
|
|
// telling Delphi to type username + Tab + password into the saved HWND.
|
|
else if ACmd = 'autofill/execute' then
|
|
begin
|
|
FAutofillPendingUser := GetParam('username');
|
|
FAutofillPendingPass := GetParam('password');
|
|
FAutofillPendingHWND := FAutofillTargetHWND;
|
|
FAutofillPendingHide := (GetParam('hide_after') = '1') or FAutofillHotkeyWasHidden;
|
|
FAutofillHotkeyWasHidden := False;
|
|
FAutofillPendingUserOnly := GetParam('field') = 'user';
|
|
FAutofillPendingClear := GetParam('clear') <> '0'; // absent = ON
|
|
FAutofillPendingNotify := GetParam('notify') <> '0'; // absent = ON
|
|
FAutofillTargetHWND := 0;
|
|
|
|
// PREMIER SEGMENT : combien la PAGE a mis a repondre.
|
|
//
|
|
// Tout ce qui precede est du JavaScript - reveil du moteur, appariement du
|
|
// titre, dechiffrement du mot de passe. Si le chiffre est gros ici et petit
|
|
// plus bas, c'est le renderer qu'il faut regarder, pas la sequence de
|
|
// frappe. Le contraire designe SendInput et la prise du premier plan.
|
|
if FAutofillClock.IsRunning then
|
|
LogLine(Format('Autofill: page answered in %d ms',
|
|
[FAutofillClock.ElapsedMilliseconds]));
|
|
|
|
// Small timer so SetForegroundWindow has time to take effect before
|
|
// SendInput fires — avoids the first keystrokes going to our window.
|
|
// TTimer.OnTimer is a TNotifyEvent (method, not anon proc) → we use a
|
|
// dedicated method on the form and stash the payload in fields.
|
|
var LTimer := TTimer.Create(Self);
|
|
LTimer.Interval := 60;
|
|
LTimer.OnTimer := AutofillTimerTick;
|
|
LTimer.Enabled := True;
|
|
end
|
|
|
|
// cancel: JS found no match or user dismissed the picker — nothing to type.
|
|
// Hand the foreground back to the window the hotkey came from (Esc in the
|
|
// quick-search means "never mind, return me to where I was"). We're the
|
|
// foreground process at that moment, so plain SetForegroundWindow works.
|
|
else if ACmd = 'autofill/cancel' then
|
|
begin
|
|
// refocus=0 — JS is REFUSING the request, not ending a user interaction:
|
|
// nothing must move on screen. During an export a native Save dialog owns
|
|
// the foreground, and pushing it behind leaves the app disabled by a modal
|
|
// the user can no longer find.
|
|
var LRefocus := GetParam('refocus') <> '0';
|
|
if LRefocus and (FAutofillTargetHWND <> 0) then
|
|
SetForegroundWindow(FAutofillTargetHWND);
|
|
FAutofillTargetHWND := 0;
|
|
// The window only came out of the tray to show the picker, so dismissing it
|
|
// puts it back — same as quick-search, which hides on Esc too. Done after
|
|
// handing focus to the target above.
|
|
if FAutofillHotkeyWasHidden and LRefocus then
|
|
begin
|
|
FAutofillHotkeyWasHidden := False;
|
|
// Foreground already handed to the target above.
|
|
FBridge.MinimizeToTray(True, False);
|
|
end;
|
|
LogLine('Autofill cancelled (no match or dismissed)');
|
|
end
|
|
|
|
// focus: JS asks us to bring the main window to front (e.g. when the
|
|
// autofill picker opens — without this the picker is shown in the
|
|
// WebView but the user might not notice if our window was minimised
|
|
// or behind other apps). The Target HWND stays saved; ExecuteAutofill
|
|
// restores it later via ForceForegroundWindow.
|
|
else if ACmd = 'app/focus' then
|
|
begin
|
|
RestoreWindowFromTray;
|
|
LogLine('App brought to front (autofill picker)');
|
|
end
|
|
|
|
// Used by the quick-search modal: after the user picks an entry the
|
|
// password is on the clipboard — if the app was hidden when invoked
|
|
// from the tray menu, hide it again so the user can paste straight
|
|
// into the target app without alt-tabbing.
|
|
else if ACmd = 'app/minimize' then
|
|
// keepclip=1 → don't wipe the clipboard on minimise (quick-search
|
|
// copy-then-hide flow). Default clears it as before.
|
|
FBridge.MinimizeToTray(GetParam('keepclip') <> '1')
|
|
|
|
// Tray balloon notifications on/off. JS pushes the user setting at
|
|
// startup (settings_json sync) and whenever they flip the toggle.
|
|
else if ACmd = 'tray/notifications' then
|
|
begin
|
|
FBridge.ShowNotifications := GetParam('enabled') = '1';
|
|
LogLine('Tray notifications ' +
|
|
IfThen(FBridge.ShowNotifications, 'enabled', 'disabled'));
|
|
end
|
|
|
|
else if ACmd = 'app/ready' then
|
|
begin
|
|
WebBrowser.SetFocus;
|
|
WebBrowser.ExecuteJavaScript(
|
|
'setTimeout(()=>{var u=document.getElementById("loginUsername"),' +
|
|
'p=document.getElementById("loginPassword");' +
|
|
'if(u&&u.value){p&&p.focus();}else{u&&u.focus();}},0)');
|
|
end
|
|
|
|
else if ACmd = 'app/theme' then
|
|
FBridge.ApplyTitleBarTheme(GetParam('mode') = 'dark')
|
|
|
|
// Build/version string exposed to JS for the Settings → About panel.
|
|
// Hardcoded const — bump manually on releases. Kept simple to avoid
|
|
// pulling Windows resource version info at runtime.
|
|
else if ACmd = 'app/version' then
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onVersionResult)' +
|
|
'Bridge.onVersionResult("' + APP_VERSION + '")')
|
|
|
|
// Launch context: the HKCU Run entry passes -tray so we can tell
|
|
// "Windows started me at boot" from "user double-clicked the exe".
|
|
// Useful for Settings → Account display and for conditional behavior
|
|
// (e.g. skip first-time tooltips on autostart).
|
|
else if ACmd = 'app/launch-mode' then
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onLaunchModeResult)' +
|
|
'Bridge.onLaunchModeResult("' +
|
|
IfThen(FindCmdLineSwitch('tray', True), 'auto', 'manual') + '")')
|
|
|
|
// Open the entry's site in the user's default browser. We restrict the
|
|
// scheme to http(s) so JS can't smuggle a file:// or other handler that
|
|
// would invoke arbitrary Windows applications.
|
|
else if ACmd = 'app/open-url' then
|
|
begin
|
|
var LUrl := GetParam('url');
|
|
if (LUrl <> '') and
|
|
(LUrl.ToLower.StartsWith('http://') or LUrl.ToLower.StartsWith('https://')) then
|
|
begin
|
|
ShellExecute(0, 'open', PChar(LUrl), nil, nil, 1); // SW_SHOWNORMAL = 1
|
|
LogLine('Opened URL: ' + LUrl);
|
|
end
|
|
else
|
|
LogLine('Refused to open non-http(s) URL: ' + LUrl);
|
|
end
|
|
|
|
// ---- Device-bound prefs (DPAPI key/value) ----------------------------
|
|
// Used for prefs that must survive the ephemeral-port reset of the
|
|
// WebView2 localStorage (rememberedUsername, etc.).
|
|
else if ACmd = 'prefs/get' then
|
|
begin
|
|
var LKey := GetParam('key');
|
|
if LKey = '' then Exit;
|
|
var LVal := PM.UserPrefs.GetPref(LKey);
|
|
var LEscapedKey := StringReplace(LKey, '\', '\\', [rfReplaceAll]);
|
|
LEscapedKey := StringReplace(LEscapedKey, '"', '\"', [rfReplaceAll]);
|
|
var LEscapedVal := StringReplace(LVal, '\', '\\', [rfReplaceAll]);
|
|
LEscapedVal := StringReplace(LEscapedVal, '"', '\"', [rfReplaceAll]);
|
|
LEscapedVal := StringReplace(LEscapedVal, #13, '\r', [rfReplaceAll]);
|
|
LEscapedVal := StringReplace(LEscapedVal, #10, '\n', [rfReplaceAll]);
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onPrefResult)' +
|
|
'Bridge.onPrefResult("' + LEscapedKey + '","' + LEscapedVal + '")');
|
|
end
|
|
|
|
// Existence probe. Some prefs ARE secrets (the auto-backup password, the sync
|
|
// passwords) and the UI mostly only needs to know whether one is set. Asking
|
|
// through prefs/get would interpolate the secret into an ExecuteJavaScript
|
|
// string — measured in a renderer dump, where it also showed up in the
|
|
// DevTools protocol buffers, and it stayed there after the vault auto-locked.
|
|
// The answer rides the same callback under "<key>#has".
|
|
else if ACmd = 'prefs/has' then
|
|
begin
|
|
var LHasKey := GetParam('key');
|
|
if LHasKey = '' then Exit;
|
|
var LEsc := StringReplace(LHasKey, '\', '\\', [rfReplaceAll]);
|
|
LEsc := StringReplace(LEsc, '"', '\"', [rfReplaceAll]);
|
|
var LSet := '';
|
|
if PM.UserPrefs.GetPref(LHasKey) <> '' then LSet := '1';
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onPrefResult)' +
|
|
'Bridge.onPrefResult("' + LEsc + '#has","' + LSet + '")');
|
|
end
|
|
|
|
// ---- Barrette (Fill bar) : reglages depuis Settings -> Fill bar ---------
|
|
// Rien de secret ici : quels boutons, icones ou mots, opacite, allumee.
|
|
else if ACmd = 'fillbar/get' then
|
|
begin
|
|
var LFb := TJSONObject.Create;
|
|
try
|
|
LFb.AddPair('on', TJSONBool.Create(FBridge.LazyBarVisible));
|
|
LFb.AddPair('buttons', PM.UserPrefs.GetPref('lazyButtons'));
|
|
LFb.AddPair('icons', TJSONBool.Create(PM.UserPrefs.GetPref('lazyIcons') = '1'));
|
|
LFb.AddPair('alpha', PM.UserPrefs.GetPref('lazyAlphaPct'));
|
|
LFb.AddPair('clear', TJSONBool.Create(PM.UserPrefs.GetPref('lazyClear') = '1'));
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onFillBar)Bridge.onFillBar(' + LFb.ToJSON + ')');
|
|
finally
|
|
LFb.Free;
|
|
end;
|
|
end
|
|
|
|
else if ACmd = 'fillbar/set' then
|
|
begin
|
|
PM.UserPrefs.SetPref('lazyButtons', GetParam('buttons'));
|
|
if GetParam('icons') = '1' then PM.UserPrefs.SetPref('lazyIcons', '1')
|
|
else PM.UserPrefs.SetPref('lazyIcons', '0');
|
|
if GetParam('clear') = '1' then PM.UserPrefs.SetPref('lazyClear', '1')
|
|
else PM.UserPrefs.SetPref('lazyClear', '0');
|
|
var LAlpha := StrToIntDef(GetParam('alpha'), 60);
|
|
if (LAlpha >= 25) and (LAlpha <= 100) then
|
|
PM.UserPrefs.SetPref('lazyAlphaPct', IntToStr(LAlpha));
|
|
LogLine('fillbar/set: ' + GetParam('buttons') + ' icons=' + GetParam('icons') +
|
|
' alpha=' + GetParam('alpha') + ' on=' + GetParam('on'));
|
|
if GetParam('on') = '1' then
|
|
begin
|
|
if FBridge.LazyBarVisible then FBridge.ReloadLazyBar
|
|
else FBridge.SetLazyBar(True);
|
|
end
|
|
else
|
|
FBridge.SetLazyBar(False);
|
|
end
|
|
|
|
else if ACmd = 'prefs/set' then
|
|
begin
|
|
var LKey := GetParam('key');
|
|
if LKey = '' then Exit;
|
|
PM.UserPrefs.SetPref(LKey, GetParam('value'));
|
|
end
|
|
|
|
// ---- QR des reglages de synchro, pour le telephone ------------------
|
|
// L'EXE FABRIQUE LE QR LUI-MEME : l'URL et le jeton se lisent ici, dans les
|
|
// prefs DPAPI, et seule la matrice noir/blanc part vers la page - le jeton
|
|
// ne transite pas en clair par le moteur de rendu (meme raison que
|
|
// prefs/has). Le mot de passe de synchro n'y est JAMAIS : un QR se
|
|
// photographie par-dessus l'epaule.
|
|
// UN LIEN pmserver://sync?u=&t= ET NON DU TEXTE. Le scanner du telephone
|
|
// envoie tout texte a une recherche web (Google, Chrome) - le jeton serait
|
|
// parti chez Google a un appui pres. Un lien a schema propre n'a qu'une
|
|
// application pour l'ouvrir : la notre.
|
|
// QR 2FA affiche par un site : la fenetre s'efface le temps de la capture
|
|
// (elle recouvre souvent le QR), puis revient. Seul un lien otpauth:// part
|
|
// vers la page - le reste de l'ecran ne quitte pas la memoire de l'exe.
|
|
else if ACmd = 'qr/screen' then
|
|
begin
|
|
LogLine('qr/screen: scanning');
|
|
FBridge.MinimizeToTray(False, False, False);
|
|
TThread.CreateAnonymousThread(
|
|
procedure
|
|
var
|
|
LText: string;
|
|
LFound: Boolean;
|
|
begin
|
|
Sleep(400); // laisser Windows retirer la fenetre de l'ecran
|
|
LText := ScanScreenForQr;
|
|
// UN QR LU N'EST PAS UN QR DE 2FA. Le texte d'un autre QR ne part pas a
|
|
// la page - il n'a rien a y faire -, mais le FAIT qu'un QR a ete lu,
|
|
// si : "aucun QR" alors qu'il y en avait un envoyait chercher ailleurs.
|
|
LFound := LText <> '';
|
|
if Pos('otpauth://', LowerCase(LText)) <> 1 then LText := '';
|
|
TThread.Queue(nil,
|
|
procedure
|
|
var
|
|
LJs: TJSONString;
|
|
begin
|
|
RestoreWindowFromTray;
|
|
if LText <> '' then LogLine('qr/screen: otpauth QR found')
|
|
else if LFound then LogLine('qr/screen: a QR code, but not a 2FA one')
|
|
else LogLine('qr/screen: no QR code on screen');
|
|
LJs := TJSONString.Create(LText);
|
|
try
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onScreenQr)Bridge.onScreenQr(' + LJs.ToJSON
|
|
+ ',' + BoolToStr(LFound, True).ToLower + ')');
|
|
finally
|
|
LJs.Free;
|
|
end;
|
|
end);
|
|
end).Start;
|
|
end
|
|
|
|
else if ACmd = 'sync/qr' then
|
|
begin
|
|
var LQrUrl := PM.UserPrefs.GetPref('syncUrl');
|
|
var LQrTok := PM.UserPrefs.GetPref('syncPwd');
|
|
// WEBDAV : le telephone attend "utilisateur:mot-de-passe" dans le jeton
|
|
// (il en fait une authentification Basic).
|
|
if (Pos('/api/v1/repos/', LQrUrl) = 0) and
|
|
not ContainsText(LQrUrl, 'api.github.com/repos/') and
|
|
(PM.UserPrefs.GetPref('syncUser') <> '') and (LQrTok <> '') then
|
|
LQrTok := PM.UserPrefs.GetPref('syncUser') + ':' + LQrTok;
|
|
var LBits := '';
|
|
var LSize := 0;
|
|
if (LQrUrl <> '') and (LQrTok <> '') then
|
|
begin
|
|
var LQr := TDelphiZXingQRCode.Create;
|
|
var LSb := TStringBuilder.Create;
|
|
try
|
|
LQr.Encoding := qrUTF8NoBOM;
|
|
LQr.QuietZone := 4;
|
|
// LE PLUS COURT POSSIBLE : moins de caracteres = modules plus gros,
|
|
// lisibles par une camera. L'adresse du depot plutot que l'URL d'API
|
|
// (le telephone la rallonge a l'enregistrement), et seuls les
|
|
// caracteres qui casseraient la requete sont echappes - TNetEncoding
|
|
// changeait chaque ":" et "/" en trois caracteres.
|
|
var LShort := LQrUrl;
|
|
var LApi := Pos('/api/v1/repos/', LShort);
|
|
// GitHub : l'adresse du depot est sur github.com, l'API sur api.github.com.
|
|
// RACCOURCI SEULEMENT POUR LE NOM PAR DEFAUT COURANT. Un depot
|
|
// configure avec un autre nom - dont l'ancien vault-sync.json - voyage
|
|
// en URL complete : le telephone rallongerait sinon vers le defaut et
|
|
// les deux appareils ne regarderaient pas le meme fichier.
|
|
const SyncDefaultFile = '/contents/data.json';
|
|
if StartsText('https://api.github.com/repos/', LShort) and
|
|
EndsText(SyncDefaultFile, LShort) then
|
|
LShort := 'https://github.com/' +
|
|
Copy(LShort, Length('https://api.github.com/repos/') + 1,
|
|
Length(LShort) - Length('https://api.github.com/repos/') -
|
|
Length(SyncDefaultFile))
|
|
else if (LApi > 0) and EndsText(SyncDefaultFile, LShort) then
|
|
LShort := Copy(LShort, 1, LApi) +
|
|
Copy(LShort, LApi + Length('/api/v1/repos/'),
|
|
Length(LShort) - LApi - Length('/api/v1/repos/') - Length(SyncDefaultFile) + 1);
|
|
LShort := StringReplace(LShort, '%', '%25', [rfReplaceAll]);
|
|
LShort := StringReplace(LShort, '&', '%26', [rfReplaceAll]);
|
|
LShort := StringReplace(LShort, '#', '%23', [rfReplaceAll]);
|
|
LShort := StringReplace(LShort, '+', '%2B', [rfReplaceAll]);
|
|
LShort := StringReplace(LShort, ' ', '%20', [rfReplaceAll]);
|
|
LQr.Data := 'pmserver://sync?u=' + LShort + '&t=' + TNetEncoding.URL.Encode(LQrTok);
|
|
LSize := LQr.Rows;
|
|
for var R := 0 to LQr.Rows - 1 do
|
|
for var C := 0 to LQr.Columns - 1 do
|
|
if LQr.IsBlack[R, C] then LSb.Append('1') else LSb.Append('0');
|
|
LBits := LSb.ToString;
|
|
finally
|
|
LSb.Free;
|
|
LQr.Free;
|
|
end;
|
|
end;
|
|
if LSize > 0 then
|
|
LogLine('sync/qr: ' + IntToStr(LSize) + ' modules')
|
|
else
|
|
LogLine('sync/qr: URL or token not set');
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onSyncQr)Bridge.onSyncQr(' + IntToStr(LSize) +
|
|
',"' + LBits + '")');
|
|
end
|
|
|
|
// ---- Licence hors ligne (PM.Licence) ---------------------------------
|
|
// cmd://licence/status -> Bridge.onLicenceResult(ok, porteur)
|
|
// cmd://licence/activate?key=<...> -> idem, apres verification + rangement
|
|
//
|
|
// La cle est du base64url plus un point : que des caracteres surs dans une
|
|
// URL, donc rien a encoder ni a decoder de special sur ce trajet.
|
|
// Desactiver pour eprouver l'activation. Garde par la presence de
|
|
// config.txt, comme Ctrl+Shift+D : un exe livre n'en a pas, la commande y
|
|
// est donc inerte. L'alternative — supprimer prefs.bin — emporterait aussi
|
|
// le mode de deverrouillage, le nom retenu et les reglages de sync.
|
|
else if ACmd = 'licence/clear' then
|
|
begin
|
|
if not FileExists('config.txt') then Exit;
|
|
PM.Licence.ClearLicence;
|
|
LogLine('Licence cleared (debug)');
|
|
end
|
|
|
|
else if (ACmd = 'licence/status') or (ACmd = 'licence/activate') then
|
|
begin
|
|
var LLicOk: Boolean;
|
|
var LLicInfo: TLicenceInfo;
|
|
if ACmd = 'licence/activate' then
|
|
begin
|
|
LLicOk := PM.Licence.ActivateLicence(GetParam('key'), LLicInfo);
|
|
// On journalise le VERDICT, jamais la cle : le journal se copie et
|
|
// s'envoie, et une licence valable est une valeur transmissible.
|
|
LogLine('Licence activation: ' + BoolToStr(LLicOk, True));
|
|
end
|
|
else
|
|
begin
|
|
LLicOk := PM.Licence.IsLicensed;
|
|
LLicInfo := PM.Licence.LicenceInfo;
|
|
{$IFDEF LICENCE_TEST_SKIP_SCREEN}
|
|
// EXE D'ESSAI UNIQUEMENT : simule une 1re couche rapiecee, pour eprouver
|
|
// la 2e depuis l'application elle-meme. A la COMPILATION et jamais par un
|
|
// reglage — un interrupteur dans config.txt, que n'importe qui peut
|
|
// creer a cote de l'exe, ferait sauter la 1re couche sans rapiecage.
|
|
// La 2e couche, elle, verifie toujours la vraie licence : c'est l'essai.
|
|
LLicOk := True;
|
|
LogLine('LICENCE TEST BUILD: ecran d''activation saute (1re couche simulee rapiecee)');
|
|
{$ENDIF}
|
|
end;
|
|
// Le nom, le courriel et la date viennent d'une charge utile QUE NOUS AVONS
|
|
// SIGNEE, mais ils sont interpoles dans du JavaScript : on les echappe
|
|
// comme n'importe quelle chaine etrangere. Signee ne veut pas dire
|
|
// inoffensive — c'est nous qui les avons tapes au moment d'emettre.
|
|
var LFields := TArray<string>.Create(
|
|
LLicInfo.Holder, LLicInfo.Email, LLicInfo.IssuedOn);
|
|
for var LI := 0 to High(LFields) do
|
|
begin
|
|
LFields[LI] := StringReplace(LFields[LI], '\', '\\', [rfReplaceAll]);
|
|
LFields[LI] := StringReplace(LFields[LI], '"', '\"', [rfReplaceAll]);
|
|
LFields[LI] := StringReplace(LFields[LI], #13, '\r', [rfReplaceAll]);
|
|
LFields[LI] := StringReplace(LFields[LI], #10, '\n', [rfReplaceAll]);
|
|
end;
|
|
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onLicenceResult)' +
|
|
'Bridge.onLicenceResult(' + BoolToStr(LLicOk, True).ToLower +
|
|
',"' + LFields[0] +
|
|
'","' + PM.Licence.ThisMachineId +
|
|
'","' + LFields[1] +
|
|
'","' + LFields[2] +
|
|
'",' + BoolToStr(FileExists('config.txt'), True).ToLower + ')');
|
|
end
|
|
|
|
// ---- Start with Windows (HKCU Run registry) --------------------------
|
|
else if ACmd = 'autostart/get' then
|
|
begin
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onAutoStartStatus)' +
|
|
'Bridge.onAutoStartStatus(' +
|
|
BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')');
|
|
end
|
|
|
|
// ---- Favicon proxy (Delphi-side fetch to keep CSP tight + privacy
|
|
// centralised on one upstream domain). Async: the HTTP GET would
|
|
// block the main thread for up to 5 s on slow networks.
|
|
else if ACmd = 'favicon/fetch' then
|
|
begin
|
|
var LHost := GetParam('host');
|
|
var LReqId := GetParam('reqId'); // opaque, echoed back to JS resolver
|
|
if LHost = '' then Exit;
|
|
LogLine('favicon/fetch host="' + LHost + '" reqId=' + LReqId);
|
|
TThread.CreateAnonymousThread(
|
|
procedure
|
|
var
|
|
LDataUri: string;
|
|
begin
|
|
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
|
|
if LDataUri = '' then
|
|
LogLine('favicon: NO RESULT for "' + LHost +
|
|
'" (TLS error? OpenSSL DLLs missing? DDG 404?)')
|
|
else
|
|
LogLine(Format('favicon: got %d bytes for "%s"',
|
|
[Length(LDataUri), LHost]));
|
|
end);
|
|
TThread.Queue(nil,
|
|
procedure
|
|
var
|
|
LEscHost, LEscData, LEscReq: string;
|
|
begin
|
|
LEscHost := StringReplace(LHost, '\', '\\', [rfReplaceAll]);
|
|
LEscHost := StringReplace(LEscHost, '"', '\"', [rfReplaceAll]);
|
|
LEscReq := StringReplace(LReqId, '\', '\\', [rfReplaceAll]);
|
|
LEscReq := StringReplace(LEscReq, '"', '\"', [rfReplaceAll]);
|
|
// The data URI is base64 (ASCII-safe) plus a small prefix —
|
|
// no embedded quotes by construction, but escape anyway.
|
|
LEscData := StringReplace(LDataUri, '\', '\\', [rfReplaceAll]);
|
|
LEscData := StringReplace(LEscData, '"', '\"', [rfReplaceAll]);
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onFaviconResult)' +
|
|
'Bridge.onFaviconResult("' + LEscReq + '","' + LEscHost + '","' +
|
|
LEscData + '")');
|
|
end);
|
|
end).Start;
|
|
end
|
|
|
|
else if ACmd = 'autostart/set' then
|
|
begin
|
|
var LOk := PM.AutoStart.SetAutoStart(GetParam('enabled') = '1');
|
|
LogLine(Format('Autostart set to %s (ok=%s)',
|
|
[GetParam('enabled'), BoolToStr(LOk, True)]));
|
|
// Echo back the resulting state so the UI re-syncs (covers the
|
|
// case where the registry write was blocked silently).
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onAutoStartStatus)' +
|
|
'Bridge.onAutoStartStatus(' +
|
|
BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')');
|
|
end
|
|
|
|
// ---- WebDAV remote sync (THTTPClient → WinHTTP, async) --------------
|
|
// cmd://webdav/get | put | test ?reqId=&url=&user=&pwd=[&data=]
|
|
// Callback: Bridge.onWebdavResult(reqId, status, bodyOrError)
|
|
// - GET ok → status=200, body=base64 of response bytes
|
|
// - GET 404 → status=404, body='' (caller treats as "no remote yet")
|
|
// - PUT ok → status=200/201/204, body=''
|
|
// - test → status=200..399 means reachable, body=''
|
|
// Network errors → status=0, body=exception message.
|
|
else if (ACmd = 'webdav/get') or (ACmd = 'webdav/put') or (ACmd = 'webdav/test')
|
|
or (ACmd = 'webdav/put-commit') then
|
|
begin
|
|
var LMethod := ACmd;
|
|
var LReqId := GetParam('reqId');
|
|
var LUrl := GetParam('url');
|
|
var LUser := GetParam('user');
|
|
var LPwd := GetParam('pwd');
|
|
var LData := GetParam('data');
|
|
// put-commit: the (large) body arrived in chunks via file/chunk,
|
|
// accumulated in FFileSaveChunks keyed by reqId. Pull it out and treat
|
|
// the rest as a normal PUT.
|
|
if ACmd = 'webdav/put-commit' then
|
|
begin
|
|
var LSB: TStringBuilder;
|
|
if FFileSaveChunks.TryGetValue(LReqId, LSB) then
|
|
begin
|
|
LData := LSB.ToString;
|
|
LSB.Free;
|
|
FFileSaveChunks.Remove(LReqId);
|
|
end
|
|
else
|
|
LData := '';
|
|
LMethod := 'webdav/put';
|
|
end;
|
|
// Optimistic concurrency: JS passes the ETag it saw at pull time; we
|
|
// send it as If-Match on the push so the server rejects (412) the
|
|
// write when another device changed the file in between.
|
|
var LIfMatch := GetParam('ifmatch');
|
|
// GITEA, reconnu a l'URL. Meme transport, trois differences : le jeton
|
|
// remplace l'authentification Basic, le corps d'un PUT est du JSON que JS
|
|
// a deja mis en forme (le sha y voyage, pas dans un If-Match), et le test
|
|
// de connexion se fait par GET - l'API des contenus ne repond pas a HEAD.
|
|
// GitHub parle la meme API des contenus : meme traitement.
|
|
var LGitea := LUrl.ToLower.Contains('/api/v1/repos/') or
|
|
LUrl.ToLower.Contains('api.github.com/repos/');
|
|
TThread.CreateAnonymousThread(
|
|
procedure
|
|
var
|
|
LHttp: System.Net.HttpClient.THTTPClient;
|
|
LResp: System.Net.HttpClient.IHTTPResponse;
|
|
LBodyStream: TBytesStream;
|
|
LReqStream: TBytesStream;
|
|
LBytes: TBytes;
|
|
LBodyB64: string;
|
|
LStatus: Integer;
|
|
LErr: string;
|
|
LEtag: string;
|
|
begin
|
|
// TROIS ESSAIS, ATTENTE DE CONNEXION COURTE. GitHub publie plusieurs
|
|
// adresses et l'une d'elles, 140.82.121.5, n'est joignable depuis ce
|
|
// reseau ni par l'application ni par un navigateur - verifie des deux
|
|
// cotes. Un navigateur passe aussitot a la suivante ; nous attendions
|
|
// le delai entier puis abandonnions. Seul un status 0 - la requete n'a
|
|
// pas abouti - fait recommencer : un 404 ou un 409 sont des reponses.
|
|
LStatus := 0;
|
|
for var LTry := 1 to 3 do
|
|
begin
|
|
LStatus := 0;
|
|
LBodyB64 := '';
|
|
LErr := '';
|
|
LEtag := '';
|
|
try
|
|
LHttp := System.Net.HttpClient.THTTPClient.Create;
|
|
try
|
|
LHttp.ConnectionTimeout := 8000; // voir la boucle ci-dessus
|
|
LHttp.ResponseTimeout := 30000;
|
|
// GitHub REFUSE toute requete sans User-Agent (403) ; Gitea l'ignore.
|
|
LHttp.UserAgent := 'PMServer';
|
|
// "token <PAT>" et non "Bearer" : c'est la forme que Gitea attend
|
|
// d'un jeton d'acces personnel, et l'autre donne un 401 muet.
|
|
if LGitea then
|
|
begin
|
|
// Pas d'en-tete du tout sans jeton : la verification "depot
|
|
// public" se fait volontairement en anonyme.
|
|
if LPwd <> '' then
|
|
LHttp.CustomHeaders['Authorization'] := 'token ' + LPwd;
|
|
end
|
|
else if (LUser <> '') then
|
|
begin
|
|
LHttp.CredentialsStorage.AddCredential(
|
|
System.Net.URLClient.TCredentialsStorage.TCredential.Create(
|
|
System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd));
|
|
end;
|
|
if (LMethod = 'webdav/get') or ((LMethod = 'webdav/test') and LGitea) then
|
|
begin
|
|
LBodyStream := TBytesStream.Create;
|
|
try
|
|
LResp := LHttp.Get(LUrl, LBodyStream);
|
|
LStatus := LResp.StatusCode;
|
|
LEtag := LResp.HeaderValue['ETag'];
|
|
if (LStatus >= 200) and (LStatus < 300) and (LBodyStream.Size > 0) then
|
|
begin
|
|
SetLength(LBytes, LBodyStream.Size);
|
|
Move(LBodyStream.Bytes[0], LBytes[0], LBodyStream.Size);
|
|
LBodyB64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
|
|
LBodyB64 := StringReplace(LBodyB64, #13, '', [rfReplaceAll]);
|
|
LBodyB64 := StringReplace(LBodyB64, #10, '', [rfReplaceAll]);
|
|
end;
|
|
finally
|
|
LBodyStream.Free;
|
|
end;
|
|
end
|
|
else if LMethod = 'webdav/put' then
|
|
begin
|
|
LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
|
|
LReqStream := TBytesStream.Create(LBytes);
|
|
// La reponse est LUE meme en echec : Gitea explique un 409 ou un
|
|
// 422 dans son corps, et sans cela le message d'erreur se
|
|
// reduisait au code, ce qui ne dit pas quoi corriger.
|
|
LBodyStream := TBytesStream.Create;
|
|
try
|
|
if LGitea then
|
|
LResp := LHttp.Put(LUrl, LReqStream, LBodyStream,
|
|
[System.Net.URLClient.TNetHeader.Create('Content-Type', 'application/json')])
|
|
else if LIfMatch <> '' then
|
|
LResp := LHttp.Put(LUrl, LReqStream, LBodyStream,
|
|
[System.Net.URLClient.TNetHeader.Create('If-Match', LIfMatch)])
|
|
else
|
|
LResp := LHttp.Put(LUrl, LReqStream, LBodyStream);
|
|
LStatus := LResp.StatusCode;
|
|
LEtag := LResp.HeaderValue['ETag'];
|
|
if ((LStatus < 200) or (LStatus >= 300)) and (LBodyStream.Size > 0) then
|
|
begin
|
|
SetLength(LBytes, LBodyStream.Size);
|
|
Move(LBodyStream.Bytes[0], LBytes[0], LBodyStream.Size);
|
|
// Tronque : le corps d'une erreur tient en une phrase utile,
|
|
// et le reste n'a rien a faire dans un appel JavaScript.
|
|
LErr := Copy(TEncoding.UTF8.GetString(LBytes), 1, 300);
|
|
end;
|
|
finally
|
|
LBodyStream.Free;
|
|
LReqStream.Free;
|
|
end;
|
|
end
|
|
else // webdav/test — HEAD is widely supported even when PROPFIND isn't
|
|
begin
|
|
LResp := LHttp.Head(LUrl);
|
|
LStatus := LResp.StatusCode;
|
|
end;
|
|
finally
|
|
LHttp.Free;
|
|
end;
|
|
except
|
|
on E: Exception do
|
|
begin
|
|
LStatus := 0;
|
|
LErr := E.Message;
|
|
end;
|
|
end;
|
|
if LStatus <> 0 then Break;
|
|
end;
|
|
TThread.Queue(nil,
|
|
procedure
|
|
var
|
|
LEscReq, LEscPayload, LEscEtag: string;
|
|
begin
|
|
LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
|
|
// GET success path → ship body base64. Otherwise the field
|
|
// carries either the empty string or the exception message
|
|
// (for status=0 network errors).
|
|
if ((LMethod = 'webdav/get') or ((LMethod = 'webdav/test') and LGitea))
|
|
and (LStatus >= 200) and (LStatus < 300) then
|
|
LEscPayload := LBodyB64
|
|
else
|
|
LEscPayload := LErr;
|
|
LEscPayload := StringReplace(LEscPayload, '\', '\\', [rfReplaceAll]);
|
|
LEscPayload := StringReplace(LEscPayload, '"', '\"', [rfReplaceAll]);
|
|
LEscPayload := StringReplace(LEscPayload, #13, '', [rfReplaceAll]);
|
|
LEscPayload := StringReplace(LEscPayload, #10, '\n', [rfReplaceAll]);
|
|
LEscEtag := StringReplace(LEtag, '\', '\\', [rfReplaceAll]);
|
|
LEscEtag := StringReplace(LEscEtag, '"', '\"', [rfReplaceAll]);
|
|
LEscEtag := StringReplace(LEscEtag, #13, '', [rfReplaceAll]);
|
|
LEscEtag := StringReplace(LEscEtag, #10, '', [rfReplaceAll]);
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onWebdavResult)' +
|
|
'Bridge.onWebdavResult("' + LEscReq + '",' +
|
|
IntToStr(LStatus) + ',"' + LEscPayload + '","' + LEscEtag + '")');
|
|
LogLine(Format('%s %s → %d (%d bytes payload, etag=%s)',
|
|
[LMethod, LUrl, LStatus, Length(LEscPayload), LEtag]));
|
|
end);
|
|
end).Start;
|
|
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
|
|
// 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 LReqId := GetParam('reqId');
|
|
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]);
|
|
WebBrowser.ExecuteJavaScript(
|
|
'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) -----------------
|
|
// 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
|
|
// Single-shot silent write (small payload).
|
|
WriteDecodedFile(GetParam('path'), GetParam('data'), GetParam('reqId'))
|
|
|
|
// Chunked silent write: chunks accumulated via file/chunk, committed
|
|
// here (large auto-backups exceed a single cmd:// URL otherwise).
|
|
else if ACmd = 'file/write-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);
|
|
WriteDecodedFile(GetParam('path'), LFull, LReqId);
|
|
end
|
|
else
|
|
begin
|
|
var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&Bridge.onFileWriteResult)' +
|
|
'Bridge.onFileWriteResult("' + LEscReq + '",false,"no chunks buffered")');
|
|
end;
|
|
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;
|
|
|
|
procedure TMainForm.BridgeSystemLock;
|
|
begin
|
|
// Windows session locked or system suspending — delegate to Bridge.onSystemLock
|
|
// in JS which honours the "quick unlock" opt-out (DPAPI already gates
|
|
// access via the Windows account, so re-locking on top of Windows lock
|
|
// is redundant for users who enabled it).
|
|
LogLine('Windows session locked / suspend — notifying JS');
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&typeof Bridge.onSystemLock==="function")Bridge.onSystemLock();' +
|
|
'else if(typeof lockVault==="function")lockVault("Windows session locked or computer asleep")');
|
|
end;
|
|
|
|
procedure TMainForm.BridgeTrayRestore;
|
|
begin
|
|
RestoreWindowFromTray;
|
|
// Notify the JS layer: the UI may want to reset the auto-lock timer,
|
|
// refresh state, or show a "welcome back" toast.
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&typeof Bridge.onTrayRestore==="function")Bridge.onTrayRestore()');
|
|
LogLine('Restored from tray');
|
|
end;
|
|
|
|
procedure TMainForm.BridgeLockRequest;
|
|
begin
|
|
// User picked "Lock vault" from the tray menu. Trigger lockVault() in
|
|
// JS — same path as the WTS_SESSION_LOCK auto-lock.
|
|
LogLine('Lock requested from tray menu');
|
|
WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault("tray menu")');
|
|
end;
|
|
|
|
procedure TMainForm.BridgeQuit;
|
|
begin
|
|
// Re-entry guard: if Quit was already requested, ignore further calls.
|
|
if FQuitting then Exit;
|
|
|
|
LogLine('>>> BridgeQuit invoked (Quit from tray menu)');
|
|
FQuitting := True;
|
|
// Restore the form first so the tray icon goes away and FormDestroy
|
|
// executes from a normal (non-hidden) state. RestoreFromTray also
|
|
// deletes the tray icon.
|
|
RestoreWindowFromTray;
|
|
Application.Terminate;
|
|
end;
|
|
|
|
// ---- Window maximised-state preservation --------------------------------
|
|
// FMX forgets a window was maximised when Windows minimises it from the
|
|
// outside (Win+D / Show desktop / taskbar): it comes back "normal". None of
|
|
// our own restore paths run for a taskbar restore, and three cheaper signals
|
|
// were measured and rejected against real traces:
|
|
// * FMX WindowState — does not follow a native un-maximise
|
|
// * WPF_RESTORETOMAXIMIZED — set for ANY maximised window, not just a restore
|
|
// * foreground ownership — already ours during the restore's resize
|
|
// So observe the minimise itself, at the OS level, with a WinEvent hook.
|
|
|
|
procedure WinStateEventProc(hWinEventHook: THandle; AEvent: DWORD; AWnd: HWND;
|
|
idObject, idChild: LongInt; idEventThread, dwmsEventTime: DWORD); stdcall;
|
|
begin
|
|
if (idObject <> OBJID_WINDOW) or (AWnd = 0) or (MainForm = nil) then Exit;
|
|
// No HWND filter: an FMX app owns TWO top-level windows — the form and the
|
|
// taskbar proxy (see FindFMXAppWindow in PM.Bridge) — and Win+D minimises
|
|
// the proxy, which an equality test against the form handle rejected. The
|
|
// hook is already scoped to our own process, and WebView2 lives in separate
|
|
// processes, so anything arriving here is ours.
|
|
if AEvent = EVENT_SYSTEM_MINIMIZESTART then MainForm.WinStateMinimizeStart
|
|
else if AEvent = EVENT_SYSTEM_MINIMIZEEND then MainForm.WinStateMinimizeEnd;
|
|
end;
|
|
|
|
// Single entry point for every tray restore. ExecuteAutofill really minimises
|
|
// the window (SW_MINIMIZE) before typing, which raises the guard; if the window
|
|
// is then HIDDEN to the tray instead of being un-minimised, MINIMIZEEND may
|
|
// never arrive and the guard would stay up forever, silently freezing the
|
|
// tracked window state. RestoreFromTray replays its own saved placement, so
|
|
// there is nothing left for the guard to protect by then.
|
|
procedure TMainForm.RestoreWindowFromTray;
|
|
begin
|
|
FIgnoreResizes := False;
|
|
// A --tray launch turned the proxy into a tool window to keep it off the
|
|
// taskbar during startup; put it back before showing anything.
|
|
TrayProxyShowInTaskbar;
|
|
FBridge.RestoreFromTray;
|
|
end;
|
|
|
|
// The window is going down: from here until the restore settles, every resize
|
|
// FMX fires is part of that transition, never a user gesture.
|
|
procedure TMainForm.WinStateMinimizeStart;
|
|
begin
|
|
FIgnoreResizes := True;
|
|
end;
|
|
|
|
// Back up: re-apply what the user had.
|
|
procedure TMainForm.WinStateMinimizeEnd;
|
|
begin
|
|
// Inline, not queued: by the time this hook fires Windows has already put the
|
|
// window back at its normal size, and measurement showed nothing touches the
|
|
// state afterwards — the extra message-loop turn a ForceQueue costs was the
|
|
// whole flicker, one painted normal-sized frame before the correction landed.
|
|
ApplyWantedWindowState;
|
|
// Second pass anyway, in case FMX sizes the window after us on some path.
|
|
// Idempotent: it only acts when the state still disagrees.
|
|
TThread.ForceQueue(nil,
|
|
procedure
|
|
begin
|
|
ApplyWantedWindowState;
|
|
// Release the guard only after our own ShowWindow's resize has passed.
|
|
TThread.ForceQueue(nil, procedure begin FIgnoreResizes := False; end);
|
|
end);
|
|
end;
|
|
|
|
procedure TMainForm.ApplyWantedWindowState;
|
|
var
|
|
LHwnd: HWND;
|
|
begin
|
|
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
|
|
if (LHwnd = 0) or IsIconic(LHwnd) then Exit;
|
|
if FWantMaximized and (not IsZoomed(LHwnd)) then
|
|
begin
|
|
LogLine('[winstate] re-maximising after restore');
|
|
ShowWindow(LHwnd, SW_SHOWMAXIMIZED);
|
|
end
|
|
else if (not FWantMaximized) and IsZoomed(LHwnd) then
|
|
begin
|
|
LogLine('[winstate] restoring to normal after restore');
|
|
ShowWindow(LHwnd, SW_RESTORE);
|
|
end;
|
|
end;
|
|
|
|
// Track the state the user settles on. Ignored entirely while a minimise /
|
|
// restore is in flight — that is the whole point of the hook above.
|
|
procedure TMainForm.FormResizeTrackState(Sender: TObject);
|
|
var
|
|
LHwnd: HWND;
|
|
begin
|
|
if FIgnoreResizes then Exit;
|
|
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
|
|
if (LHwnd = 0) or IsIconic(LHwnd) then Exit;
|
|
if FWantMaximized <> IsZoomed(LHwnd) then
|
|
begin
|
|
FWantMaximized := IsZoomed(LHwnd);
|
|
end;
|
|
end;
|
|
|
|
procedure TMainForm.AutofillTimerTick(Sender: TObject);
|
|
var
|
|
TargetHwnd: HWND;
|
|
PendingUser, PendingPass: string;
|
|
HideAfter, UserOnly, ClearFirst, NotifyFail: Boolean;
|
|
ForegroundAfter: HWND;
|
|
begin
|
|
TargetHwnd := FAutofillPendingHWND;
|
|
PendingUser := FAutofillPendingUser;
|
|
PendingPass := FAutofillPendingPass;
|
|
HideAfter := FAutofillPendingHide;
|
|
UserOnly := FAutofillPendingUserOnly;
|
|
ClearFirst := FAutofillPendingClear;
|
|
NotifyFail := FAutofillPendingNotify;
|
|
FAutofillPendingHWND := 0;
|
|
FAutofillPendingUser := '';
|
|
FAutofillPendingPass := '';
|
|
FAutofillPendingHide := False;
|
|
FAutofillPendingUserOnly := False;
|
|
FAutofillPendingClear := False;
|
|
FAutofillPendingNotify := False;
|
|
|
|
TTimer(Sender).Enabled := False;
|
|
TTimer(Sender).Free;
|
|
|
|
// ARestoreAfter = not HideAfter: if the app was open before the hotkey
|
|
// (quick-search beside the target), it stays visible during the fill; if it
|
|
// started hidden, the MinimizeToTray below re-hides it anyway.
|
|
var LFillOk := FBridge.ExecuteAutofill(TargetHwnd, PendingUser, PendingPass,
|
|
UserOnly, not HideAfter, ClearFirst);
|
|
ForegroundAfter := GetForegroundWindow;
|
|
// SECOND SEGMENT, et le total. La difference entre les deux chiffres est le
|
|
// cout de la frappe : minimisation, prise du premier plan, attente de la
|
|
// relache des modificateurs, puis SendInput.
|
|
LogLine(Format('Autofill executed — target=%s, foreground_after=%s, ok=%s, ' +
|
|
'total=%d ms',
|
|
[IntToHex(TargetHwnd, 8), IntToHex(ForegroundAfter, 8),
|
|
BoolToStr(LFillOk, True), FAutofillClock.ElapsedMilliseconds]));
|
|
FAutofillClock.Stop;
|
|
// Le detail des deux attentes internes : sans lui, un total de trois secondes
|
|
// ne dit pas LAQUELLE a enfle, et les deux se corrigent differemment.
|
|
if FBridge.LastFillWaits <> '' then
|
|
LogLine('Autofill waits — ' + FBridge.LastFillWaits);
|
|
// Tell JS whether the keystrokes were actually sent — the success toast
|
|
// must not lie when the target runs elevated (UIPI drops our input).
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&typeof Bridge.onAutofillResult==="function")' +
|
|
'Bridge.onAutofillResult(' + BoolToStr(LFillOk, True).ToLower + ')');
|
|
// Window hidden (tray) → the in-app failure toast is invisible; surface
|
|
// the block via a tray balloon instead (gated by the dedicated
|
|
// "Tray alert when autofill is blocked" setting, default ON).
|
|
if (not LFillOk) and NotifyFail and
|
|
((not Self.Visible) or IsIconic(WindowHandleToPlatform(Self.Handle).Wnd)) then
|
|
FBridge.ShowBalloon('Autofill blocked',
|
|
'The target window runs as administrator — Windows silently blocks ' +
|
|
'simulated keystrokes. Copy the password instead.', 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
|
|
// The filled window must keep the foreground we just handed it.
|
|
FBridge.MinimizeToTray(True, False);
|
|
LogLine('Hidden back to tray (post-autofill).');
|
|
end;
|
|
end;
|
|
|
|
procedure TMainForm.BridgeAutofillRequest(AKind: TAutofillKind;
|
|
ATargetHWND: HWND; const ATitle: string);
|
|
var
|
|
LTitle, LKind: string;
|
|
begin
|
|
if not FServer.Active then Exit;
|
|
if NativeModalActive then Exit;
|
|
FAutofillTargetHWND := ATargetHWND;
|
|
// Tray-hidden only, like the quick-search path: a window merely minimised to
|
|
// the taskbar must not come back as a tray icon it never was.
|
|
FAutofillHotkeyWasHidden := not Self.Visible;
|
|
|
|
// Escape the title for safe injection into a JS string literal.
|
|
LTitle := ATitle;
|
|
LTitle := LTitle.Replace('\', '\\');
|
|
LTitle := LTitle.Replace('"', '\"');
|
|
|
|
case AKind of
|
|
akPasswordOnly: LKind := 'password';
|
|
akTotp: LKind := 'totp';
|
|
else LKind := 'full';
|
|
end;
|
|
|
|
// Arme AVANT l'appel : ce qui suit se passe dans le moteur, et c'est
|
|
// justement ce temps-la qu'on cherche a voir.
|
|
FAutofillClock := TStopwatch.StartNew;
|
|
WebBrowser.ExecuteJavaScript(
|
|
'if(window.Bridge&&typeof Bridge.onAutofillRequest==="function")' +
|
|
'Bridge.onAutofillRequest("' + LTitle + '","' + LKind + '")');
|
|
LogLine('Autofill ' + IfThen(FBridge.LastRequestFromBar, 'fill bar', 'hotkey') +
|
|
' (' + LKind + ') — foreground: "' + ATitle + '"');
|
|
end;
|
|
|
|
initialization
|
|
// WebView2 ships with a long list of "phone home" behaviours enabled by
|
|
// default (SmartScreen lookups, component updates, sync, UMA telemetry,
|
|
// domain reliability beacons, optimisation hints, etc.). For a vault
|
|
// that's meant to be 100% offline we disable them by passing flags
|
|
// through WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS — the standard way to
|
|
// configure the embedded Chromium without touching system policy.
|
|
//
|
|
// Must be set BEFORE the TMS FNC WebBrowser instantiates its
|
|
// CoreWebView2Environment, hence the unit initialization block.
|
|
SetEnvironmentVariable('WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS',
|
|
// Keep the renderer responsive while the window is hidden in the tray.
|
|
// Without these, Chromium freezes/throttles a backgrounded renderer, so
|
|
// the first autofill hotkey after a tray-only start (Start with Windows)
|
|
// waits 3-5s for the renderer to wake before onAutofillRequest runs.
|
|
'--disable-background-timer-throttling ' +
|
|
'--disable-backgrounding-occluded-windows ' +
|
|
'--disable-renderer-backgrounding ' +
|
|
'--disable-background-networking ' +
|
|
'--disable-sync ' +
|
|
'--disable-component-update ' +
|
|
'--no-default-browser-check ' +
|
|
'--no-pings ' +
|
|
'--disable-client-side-phishing-detection ' +
|
|
'--disable-domain-reliability ' +
|
|
'--disable-breakpad ' +
|
|
'--disable-crash-reporter ' +
|
|
'--no-experiments ' +
|
|
'--metrics-recording-only ' +
|
|
'--disable-features=MediaRouter,OptimizationHints,InterestFeedContentSuggestions,' +
|
|
'CalculateNativeWinOcclusion,HardwareMediaKeyHandling,Translate,' +
|
|
'NetworkServiceInProcess,BackgroundFetch,SafeBrowsingEnhancedProtection,' +
|
|
'AutofillServerCommunication,PrivacySandboxAdsAPIsOverride'
|
|
);
|
|
|
|
end.
|