CVR Manager 1.0.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Exterrata
2026-10-25 02:46:35 -04:00
commit 9bb4f85802
59 changed files with 7913 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
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<UpdateInfo?> 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<string> DownloadAsync(UpdateInfo info, Action<double> 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; }
}
}