diff --git a/src/PSLauncher.Updater/Program.cs b/src/PSLauncher.Updater/Program.cs
index d285f99..30fcc46 100644
--- a/src/PSLauncher.Updater/Program.cs
+++ b/src/PSLauncher.Updater/Program.cs
@@ -5,46 +5,52 @@ namespace PSLauncher.Updater;
///
/// 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).
///
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= --source= [--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;
}
}
+ ///
+ /// 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.
+ ///
+ 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;
+ }
}