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>
/// 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;
/// --source=&lt;chemin_du_nouveau_PSLauncher.exe&gt;
/// [--launch] : relance PSLauncher.exe une fois la copie faite
/// [--pid=&lt;PID&gt;] : attend que ce process se termine avant de copier
///
/// Workflow :
/// 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.
/// Logs : %LocalAppData%/PSLauncher/logs/updater.log (rotated, last 5 runs).
/// </summary>
internal static class Program
{
private static string _logPath = "";
[STAThread]
private static int Main(string[] args)
{
InitLog();
try
{
Log("==== PSLauncher.Updater started ====");
Log("args: " + string.Join(' ', args));
var opts = ParseArgs(args);
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;
}
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)
{
try
{
var proc = Process.GetProcessById(pid);
Log($"Waiting for PID {pid} to exit...");
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);
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);
break;
}
catch (IOException)
{
Thread.Sleep(250);
}
catch (IOException) { Thread.Sleep(250); }
}
Log("target unlocked.");
// 3. Backup
var backup = opts.Target + ".bak";
try { if (File.Exists(backup)) File.Delete(backup); } catch { }
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 { if (File.Exists(backup)) File.Delete(backup); } catch (Exception ex) { Log($"backup pre-clean: {ex.Message}"); }
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)
{
Console.Error.WriteLine($"Copy failed: {ex.Message}");
// Tente la restauration
Log($"BACKUP FAILED: {ex.Message}");
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 { }
return 4;
}
// 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.
// 4b. Strip Mark-of-the-Web (zone identifier) qui peut faire silencieusement bloquer
// un exe téléchargé d'internet quand on essaie de le lancer via explorer.exe.
try
{
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 (!TryRelaunchViaExplorer(opts.Target))
{
Log("explorer trick failed — fallback to direct Process.Start");
try
{
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = "\"" + opts.Target + "\"",
UseShellExecute = false,
CreateNoWindow = true,
FileName = opts.Target,
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(opts.Target)!,
});
Log("direct relaunch OK.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"Relaunch failed: {ex.Message}");
catch (Exception ex) { Log($"DIRECT RELAUNCH FAILED: {ex.Message}"); }
}
}
// 6. Cleanup différé (le .new + .bak après 30s, en background détaché)
try { File.Delete(opts.Source); } catch { }
// .bak laissé sur place : si le nouveau exe plante au lancement, l'utilisateur
// peut renommer .bak → .exe pour récupérer l'ancien.
// 6. Cleanup
try { File.Delete(opts.Source); Log("cleaned up source .new"); } catch { }
Log("==== Updater done ====");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Updater fatal: {ex}");
Log("FATAL: " + ex);
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
{
public string? Target { get; set; }
@@ -131,11 +211,24 @@ internal static class Program
var o = new Options();
foreach (var a in args)
{
if (a.StartsWith("--target=", StringComparison.OrdinalIgnoreCase)) o.Target = a["--target=".Length..];
else if (a.StartsWith("--source=", StringComparison.OrdinalIgnoreCase)) o.Source = a["--source=".Length..];
else if (a.StartsWith("--pid=", StringComparison.OrdinalIgnoreCase)) o.Pid = int.TryParse(a["--pid=".Length..], out var p) ? p : null;
else if (a.Equals("--launch", StringComparison.OrdinalIgnoreCase)) o.Launch = true;
// Strip surrounding quotes that come from the launcher's quoted arguments
var arg = a;
if (arg.StartsWith("--target=", StringComparison.OrdinalIgnoreCase))
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;
}
private static string StripQuotes(string s)
{
if (s.Length >= 2 && s[0] == '"' && s[^1] == '"')
return s.Substring(1, s.Length - 2);
return s;
}
}