using System.Diagnostics; using System.IO.Compression; using System.Net.Http.Headers; using System.Reflection; using System.Security.Cryptography; using System.Text.Json; namespace CVRManager.Services; public record UpdateInfo(Version Version, string Tag, string ZipUrl, string HashUrl, long Size); public static class UpdateService { public static string Host { get; } = Environment.GetEnvironmentVariable("CVRMANAGER_UPDATE_HOST") is { Length: > 0 } h ? h : "https://git.koneko.cat"; public static string Repo { get; } = Environment.GetEnvironmentVariable("CVRMANAGER_UPDATE_REPO") is { Length: > 0 } r ? r : "Koneko/CVRManager"; public const string AssetName = "CVRManager-win-x64.zip"; static readonly HttpClient Http = CreateClient(); static HttpClient CreateClient() { var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CVRManager", Current.ToString(3))); return http; } public static Version Current { get; } = Normalize(Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(1, 0, 0)); static Version Normalize(Version v) => new(v.Major, Math.Max(v.Minor, 0), Math.Max(v.Build, 0)); public static bool TryParseTag(string tag, out Version version) { var ok = Version.TryParse(tag.TrimStart('v', 'V'), out var v); version = ok ? Normalize(v!) : new Version(0, 0, 0); return ok; } public static async Task CheckAsync(CancellationToken ct) { using var resp = await Http.GetAsync($"{Host}/api/v1/repos/{Repo}/releases/latest", ct); if (resp.StatusCode == System.Net.HttpStatusCode.NotFound) return null; resp.EnsureSuccessStatusCode(); using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); var root = doc.RootElement; var tag = root.GetProperty("tag_name").GetString() ?? ""; if (!TryParseTag(tag, out var version) || version <= Current) return null; string? zip = null, hash = null; long size = 0; foreach (var a in root.GetProperty("assets").EnumerateArray()) { var name = a.GetProperty("name").GetString(); var url = a.GetProperty("browser_download_url").GetString(); if (name == AssetName) { zip = url; size = a.GetProperty("size").GetInt64(); } else if (name == AssetName + ".sha256") hash = url; } if (zip == null || hash == null) return null; return new UpdateInfo(version, tag, zip, hash, size); } public static async Task DownloadAsync(UpdateInfo info, Action progress, CancellationToken ct) { var dir = Path.Combine(Paths.Root, "update", info.Tag); if (Directory.Exists(dir)) Directory.Delete(dir, true); Directory.CreateDirectory(dir); var zip = Path.Combine(dir, AssetName); var expected = (await Http.GetStringAsync(info.HashUrl, ct)).Trim().Split(' ', '\t')[0].ToLowerInvariant(); using (var resp = await Http.GetAsync(info.ZipUrl, HttpCompletionOption.ResponseHeadersRead, ct)) { resp.EnsureSuccessStatusCode(); var total = resp.Content.Headers.ContentLength ?? info.Size; await using var src = await resp.Content.ReadAsStreamAsync(ct); await using var dst = File.Create(zip); var buffer = new byte[81920]; long done = 0; int n; while ((n = await src.ReadAsync(buffer, ct)) > 0) { await dst.WriteAsync(buffer.AsMemory(0, n), ct); done += n; if (total > 0) progress(done * 100.0 / total); } } string actual; await using (var fs = File.OpenRead(zip)) actual = Convert.ToHexString(await SHA256.HashDataAsync(fs, ct)).ToLowerInvariant(); if (actual != expected) throw new InvalidDataException("The downloaded update is corrupted (checksum mismatch)."); var staged = Path.Combine(dir, "files"); ZipFile.ExtractToDirectory(zip, staged); File.Delete(zip); if (!File.Exists(Path.Combine(staged, "CVRManager.exe"))) throw new InvalidDataException("The update package has no CVRManager.exe."); return staged; } public static void ApplyAndRestart(string staged) { var exe = Environment.ProcessPath ?? throw new InvalidOperationException("Cannot find the running executable."); var appDir = Path.GetDirectoryName(exe)!; var script = Path.Combine(Path.GetDirectoryName(staged)!, "apply.ps1"); File.WriteAllText(script, """ param([int]$ProcessId, [string]$Source, [string]$Target, [string]$Exe) $ErrorActionPreference = 'Stop' try { Wait-Process -Id $ProcessId -Timeout 60 -ErrorAction SilentlyContinue } catch { } $copied = $false for ($i = 0; $i -lt 20 -and -not $copied; $i++) { robocopy $Source $Target /E /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null if ($LASTEXITCODE -lt 8) { $copied = $true } else { Start-Sleep -Milliseconds 500 } } if ($copied) { Remove-Item -LiteralPath $Source -Recurse -Force -ErrorAction SilentlyContinue } Start-Process -FilePath $Exe -WorkingDirectory $Target """); var psi = new ProcessStartInfo(Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe")) { UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden, }; foreach (var a in new[] { "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-ProcessId", Environment.ProcessId.ToString(), "-Source", staged, "-Target", appDir, "-Exe", exe }) psi.ArgumentList.Add(a); Process.Start(psi); } public static bool CanWriteAppDir() { try { var probe = Path.Combine(AppContext.BaseDirectory, $".w{Environment.ProcessId}"); File.WriteAllText(probe, ""); File.Delete(probe); return true; } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return false; } } }