Add installer updater and server CLI

This commit is contained in:
Alexa
2026-05-27 20:54:53 +03:00
parent 4f44505759
commit 7852b410ee
25 changed files with 4153 additions and 703 deletions
+17 -1
View File
@@ -57,7 +57,7 @@ public sealed class ReleaseDownloader
var asset = await FindAssetAsync(
tagPrefix: version.ReleaseTagPrefix,
assetName: $"dorknet-server-{version.VersionKey}-win-x64.zip",
assetName: $"dorknet-server-{version.VersionKey}-{CurrentRid}.zip",
ct);
var zipPath = Path.Combine(AppPaths.LocalRoot, $"download-{Guid.NewGuid():N}.zip");
@@ -71,6 +71,22 @@ public sealed class ReleaseDownloader
return dir;
}
/// <summary>RID for asset naming — `win-x64`, `linux-x64`,
/// `linux-arm64`, `osx-x64`, `osx-arm64`. Matches the `-r` flag
/// the per-version release workflow passes to `dotnet publish`.</summary>
private static string CurrentRid =>
(OperatingSystem.IsWindows(), OperatingSystem.IsLinux(), OperatingSystem.IsMacOS(),
System.Runtime.InteropServices.RuntimeInformation.OSArchitecture) switch
{
(true, _, _, System.Runtime.InteropServices.Architecture.X64) => "win-x64",
(true, _, _, System.Runtime.InteropServices.Architecture.Arm64) => "win-arm64",
(_, true, _, System.Runtime.InteropServices.Architecture.X64) => "linux-x64",
(_, true, _, System.Runtime.InteropServices.Architecture.Arm64) => "linux-arm64",
(_, _, true, System.Runtime.InteropServices.Architecture.X64) => "osx-x64",
(_, _, true, System.Runtime.InteropServices.Architecture.Arm64) => "osx-arm64",
_ => "win-x64",
};
/// <summary>Returns the path to the unpacked client-patcher dir
/// for <paramref name="version"/>, downloading + caching if needed.</summary>
public async Task<string> EnsurePatcherAsync(
+23 -10
View File
@@ -102,17 +102,30 @@ public sealed class ServerProcess : IAsyncDisposable
private static string FindServerExecutable(string serverDir)
{
// Self-contained .NET publish produces a single .exe per
// RuntimeIdentifier. The server's csproj sets AssemblyName so
// we know the name; fall back to scanning if the rename
// changes.
var canonical = Path.Combine(serverDir, "DorkNet.Server.exe");
// Self-contained .NET publish drops a per-RID binary; on Windows
// it's DorkNet.Server.exe, on Linux/Mac it's DorkNet.Server
// (no extension). Try both, then scan as a last resort.
var ext = OperatingSystem.IsWindows() ? ".exe" : "";
var canonical = Path.Combine(serverDir, $"DorkNet.Server{ext}");
if (File.Exists(canonical)) return canonical;
var any = Directory.GetFiles(serverDir, "*.exe").FirstOrDefault();
if (any is null)
throw new FileNotFoundException(
$"No .exe found in {serverDir}. The release artifact may be corrupt.");
return any;
// Windows scan picks any .exe; on Unix, scan for files that are
// executable + match the expected name pattern.
if (OperatingSystem.IsWindows())
{
var any = Directory.GetFiles(serverDir, "*.exe").FirstOrDefault();
if (any is not null) return any;
}
else
{
var any = Directory.GetFiles(serverDir, "DorkNet.Server*")
.Where(p => !p.EndsWith(".dll") && !p.EndsWith(".pdb") && !p.EndsWith(".json"))
.FirstOrDefault();
if (any is not null) return any;
}
throw new FileNotFoundException(
$"No server binary found in {serverDir}. The release artifact may be corrupt.");
}
private static void ApplySqliteCompatibilityPatches(string serverDir)
+231
View File
@@ -0,0 +1,231 @@
using System.Formats.Tar;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Net.Http.Json;
using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
namespace DorkNet.Launcher.Backend;
/// <summary>Fetches <c>tunnelto</c> straight from the upstream
/// release feed (agrinman/tunnelto) the first time the launcher needs
/// it, so end users don't have to set up Scoop / Cargo / brew before
/// hosting. The downloaded binary lands in <see cref="AppPaths.LocalRoot"/>,
/// which is the first path <see cref="TunneltoTunnel"/> probes after
/// the side-by-side install dir.
///
/// <para>Upstream's release assets (as of 0.1.18) are uneven:
/// <list type="bullet">
/// <item>Windows ships a direct <c>tunnelto-windows.exe</c> — easy.</item>
/// <item>Linux ships a <c>tunnelto-linux.tar.gz</c> archive — easy.</item>
/// <item>macOS only publishes Homebrew bottles, which we can't crack
/// open cleanly. macOS falls through to a clear "brew install tunnelto"
/// error instead.</item>
/// </list></para></summary>
public sealed class TunneltoInstaller
{
private const string ReleasesApi =
"https://api.github.com/repos/agrinman/tunnelto/releases/latest";
private static readonly HttpClient Http = CreateHttp();
private static HttpClient CreateHttp()
{
var c = new HttpClient { Timeout = TimeSpan.FromMinutes(3) };
c.DefaultRequestHeaders.UserAgent.ParseAdd("DorkNet-Launcher-TunneltoInstaller/1.0");
c.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
return c;
}
/// <summary>Make sure tunnelto is available somewhere
/// <see cref="TunneltoTunnel"/> will find it. If it's already
/// installed, returns immediately. Otherwise downloads the
/// upstream release into <see cref="AppPaths.LocalRoot"/>.
/// Throws with platform-specific install hints when auto-install
/// isn't possible (currently macOS).</summary>
public async Task<string> EnsureInstalledAsync(
IProgress<DownloadProgress>? progress = null,
CancellationToken ct = default)
{
// Quickly probe the existing-install paths first so a repeat
// host start doesn't hammer the GitHub API.
var existing = TryFindExisting();
if (existing is not null) return existing;
if (OperatingSystem.IsMacOS())
{
// Upstream macOS releases are Homebrew bottles, not portable
// binaries — we'd have to fish the prefix out and that's a
// mess. Tell the user to use brew instead.
throw new PlatformNotSupportedException(
"Tunnelto auto-install isn't supported on macOS — install it manually: " +
"`brew install tunnelto` (or `cargo install tunnelto`).");
}
if (OperatingSystem.IsLinux() &&
RuntimeInformation.OSArchitecture != Architecture.X64)
{
// The only Linux asset is x64; ARM users have to build
// from source or pull via cargo.
throw new PlatformNotSupportedException(
"Tunnelto auto-install only covers Linux x64. On arm64, run: " +
"`cargo install tunnelto` (or grab a build from github.com/agrinman/tunnelto/releases).");
}
var release = await Http.GetFromJsonAsync<GitHubRelease>(ReleasesApi, ct)
?? throw new InvalidOperationException("Couldn't reach github.com/agrinman/tunnelto/releases.");
var (assetName, kind) = AssetForCurrentPlatform();
var asset = release.Assets?.FirstOrDefault(a =>
string.Equals(a.Name, assetName, StringComparison.OrdinalIgnoreCase));
if (asset?.BrowserDownloadUrl is null)
throw new InvalidOperationException(
$"The upstream release doesn't ship a '{assetName}' asset. " +
$"Got assets: {string.Join(", ", release.Assets?.Select(a => a.Name) ?? Array.Empty<string>())}");
var binaryName = OperatingSystem.IsWindows() ? "tunnelto.exe" : "tunnelto";
var dest = Path.Combine(AppPaths.LocalRoot, binaryName);
// Stream to a temp file first so a half-finished download
// doesn't leave a broken binary in place.
var stagingPath = Path.Combine(AppPaths.LocalRoot, $"tunnelto-staging-{Guid.NewGuid():N}");
try
{
await DownloadAsync(asset.BrowserDownloadUrl, stagingPath, progress, ct);
switch (kind)
{
case AssetKind.RawExe:
if (File.Exists(dest)) File.Delete(dest);
File.Move(stagingPath, dest);
break;
case AssetKind.TarGz:
ExtractTunneltoFromTarGz(stagingPath, dest);
File.Delete(stagingPath);
// Mark executable on Unix — extracted files often
// come out without the mode bits set.
if (!OperatingSystem.IsWindows())
TryChmodExec(dest);
break;
}
return dest;
}
catch
{
if (File.Exists(stagingPath)) try { File.Delete(stagingPath); } catch { }
throw;
}
}
/// <summary>Mirror of <see cref="TunneltoTunnel.ResolveExecutable"/>'s
/// search order — kept here so the installer can short-circuit when
/// the user already installed tunnelto via Scoop / brew / cargo.</summary>
private static string? TryFindExisting()
{
var isWindows = OperatingSystem.IsWindows();
var name = isWindows ? "tunnelto.exe" : "tunnelto";
var candidates = new List<string>
{
Path.Combine(AppContext.BaseDirectory, name),
Path.Combine(AppPaths.LocalRoot, name),
};
if (isWindows)
{
candidates.Add(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"scoop", "shims", name));
}
foreach (var c in candidates)
if (File.Exists(c)) return c;
// PATH scan — last resort, in case the user has it somewhere
// unusual like /usr/local/bin or via cargo's bin dir.
var onPath = Environment.GetEnvironmentVariable("PATH")?
.Split(Path.PathSeparator)
.Select(p => Path.Combine(p.Trim(), name))
.FirstOrDefault(File.Exists);
return onPath;
}
private static (string assetName, AssetKind kind) AssetForCurrentPlatform()
{
if (OperatingSystem.IsWindows()) return ("tunnelto-windows.exe", AssetKind.RawExe);
if (OperatingSystem.IsLinux()) return ("tunnelto-linux.tar.gz", AssetKind.TarGz);
throw new PlatformNotSupportedException("No tunnelto auto-install for this platform.");
}
/// <summary>Walks the tarball looking for a regular file named
/// "tunnelto" (typically at the root). Writes it to
/// <paramref name="dest"/>. The archive itself is a single-binary
/// release so we don't need to faithfully recreate the directory
/// structure.</summary>
private static void ExtractTunneltoFromTarGz(string archivePath, string dest)
{
using var fs = File.OpenRead(archivePath);
using var gz = new GZipStream(fs, CompressionMode.Decompress);
using var tar = new TarReader(gz);
while (tar.GetNextEntry() is { } entry)
{
// Only interested in the binary itself. The archive may
// contain README / LICENSE which we skip.
var fname = Path.GetFileName(entry.Name);
if (entry.EntryType != TarEntryType.RegularFile) continue;
if (!fname.Equals("tunnelto", StringComparison.OrdinalIgnoreCase)) continue;
if (File.Exists(dest)) File.Delete(dest);
using var outFs = File.Create(dest);
entry.DataStream?.CopyTo(outFs);
return;
}
throw new InvalidDataException(
"tunnelto-linux.tar.gz didn't contain a 'tunnelto' binary. Asset may be malformed.");
}
/// <summary>Mark a file as executable on Unix systems. Uses
/// <see cref="File.SetUnixFileMode"/> (available since .NET 7).
/// No-op on Windows since the NTFS execute bit doesn't apply.</summary>
private static void TryChmodExec(string path)
{
try
{
var mode = File.GetUnixFileMode(path);
File.SetUnixFileMode(path,
mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute);
}
catch { /* best-effort */ }
}
private static async Task DownloadAsync(
string url, string destPath,
IProgress<DownloadProgress>? progress,
CancellationToken ct)
{
using var resp = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
var total = resp.Content.Headers.ContentLength ?? -1L;
await using var src = await resp.Content.ReadAsStreamAsync(ct);
await using var dst = File.Create(destPath);
var buf = new byte[81920];
long read = 0;
int n;
while ((n = await src.ReadAsync(buf, ct)) > 0)
{
await dst.WriteAsync(buf.AsMemory(0, n), ct);
read += n;
progress?.Report(new DownloadProgress(read, total));
}
}
private enum AssetKind { RawExe, TarGz }
private sealed class GitHubRelease
{
[JsonPropertyName("tag_name")] public string? TagName { get; set; }
[JsonPropertyName("assets")] public List<GitHubAsset>? Assets { get; set; }
}
private sealed class GitHubAsset
{
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("browser_download_url")] public string? BrowserDownloadUrl { get; set; }
}
}
+18 -10
View File
@@ -76,26 +76,34 @@ public sealed class TunneltoTunnel : IAsyncDisposable
private static string ResolveExecutable()
{
var sideBySide = Path.Combine(AppContext.BaseDirectory, "tunnelto.exe");
var isWindows = OperatingSystem.IsWindows();
var name = isWindows ? "tunnelto.exe" : "tunnelto";
var sideBySide = Path.Combine(AppContext.BaseDirectory, name);
if (File.Exists(sideBySide)) return sideBySide;
var local = Path.Combine(AppPaths.LocalRoot, "tunnelto.exe");
var local = Path.Combine(AppPaths.LocalRoot, name);
if (File.Exists(local)) return local;
var scoopShim = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"scoop", "shims", "tunnelto.exe");
if (File.Exists(scoopShim)) return scoopShim;
if (isWindows)
{
var scoopShim = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"scoop", "shims", name);
if (File.Exists(scoopShim)) return scoopShim;
}
var onPath = Environment.GetEnvironmentVariable("PATH")?
.Split(';')
.Select(p => Path.Combine(p.Trim(), "tunnelto.exe"))
.Split(Path.PathSeparator)
.Select(p => Path.Combine(p.Trim(), name))
.FirstOrDefault(File.Exists);
if (onPath is not null) return onPath;
var hint = isWindows
? "scoop bucket add tunnelto https://github.com/asabi/scoop-tunnelto && scoop install tunnelto"
: "cargo install tunnelto # or grab a binary from github.com/agrinman/tunnelto/releases";
throw new FileNotFoundException(
"tunnelto.exe was not found. Install Tunnelto first: " +
"scoop bucket add tunnelto https://github.com/asabi/scoop-tunnelto && scoop install tunnelto");
$"{name} was not found. Install Tunnelto first: {hint}");
}
private static readonly Regex UrlRegex = new(
+166
View File
@@ -0,0 +1,166 @@
using System.IO;
using System.Net.Http;
using System.Net.Http.Json;
using System.Reflection;
using System.Text.Json.Serialization;
namespace DorkNet.Launcher.Backend;
/// <summary>Rolling-update check against GitHub releases. The launcher
/// asks for the latest release tag on startup, compares it to its own
/// assembly version, and (if newer) downloads the new self-contained
/// <c>dorknet.exe</c> next to the existing one + writes a tiny swap
/// script. The UI surfaces a banner with a button that runs the script
/// and exits — the script waits for the process to release the exe,
/// copies the new build in place, and relaunches.</summary>
public sealed class Updater
{
private const string ReleasesApi =
"https://api.github.com/repos/DorkSquadRR/DorkNet/releases/latest";
/// <summary>Asset name produced by the Inno installer's publish
/// step. Must match what's attached to GitHub releases.</summary>
private const string LauncherAssetName = "dorknet.exe";
private static readonly HttpClient Http = CreateHttp();
private static HttpClient CreateHttp()
{
var c = new HttpClient { Timeout = TimeSpan.FromMinutes(2) };
c.DefaultRequestHeaders.UserAgent.ParseAdd("DorkNet-Launcher-Updater/1.0");
c.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
return c;
}
/// <summary>Current launcher version, read from the assembly's
/// InformationalVersion / FileVersion attribute. Falls back to 0.0
/// in dev builds where neither is set.</summary>
public static Version CurrentVersion
{
get
{
var asm = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(info) && TryParseLoose(info, out var v)) return v;
var fv = asm.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version;
if (!string.IsNullOrWhiteSpace(fv) && TryParseLoose(fv, out var fvv)) return fvv;
return new Version(0, 0);
}
}
/// <summary>Looks up GitHub's latest release. Returns null on
/// network failure or if the local version is already at-or-newer
/// than what's published. Never throws — update checks must never
/// block app launch.</summary>
public async Task<UpdateInfo?> CheckAsync(CancellationToken ct = default)
{
try
{
var release = await Http.GetFromJsonAsync<GitHubRelease>(ReleasesApi, ct);
if (release is null || string.IsNullOrEmpty(release.TagName)) return null;
if (!TryParseLoose(release.TagName, out var latest)) return null;
if (latest <= CurrentVersion) return null;
var asset = release.Assets?.FirstOrDefault(a =>
string.Equals(a.Name, LauncherAssetName, StringComparison.OrdinalIgnoreCase));
if (asset?.BrowserDownloadUrl is null) return null;
return new UpdateInfo(latest, asset.BrowserDownloadUrl, release.HtmlUrl);
}
catch { return null; }
}
/// <summary>Downloads the new exe to the user's local update
/// staging dir, writes a swap .cmd next to it, and returns the
/// path to that .cmd. Caller is expected to launch it and exit.</summary>
public async Task<string> StageUpdateAsync(
UpdateInfo info,
IProgress<DownloadProgress>? progress = null,
CancellationToken ct = default)
{
var stagingDir = Path.Combine(AppPaths.LocalRoot, "update");
Directory.CreateDirectory(stagingDir);
var newExePath = Path.Combine(stagingDir, LauncherAssetName);
using (var response = await Http.GetAsync(info.DownloadUrl,
HttpCompletionOption.ResponseHeadersRead, ct))
{
response.EnsureSuccessStatusCode();
var total = response.Content.Headers.ContentLength ?? -1;
await using var src = await response.Content.ReadAsStreamAsync(ct);
await using var dst = File.Create(newExePath);
var buf = new byte[81920];
long read = 0;
int n;
while ((n = await src.ReadAsync(buf, ct)) > 0)
{
await dst.WriteAsync(buf.AsMemory(0, n), ct);
read += n;
progress?.Report(new DownloadProgress(read, total));
}
}
var currentExe = GetCurrentExePath();
var swapScript = Path.Combine(stagingDir, "swap.cmd");
// The swap script:
// 1. Pings until the running dorknet exits (we exit immediately
// after spawning it, so this resolves fast).
// 2. Copies the new exe over the old one. /Y suppresses
// confirmation; failure exits with the original errorlevel.
// 3. Starts the new exe in its install directory.
// 4. Self-deletes via the start-then-del trick.
var script =
"@echo off\r\n" +
"setlocal\r\n" +
$"set OLD=\"{currentExe}\"\r\n" +
$"set NEW=\"{newExePath}\"\r\n" +
":wait\r\n" +
"tasklist /FI \"IMAGENAME eq dorknet.exe\" 2>NUL | find /I \"dorknet.exe\" >NUL\r\n" +
"if not errorlevel 1 (timeout /t 1 /nobreak >NUL & goto wait)\r\n" +
"copy /Y %NEW% %OLD% >NUL || (echo Copy failed & exit /b 1)\r\n" +
"start \"\" %OLD%\r\n" +
"del \"%~f0\"\r\n";
await File.WriteAllTextAsync(swapScript, script, ct);
return swapScript;
}
/// <summary>Best-effort path to the currently-running exe. Used by
/// the swap script as the destination to overwrite.</summary>
public static string GetCurrentExePath()
{
// Process.MainModule.FileName is the reliable source — handles
// single-file deployments (where AppContext.BaseDirectory points
// at an extracted temp dir, not the actual exe location).
var p = System.Diagnostics.Process.GetCurrentProcess();
return p.MainModule?.FileName
?? Path.Combine(AppContext.BaseDirectory, "dorknet.exe");
}
/// <summary>Parse "v0.2.0" / "0.2.0" / "v0.2.0-rc1" loosely into a
/// <see cref="Version"/>. Pre-release suffixes are dropped.</summary>
private static bool TryParseLoose(string s, out Version version)
{
version = new Version(0, 0);
if (string.IsNullOrWhiteSpace(s)) return false;
var t = s.Trim().TrimStart('v', 'V');
// Strip pre-release / build metadata suffix (semver "-..." or "+...").
var cut = t.IndexOfAny(new[] { '-', '+' });
if (cut >= 0) t = t.Substring(0, cut);
// Pad to at least two segments so "1" → "1.0" parses cleanly.
if (!t.Contains('.')) t += ".0";
return Version.TryParse(t, out version!);
}
private sealed class GitHubRelease
{
[JsonPropertyName("tag_name")] public string? TagName { get; set; }
[JsonPropertyName("html_url")] public string? HtmlUrl { get; set; }
[JsonPropertyName("assets")] public List<GitHubAsset>? Assets { get; set; }
}
private sealed class GitHubAsset
{
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("browser_download_url")] public string? BrowserDownloadUrl { get; set; }
}
}
public sealed record UpdateInfo(Version Version, string DownloadUrl, string? ReleaseUrl);