me-web? skeleton: Avalonia 12 app, server, packaging and CI
Build / Desktop, server and tools (push) Successful in 1m7s
Build / Desktop, server and tools (push) Successful in 1m7s
The first screen (online or not, a Check button, every language with a draft notice), the desktop and Android heads, the server's /api/info and /api/ip, a release tool that builds deb, rpm, a Windows exe, macOS zips, a signed APK and the server, and the Gitea workflows that run it. Rules adapted from passolo; AGPL-3.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
commit
7589121846
76 files changed
+3082
No files matched your search
+160
@@ -0,0 +1,160 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
var root = FindRoot();
|
||||
var manifestPath = Path.Combine(root, "tools", "setup.json");
|
||||
var cache = Path.Combine(root, "tools", "cache");
|
||||
var group = Option("--group");
|
||||
var rid = Option("--rid") ?? HostRid();
|
||||
var only = Option("--only")?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var force = args.Contains("--force");
|
||||
var pin = args.Contains("--pin");
|
||||
var verify = args.Contains("--verify");
|
||||
|
||||
var manifest = JsonNode.Parse(File.ReadAllText(manifestPath));
|
||||
var assets = manifest["assets"].AsArray();
|
||||
var failures = 0;
|
||||
var pinned = false;
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromHours(1) };
|
||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("meweb-setup/1.0");
|
||||
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
var name = (string)asset["name"];
|
||||
if (group is not null && (string)asset["group"] != group) continue;
|
||||
var assetRid = (string)asset["rid"];
|
||||
if (assetRid is not null && assetRid != rid) continue;
|
||||
if (only is not null && !only.Contains(name)) continue;
|
||||
|
||||
var check = Path.Combine(root, (string)asset["check"]);
|
||||
var present = File.Exists(check) || Directory.Exists(check);
|
||||
if (verify)
|
||||
{
|
||||
Console.WriteLine($"{(present ? "ok " : "MISSING")} {name,-12} {asset["version"],-28} {asset["license"]}");
|
||||
if (!present) failures++;
|
||||
continue;
|
||||
}
|
||||
if (present && !force)
|
||||
{
|
||||
Console.WriteLine($"ok {name} (already present)");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var url = (string)asset["url"];
|
||||
var expected = ((string)asset["sha256"] ?? "").ToLowerInvariant();
|
||||
if (expected.Length == 0 && !pin)
|
||||
throw new InvalidOperationException("SHA-256 not pinned in the manifest: run once with --pin and review the value written");
|
||||
|
||||
Directory.CreateDirectory(cache);
|
||||
var file = Path.Combine(cache, $"{name}-{asset["version"]}-{Path.GetFileName(new Uri(url).LocalPath)}".Replace(' ', '_'));
|
||||
if (!File.Exists(file) || force) await Download(http, url, file, name);
|
||||
|
||||
var actual = await Hash(file);
|
||||
if (expected.Length == 0)
|
||||
{
|
||||
asset["sha256"] = actual;
|
||||
pinned = true;
|
||||
Console.WriteLine($"pin {name} sha256={actual}");
|
||||
}
|
||||
else if (actual != expected)
|
||||
{
|
||||
File.Delete(file);
|
||||
throw new InvalidOperationException($"SHA-256 mismatch: expected {expected}, got {actual}");
|
||||
}
|
||||
|
||||
var dest = Path.Combine(root, (string)asset["dest"]);
|
||||
Directory.CreateDirectory(dest);
|
||||
switch ((string)asset["kind"])
|
||||
{
|
||||
case "file":
|
||||
File.Copy(file, Path.Combine(dest, Path.GetFileName(new Uri(url).LocalPath)), overwrite: true);
|
||||
break;
|
||||
case "zip":
|
||||
ZipFile.ExtractToDirectory(file, dest, overwriteFiles: true);
|
||||
break;
|
||||
case "tar":
|
||||
Run("tar", "-xf", file, "-C", dest);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"unknown kind: {asset["kind"]}");
|
||||
}
|
||||
if (!File.Exists(check) && !Directory.Exists(check))
|
||||
throw new InvalidOperationException($"{asset["check"]} is missing after extraction");
|
||||
Console.WriteLine($"done {name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures++;
|
||||
Console.Error.WriteLine($"ERROR {name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (pinned)
|
||||
File.WriteAllText(manifestPath, manifest.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }) + Environment.NewLine);
|
||||
|
||||
return failures == 0 ? 0 : 1;
|
||||
|
||||
string Option(string key)
|
||||
{
|
||||
var i = Array.IndexOf(args, key);
|
||||
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
|
||||
}
|
||||
|
||||
static string FindRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeWeb.slnx"))) dir = dir.Parent;
|
||||
return dir?.FullName ?? throw new InvalidOperationException("run from inside the repository (MeWeb.slnx not found)");
|
||||
}
|
||||
|
||||
static async Task Download(HttpClient http, string url, string file, string name)
|
||||
{
|
||||
var partial = file + ".part";
|
||||
using var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var total = response.Content.Headers.ContentLength ?? -1;
|
||||
await using (var source = await response.Content.ReadAsStreamAsync())
|
||||
await using (var target = File.Create(partial))
|
||||
{
|
||||
var buffer = new byte[1 << 20];
|
||||
var done = 0L;
|
||||
var lastReport = Stopwatch.StartNew();
|
||||
var read = default(int);
|
||||
while ((read = await source.ReadAsync(buffer)) > 0)
|
||||
{
|
||||
await target.WriteAsync(buffer.AsMemory(0, read));
|
||||
done += read;
|
||||
if (lastReport.ElapsedMilliseconds < 2000) continue;
|
||||
lastReport.Restart();
|
||||
Console.WriteLine(total > 0 ? $"fetch {name}: {done * 100 / total}% ({done >> 20}/{total >> 20} MB)" : $"fetch {name}: {done >> 20} MB");
|
||||
}
|
||||
}
|
||||
File.Move(partial, file, overwrite: true);
|
||||
}
|
||||
|
||||
static async Task<string> Hash(string file)
|
||||
{
|
||||
await using var stream = File.OpenRead(file);
|
||||
return Convert.ToHexStringLower(await SHA256.HashDataAsync(stream));
|
||||
}
|
||||
|
||||
static void Run(string file, params string[] arguments)
|
||||
{
|
||||
var info = new ProcessStartInfo(file) { UseShellExecute = false };
|
||||
foreach (var argument in arguments) info.ArgumentList.Add(argument);
|
||||
using var process = Process.Start(info) ?? throw new InvalidOperationException($"cannot start {file}");
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0) throw new InvalidOperationException($"{file} exited with code {process.ExitCode}");
|
||||
}
|
||||
|
||||
static string HostRid() =>
|
||||
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "win-x64"
|
||||
: RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx-arm64"
|
||||
: "linux-x64";
|
||||
Reference in new issue
Block a user