feat(autostart): "Start with Windows" toggle

- PM.AutoStart wraps HKCU\Software\Microsoft\Windows\CurrentVersion\Run.
  Value "PMServer" = "<exe>" -tray. Per-user, no admin required, shows
  up in Task Manager → Startup so the user can override from there.

- UMainForm honours the -tray CLI flag (set by the registry entry):
  after the server starts, MinimizeToTray via TThread.ForceQueue so the
  app comes up directly in the tray with no visible window flash.

- Bridge cmd://autostart/{get,set} + Bridge.getAutoStart() /
  setAutoStart() / onAutoStartStatus(). Settings exposes a toggle in
  the Security section, visible only when Bridge.active (the PHP
  frontend can't touch the registry).

- Toggle reads "on" only when the registered command matches the
  current exe path, so a stale entry from a moved exe lets the user
  re-enable to refresh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 21:39:37 +01:00
parent 40b3154a34
commit 33e4b4b614
8 changed files with 213 additions and 1 deletions
+1
View File
@@ -18,6 +18,7 @@ uses
PM.Bridge in 'Source\PM.Bridge.pas',
PM.QuickUnlock in 'Source\PM.QuickUnlock.pas',
PM.UserPrefs in 'Source\PM.UserPrefs.pas',
PM.AutoStart in 'Source\PM.AutoStart.pas',
PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas',
PM.Handler.Ping in 'Handlers\PM.Handler.Ping.pas',
PM.Handler.Auth in 'Handlers\PM.Handler.Auth.pas',
+1
View File
@@ -220,6 +220,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
<DCCReference Include="Source\PM.QuickUnlock.pas"/>
<DCCReference Include="Source\PM.UserPrefs.pas"/>
<DCCReference Include="Source\PM.SingleInstance.pas"/>
<DCCReference Include="Source\PM.AutoStart.pas"/>
<DCCReference Include="Handlers\PM.Handler.Ping.pas"/>
<DCCReference Include="Handlers\PM.Handler.Auth.pas"/>
<DCCReference Include="Handlers\PM.Handler.Folders.pas"/>
+98
View File
@@ -0,0 +1,98 @@
unit PM.AutoStart;
{
Start-with-Windows toggle.
Mechanism: HKCU\Software\Microsoft\Windows\CurrentVersion\Run
- Per-user (no admin needed)
- Visible in Task Manager → Startup tab (user can disable from there)
- Standard pattern used by 1Password, Bitwarden, Slack, Discord, etc.
Command line: launched with --tray so PMServer starts minimized to the
tray instead of popping a window during Windows login.
Value name "PMServer" — fixed string, not exe path-derived, so moving
the exe + flipping the toggle off/on cleans up the old entry.
}
interface
function IsAutoStartEnabled: Boolean;
// Returns True on success. Failures (registry locked, etc.) are silent —
// caller surfaces the resulting status via IsAutoStartEnabled.
function SetAutoStart(AEnabled: Boolean): Boolean;
implementation
uses
System.SysUtils, System.Win.Registry, Winapi.Windows;
const
RUN_KEY = 'Software\Microsoft\Windows\CurrentVersion\Run';
VALUE_NAME = 'PMServer';
TRAY_ARG = '-tray'; // single dash so Delphi's FindCmdLineSwitch picks it up
function BuildAutoStartCommand: string;
begin
// Quote the exe path (may contain spaces) and append the silent-start
// arg. Single string written to a REG_SZ value — Windows splits args
// the same way CommandLineToArgvW does.
Result := '"' + ParamStr(0) + '" ' + TRAY_ARG;
end;
function IsAutoStartEnabled: Boolean;
var
R: TRegistry;
LCurrent: string;
begin
Result := False;
R := TRegistry.Create(KEY_READ);
try
R.RootKey := HKEY_CURRENT_USER;
if not R.OpenKeyReadOnly(RUN_KEY) then Exit;
try
if not R.ValueExists(VALUE_NAME) then Exit;
LCurrent := R.ReadString(VALUE_NAME);
// Consider the toggle "on" only if the registered command points
// to OUR exe — a stale entry from a moved exe should read as off
// so the user can re-enable to refresh the path.
Result := SameText(LCurrent, BuildAutoStartCommand);
finally
R.CloseKey;
end;
finally
R.Free;
end;
end;
function SetAutoStart(AEnabled: Boolean): Boolean;
var
R: TRegistry;
begin
Result := False;
R := TRegistry.Create(KEY_READ or KEY_WRITE);
try
R.RootKey := HKEY_CURRENT_USER;
if not R.OpenKey(RUN_KEY, True) then Exit;
try
if AEnabled then
begin
R.WriteString(VALUE_NAME, BuildAutoStartCommand);
Result := True;
end
else
begin
if R.ValueExists(VALUE_NAME) then
R.DeleteValue(VALUE_NAME);
Result := True;
end;
finally
R.CloseKey;
end;
finally
R.Free;
end;
end;
end.
+37 -1
View File
@@ -11,7 +11,7 @@ uses
FMX.Dialogs, FMX.DialogService,
FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes,
FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser,
PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs;
PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs, PM.AutoStart;
type
TMainForm = class(TForm)
@@ -131,6 +131,20 @@ begin
end;
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. Defer to ForceQueue so the form has finished its
// initial Show before we hide it — minimises the visible flash.
if FindCmdLineSwitch('tray', True) then
begin
TThread.ForceQueue(nil,
procedure
begin
FBridge.MinimizeToTray;
LogLine('Launched with --tray, minimised at startup');
end);
end;
end;
procedure TMainForm.FormDestroy(Sender: TObject);
@@ -552,6 +566,28 @@ begin
PM.UserPrefs.SetPref(LKey, GetParam('value'));
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
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
else
LogLine('Bridge: unknown command "' + ACmd + '"');
end;
Binary file not shown.