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>
235 lines
8.0 KiB
C#
235 lines
8.0 KiB
C#
using System.Diagnostics;
|
|
|
|
namespace PSLauncher.Updater;
|
|
|
|
/// <summary>
|
|
/// Mini exe qui remplace le launcher principal (qui ne peut pas s'auto-écraser pendant qu'il tourne).
|
|
///
|
|
/// 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
|
|
///
|
|
/// 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))
|
|
{
|
|
Log("ERROR missing --target/--source");
|
|
return 2;
|
|
}
|
|
Log($"target = {opts.Target}");
|
|
Log($"source = {opts.Source}");
|
|
Log($"pid = {opts.Pid}");
|
|
Log($"launch = {opts.Launch}");
|
|
|
|
// 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 (Exception ex) { Log($"PID lookup: {ex.Message} (already dead, continuing)"); }
|
|
}
|
|
|
|
// 2. Wait for target file to be unlocked
|
|
var deadline = DateTime.UtcNow.AddSeconds(60);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (!File.Exists(opts.Target)) break;
|
|
try
|
|
{
|
|
using var fs = File.Open(opts.Target, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
|
|
break;
|
|
}
|
|
catch (IOException) { Thread.Sleep(250); }
|
|
}
|
|
Log("target unlocked.");
|
|
|
|
// 3. Backup
|
|
var backup = opts.Target + ".bak";
|
|
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);
|
|
Log($"backup OK -> {backup}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
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;
|
|
}
|
|
|
|
// 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 = opts.Target,
|
|
UseShellExecute = true,
|
|
WorkingDirectory = Path.GetDirectoryName(opts.Target)!,
|
|
});
|
|
Log("direct relaunch OK.");
|
|
}
|
|
catch (Exception ex) { Log($"DIRECT RELAUNCH FAILED: {ex.Message}"); }
|
|
}
|
|
}
|
|
|
|
// 6. Cleanup
|
|
try { File.Delete(opts.Source); Log("cleaned up source .new"); } catch { }
|
|
Log("==== Updater done ====");
|
|
return 0;
|
|
}
|
|
catch (Exception 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; }
|
|
public string? Source { get; set; }
|
|
public bool Launch { get; set; }
|
|
public int? Pid { get; set; }
|
|
}
|
|
|
|
private static Options ParseArgs(string[] args)
|
|
{
|
|
var o = new Options();
|
|
foreach (var a in args)
|
|
{
|
|
// 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;
|
|
}
|
|
}
|