launcher: orchestrate update flow and wire velopack self-update
This commit is contained in:
41
src/Metin2Launcher/GameLaunch/GameProcess.cs
Normal file
41
src/Metin2Launcher/GameLaunch/GameProcess.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user