launcher: orchestrate update flow and wire velopack self-update

This commit is contained in:
Jan Nedbal
2026-04-14 11:12:57 +02:00
parent ae33470f7f
commit 8e288fd18e
2 changed files with 193 additions and 7 deletions

View File

@@ -0,0 +1,41 @@
using System.Diagnostics;
using Metin2Launcher.Logging;
namespace Metin2Launcher.GameLaunch;
/// <summary>Starts the Metin2 game executable and detaches.</summary>
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;
}
}
}

View File

@@ -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;
/// <summary>
/// Launcher entry point. Wired to a minimal scaffold in the first commit;
/// later commits extend this into the full update + game launch flow.
/// </summary>
public static class Program
{
public static int Main(string[] args)
public static async Task<int> 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<bool> 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<AtomicApplier.StagedFile>();
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");
}
}