mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
Add installer updater and server CLI
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user