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; + } }