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
@@ -0,0 +1,51 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
|
||||
using MeWeb.App;
|
||||
using MeWeb.App.Services;
|
||||
using MeWeb.App.ViewModels;
|
||||
using MeWeb.App.Views;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
var output = args.Length > 0 ? args[0] : Path.Combine("dist", "screenshots");
|
||||
var codes = args.Length > 1 ? args[1].Split(',', StringSplitOptions.RemoveEmptyEntries) : Languages.All.Select(l => l.Code).ToArray();
|
||||
Directory.CreateDirectory(output);
|
||||
|
||||
var session = HeadlessUnitTestSession.StartNew(typeof(ScreenshotEntry));
|
||||
await session.Dispatch(() =>
|
||||
{
|
||||
foreach (var (width, height, label) in new[] { (1100, 760, "desktop"), (400, 820, "phone") })
|
||||
{
|
||||
foreach (var code in codes)
|
||||
{
|
||||
Localizer.Instance.SetLanguage(Languages.Find(code) ?? Languages.Fallback);
|
||||
var model = App.Services.GetRequiredService<MainViewModel>();
|
||||
var window = new Window { Width = width, Height = height, Content = new MainView { DataContext = model } };
|
||||
window.Show();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
var frame = window.CaptureRenderedFrame();
|
||||
var path = Path.Combine(output, $"main-{label}-{code}.png");
|
||||
if (frame is not null)
|
||||
{
|
||||
using var file = File.Create(path);
|
||||
frame.Save(file, new PngBitmapEncoderOptions());
|
||||
}
|
||||
Console.WriteLine($"{path} {(frame is null ? "no frame" : "ok")}");
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
Environment.Exit(0);
|
||||
|
||||
public static class ScreenshotEntry
|
||||
{
|
||||
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<MeWeb.App.App>()
|
||||
.UseSkia()
|
||||
.UseHarfBuzz()
|
||||
.WithInterFont()
|
||||
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>MeWeb.Screenshots</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Headless" />
|
||||
<PackageReference Include="Avalonia.Skia" />
|
||||
<PackageReference Include="Avalonia.HarfBuzz" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\MeWeb.App\MeWeb.App.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
. "$(dirname "$0")/sdk.sh"
|
||||
dotnet --version
|
||||
for project in src/MeWeb.Desktop/MeWeb.Desktop.csproj src/MeWeb.Server/MeWeb.Server.csproj tools/Screenshots/Screenshots.csproj; do
|
||||
echo "== $project"
|
||||
dotnet build "$project" -c Release -nologo -nodeReuse:false -warnaserror
|
||||
done
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
GITEA=${GITEA_URL:-https://git.thepra.dev}
|
||||
ORG=${GITEA_ORG:-Arasaka}
|
||||
NAME=${1:-MeWeb}
|
||||
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
|
||||
cred=$(printf 'protocol=https\nhost=%s\n\n' "${GITEA#https://}" | git credential fill)
|
||||
user=$(printf '%s\n' "$cred" | sed -n 's/^username=//p')
|
||||
pass=$(printf '%s\n' "$cred" | sed -n 's/^password=//p')
|
||||
[ -n "$user" ] && [ -n "$pass" ] || { echo "no stored git credential for $GITEA"; exit 1; }
|
||||
|
||||
out=$(mktemp)
|
||||
trap 'rm -f "$out"' EXIT
|
||||
body=$(printf '{"name":"%s","private":false,"default_branch":"master","description":"me-web? Can you run your own server from here? Checks your connection and device and explains what stands in the way."}' "$NAME")
|
||||
code=$(curl -s -o "$out" -w '%{http_code}' -u "$user:$pass" -H 'Content-Type: application/json' \
|
||||
-X POST "$GITEA/api/v1/orgs/$ORG/repos" -d "$body")
|
||||
case "$code" in
|
||||
201) echo "created $GITEA/$ORG/$NAME" ;;
|
||||
409) echo "already exists: $GITEA/$ORG/$NAME" ;;
|
||||
*) echo "Gitea answered $code:"; cat "$out"; echo; exit 1 ;;
|
||||
esac
|
||||
|
||||
cd "$ROOT"
|
||||
git remote get-url origin >/dev/null 2>&1 || git remote add origin "$GITEA/$ORG/$NAME.git"
|
||||
git push -u origin master
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
GITEA=${GITEA_URL:-https://git.thepra.dev}
|
||||
ORG=${GITEA_ORG:-Arasaka}
|
||||
REPO=${GITEA_REPO:-MeWeb}
|
||||
name=$1
|
||||
file=$2
|
||||
[ -f "$file" ] || { echo "usage: set_secret.sh NAME FILE (the file holds the value)"; exit 1; }
|
||||
|
||||
cred=$(printf 'protocol=https\nhost=%s\n\n' "${GITEA#https://}" | git credential fill)
|
||||
user=$(printf '%s\n' "$cred" | sed -n 's/^username=//p')
|
||||
pass=$(printf '%s\n' "$cred" | sed -n 's/^password=//p')
|
||||
[ -n "$user" ] && [ -n "$pass" ] || { echo "no stored git credential for $GITEA"; exit 1; }
|
||||
|
||||
body=$(python3 -c 'import json,sys; print(json.dumps({"data": open(sys.argv[1]).read().strip()}))' "$file")
|
||||
code=$(printf '%s' "$body" | curl -s -o /dev/null -w '%{http_code}' -u "$user:$pass" -H 'Content-Type: application/json' \
|
||||
-X PUT "$GITEA/api/v1/repos/$ORG/$REPO/actions/secrets/$name" --data-binary @-)
|
||||
case "$code" in
|
||||
201|204) echo "secret $name set on $ORG/$REPO" ;;
|
||||
*) echo "Gitea answered $code for $name"; exit 1 ;;
|
||||
esac
|
||||
@@ -0,0 +1,367 @@
|
||||
using System.Diagnostics;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
|
||||
const string KeystoreVariable = "MEWEB_KEYSTORE";
|
||||
const string KeystorePasswordVariable = "MEWEB_KEYSTORE_PASS";
|
||||
const string KeyAlias = "meweb";
|
||||
string[] targetNames = ["linux", "windows", "macos", "android", "server"];
|
||||
const UnixFileMode ExecutableMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | UnixFileMode.GroupRead
|
||||
| UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
|
||||
const UnixFileMode RegularMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
var root = FindRoot();
|
||||
var version = XDocument.Load(Path.Combine(root, "Directory.Build.props")).Descendants("Version").FirstOrDefault()?.Value.Trim();
|
||||
if (version is null || !Regex.IsMatch(version, @"^\d+\.\d{1,2}\.\d{1,2}$"))
|
||||
{
|
||||
Console.Error.WriteLine($"invalid Version in Directory.Build.props: '{version}' (expected x.y.z)");
|
||||
return 2;
|
||||
}
|
||||
var words = args.Where((a, i) => !a.StartsWith("--", StringComparison.Ordinal) && (i == 0 || !args[i - 1].StartsWith("--", StringComparison.Ordinal)))
|
||||
.Select(a => a.ToLowerInvariant()).ToList();
|
||||
if (words.Count == 0 || words.Any(w => w != "all" && !targetNames.Contains(w)))
|
||||
{
|
||||
Console.Error.WriteLine("usage: tools/release.sh <all|linux|windows|macos|android|server>... [--commit <sha>]");
|
||||
return 2;
|
||||
}
|
||||
var targets = words.Contains("all") ? targetNames.ToList() : targetNames.Where(words.Contains).ToList();
|
||||
var commit = Option("--commit") ?? "dev";
|
||||
var builtAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||
var output = Path.Combine(root, "releases", version);
|
||||
var desktop = Path.Combine(root, "src", "MeWeb.Desktop", "MeWeb.Desktop.csproj");
|
||||
var android = Path.Combine(root, "src", "MeWeb.Android", "MeWeb.Android.csproj");
|
||||
var server = Path.Combine(root, "src", "MeWeb.Server", "MeWeb.Server.csproj");
|
||||
var branding = Path.Combine(root, "branding");
|
||||
Directory.CreateDirectory(output);
|
||||
Console.WriteLine($"me-web? {version} ({commit}): {string.Join(", ", targets)} -> {output}");
|
||||
|
||||
var failed = false;
|
||||
foreach (var target in targets)
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"== {target}");
|
||||
var detail = target switch
|
||||
{
|
||||
"linux" => Linux(),
|
||||
"windows" => Windows(),
|
||||
"macos" => MacOs(),
|
||||
"android" => Android(),
|
||||
_ => Server(),
|
||||
};
|
||||
Console.WriteLine($"ok {target}: {detail}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed = true;
|
||||
Console.Error.WriteLine($"FAIL {target}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
WriteSums();
|
||||
return failed ? 1 : 0;
|
||||
|
||||
string[] Stamp() => [$"-p:Version={version}", $"-p:BuildCommit={commit}", $"-p:BuildTimeUtc={builtAt}"];
|
||||
|
||||
string Linux()
|
||||
{
|
||||
var stage = Reset(Path.Combine(root, "dist", "linux"));
|
||||
var installed = Path.Combine(stage, "app");
|
||||
Dotnet(["publish", desktop, "-c", "Release", "-r", "linux-x64", "--self-contained", "true", "-p:DebugType=none", "-o", installed, .. Stamp()]);
|
||||
foreach (var required in new[] { "meweb", "libSkiaSharp.so", "libHarfBuzzSharp.so" })
|
||||
if (!File.Exists(Path.Combine(installed, required))) throw new InvalidOperationException($"{required} missing from the Linux build");
|
||||
foreach (var junk in Directory.GetFiles(installed, "*.pdb").Append(Path.Combine(installed, "libcoreclrtraceptprovider.so")))
|
||||
if (File.Exists(junk)) File.Delete(junk);
|
||||
NormaliseModes(installed, ["meweb", "createdump"]);
|
||||
File.WriteAllText(Path.Combine(stage, "software.arasaka.meweb.desktop"), """
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=me-web?
|
||||
Comment=Can you run your own server from here?
|
||||
Comment[it]=Si può ospitare un server da questa connessione?
|
||||
Exec=meweb
|
||||
TryExec=meweb
|
||||
Icon=software.arasaka.meweb
|
||||
Terminal=false
|
||||
Categories=Network;Utility;
|
||||
StartupWMClass=meweb
|
||||
|
||||
""");
|
||||
File.WriteAllText(Path.Combine(stage, "postinstall.sh"), """
|
||||
#!/bin/sh
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database -q /usr/share/applications || true; fi
|
||||
if command -v gtk-update-icon-cache >/dev/null 2>&1; then gtk-update-icon-cache -q -f -t /usr/share/icons/hicolor || true; fi
|
||||
exit 0
|
||||
|
||||
""");
|
||||
File.Copy(Path.Combine(stage, "postinstall.sh"), Path.Combine(stage, "postremove.sh"), overwrite: true);
|
||||
var png = Path.Combine(branding, "meweb-512.png");
|
||||
var svg = Path.Combine(branding, "meweb.svg");
|
||||
var yaml = Path.Combine(stage, "nfpm.yaml");
|
||||
File.WriteAllText(yaml, $"""
|
||||
name: meweb
|
||||
arch: amd64
|
||||
platform: linux
|
||||
version: {version}
|
||||
release: "1"
|
||||
section: net
|
||||
priority: optional
|
||||
maintainer: Arasaka di Hvozdikov Yevheniy <https://arasaka.software/contact>
|
||||
vendor: Arasaka di Hvozdikov Yevheniy
|
||||
homepage: https://git.thepra.dev/Arasaka/MeWeb
|
||||
license: AGPL-3.0-only
|
||||
description: |
|
||||
Can you run your own server from here? me-web? checks your connection and device and
|
||||
explains, in plain words, what you could host at home and what stands in the way.
|
||||
contents:
|
||||
- src: {installed}
|
||||
dst: /opt/meweb
|
||||
type: tree
|
||||
- src: /opt/meweb/meweb
|
||||
dst: /usr/bin/meweb
|
||||
type: symlink
|
||||
- src: {stage}/software.arasaka.meweb.desktop
|
||||
dst: /usr/share/applications/software.arasaka.meweb.desktop
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: {png}
|
||||
dst: /usr/share/icons/hicolor/512x512/apps/software.arasaka.meweb.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: {svg}
|
||||
dst: /usr/share/icons/hicolor/scalable/apps/software.arasaka.meweb.svg
|
||||
file_info:
|
||||
mode: 0644
|
||||
scripts:
|
||||
postinstall: {stage}/postinstall.sh
|
||||
postremove: {stage}/postremove.sh
|
||||
overrides:
|
||||
deb:
|
||||
depends:
|
||||
- libc6 (>= 2.28)
|
||||
- libgcc-s1
|
||||
- libstdc++6
|
||||
- libfontconfig1
|
||||
- libx11-6
|
||||
- libice6
|
||||
- libsm6
|
||||
- libssl3t64 | libssl3
|
||||
- libicu78 | libicu76 | libicu74 | libicu72 | libicu70
|
||||
- xdg-utils
|
||||
recommends:
|
||||
- fonts-noto-core
|
||||
- fonts-noto-cjk
|
||||
rpm:
|
||||
depends:
|
||||
- libc.so.6(GLIBC_2.28)(64bit)
|
||||
- libstdc++.so.6(GLIBCXX_3.4.22)(64bit)
|
||||
- libgcc_s.so.1()(64bit)
|
||||
- libfontconfig.so.1()(64bit)
|
||||
- libX11.so.6()(64bit)
|
||||
- libICE.so.6()(64bit)
|
||||
- libSM.so.6()(64bit)
|
||||
- libssl.so.3()(64bit)
|
||||
- libcrypto.so.3()(64bit)
|
||||
- libicu
|
||||
- /usr/bin/xdg-open
|
||||
recommends:
|
||||
- google-noto-sans-fonts
|
||||
- google-noto-sans-cjk-fonts
|
||||
rpm:
|
||||
summary: Can you run your own server from here?
|
||||
compression: zstd
|
||||
deb:
|
||||
compression: zstd
|
||||
|
||||
""");
|
||||
var nfpm = Path.Combine(root, "tools", "packaging", "linux-x64", "nfpm");
|
||||
if (!File.Exists(nfpm)) RunTool("setup.cs", "--group", "packaging");
|
||||
var names = new List<string>();
|
||||
foreach (var packager in new[] { "deb", "rpm" })
|
||||
{
|
||||
var name = $"MeWeb-{version}-linux-x64.{packager}";
|
||||
Run(nfpm, "package", "--config", yaml, "--packager", packager, "--target", Path.Combine(output, name));
|
||||
names.Add($"{name} ({Size(Path.Combine(output, name))})");
|
||||
}
|
||||
return string.Join(", ", names);
|
||||
}
|
||||
|
||||
string Windows()
|
||||
{
|
||||
var stage = Reset(Path.Combine(root, "dist", "windows"));
|
||||
Dotnet(["publish", desktop, "-c", "Release", "-r", "win-x64", "--self-contained", "true", "-p:PublishSingleFile=true",
|
||||
"-p:IncludeNativeLibrariesForSelfExtract=true", "-p:EnableCompressionInSingleFile=true", "-p:DebugType=none", "-o", stage, .. Stamp()]);
|
||||
var exe = Path.Combine(stage, "meweb.exe");
|
||||
if (!File.Exists(exe)) throw new InvalidOperationException("meweb.exe missing from the Windows build");
|
||||
var name = $"MeWeb-{version}-windows-x64.exe";
|
||||
File.Copy(exe, Path.Combine(output, name), overwrite: true);
|
||||
return $"{name} ({Size(Path.Combine(output, name))})";
|
||||
}
|
||||
|
||||
string MacOs()
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var (rid, label) in new[] { ("osx-arm64", "arm64"), ("osx-x64", "x64") })
|
||||
{
|
||||
var stage = Reset(Path.Combine(root, "dist", "macos-" + label));
|
||||
var bundle = Path.Combine(stage, "me-web.app");
|
||||
var contents = Path.Combine(bundle, "Contents");
|
||||
var binaries = Path.Combine(contents, "MacOS");
|
||||
var resources = Path.Combine(contents, "Resources");
|
||||
Directory.CreateDirectory(resources);
|
||||
Dotnet(["publish", desktop, "-c", "Release", "-r", rid, "--self-contained", "true", "-p:DebugType=none", "-o", binaries, .. Stamp()]);
|
||||
if (!File.Exists(Path.Combine(binaries, "meweb"))) throw new InvalidOperationException($"meweb missing from the {rid} build");
|
||||
foreach (var pdb in Directory.GetFiles(binaries, "*.pdb")) File.Delete(pdb);
|
||||
File.Copy(Path.Combine(branding, "meweb.icns"), Path.Combine(resources, "meweb.icns"), overwrite: true);
|
||||
File.WriteAllText(Path.Combine(contents, "Info.plist"), $"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key><string>me-web?</string>
|
||||
<key>CFBundleDisplayName</key><string>me-web?</string>
|
||||
<key>CFBundleIdentifier</key><string>software.arasaka.meweb</string>
|
||||
<key>CFBundleVersion</key><string>{version}</string>
|
||||
<key>CFBundleShortVersionString</key><string>{version}</string>
|
||||
<key>CFBundleExecutable</key><string>meweb</string>
|
||||
<key>CFBundleIconFile</key><string>meweb.icns</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>LSMinimumSystemVersion</key><string>13.0</string>
|
||||
<key>NSHighResolutionCapable</key><true/>
|
||||
<key>NSLocalNetworkUsageDescription</key><string>me-web? looks for your router to see whether it can open a port for a test.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
""");
|
||||
NormaliseModes(binaries, ["meweb", "createdump"]);
|
||||
var name = $"MeWeb-{version}-macos-{label}.zip";
|
||||
var zip = Path.Combine(output, name);
|
||||
if (File.Exists(zip)) File.Delete(zip);
|
||||
RunIn(stage, "zip", "-q", "-r", "-y", zip, "me-web.app");
|
||||
names.Add($"{name} ({Size(zip)})");
|
||||
}
|
||||
return string.Join(", ", names);
|
||||
}
|
||||
|
||||
string Android()
|
||||
{
|
||||
var keystore = Environment.GetEnvironmentVariable(KeystoreVariable);
|
||||
if (string.IsNullOrEmpty(keystore) || !File.Exists(keystore) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable(KeystorePasswordVariable)))
|
||||
throw new InvalidOperationException($"{KeystoreVariable} (a keystore file) and {KeystorePasswordVariable} must be set to sign the APK");
|
||||
var jdk = Jdk();
|
||||
var sdk = AndroidSdk(jdk);
|
||||
EnsureWorkload("android");
|
||||
var stage = Reset(Path.Combine(root, "dist", "android"));
|
||||
Dotnet(["publish", android, "-c", "Release", "-f", "net10.0-android", "-o", stage, $"-p:JavaSdkDirectory={jdk}", $"-p:AndroidSdkDirectory={sdk}",
|
||||
"-p:AndroidKeyStore=true", $"-p:AndroidSigningKeyStore={keystore}", $"-p:AndroidSigningKeyAlias={KeyAlias}",
|
||||
$"-p:AndroidSigningStorePass=env:{KeystorePasswordVariable}", $"-p:AndroidSigningKeyPass=env:{KeystorePasswordVariable}", .. Stamp()]);
|
||||
var apk = Directory.GetFiles(stage, "*-Signed.apk").SingleOrDefault() ?? throw new InvalidOperationException("signed APK not found");
|
||||
var name = $"MeWeb-{version}-android.apk";
|
||||
File.Copy(apk, Path.Combine(output, name), overwrite: true);
|
||||
return $"{name} ({Size(Path.Combine(output, name))})";
|
||||
}
|
||||
|
||||
string Server()
|
||||
{
|
||||
var stage = Reset(Path.Combine(root, "dist", "server"));
|
||||
Dotnet(["publish", server, "-c", "Release", "-r", "linux-x64", "--self-contained", "true", "-p:DebugType=none", "-o", stage, .. Stamp()]);
|
||||
if (!File.Exists(Path.Combine(stage, "MeWeb.Server"))) throw new InvalidOperationException("MeWeb.Server missing from the server build");
|
||||
NormaliseModes(stage, ["MeWeb.Server", "createdump"]);
|
||||
var name = $"MeWeb-Server-{version}-linux-x64.tar.gz";
|
||||
var archive = Path.Combine(output, name);
|
||||
using (var file = File.Create(archive))
|
||||
using (var gzip = new GZipStream(file, CompressionLevel.Optimal))
|
||||
TarFile.CreateFromDirectory(stage, gzip, includeBaseDirectory: false);
|
||||
return $"{name} ({Size(archive)})";
|
||||
}
|
||||
|
||||
string Jdk()
|
||||
{
|
||||
var candidate = Directory.Exists(Path.Combine(root, "tools", "jdk", "linux-x64"))
|
||||
? Directory.GetDirectories(Path.Combine(root, "tools", "jdk", "linux-x64")).FirstOrDefault(d => File.Exists(Path.Combine(d, "bin", "javac")))
|
||||
: null;
|
||||
if (candidate is not null) return candidate;
|
||||
RunTool("setup.cs", "--group", "jdk");
|
||||
return Directory.GetDirectories(Path.Combine(root, "tools", "jdk", "linux-x64")).First(d => File.Exists(Path.Combine(d, "bin", "javac")));
|
||||
}
|
||||
|
||||
string AndroidSdk(string jdk)
|
||||
{
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
string[] candidates = [Environment.GetEnvironmentVariable("ANDROID_HOME"), Path.Combine(home, "Android", "Sdk"), Path.Combine(root, "tools", "android-sdk")];
|
||||
var sdk = candidates.FirstOrDefault(c => !string.IsNullOrEmpty(c) && Directory.Exists(Path.Combine(c, "platforms", "android-36"))) ?? Path.Combine(root, "tools", "android-sdk");
|
||||
if (!Directory.Exists(Path.Combine(sdk, "platforms", "android-36")) || !Directory.Exists(Path.Combine(sdk, "build-tools")))
|
||||
Dotnet(["build", android, "-t:InstallAndroidDependencies", "-f", "net10.0-android", $"-p:AndroidSdkDirectory={sdk}", $"-p:JavaSdkDirectory={jdk}",
|
||||
"-p:AcceptAndroidSDKLicenses=True"]);
|
||||
return sdk;
|
||||
}
|
||||
|
||||
void EnsureWorkload(string name)
|
||||
{
|
||||
var installed = Capture("dotnet", "workload", "list");
|
||||
if (!installed.Split('\n').Any(l => l.Trim().StartsWith(name + " ", StringComparison.Ordinal))) Dotnet(["workload", "install", name]);
|
||||
}
|
||||
|
||||
void WriteSums()
|
||||
{
|
||||
var lines = Directory.GetFiles(output).Where(f => !f.EndsWith("SHA256SUMS.txt", StringComparison.Ordinal)).OrderBy(f => f, StringComparer.Ordinal)
|
||||
.Select(f => $"{Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(f)))} {Path.GetFileName(f)}");
|
||||
File.WriteAllLines(Path.Combine(output, "SHA256SUMS.txt"), lines);
|
||||
}
|
||||
|
||||
void NormaliseModes(string folder, string[] executables)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories))
|
||||
File.SetUnixFileMode(file, executables.Contains(Path.GetFileName(file)) ? ExecutableMode : RegularMode);
|
||||
foreach (var dir in Directory.EnumerateDirectories(folder, "*", SearchOption.AllDirectories)) File.SetUnixFileMode(dir, ExecutableMode);
|
||||
}
|
||||
|
||||
string Reset(string folder)
|
||||
{
|
||||
if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true);
|
||||
Directory.CreateDirectory(folder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
string Size(string file) => $"{new FileInfo(file).Length / 1024.0 / 1024.0:0.0} MB";
|
||||
|
||||
string Option(string key)
|
||||
{
|
||||
var i = Array.IndexOf(args, key);
|
||||
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
|
||||
}
|
||||
|
||||
void Dotnet(string[] arguments) => Run("dotnet", [.. arguments, "-nologo", "-nodeReuse:false"]);
|
||||
|
||||
void RunTool(string script, params string[] arguments) => RunIn(root, "dotnet", ["run", Path.Combine("tools", script), "--", .. arguments]);
|
||||
|
||||
void Run(string file, params string[] arguments) => RunIn(root, file, arguments);
|
||||
|
||||
void RunIn(string directory, string file, params string[] arguments)
|
||||
{
|
||||
var info = new ProcessStartInfo(file) { UseShellExecute = false, WorkingDirectory = directory };
|
||||
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} {string.Join(' ', arguments.Take(2))} exited with code {process.ExitCode}");
|
||||
}
|
||||
|
||||
string Capture(string file, params string[] arguments)
|
||||
{
|
||||
var info = new ProcessStartInfo(file) { UseShellExecute = false, RedirectStandardOutput = true };
|
||||
foreach (var argument in arguments) info.ArgumentList.Add(argument);
|
||||
using var process = Process.Start(info) ?? throw new InvalidOperationException($"cannot start {file}");
|
||||
var text = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
return text;
|
||||
}
|
||||
|
||||
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)");
|
||||
}
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
. "$(dirname "$0")/sdk.sh"
|
||||
exec dotnet run tools/release.cs -- "$@"
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
sdk=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$root/global.json" | head -n 1)
|
||||
has_sdk() { [ -x "$1/dotnet" ] && [ -w "$1" ] && (cd "$root" && "$1/dotnet" --version >/dev/null 2>&1); }
|
||||
dotnet_dir=""
|
||||
for candidate in "$DOTNET_ROOT" "$HOME/.dotnet"; do
|
||||
if [ -n "$candidate" ] && has_sdk "$candidate"; then dotnet_dir="$candidate"; break; fi
|
||||
done
|
||||
if [ -z "$dotnet_dir" ]; then
|
||||
dotnet_dir="$HOME/.dotnet"
|
||||
echo ".NET SDK $sdk not found: installing it into $dotnet_dir"
|
||||
script="${TMPDIR:-/tmp}/dotnet-install.sh"
|
||||
curl -fsSL https://dot.net/v1/dotnet-install.sh -o "$script"
|
||||
bash "$script" --version "$sdk" --install-dir "$dotnet_dir"
|
||||
fi
|
||||
export DOTNET_ROOT="$dotnet_dir" PATH="$dotnet_dir:$PATH" DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1
|
||||
cd "$root"
|
||||
+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";
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"assets": [
|
||||
{
|
||||
"name": "nfpm",
|
||||
"group": "packaging",
|
||||
"version": "2.47.0",
|
||||
"license": "MIT (build tool, not distributed)",
|
||||
"rid": "linux-x64",
|
||||
"url": "https://github.com/goreleaser/nfpm/releases/download/v2.47.0/nfpm_2.47.0_Linux_x86_64.tar.gz",
|
||||
"sha256": "0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783",
|
||||
"kind": "tar",
|
||||
"dest": "tools/packaging/linux-x64",
|
||||
"check": "tools/packaging/linux-x64/nfpm"
|
||||
},
|
||||
{
|
||||
"name": "jdk",
|
||||
"group": "jdk",
|
||||
"version": "Microsoft OpenJDK 21.0.12",
|
||||
"license": "GPL-2.0 with Classpath Exception (build tool, not distributed)",
|
||||
"rid": "linux-x64",
|
||||
"url": "https://aka.ms/download-jdk/microsoft-jdk-21.0.12-linux-x64.tar.gz",
|
||||
"sha256": "f2a84ad31ebeaf3a26252dd86a4a8e1b74aefb6bfc8e55fd20190110d1353c0f",
|
||||
"kind": "tar",
|
||||
"dest": "tools/jdk/linux-x64",
|
||||
"check": "tools/jdk/linux-x64/jdk-21.0.12+8/bin/java"
|
||||
},
|
||||
{
|
||||
"name": "jdk",
|
||||
"group": "jdk",
|
||||
"version": "Microsoft OpenJDK 21.0.12",
|
||||
"license": "GPL-2.0 with Classpath Exception (build tool, not distributed)",
|
||||
"rid": "win-x64",
|
||||
"url": "https://aka.ms/download-jdk/microsoft-jdk-21.0.12-windows-x64.zip",
|
||||
"sha256": "bf27a5d6298c736af8daf5b8c883098e83291446e5766118d8a5ea6a2617195d",
|
||||
"kind": "zip",
|
||||
"dest": "tools/jdk/win-x64",
|
||||
"check": "tools/jdk/win-x64/jdk-21.0.12+8/bin/java.exe"
|
||||
},
|
||||
{
|
||||
"name": "jdk",
|
||||
"group": "jdk",
|
||||
"version": "Microsoft OpenJDK 21.0.12",
|
||||
"license": "GPL-2.0 with Classpath Exception (build tool, not distributed)",
|
||||
"rid": "osx-arm64",
|
||||
"url": "https://aka.ms/download-jdk/microsoft-jdk-21.0.12-macos-aarch64.tar.gz",
|
||||
"sha256": "56f338a3c6af071649047e78f2d31a2ca97c57be6c1b2c213823acb016463ee1",
|
||||
"kind": "tar",
|
||||
"dest": "tools/jdk/osx-arm64",
|
||||
"check": "tools/jdk/osx-arm64/jdk-21.0.12+8/Contents/Home/bin/java"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user