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` | | Quick unlock DPAPI (device-bound) | `delphi-backend/Source/PM.QuickUnlock.pas` |
| Prefs key/value DPAPI (`prefs.bin`) | `delphi-backend/Source/PM.UserPrefs.pas` | | Prefs key/value DPAPI (`prefs.bin`) | `delphi-backend/Source/PM.UserPrefs.pas` |
| Single-instance mutex + broadcast | `delphi-backend/Source/PM.SingleInstance.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` | | Handlers REST | `delphi-backend/Handlers/PM.Handler.*.pas` |
| Frontend complet | `js/app.js` | | Frontend complet | `js/app.js` |
| HTML racine | `index.html` | | HTML racine | `index.html` |
@@ -51,6 +52,7 @@ Commandes connues :
- `clipboard/copy`, `clipboard/clear`, `clipboard/read` (Paste custom menu) - `clipboard/copy`, `clipboard/clear`, `clipboard/read` (Paste custom menu)
- `quickunlock/{store,get,clear,status}` - `quickunlock/{store,get,clear,status}`
- `prefs/{get,set}?key=...` (device-bound DPAPI key/value, voir plus bas) - `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}` - `autofill/{configure,hotkeys,execute,cancel}`
- `app/focus` (ramène la fenêtre au premier plan, pour le picker) - `app/focus` (ramène la fenêtre au premier plan, pour le picker)
- `app/ready` (page chargée → SetFocus WebBrowser + DOM focus auth input) - `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 Menu : Open / Lock vault / Quit (via `TrackPopupMenu`, themé par
`SetPreferredAppMode` ci-dessus). `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`) ## Device-bound prefs (`PM.UserPrefs`)
**Problème résolu** : le serveur HTTP bind un port éphémère aléatoire **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.Bridge in 'Source\PM.Bridge.pas',
PM.QuickUnlock in 'Source\PM.QuickUnlock.pas', PM.QuickUnlock in 'Source\PM.QuickUnlock.pas',
PM.UserPrefs in 'Source\PM.UserPrefs.pas', PM.UserPrefs in 'Source\PM.UserPrefs.pas',
PM.AutoStart in 'Source\PM.AutoStart.pas',
PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas', PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas',
PM.Handler.Ping in 'Handlers\PM.Handler.Ping.pas', PM.Handler.Ping in 'Handlers\PM.Handler.Ping.pas',
PM.Handler.Auth in 'Handlers\PM.Handler.Auth.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.QuickUnlock.pas"/>
<DCCReference Include="Source\PM.UserPrefs.pas"/> <DCCReference Include="Source\PM.UserPrefs.pas"/>
<DCCReference Include="Source\PM.SingleInstance.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.Ping.pas"/>
<DCCReference Include="Handlers\PM.Handler.Auth.pas"/> <DCCReference Include="Handlers\PM.Handler.Auth.pas"/>
<DCCReference Include="Handlers\PM.Handler.Folders.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.Dialogs, FMX.DialogService,
FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes, FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes,
FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser, FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser,
PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs; PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs, PM.AutoStart;
type type
TMainForm = class(TForm) TMainForm = class(TForm)
@@ -131,6 +131,20 @@ begin
end; end;
btnStartClick(Nil); btnStartClick(Nil);
btnToggleLogClick(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; end;
procedure TMainForm.FormDestroy(Sender: TObject); procedure TMainForm.FormDestroy(Sender: TObject);
@@ -552,6 +566,28 @@ begin
PM.UserPrefs.SetPref(LKey, GetParam('value')); PM.UserPrefs.SetPref(LKey, GetParam('value'));
end 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 else
LogLine('Bridge: unknown command "' + ACmd + '"'); LogLine('Bridge: unknown command "' + ACmd + '"');
end; end;
Binary file not shown.
+14
View File
@@ -396,6 +396,20 @@
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</div> </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"> <div class="setting-row" id="settingAutofillRow">
<span> <span>
Autofill with global hotkey 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 === ''). // Active only when running inside the Delphi-hosted WebView2 (API === '').
// Falls back to navigator.clipboard for the standalone PHP frontend. // Falls back to navigator.clipboard for the standalone PHP frontend.
const prefResolvers = {}; const prefResolvers = {};
let autoStartResolver = null;
const Bridge = (() => { const Bridge = (() => {
const active = (API === ''); const active = (API === '');
@@ -188,6 +189,35 @@ const Bridge = (() => {
r(value || ''); 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); $('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull);
$('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd); $('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd);
$('#settingAutofillHotkeysRow').style.display = Bridge.active ? '' : 'none'; $('#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; $('#settingUser').textContent = state.username;
// Async: query server for recovery key state and update the label // Async: query server for recovery key state and update the label
refreshRecoveryStatus(); refreshRecoveryStatus();
@@ -5436,6 +5474,13 @@ async function init() {
toast('Breach check disabled'); 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 => { $('#settingAutofill').addEventListener('change', e => {
state.autofillEnabled = e.target.checked; state.autofillEnabled = e.target.checked;
localStorage.setItem('autofillEnabled', state.autofillEnabled ? '1' : '0'); localStorage.setItem('autofillEnabled', state.autofillEnabled ? '1' : '0');