Files
MeWeb/tools/release.cs
T
thepraandClaude Opus 5.5 eb8be87284
Build / Desktop, server and tools (push) Successful in 59s
Release / Every platform, the server, the release (push) Successful in 8m42s
me-web? 0.5: the shared exit measured, the route from the server, the network's upstreams
- Behind your provider's shared address, me-web? now shows how its exit hands out ports
  (kept, one after another, from one block, or at random), the range it gave this device,
  and how many customers may share the address at most; the estimate sharpens with each
  check on the same address.
- It tests whether two devices behind the same address can reach each other (hairpin), and
  how long an unused connection stays open: the card says "measuring" for about two minutes
  after the check, then the answer.
- With the test from outside, the publisher's server also traces the route back to your
  address: your provider's routers and the networks in between appear on the path, with
  their names.
- The provider card says which networks your provider reaches the internet through.
- On mobile data, the path shows the operator and the type of network (5G, 4G) read from the
  phone; on Android 13 and later the phone asks once for permission to read the network type.
- The speed card adds the round trip to the publisher's server.
- The network data now comes from the publisher's server, refreshed there from open sources:
  iptoasn.com, DB-IP Lite (CC BY 4.0), the University of Oregon Route Views Project (CC BY 4.0)
  and the Android Open Source Project's list of mobile operators (Apache 2.0); the release
  notes list each source's date and checksum.
- Source links in the cards wrap instead of being cut off.
- New strings, drafted in every language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-15 11:11:24 +02:00

417 lines
19 KiB
C#

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" && w != "notes" && !targetNames.Contains(w)))
{
Console.Error.WriteLine("usage: tools/release.sh <all|linux|windows|macos|android|server|notes>... [--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}");
if (targets.Count == 0)
{
WriteNotes();
Console.WriteLine(File.ReadAllText(Path.Combine(root, "artifacts", "release-notes.md")));
return 0;
}
var failed = false;
if (targets.Any(t => t != "server"))
{
Console.WriteLine("== knowledge");
RunTool("knowledge.cs");
}
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();
WriteNotes();
return failed ? 1 : 0;
void WriteNotes()
{
var source = Path.Combine(root, "artifacts", "knowledge", "SOURCE.txt");
var lines = Changes();
lines.AddRange(
[
$"me-web? {version}, commit {commit}, built {builtAt}.",
"",
"Built with these open network datasets:",
"",
]);
lines.AddRange(File.Exists(source) ? File.ReadAllLines(source).Select(l => l.Length > 0 ? $" {l}" : "") : [" (no network data in this build)"]);
lines.AddRange(["", "SHA256SUMS.txt lists every file. The APK's signing certificate SHA-256 is 5f52cff4f52f4e0b6da6cd044bfceaf78d1d42db24f2470602db7f19cbfdf846."]);
Directory.CreateDirectory(Path.Combine(root, "artifacts"));
File.WriteAllLines(Path.Combine(root, "artifacts", "release-notes.md"), lines);
}
List<string> Changes()
{
var previous = Capture("git", "-C", root, "describe", "--tags", "--abbrev=0", "--match", "v*", "HEAD^").Trim();
var log = Capture("git", "-C", root, "log", "--no-merges", "--reverse", "--format=%x1e%s%x1f%b", previous.Length > 0 ? $"{previous}..HEAD" : "HEAD");
var lines = new List<string>();
foreach (var entry in log.Split('\x1e', StringSplitOptions.RemoveEmptyEntries))
{
var parts = entry.Split('\x1f');
var subject = Regex.Replace(parts[0].Trim(), @"^me-web\? [\d.]+:\s*", "");
if (subject.Length == 0) continue;
var body = (parts.Length > 1 ? parts[1] : "").Split('\n').Select(l => l.TrimEnd())
.Where(l => !Regex.IsMatch(l, @"^[A-Za-z-]+-(By|Session|Off-By):", RegexOptions.IgnoreCase))
.Where(l => !l.Contains("claude", StringComparison.OrdinalIgnoreCase))
.SkipWhile(l => l.Length == 0).Reverse().SkipWhile(l => l.Length == 0).Reverse().ToList();
lines.AddRange([$"### {char.ToUpperInvariant(subject[0])}{subject[1..]}", ""]);
if (body.Count > 0) lines.AddRange([.. body, ""]);
}
return lines.Count == 0 ? [] : [$"## What's new in {version}", "", .. lines];
}
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");
EnsureWorkload("android");
var jdk = Jdk();
var sdk = AndroidSdk(jdk);
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))) Run("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)");
}