using System.Diagnostics; namespace PSLauncher.Updater; /// /// 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). /// 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; } } /// /// 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; } 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; } }