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:
@@ -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=<chemin_complet_de_PSLauncher.exe>
|
||||
/// --source=<chemin_du_nouveau_PSLauncher.exe>
|
||||
/// [--launch] : relance PSLauncher.exe une fois la copie faite
|
||||
/// [--pid=<PID>] : 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)
|
||||
{
|
||||
try
|
||||
if (!TryRelaunchViaExplorer(opts.Target))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
Log("explorer trick failed — fallback to direct Process.Start");
|
||||
try
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = "\"" + opts.Target + "\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Relaunch failed: {ex.Message}");
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = opts.Target,
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(opts.Target)!,
|
||||
});
|
||||
Log("direct relaunch OK.");
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user