Self-update: UAC-aware, de-elevated relaunch, user-mode installer

Three coordinated changes so the auto-update flow works on every
existing install location.

LauncherSelfUpdater.cs
----------------------
- Probe the target directory with a write/delete of a temp file. If
  it fails (e.g. install in Program Files without admin), set
  UseShellExecute=true + Verb=runas on the Updater process so UAC
  prompts the user once for elevation. If the directory is writable
  (user-mode install or portable layout), skip elevation entirely.
- Switched from ProcessStartInfo.ArgumentList to a quoted Arguments
  string because Verb=runas requires UseShellExecute=true, which
  ignores ArgumentList. Paths get explicit quotes to survive spaces.

Updater Program.cs
------------------
After the file swap, the relaunch was a direct Process.Start(target).
With UAC elevation that propagates admin to the new launcher, then to
its child PROSERVE_UE_5_5.exe — undesirable. Replace with
`explorer.exe "<target>"`: explorer is always running in the user's
normal token, opens the exe via shell, the new process inherits the
non-elevated session. Standard de-elevation trick.

installer/PSLauncher.iss
------------------------
Switch from system-wide install (Program Files, requires admin) to
per-user (DefaultDirName={localappdata}\Programs\..., PrivilegesRequired=
lowest). Auto-update then never needs UAC at all on fresh installs.
Existing installs in Program Files keep working thanks to the runas
fallback above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 10:57:32 +02:00
parent 56069f606d
commit f2a1de9aac
5 changed files with 45 additions and 15 deletions

BIN
PSLauncher-0.6.0.exe Normal file

Binary file not shown.

View File

@@ -28,7 +28,9 @@ AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL} AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL} AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL} AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppPublisher}\{#MyAppShortName} ; Installation user-mode (pas d'admin requis) : le launcher pourra se mettre à jour
; tout seul via PSLauncher.Updater.exe sans déclencher de UAC.
DefaultDirName={localappdata}\Programs\{#MyAppPublisher}\{#MyAppShortName}
DefaultGroupName={#MyAppName} DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes DisableProgramGroupPage=yes
OutputDir=output OutputDir=output
@@ -37,8 +39,9 @@ SetupIconFile=
Compression=lzma2/ultra Compression=lzma2/ultra
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
PrivilegesRequired=admin PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog PrivilegesRequiredOverridesAllowed=dialog
UsedUserAreasWarning=no
ArchitecturesAllowed=x64 ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64 ArchitecturesInstallIn64BitMode=x64
UninstallDisplayName={#MyAppName} UninstallDisplayName={#MyAppName}

Binary file not shown.

View File

@@ -84,23 +84,45 @@ public sealed class LauncherSelfUpdater : ILauncherSelfUpdater
File.Move(downloadedPath, newPath); File.Move(downloadedPath, newPath);
} }
// 5. Lance l'updater détaché // 5. Lance l'updater détaché. Si le target est dans un dossier protégé
// (Program Files, etc.), on ne pourra pas le remplacer sans admin :
// on demande l'élévation via ShellExecute + Verb=runas (UAC popup).
var pid = Environment.ProcessId; var pid = Environment.ProcessId;
var needsElevation = !CanWriteTo(targetExe);
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
{ {
FileName = updaterPath, FileName = updaterPath,
UseShellExecute = false, UseShellExecute = needsElevation, // requis pour Verb=runas
CreateNoWindow = true, CreateNoWindow = !needsElevation,
Verb = needsElevation ? "runas" : "",
Arguments = string.Join(' ', new[]
{
$"--target=\"{targetExe}\"",
$"--source=\"{newPath}\"",
$"--pid={pid}",
"--launch",
}),
}; };
psi.ArgumentList.Add($"--target={targetExe}");
psi.ArgumentList.Add($"--source={newPath}");
psi.ArgumentList.Add($"--pid={pid}");
psi.ArgumentList.Add("--launch");
_logger.LogInformation("Spawning updater {Updater} (target={Target}, source={Source}, pid={Pid})", _logger.LogInformation("Spawning updater {Updater} (target={Target}, source={Source}, pid={Pid}, elevate={Elevate})",
updaterPath, targetExe, newPath, pid); updaterPath, targetExe, newPath, pid, needsElevation);
SysProcess.Start(psi); SysProcess.Start(psi);
return true; // l'appelant doit Application.Current.Shutdown() après return true; // l'appelant doit Application.Current.Shutdown() après
} }
/// <summary>Test rapide : peut-on écrire à côté du fichier cible (= dans son dossier) ?</summary>
private static bool CanWriteTo(string filePath)
{
try
{
var dir = Path.GetDirectoryName(filePath)!;
var probe = Path.Combine(dir, ".pslauncher-write-test-" + Guid.NewGuid().ToString("N").Substring(0, 8));
File.WriteAllText(probe, "");
File.Delete(probe);
return true;
}
catch { return false; }
}
} }

View File

@@ -82,16 +82,21 @@ internal static class Program
return 4; return 4;
} }
// 5. Relance // 5. Relance.
// Si on a été lancé en admin (Verb=runas via UAC), un Process.Start direct
// propagerait l'élévation au launcher et donc à tous ses sous-processus
// (PROSERVE_UE_5_5.exe), ce qu'on ne veut pas. On passe par explorer.exe
// qui est non-élévé : il ouvre l'exe dans la session utilisateur normale.
if (opts.Launch) if (opts.Launch)
{ {
try try
{ {
Process.Start(new ProcessStartInfo Process.Start(new ProcessStartInfo
{ {
FileName = opts.Target, FileName = "explorer.exe",
UseShellExecute = true, Arguments = "\"" + opts.Target + "\"",
WorkingDirectory = Path.GetDirectoryName(opts.Target)!, UseShellExecute = false,
CreateNoWindow = true,
}); });
} }
catch (Exception ex) catch (Exception ex)