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
+17
View File
@@ -37,6 +37,7 @@ plus courant après modif frontend.
| Quick unlock DPAPI (device-bound) | `delphi-backend/Source/PM.QuickUnlock.pas` |
| Prefs key/value DPAPI (`prefs.bin`) | `delphi-backend/Source/PM.UserPrefs.pas` |
| Single-instance mutex + broadcast | `delphi-backend/Source/PM.SingleInstance.pas` |
| Start with Windows (HKCU Run) | `delphi-backend/Source/PM.AutoStart.pas` |
| Handlers REST | `delphi-backend/Handlers/PM.Handler.*.pas` |
| Frontend complet | `js/app.js` |
| HTML racine | `index.html` |
@@ -51,6 +52,7 @@ Commandes connues :
- `clipboard/copy`, `clipboard/clear`, `clipboard/read` (Paste custom menu)
- `quickunlock/{store,get,clear,status}`
- `prefs/{get,set}?key=...` (device-bound DPAPI key/value, voir plus bas)
- `autostart/{get,set}?enabled=1|0` (HKCU Run registry, "Start with Windows")
- `autofill/{configure,hotkeys,execute,cancel}`
- `app/focus` (ramène la fenêtre au premier plan, pour le picker)
- `app/ready` (page chargée → SetFocus WebBrowser + DOM focus auth input)
@@ -156,6 +158,21 @@ la fenêtre + clipboard clear + balloon first-time.
Menu : Open / Lock vault / Quit (via `TrackPopupMenu`, themé par
`SetPreferredAppMode` ci-dessus).
## Start with Windows (`PM.AutoStart`)
- Toggle dans Settings → "Start with Windows" (visible seulement quand
`Bridge.active`)
- Mécanisme : `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`,
valeur `"PMServer"` = `"<exe-path>" -tray` (single dash — `--tray`
ne match pas `FindCmdLineSwitch`)
- Per-user, pas d'admin requis. Visible dans Task Manager → Startup tab
- Toggle "on" = registered AND la valeur pointe vers notre exe courant
(un old entry d'un exe déplacé lit comme "off" → user peut re-enable
pour rafraîchir le path)
- CLI flag `-tray` (`FindCmdLineSwitch('tray', True)`) dans FormCreate
`FBridge.MinimizeToTray` via `TThread.ForceQueue` (defer après la
show initiale FMX pour minimiser le flash)
## Device-bound prefs (`PM.UserPrefs`)
**Problème résolu** : le serveur HTTP bind un port éphémère aléatoire
+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.
+14
View File
@@ -396,6 +396,20 @@
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row" id="settingAutoStartRow">
<span>
Start with Windows
<small class="setting-hint">
Launch this app in the tray when you sign in to
Windows — so the autofill hotkeys are ready
immediately. Device-only setting (per Windows user).
</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingAutoStart">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row" id="settingAutofillRow">
<span>
Autofill with global hotkey
+45
View File
@@ -14,6 +14,7 @@ const API = (location.pathname.indexOf('/password-manager/') === 0)
// Active only when running inside the Delphi-hosted WebView2 (API === '').
// Falls back to navigator.clipboard for the standalone PHP frontend.
const prefResolvers = {};
let autoStartResolver = null;
const Bridge = (() => {
const active = (API === '');
@@ -188,6 +189,35 @@ const Bridge = (() => {
r(value || '');
}
},
// ---- Start with Windows (HKCU Run registry) -----------------------
getAutoStart() {
if (!active) return Promise.resolve(false);
return new Promise(resolve => {
autoStartResolver = resolve;
cmd('cmd://autostart/get');
setTimeout(() => {
if (autoStartResolver === resolve) {
autoStartResolver = null;
resolve(false);
}
}, 2000);
});
},
setAutoStart(enabled) {
if (!active) return;
cmd('cmd://autostart/set?enabled=' + (enabled ? '1' : '0'));
},
onAutoStartStatus(enabled) {
if (autoStartResolver) {
const r = autoStartResolver;
autoStartResolver = null;
r(!!enabled);
}
state.autoStartEnabled = !!enabled;
const cb = document.getElementById('settingAutoStart');
if (cb) cb.checked = !!enabled;
},
};
})();
@@ -4851,6 +4881,14 @@ function openSettings() {
$('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
$('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd);
$('#settingAutofillHotkeysRow').style.display = Bridge.active ? '' : 'none';
// Start-with-Windows toggle: only meaningful inside the Delphi host
// (registry access). Hide for the PHP frontend.
$('#settingAutoStartRow').style.display = Bridge.active ? '' : 'none';
if (Bridge.active) {
// Fire-and-forget: the bridge response updates the checkbox via
// Bridge.onAutoStartStatus.
Bridge.getAutoStart();
}
$('#settingUser').textContent = state.username;
// Async: query server for recovery key state and update the label
refreshRecoveryStatus();
@@ -5436,6 +5474,13 @@ async function init() {
toast('Breach check disabled');
}
});
$('#settingAutoStart').addEventListener('change', e => {
if (!Bridge.active) return;
Bridge.setAutoStart(e.target.checked);
toast(e.target.checked
? 'Will start with Windows (in tray)'
: 'Wont start with Windows');
});
$('#settingAutofill').addEventListener('change', e => {
state.autofillEnabled = e.target.checked;
localStorage.setItem('autofillEnabled', state.autofillEnabled ? '1' : '0');