From 8e288fd18e8fd10874a49e81d81396bd9b11763a Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 11:12:57 +0200 Subject: [PATCH] launcher: orchestrate update flow and wire velopack self-update --- src/Metin2Launcher/GameLaunch/GameProcess.cs | 41 +++++ src/Metin2Launcher/Program.cs | 159 ++++++++++++++++++- 2 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 src/Metin2Launcher/GameLaunch/GameProcess.cs diff --git a/src/Metin2Launcher/GameLaunch/GameProcess.cs b/src/Metin2Launcher/GameLaunch/GameProcess.cs new file mode 100644 index 0000000..ecbd6f5 --- /dev/null +++ b/src/Metin2Launcher/GameLaunch/GameProcess.cs @@ -0,0 +1,41 @@ +using System.Diagnostics; +using Metin2Launcher.Logging; + +namespace Metin2Launcher.GameLaunch; + +/// Starts the Metin2 game executable and detaches. +public static class GameProcess +{ + public static bool Launch(string clientRoot, string executableName) + { + var exePath = Path.Combine(clientRoot, executableName); + if (!File.Exists(exePath)) + { + Log.Error($"game executable not found at {exePath}"); + return false; + } + + try + { + var psi = new ProcessStartInfo + { + FileName = exePath, + WorkingDirectory = clientRoot, + UseShellExecute = false, + }; + var p = Process.Start(psi); + if (p is null) + { + Log.Error("Process.Start returned null"); + return false; + } + Log.Info($"launched {executableName} pid={p.Id}"); + return true; + } + catch (Exception ex) + { + Log.Error($"failed to launch {executableName}", ex); + return false; + } + } +} diff --git a/src/Metin2Launcher/Program.cs b/src/Metin2Launcher/Program.cs index 87627d4..a1bd518 100644 --- a/src/Metin2Launcher/Program.cs +++ b/src/Metin2Launcher/Program.cs @@ -1,16 +1,161 @@ +using Metin2Launcher.Apply; +using Metin2Launcher.Config; +using Metin2Launcher.GameLaunch; using Metin2Launcher.Logging; +using Metin2Launcher.Manifest; +using Metin2Launcher.Transfer; +using Velopack; +using Velopack.Sources; namespace Metin2Launcher; -/// -/// Launcher entry point. Wired to a minimal scaffold in the first commit; -/// later commits extend this into the full update + game launch flow. -/// public static class Program { - public static int Main(string[] args) + public static async Task Main(string[] args) { - Log.Info("metin2 launcher scaffold: " + string.Join(' ', args)); - return 0; + // Velopack must be wired at the very top of Main so it can handle the + // install / firstrun / uninstall hooks without the rest of our code running. + VelopackApp.Build().SetArgs(args).Run(); + + var clientRoot = Directory.GetCurrentDirectory(); + var stateDir = Path.Combine(clientRoot, LauncherConfig.StateDirName); + var stagingDir = Path.Combine(stateDir, LauncherConfig.StagingDirName); + Directory.CreateDirectory(stateDir); + Log.SetLogFilePath(Path.Combine(stateDir, "launcher.log")); + + Log.Info($"metin2 launcher starting in {clientRoot}"); + + using var cts = new CancellationTokenSource(LauncherConfig.TotalUpdateTimeout); + var ct = cts.Token; + + var updatedOk = false; + try + { + updatedOk = await RunUpdateAsync(clientRoot, stagingDir, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + Log.Warn("update cancelled / timed out"); + } + catch (Exception ex) + { + Log.Error("update flow crashed, falling back to offline launch", ex); + } + + // Velopack self-update of the launcher binary itself runs independently — its + // result doesn't affect whether we launch the game this session. + try + { + await TrySelfUpdateAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) + { + Log.Warn("velopack self-update check failed: " + ex.Message); + } + + if (!updatedOk) + Log.Warn("proceeding with current local client (offline fallback)"); + + var launched = GameProcess.Launch(clientRoot, LauncherConfig.GameExecutable); + return launched ? 0 : 1; + } + + private static async Task RunUpdateAsync(string clientRoot, string stagingDir, CancellationToken ct) + { + using var http = new HttpClient { Timeout = LauncherConfig.BlobFetchTimeout }; + http.DefaultRequestHeaders.UserAgent.ParseAdd("Metin2Launcher/0.1"); + + var loader = new ManifestLoader(http); + LoadedManifest loaded; + try + { + using var fetchCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + fetchCts.CancelAfter(LauncherConfig.ManifestFetchTimeout); + loaded = await loader.FetchAsync( + LauncherConfig.ManifestUrl, LauncherConfig.SignatureUrl, fetchCts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + Log.Warn("manifest fetch failed: " + ex.Message); + return false; + } + + if (!SignatureVerifier.Verify(loaded.RawBytes, loaded.Signature, LauncherConfig.PublicKeyHex)) + { + // Signature failure is the one case where we do NOT fall back silently. + Log.Error("manifest signature verification FAILED — refusing to launch"); + throw new InvalidOperationException("manifest signature invalid"); + } + + var manifest = loaded.Manifest; + Log.Info($"manifest {manifest.Version} verified, {manifest.Files.Count} files"); + + var platform = OperatingSystem.IsWindows() ? "windows" : "linux"; + var applicable = manifest.Files.Where(f => f.AppliesTo(platform)).ToList(); + + var downloader = new BlobDownloader(http, LauncherConfig.BlobBaseUrl, LauncherConfig.MaxBlobRetries); + var toApply = new List(); + + foreach (var file in applicable) + { + ct.ThrowIfCancellationRequested(); + var finalPath = Path.Combine(clientRoot, file.Path.Replace('/', Path.DirectorySeparatorChar)); + var stagingPath = Path.Combine(stagingDir, file.Path.Replace('/', Path.DirectorySeparatorChar)); + + var localHash = FileHasher.HashFileOrNull(finalPath); + if (localHash == file.Sha256) + continue; // already up to date + + try + { + await downloader.DownloadAsync(file.Sha256, file.Size, stagingPath, ct).ConfigureAwait(false); + toApply.Add(new AtomicApplier.StagedFile(stagingPath, finalPath)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + if (file.IsRequired) + { + Log.Error($"required file {file.Path} failed to download", ex); + return false; + } + Log.Warn($"optional file {file.Path} skipped: {ex.Message}"); + } + } + + try + { + AtomicApplier.Apply(toApply); + } + catch (Exception ex) + { + Log.Error("apply failed", ex); + return false; + } + + var currentManifestPath = Path.Combine(clientRoot, LauncherConfig.StateDirName, "current-manifest.json"); + File.WriteAllBytes(currentManifestPath, loaded.RawBytes); + Log.Info($"update complete: {toApply.Count} files applied"); + return true; + } + + private static async Task TrySelfUpdateAsync(CancellationToken ct) + { + // Velopack only functions inside an installed Velopack app. On a dev checkout or + // non-installed copy, UpdateManager.IsInstalled is false and we skip silently. + var um = new UpdateManager(new SimpleWebSource(LauncherConfig.VelopackFeedUrl)); + if (!um.IsInstalled) + { + Log.Info("velopack: not running as an installed app, skipping self-update"); + return; + } + var info = await um.CheckForUpdatesAsync().ConfigureAwait(false); + if (info is null) + { + Log.Info("velopack: launcher is up to date"); + return; + } + Log.Info($"velopack: downloading launcher update {info.TargetFullRelease?.Version}"); + await um.DownloadUpdatesAsync(info).ConfigureAwait(false); + Log.Info("velopack: launcher update staged, will apply on next restart"); } }