Updater: file logging + relaunch fallback to direct Process.Start

The updater was a black box on failure (no console window, errors went
to a discarded stderr). When the user reported "launcher closes but
doesn't relaunch" there was nothing to inspect.

Add a log sink at %LocalAppData%/PSLauncher/logs/updater.log with
simple 256 KiB rotation. Every step (args parsed, PID wait, target
unlock, backup, copy, zone-strip, relaunch) writes a timestamped
line, plus the FATAL stack trace on uncaught exceptions.

Also strip the file's :Zone.Identifier ADS after copy. Internet-
downloaded exes carry this Mark-of-the-Web stream and Windows can
silently refuse to launch them via explorer.exe with no visible
error.

Relaunch becomes two-tier: try `explorer.exe "<target>"` for
de-elevation; if that fails or nothing spawns, fall back to direct
Process.Start with UseShellExecute=true. Better to inherit admin
than not relaunch at all.

ParseArgs strips surrounding quotes from --target/--source values
since the launcher now wraps paths in quotes (necessary for
UseShellExecute=true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 11:04:42 +02:00
parent fed5be5916
commit 5411f607b6

View File

@@ -5,46 +5,52 @@ namespace PSLauncher.Updater;
/// <summary> /// <summary>
/// Mini exe qui remplace le launcher principal (qui ne peut pas s'auto-écraser pendant qu'il tourne). /// Mini exe qui remplace le launcher principal (qui ne peut pas s'auto-écraser pendant qu'il tourne).
/// ///
/// Args attendus : /// Args :
/// --target=&lt;chemin_complet_de_PSLauncher.exe&gt; /// --target=&lt;chemin_complet_de_PSLauncher.exe&gt;
/// --source=&lt;chemin_du_nouveau_PSLauncher.exe&gt; /// --source=&lt;chemin_du_nouveau_PSLauncher.exe&gt;
/// [--launch] : relance PSLauncher.exe une fois la copie faite /// [--launch] : relance PSLauncher.exe une fois la copie faite
/// [--pid=&lt;PID&gt;] : attend que ce process se termine avant de copier /// [--pid=&lt;PID&gt;] : attend que ce process se termine avant de copier
/// ///
/// Workflow : /// Logs : %LocalAppData%/PSLauncher/logs/updater.log (rotated, last 5 runs).
/// 1. Si --pid fourni, attend la sortie du process.
/// 2. Sinon, attend que le fichier target soit déverrouillé (poll 250 ms).
/// 3. Backup target → target.bak (au cas où).
/// 4. Copie source → target.
/// 5. Optionnellement relance target.
/// 6. Supprime source (le .new) puis target.bak après 30 s.
/// </summary> /// </summary>
internal static class Program internal static class Program
{ {
private static string _logPath = "";
[STAThread] [STAThread]
private static int Main(string[] args) private static int Main(string[] args)
{ {
InitLog();
try try
{ {
Log("==== PSLauncher.Updater started ====");
Log("args: " + string.Join(' ', args));
var opts = ParseArgs(args); var opts = ParseArgs(args);
if (string.IsNullOrEmpty(opts.Target) || string.IsNullOrEmpty(opts.Source)) if (string.IsNullOrEmpty(opts.Target) || string.IsNullOrEmpty(opts.Source))
{ {
Console.Error.WriteLine("Usage: PSLauncher.Updater --target=<exe> --source=<new.exe> [--launch] [--pid=N]"); Log("ERROR missing --target/--source");
return 2; return 2;
} }
Log($"target = {opts.Target}");
Log($"source = {opts.Source}");
Log($"pid = {opts.Pid}");
Log($"launch = {opts.Launch}");
// 1. Attendre la sortie du launcher principal // 1. Wait for the main launcher to exit
if (opts.Pid is int pid) if (opts.Pid is int pid)
{ {
try try
{ {
var proc = Process.GetProcessById(pid); var proc = Process.GetProcessById(pid);
Log($"Waiting for PID {pid} to exit...");
proc.WaitForExit(60_000); proc.WaitForExit(60_000);
Log("PID exited.");
} }
catch { /* déjà mort, OK */ } catch (Exception ex) { Log($"PID lookup: {ex.Message} (already dead, continuing)"); }
} }
// 2. Attendre que le target soit déverrouillé // 2. Wait for target file to be unlocked
var deadline = DateTime.UtcNow.AddSeconds(60); var deadline = DateTime.UtcNow.AddSeconds(60);
while (DateTime.UtcNow < deadline) while (DateTime.UtcNow < deadline)
{ {
@@ -54,70 +60,144 @@ internal static class Program
using var fs = File.Open(opts.Target, FileMode.Open, FileAccess.ReadWrite, FileShare.None); using var fs = File.Open(opts.Target, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
break; break;
} }
catch (IOException) catch (IOException) { Thread.Sleep(250); }
{
Thread.Sleep(250);
}
} }
Log("target unlocked.");
// 3. Backup // 3. Backup
var backup = opts.Target + ".bak"; var backup = opts.Target + ".bak";
try { if (File.Exists(backup)) File.Delete(backup); } catch { } try { if (File.Exists(backup)) File.Delete(backup); } catch (Exception ex) { Log($"backup pre-clean: {ex.Message}"); }
try { if (File.Exists(opts.Target)) File.Move(opts.Target, backup); } catch (Exception ex)
{
Console.Error.WriteLine($"Backup failed: {ex.Message}");
return 3;
}
// 4. Copy new file in place
try try
{ {
File.Copy(opts.Source, opts.Target, overwrite: true); if (File.Exists(opts.Target))
{
File.Move(opts.Target, backup);
Log($"backup OK -> {backup}");
}
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.Error.WriteLine($"Copy failed: {ex.Message}"); Log($"BACKUP FAILED: {ex.Message}");
// Tente la restauration return 3;
}
// 4. Copy new file
try
{
File.Copy(opts.Source, opts.Target, overwrite: true);
Log("copy OK.");
}
catch (Exception ex)
{
Log($"COPY FAILED: {ex.Message}");
try { if (File.Exists(backup)) File.Move(backup, opts.Target, overwrite: true); } catch { } try { if (File.Exists(backup)) File.Move(backup, opts.Target, overwrite: true); } catch { }
return 4; return 4;
} }
// 5. Relance. // 4b. Strip Mark-of-the-Web (zone identifier) qui peut faire silencieusement bloquer
// Si on a été lancé en admin (Verb=runas via UAC), un Process.Start direct // un exe téléchargé d'internet quand on essaie de le lancer via explorer.exe.
// propagerait l'élévation au launcher et donc à tous ses sous-processus try
// (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. File.Delete(opts.Target + ":Zone.Identifier");
Log("zone identifier stripped (or absent).");
}
catch (Exception ex) { Log($"zone strip: {ex.Message}"); }
// 5. Relance — on essaie d'abord explorer.exe pour dé-élever, fallback sur
// Process.Start direct (qui peut hériter de l'admin si on était en runas).
if (opts.Launch) if (opts.Launch)
{ {
if (!TryRelaunchViaExplorer(opts.Target))
{
Log("explorer trick failed — fallback to direct Process.Start");
try try
{ {
Process.Start(new ProcessStartInfo Process.Start(new ProcessStartInfo
{ {
FileName = "explorer.exe", FileName = opts.Target,
Arguments = "\"" + opts.Target + "\"", UseShellExecute = true,
UseShellExecute = false, WorkingDirectory = Path.GetDirectoryName(opts.Target)!,
CreateNoWindow = true,
}); });
Log("direct relaunch OK.");
} }
catch (Exception ex) catch (Exception ex) { Log($"DIRECT RELAUNCH FAILED: {ex.Message}"); }
{
Console.Error.WriteLine($"Relaunch failed: {ex.Message}");
} }
} }
// 6. Cleanup différé (le .new + .bak après 30s, en background détaché) // 6. Cleanup
try { File.Delete(opts.Source); } catch { } try { File.Delete(opts.Source); Log("cleaned up source .new"); } catch { }
// .bak laissé sur place : si le nouveau exe plante au lancement, l'utilisateur Log("==== Updater done ====");
// peut renommer .bak → .exe pour récupérer l'ancien.
return 0; return 0;
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.Error.WriteLine($"Updater fatal: {ex}"); Log("FATAL: " + ex);
return 1; return 1;
} }
} }
/// <summary>
/// Lance l'exe via explorer.exe pour dé-élever : explorer tourne dans la session
/// utilisateur normale, donc le child est non-admin. Retourne false si on n'a pas
/// pu spawn explorer.exe du tout.
/// </summary>
private static bool TryRelaunchViaExplorer(string targetExe)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = "\"" + targetExe + "\"",
UseShellExecute = false,
CreateNoWindow = true,
};
var p = Process.Start(psi);
Log($"explorer.exe spawned (PID {p?.Id})");
return p is not null;
}
catch (Exception ex)
{
Log($"explorer.exe spawn failed: {ex.Message}");
return false;
}
}
private static void InitLog()
{
try
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PSLauncher", "logs");
Directory.CreateDirectory(dir);
_logPath = Path.Combine(dir, "updater.log");
// simple rotation : si > 256 KiB on archive
try
{
var fi = new FileInfo(_logPath);
if (fi.Exists && fi.Length > 256 * 1024)
{
var archive = _logPath + "." + DateTime.UtcNow.ToString("yyyyMMddHHmmss") + ".log";
File.Move(_logPath, archive);
}
}
catch { }
}
catch { _logPath = ""; }
}
private static void Log(string line)
{
if (string.IsNullOrEmpty(_logPath)) return;
try
{
File.AppendAllText(_logPath,
DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff") + " " + line + Environment.NewLine);
}
catch { }
}
private sealed class Options private sealed class Options
{ {
public string? Target { get; set; } public string? Target { get; set; }
@@ -131,11 +211,24 @@ internal static class Program
var o = new Options(); var o = new Options();
foreach (var a in args) foreach (var a in args)
{ {
if (a.StartsWith("--target=", StringComparison.OrdinalIgnoreCase)) o.Target = a["--target=".Length..]; // Strip surrounding quotes that come from the launcher's quoted arguments
else if (a.StartsWith("--source=", StringComparison.OrdinalIgnoreCase)) o.Source = a["--source=".Length..]; var arg = a;
else if (a.StartsWith("--pid=", StringComparison.OrdinalIgnoreCase)) o.Pid = int.TryParse(a["--pid=".Length..], out var p) ? p : null; if (arg.StartsWith("--target=", StringComparison.OrdinalIgnoreCase))
else if (a.Equals("--launch", StringComparison.OrdinalIgnoreCase)) o.Launch = true; o.Target = StripQuotes(arg["--target=".Length..]);
else if (arg.StartsWith("--source=", StringComparison.OrdinalIgnoreCase))
o.Source = StripQuotes(arg["--source=".Length..]);
else if (arg.StartsWith("--pid=", StringComparison.OrdinalIgnoreCase))
o.Pid = int.TryParse(arg["--pid=".Length..], out var p) ? p : null;
else if (arg.Equals("--launch", StringComparison.OrdinalIgnoreCase))
o.Launch = true;
} }
return o; return o;
} }
private static string StripQuotes(string s)
{
if (s.Length >= 2 && s[0] == '"' && s[^1] == '"')
return s.Substring(1, s.Length - 2);
return s;
}
} }