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
161 lines
5.7 KiB
C#
161 lines
5.7 KiB
C#
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";
|