CVR Manager 1.0.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user