launcher: v0.1 — PhotinoNET shell + multi-version orchestration

Single-download .NET 9 self-contained launcher for hosting / joining
DorkNet servers. Reads versions.json from main to discover supported
client builds, fetches the matching server binary + client patcher
from GitHub Releases per branch, manages the server subprocess +
Cloudflare quick-tunnel, and wraps it all in a PhotinoNET window.

Code layout:
- Program.cs           PhotinoNET window + message bridge bootstrap
- Backend/             AppPaths, AppState, VersionsManifest,
                       ReleaseDownloader, ServerProcess, ClientPatcher,
                       Tunnel, RecRoomPicker, JoinCode, MessageBridge
- ui/                  index.html + style.css + app.js (no build step)
- RELEASES.md          GitHub Releases artifact contract per-version
                       branches must publish to

Per-version branches need to ship two release assets per tag:
- dorknet-server-{version_key}-win-x64.zip
- dorknet-clientpatch-{version_key}.zip
(see launcher/RELEASES.md for the full naming + arg contract)

Photon AppIds, JWT secret, and the picked Rec Room path stay
per-machine; the launcher never bundles them.
This commit is contained in:
Alexa
2026-05-26 20:54:16 +03:00
parent ce0714ed1a
commit df9dac053d
18 changed files with 1870 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
namespace DorkNet.Launcher.Backend;
/// <summary>Single source of truth for where the Easy app stores
/// per-user state, cached server binaries, and run-time logs. Roots at
/// <c>%APPDATA%\DorkNet</c> (roaming) for state/config, and
/// <c>%LOCALAPPDATA%\DorkNet</c> (non-roaming) for the larger cached
/// server binaries — the launcher works without either, but caching
/// downloads avoids re-fetching on every launch.</summary>
public static class AppPaths
{
public static string RoamingRoot { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"DorkNet");
public static string LocalRoot { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"DorkNet");
/// <summary>Per-user app state — mode (host/join), picked Rec Room
/// path, selected version, Photon AppIds, etc. Roaming so it
/// follows the user across machines if they use roaming profiles.</summary>
public static string StateFile => Path.Combine(RoamingRoot, "state.json");
/// <summary>Downloaded server binaries, one folder per version key
/// (e.g. <c>servers\december_2020_12_18\</c>). Big — these are
/// self-contained .NET publishes, ~50 MB each. Local (non-roaming)
/// so they don't bloat domain roaming.</summary>
public static string ServersRoot => Path.Combine(LocalRoot, "servers");
/// <summary>Downloaded client-patcher zips and the unpacked patcher
/// files, one folder per version.</summary>
public static string PatchersRoot => Path.Combine(LocalRoot, "patchers");
/// <summary>Cached version-manifest snapshot + ETag — lets the
/// launcher boot offline (using the last-seen versions.json) and
/// skips the round-trip when nothing changed.</summary>
public static string ManifestCacheFile => Path.Combine(LocalRoot, "versions.cache.json");
/// <summary>Rolling log of launcher operations + the server's
/// stdout/stderr per-session. Useful when "it didn't start" needs
/// to be debugged.</summary>
public static string LogsRoot => Path.Combine(LocalRoot, "logs");
public static void EnsureDirectoriesExist()
{
Directory.CreateDirectory(RoamingRoot);
Directory.CreateDirectory(LocalRoot);
Directory.CreateDirectory(ServersRoot);
Directory.CreateDirectory(PatchersRoot);
Directory.CreateDirectory(LogsRoot);
}
}
+82
View File
@@ -0,0 +1,82 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DorkNet.Launcher.Backend;
/// <summary>Persisted user choices that should survive across launches:
/// host-vs-join mode (asked on first launch, switchable in settings),
/// the picked Rec Room install path, the selected server version, and
/// any Photon AppIds the user has configured. Serialized as JSON at
/// <see cref="AppPaths.StateFile"/>.</summary>
public sealed class AppState
{
[JsonPropertyName("mode")]
public AppMode Mode { get; set; } = AppMode.Unset;
/// <summary>Path to the user's Recroom_Release_Data folder. Picked
/// via the folder dialog; never auto-detected per project policy
/// (no Steam library scanning).</summary>
[JsonPropertyName("recRoomPath")]
public string? RecRoomPath { get; set; }
/// <summary>The version key currently selected for host mode
/// (e.g. <c>december_2020_12_18</c>). For join mode this is set
/// from the parsed join code.</summary>
[JsonPropertyName("selectedVersion")]
public string? SelectedVersion { get; set; }
/// <summary>Photon Realtime AppId for host mode. The user gets
/// their own (free at dashboard.photonengine.com); DorkNet never
/// ships one. Empty until set.</summary>
[JsonPropertyName("photonAppId")]
public string PhotonAppId { get; set; } = string.Empty;
[JsonPropertyName("photonVoiceAppId")]
public string PhotonVoiceAppId { get; set; } = string.Empty;
[JsonPropertyName("photonRegion")]
public string PhotonRegion { get; set; } = "us";
/// <summary>Host's server name shown in the join code. Empty until
/// the user fills it in on first-run host setup.</summary>
[JsonPropertyName("serverName")]
public string ServerName { get; set; } = string.Empty;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
public static AppState Load()
{
if (!File.Exists(AppPaths.StateFile)) return new AppState();
try
{
var json = File.ReadAllText(AppPaths.StateFile);
return JsonSerializer.Deserialize<AppState>(json, JsonOptions) ?? new AppState();
}
catch
{
// Corrupt state file — start fresh rather than crash on every
// launch. The user's mode pick + Photon config gets lost but
// they can re-enter; the alternative is unrecoverable.
return new AppState();
}
}
public void Save()
{
var json = JsonSerializer.Serialize(this, JsonOptions);
File.WriteAllText(AppPaths.StateFile, json);
}
}
public enum AppMode
{
/// <summary>First launch — show the host-vs-join wizard.</summary>
Unset,
Host,
Join,
}
+71
View File
@@ -0,0 +1,71 @@
using System.Diagnostics;
namespace DorkNet.Launcher.Backend;
/// <summary>Invokes the per-version branch's <c>install-plugin.ps1</c>
/// (or <c>install-legacy-client.ps1</c> as a fallback) on the user's
/// Rec Room install. The patcher zip is unpacked by
/// <see cref="ReleaseDownloader.EnsurePatcherAsync"/> first; we just
/// run the script from inside that unpacked tree.</summary>
public sealed class ClientPatcher
{
public async Task<PatchResult> ApplyAsync(
string patcherDir,
string recRoomPath,
string photonAppId,
string photonVoiceAppId,
string apexHost,
CancellationToken ct = default)
{
var script = Path.Combine(patcherDir, "install-plugin.ps1");
if (!File.Exists(script))
{
// Some branches may ship only the legacy installer.
script = Path.Combine(patcherDir, "install-legacy-client.ps1");
if (!File.Exists(script))
{
return PatchResult.Failure(
"Patcher zip is missing install-plugin.ps1 and install-legacy-client.ps1. " +
"Open an issue with the version key + the contents of " +
$"{patcherDir}.");
}
}
var psi = new ProcessStartInfo
{
FileName = "powershell.exe",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
WorkingDirectory = patcherDir,
};
psi.ArgumentList.Add("-NoProfile");
psi.ArgumentList.Add("-ExecutionPolicy"); psi.ArgumentList.Add("Bypass");
psi.ArgumentList.Add("-File"); psi.ArgumentList.Add(script);
psi.ArgumentList.Add("-RecRoomPath"); psi.ArgumentList.Add(recRoomPath);
psi.ArgumentList.Add("-PhotonAppId"); psi.ArgumentList.Add(photonAppId);
psi.ArgumentList.Add("-PhotonVoiceAppId");
psi.ArgumentList.Add(string.IsNullOrEmpty(photonVoiceAppId) ? photonAppId : photonVoiceAppId);
if (!string.IsNullOrEmpty(apexHost))
{
psi.ArgumentList.Add("-ServerHost"); psi.ArgumentList.Add(apexHost);
}
using var p = Process.Start(psi)
?? throw new InvalidOperationException("Failed to spawn powershell.exe");
var stdout = await p.StandardOutput.ReadToEndAsync(ct);
var stderr = await p.StandardError.ReadToEndAsync(ct);
await p.WaitForExitAsync(ct);
return p.ExitCode == 0
? PatchResult.Success(stdout)
: PatchResult.Failure(string.IsNullOrEmpty(stderr) ? stdout : stderr);
}
}
public sealed record PatchResult(bool Ok, string Log)
{
public static PatchResult Success(string log) => new(true, log);
public static PatchResult Failure(string log) => new(false, log);
}
+67
View File
@@ -0,0 +1,67 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DorkNet.Launcher.Backend;
/// <summary>Compact URL-safe encoding of the bits a joining player
/// needs: the host's public address (typically a Cloudflare-Tunnel
/// hostname), the Photon AppIds + region they used, the host's
/// server name, and the version key so the joiner's launcher fetches
/// the matching client patcher.
///
/// <para>Format: base64-url of a small JSON object. Roughly 200-300
/// chars including padding — short enough to paste in chat / Discord
/// DM without line-wrapping.</para></summary>
public static class JoinCode
{
public static string Encode(JoinPayload payload)
{
var json = JsonSerializer.SerializeToUtf8Bytes(payload);
return Convert.ToBase64String(json)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
public static JoinPayload? Decode(string code)
{
try
{
var padded = code.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
var json = Convert.FromBase64String(padded);
return JsonSerializer.Deserialize<JoinPayload>(json);
}
catch { return null; }
}
}
public sealed class JoinPayload
{
/// <summary>Public hostname the joining patcher rewrites
/// <c>*.rec.net</c> URIs to. Cloudflare Tunnel typically gives
/// <c>random-words.trycloudflare.com</c>.</summary>
[JsonPropertyName("host")] public string Host { get; set; } = "";
/// <summary>Version key from versions.json. Tells the joiner which
/// per-version branch's client patcher to fetch + install.</summary>
[JsonPropertyName("v")] public string VersionKey { get; set; } = "";
/// <summary>Photon Realtime AppId. The joining client patcher will
/// rewrite the in-binary AppId to this so the joiner reaches the
/// host's Photon-Cloud match instances.</summary>
[JsonPropertyName("pa")] public string PhotonAppId { get; set; } = "";
[JsonPropertyName("pv")] public string PhotonVoiceAppId { get; set; } = "";
[JsonPropertyName("pr")] public string PhotonRegion { get; set; } = "us";
/// <summary>Server name shown in the joiner's "you're about to
/// connect to ..." preview.</summary>
[JsonPropertyName("n")] public string Name { get; set; } = "";
}
+218
View File
@@ -0,0 +1,218 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Photino.NET;
namespace DorkNet.Launcher.Backend;
/// <summary>The JS ↔ C# message router. Both sides use a small JSON
/// envelope:
/// <code>{ "type": "command-or-event-name", "payload": { ... }, "requestId": "optional-string" }</code>
/// On the JS side, <c>window.external.sendMessage(JSON.stringify(envelope))</c>
/// pushes to C#; <c>window.external.receiveMessage(cb)</c> registers
/// the inbound handler. On C# side, this class consumes the inbound
/// envelopes via <see cref="HandleAsync"/> and routes by type.</summary>
public sealed class MessageBridge
{
private readonly PhotinoWindow _window;
private readonly AppState _state;
private readonly ReleaseDownloader _releases = new();
private readonly ClientPatcher _patcher = new();
private readonly ServerProcess _server = new();
private Tunnel? _tunnel;
private VersionsManifest? _manifestCache;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public MessageBridge(PhotinoWindow window)
{
_window = window;
_state = AppState.Load();
}
public async Task HandleAsync(string raw)
{
var envelope = JsonSerializer.Deserialize<Envelope>(raw, JsonOptions);
if (envelope is null) return;
// Dispatch table. Add new commands here. Keep handlers tight —
// long work goes in Backend/* classes, not inline.
switch (envelope.Type)
{
case "init":
await InitAsync(envelope);
break;
case "set-mode":
_state.Mode = Enum.TryParse<AppMode>(envelope.Payload?.GetProperty("mode").GetString(), true, out var m) ? m : AppMode.Unset;
_state.Save();
SendEvent("state-changed", _state);
break;
case "pick-recroom":
var picked = RecRoomPicker.Pick(_state.RecRoomPath);
if (picked is not null)
{
var (ok, reason) = RecRoomPicker.Validate(picked);
if (!ok) { SendEvent("error", new { source = "pick-recroom", message = reason }); break; }
_state.RecRoomPath = picked;
_state.Save();
SendEvent("state-changed", _state);
}
break;
case "set-photon":
ApplyPhoton(envelope.Payload);
_state.Save();
SendEvent("state-changed", _state);
break;
case "set-server-name":
_state.ServerName = envelope.Payload?.GetProperty("name").GetString() ?? "";
_state.Save();
SendEvent("state-changed", _state);
break;
case "fetch-versions":
_manifestCache = await new VersionsManifest().FetchAsync();
SendEvent("versions", _manifestCache);
break;
case "host-start":
await HostStartAsync(envelope);
break;
case "host-stop":
await HostStopAsync();
break;
case "decode-join-code":
var decoded = JoinCode.Decode(envelope.Payload?.GetProperty("code").GetString() ?? "");
SendEvent("join-code-decoded", decoded);
break;
case "join-apply":
await JoinApplyAsync(envelope);
break;
default:
SendEvent("error", new { source = "bridge", message = $"unknown command: {envelope.Type}" });
break;
}
}
private async Task InitAsync(Envelope envelope)
{
SendEvent("state-changed", _state);
_manifestCache = await new VersionsManifest().FetchAsync();
SendEvent("versions", _manifestCache);
}
private void ApplyPhoton(JsonElement? payload)
{
if (payload is not { } p) return;
if (p.TryGetProperty("appId", out var a)) _state.PhotonAppId = a.GetString() ?? "";
if (p.TryGetProperty("voiceAppId", out var v)) _state.PhotonVoiceAppId = v.GetString() ?? "";
if (p.TryGetProperty("region", out var r)) _state.PhotonRegion = r.GetString() ?? "us";
}
private async Task HostStartAsync(Envelope envelope)
{
if (_manifestCache is null)
_manifestCache = await new VersionsManifest().FetchAsync();
var versionKey = envelope.Payload?.GetProperty("versionKey").GetString();
var version = _manifestCache?.Branches.FirstOrDefault(b => b.VersionKey == versionKey);
if (version is null) { SendEvent("error", new { source = "host-start", message = $"unknown version: {versionKey}" }); return; }
if (string.IsNullOrEmpty(_state.PhotonAppId)) { SendEvent("error", new { source = "host-start", message = "Photon AppId not set — fill it in under Settings." }); return; }
SendEvent("host-status", new { stage = "downloading-server" });
var progress = new Progress<DownloadProgress>(p =>
SendEvent("download-progress", new { fraction = p.Fraction, bytes = p.BytesRead, total = p.TotalBytes }));
var serverDir = await _releases.EnsureServerAsync(version, progress);
SendEvent("host-status", new { stage = "opening-tunnel" });
_tunnel = new Tunnel();
var publicUrl = await _tunnel.StartAsync(localPort: 5005);
var apex = new Uri(publicUrl).Host;
SendEvent("host-status", new { stage = "starting-server" });
await _server.StartAsync(serverDir, _state, apex);
_state.SelectedVersion = version.VersionKey;
_state.Save();
SendEvent("host-status", new { stage = "patching-client" });
var patcherDir = await _releases.EnsurePatcherAsync(version, progress);
var patchResult = await _patcher.ApplyAsync(
patcherDir,
_state.RecRoomPath ?? "",
_state.PhotonAppId,
_state.PhotonVoiceAppId,
apex);
if (!patchResult.Ok)
{
SendEvent("error", new { source = "patcher", message = patchResult.Log });
return;
}
var joinCode = JoinCode.Encode(new JoinPayload
{
Host = apex,
VersionKey = version.VersionKey,
PhotonAppId = _state.PhotonAppId,
PhotonVoiceAppId = _state.PhotonVoiceAppId,
PhotonRegion = _state.PhotonRegion,
Name = _state.ServerName,
});
SendEvent("host-status", new { stage = "ready", publicUrl, joinCode });
}
private async Task HostStopAsync()
{
await _server.StopAsync();
if (_tunnel is not null) { await _tunnel.DisposeAsync(); _tunnel = null; }
SendEvent("host-status", new { stage = "stopped" });
}
private async Task JoinApplyAsync(Envelope envelope)
{
var code = envelope.Payload?.GetProperty("code").GetString();
var payload = JoinCode.Decode(code ?? "");
if (payload is null) { SendEvent("error", new { source = "join", message = "invalid join code" }); return; }
if (string.IsNullOrEmpty(_state.RecRoomPath)) { SendEvent("error", new { source = "join", message = "pick your Rec Room install first" }); return; }
if (_manifestCache is null)
_manifestCache = await new VersionsManifest().FetchAsync();
var version = _manifestCache?.Branches.FirstOrDefault(b => b.VersionKey == payload.VersionKey);
if (version is null) { SendEvent("error", new { source = "join", message = $"host is on a version this launcher doesn't know about: {payload.VersionKey}" }); return; }
SendEvent("join-status", new { stage = "downloading-patcher" });
var progress = new Progress<DownloadProgress>(p =>
SendEvent("download-progress", new { fraction = p.Fraction, bytes = p.BytesRead, total = p.TotalBytes }));
var patcherDir = await _releases.EnsurePatcherAsync(version, progress);
SendEvent("join-status", new { stage = "patching-client" });
var patchResult = await _patcher.ApplyAsync(
patcherDir,
_state.RecRoomPath!,
payload.PhotonAppId,
payload.PhotonVoiceAppId,
payload.Host);
if (!patchResult.Ok)
{
SendEvent("error", new { source = "patcher", message = patchResult.Log });
return;
}
SendEvent("join-status", new { stage = "ready" });
}
public void SendEvent(string type, object? payload)
{
var json = JsonSerializer.Serialize(
new { type, payload },
JsonOptions);
_window.SendWebMessage(json);
}
private sealed class Envelope
{
[JsonPropertyName("type")] public string Type { get; set; } = "";
[JsonPropertyName("payload")] public JsonElement? Payload { get; set; }
[JsonPropertyName("requestId")] public string? RequestId { get; set; }
}
}
+41
View File
@@ -0,0 +1,41 @@
using System.Windows.Forms;
namespace DorkNet.Launcher.Backend;
/// <summary>Windows folder picker for the user's
/// <c>Recroom_Release_Data</c> directory. Per project policy: never
/// auto-detect via Steam library scanning, always ask the user.</summary>
public static class RecRoomPicker
{
public static string? Pick(string? initialPath = null)
{
using var dlg = new FolderBrowserDialog
{
Description = "Select your Rec Room install's *_Data folder " +
"(e.g. Recroom_Release_Data inside the install).",
UseDescriptionForTitle = true,
ShowNewFolderButton = false,
InitialDirectory = initialPath ?? Environment.GetFolderPath(
Environment.SpecialFolder.ProgramFilesX86),
};
return dlg.ShowDialog() == DialogResult.OK ? dlg.SelectedPath : null;
}
/// <summary>Quick sanity check that the picked path looks like a
/// Rec Room install. Doesn't validate the build version — that's
/// the patcher's job. Just checks for the canonical files so we
/// catch "user picked their Documents folder" mistakes early.</summary>
public static (bool ok, string reason) Validate(string path)
{
if (string.IsNullOrEmpty(path)) return (false, "no path selected");
if (!Directory.Exists(path)) return (false, $"folder does not exist: {path}");
// Recroom_Release_Data/StreamingAssets is the consistent
// checkable subfolder across all Rec Room builds we target.
var streaming = Path.Combine(path, "StreamingAssets");
if (!Directory.Exists(streaming))
return (false, "folder is missing StreamingAssets — pick the *_Data folder inside your Rec Room install");
return (true, "");
}
}
+166
View File
@@ -0,0 +1,166 @@
using System.IO.Compression;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
namespace DorkNet.Launcher.Backend;
/// <summary>Fetches the latest server + client-patcher release
/// artifacts for a given version branch from GitHub Releases. Caches
/// under <see cref="AppPaths.ServersRoot"/> /
/// <see cref="AppPaths.PatchersRoot"/> so re-launches don't re-download
/// the same artifact.
///
/// <para>Per-branch release naming convention (see RELEASES.md):
/// <list type="bullet">
/// <item><c>dorknet-server-{version_key}-win-x64.zip</c></item>
/// <item><c>dorknet-clientpatch-{version_key}.zip</c></item>
/// </list>
/// The branch's <c>release_tag_prefix</c> is the prefix for tag names
/// (e.g. <c>v1-december-2026.05.25</c>) so the launcher finds tags
/// regardless of the suffix scheme used.</para></summary>
public sealed class ReleaseDownloader
{
private const string ReleasesApi =
"https://api.github.com/repos/DorkSquadRR/DorkNet/releases";
private static readonly HttpClient Http = CreateHttpClient();
private static HttpClient CreateHttpClient()
{
var c = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
// GitHub requires a User-Agent on API calls.
c.DefaultRequestHeaders.UserAgent.ParseAdd("DorkNet-Launcher/0.1");
c.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
return c;
}
/// <summary>Returns the path to the unpacked server-binary
/// directory for <paramref name="version"/>, downloading + caching
/// it if not already present.</summary>
public async Task<string> EnsureServerAsync(
VersionEntry version,
IProgress<DownloadProgress>? progress,
CancellationToken ct = default)
{
var dir = Path.Combine(AppPaths.ServersRoot, version.VersionKey);
var marker = Path.Combine(dir, ".dorknet-version");
// If the marker file exists, the server is already unpacked
// for this tag and re-downloading would be wasted bandwidth.
if (File.Exists(marker)) return dir;
var asset = await FindAssetAsync(
tagPrefix: version.ReleaseTagPrefix,
assetName: $"dorknet-server-{version.VersionKey}-win-x64.zip",
ct);
var zipPath = Path.Combine(AppPaths.LocalRoot, $"download-{Guid.NewGuid():N}.zip");
await DownloadAsync(asset.BrowserDownloadUrl, zipPath, progress, ct);
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
Directory.CreateDirectory(dir);
ZipFile.ExtractToDirectory(zipPath, dir);
File.Delete(zipPath);
File.WriteAllText(marker, asset.NodeId);
return dir;
}
/// <summary>Returns the path to the unpacked client-patcher dir
/// for <paramref name="version"/>, downloading + caching if needed.</summary>
public async Task<string> EnsurePatcherAsync(
VersionEntry version,
IProgress<DownloadProgress>? progress,
CancellationToken ct = default)
{
var dir = Path.Combine(AppPaths.PatchersRoot, version.VersionKey);
var marker = Path.Combine(dir, ".dorknet-version");
if (File.Exists(marker)) return dir;
var asset = await FindAssetAsync(
tagPrefix: version.ReleaseTagPrefix,
assetName: $"dorknet-clientpatch-{version.VersionKey}.zip",
ct);
var zipPath = Path.Combine(AppPaths.LocalRoot, $"download-{Guid.NewGuid():N}.zip");
await DownloadAsync(asset.BrowserDownloadUrl, zipPath, progress, ct);
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
Directory.CreateDirectory(dir);
ZipFile.ExtractToDirectory(zipPath, dir);
File.Delete(zipPath);
File.WriteAllText(marker, asset.NodeId);
return dir;
}
private async Task<ReleaseAsset> FindAssetAsync(
string tagPrefix, string assetName, CancellationToken ct)
{
// List releases (paginated, but we only need the most recent
// matching prefix). Default page size is 30 which covers all
// realistic per-version release histories.
var releases = await Http.GetFromJsonAsync<List<Release>>(ReleasesApi, ct)
?? throw new InvalidOperationException("GitHub Releases returned null");
var matching = releases
.Where(r => r.TagName.StartsWith(tagPrefix, StringComparison.Ordinal))
.OrderByDescending(r => r.PublishedAt)
.ToList();
if (matching.Count == 0)
throw new InvalidOperationException(
$"No release found with tag prefix '{tagPrefix}'. " +
"Run the per-version branch's release workflow before booting host mode.");
foreach (var rel in matching)
{
var asset = rel.Assets.FirstOrDefault(a =>
a.Name.Equals(assetName, StringComparison.Ordinal));
if (asset is not null) return asset;
}
throw new InvalidOperationException(
$"Found {matching.Count} release(s) matching '{tagPrefix}' but none contained " +
$"the asset '{assetName}'. Check the release-workflow asset naming.");
}
private 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 totalBytes = resp.Content.Headers.ContentLength ?? -1L;
await using var src = await resp.Content.ReadAsStreamAsync(ct);
await using var dst = File.Create(destPath);
var buffer = new byte[81920];
long readTotal = 0;
int read;
while ((read = await src.ReadAsync(buffer, ct)) > 0)
{
await dst.WriteAsync(buffer.AsMemory(0, read), ct);
readTotal += read;
progress?.Report(new DownloadProgress(readTotal, totalBytes));
}
}
private sealed class Release
{
[JsonPropertyName("tag_name")] public string TagName { get; set; } = "";
[JsonPropertyName("published_at")] public DateTimeOffset PublishedAt { get; set; }
[JsonPropertyName("assets")] public List<ReleaseAsset> Assets { get; set; } = new();
}
private sealed class ReleaseAsset
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("browser_download_url")] public string BrowserDownloadUrl { get; set; } = "";
[JsonPropertyName("node_id")] public string NodeId { get; set; } = "";
}
}
public readonly record struct DownloadProgress(long BytesRead, long TotalBytes)
{
public double Fraction => TotalBytes > 0 ? (double)BytesRead / TotalBytes : 0;
}
+125
View File
@@ -0,0 +1,125 @@
using System.Diagnostics;
using System.Text;
namespace DorkNet.Launcher.Backend;
/// <summary>Spawns + manages the downloaded server binary as a child
/// process. Each running launcher owns one server at a time — the
/// previous one is killed before starting a new one.</summary>
public sealed class ServerProcess : IAsyncDisposable
{
private Process? _process;
private readonly StringBuilder _stderrBuffer = new();
public string? StdoutLogPath { get; private set; }
public bool IsRunning => _process is not null && !_process.HasExited;
/// <summary>Boot the server in <paramref name="serverDir"/>.
/// Passes the user's Photon AppIds + apex domain via environment
/// so we don't need to write a per-launch appsettings.Local.json.</summary>
public Task StartAsync(
string serverDir,
AppState state,
string apex,
CancellationToken ct = default)
{
if (IsRunning)
throw new InvalidOperationException("A server is already running; stop it first.");
var exe = FindServerExecutable(serverDir);
StdoutLogPath = Path.Combine(AppPaths.LogsRoot, $"server-{DateTime.UtcNow:yyyyMMdd-HHmmss}.log");
var psi = new ProcessStartInfo
{
FileName = exe,
WorkingDirectory = serverDir,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
psi.Environment["ASPNETCORE_ENVIRONMENT"] = "Development";
psi.Environment["ASPNETCORE_URLS"] = "http://127.0.0.1:5005";
psi.Environment["Database__Provider"] = "sqlite";
psi.Environment["Jwt__Secret"] = EnsureJwtSecret();
psi.Environment["Photon__AppId"] = state.PhotonAppId;
psi.Environment["Photon__VoiceAppId"] =
string.IsNullOrEmpty(state.PhotonVoiceAppId) ? state.PhotonAppId : state.PhotonVoiceAppId;
psi.Environment["Photon__CloudRegion"] = state.PhotonRegion;
psi.Environment["Domain__Apex"] = apex;
_process = new Process { StartInfo = psi, EnableRaisingEvents = true };
_process.OutputDataReceived += (_, e) => AppendLog(e.Data);
_process.ErrorDataReceived += (_, e) => { AppendLog(e.Data); if (e.Data is not null) _stderrBuffer.AppendLine(e.Data); };
_process.Start();
_process.BeginOutputReadLine();
_process.BeginErrorReadLine();
return Task.CompletedTask;
}
private void AppendLog(string? line)
{
if (line is null || StdoutLogPath is null) return;
try { File.AppendAllText(StdoutLogPath, line + Environment.NewLine); }
catch { /* disk full / locked / can't help here */ }
}
public async Task StopAsync()
{
if (_process is null || _process.HasExited) return;
try
{
_process.CloseMainWindow();
if (!_process.WaitForExit(2000))
{
_process.Kill(entireProcessTree: true);
}
await _process.WaitForExitAsync();
}
catch { /* already gone */ }
}
public string TailStderr(int lines = 30)
{
var all = _stderrBuffer.ToString().Split('\n');
return string.Join('\n', all[Math.Max(0, all.Length - lines)..]);
}
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");
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;
}
/// <summary>JWT signing key — random per-machine, persisted under
/// LocalAppData so re-launches keep the same key (so existing
/// tokens stay valid).</summary>
private static string EnsureJwtSecret()
{
var path = Path.Combine(AppPaths.LocalRoot, "jwt.secret");
if (File.Exists(path))
{
var existing = File.ReadAllText(path).Trim();
if (existing.Length >= 64) return existing;
}
var bytes = new byte[64];
System.Security.Cryptography.RandomNumberGenerator.Fill(bytes);
var secret = Convert.ToBase64String(bytes);
File.WriteAllText(path, secret);
return secret;
}
public async ValueTask DisposeAsync()
{
await StopAsync();
_process?.Dispose();
}
}
+88
View File
@@ -0,0 +1,88 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
namespace DorkNet.Launcher.Backend;
/// <summary>Wraps Cloudflare's <c>cloudflared</c> quick-tunnel so the
/// host doesn't need to open ports / configure DNS. Each launch gets
/// a fresh random <c>*.trycloudflare.com</c> URL.
///
/// <para>Requires <c>cloudflared.exe</c> on PATH or alongside the
/// launcher binary. If absent, returns a clear error and the host
/// can fall back to LAN-only play.</para></summary>
public sealed class Tunnel : IAsyncDisposable
{
private Process? _process;
private TaskCompletionSource<string>? _urlReady;
public string? PublicUrl { get; private set; }
public async Task<string> StartAsync(int localPort, CancellationToken ct = default)
{
var exe = FindCloudflared() ??
throw new FileNotFoundException(
"cloudflared not found. Install from " +
"https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ " +
"and put cloudflared.exe on PATH (or next to the DorkNet launcher).");
_urlReady = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var psi = new ProcessStartInfo
{
FileName = exe,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
psi.ArgumentList.Add("tunnel");
psi.ArgumentList.Add("--no-autoupdate");
psi.ArgumentList.Add("--url");
psi.ArgumentList.Add($"http://127.0.0.1:{localPort}");
_process = Process.Start(psi) ??
throw new InvalidOperationException("Failed to spawn cloudflared.exe");
_process.OutputDataReceived += (_, e) => ParseLine(e.Data);
_process.ErrorDataReceived += (_, e) => ParseLine(e.Data);
_process.BeginOutputReadLine();
_process.BeginErrorReadLine();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
PublicUrl = await _urlReady.Task.WaitAsync(timeoutCts.Token);
return PublicUrl;
}
private static readonly Regex UrlRegex = new(
@"https://[a-z0-9\-]+\.trycloudflare\.com",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private void ParseLine(string? line)
{
if (line is null || _urlReady is null || _urlReady.Task.IsCompleted) return;
var match = UrlRegex.Match(line);
if (match.Success) _urlReady.TrySetResult(match.Value);
}
private static string? FindCloudflared()
{
var sideBySide = Path.Combine(AppContext.BaseDirectory, "cloudflared.exe");
if (File.Exists(sideBySide)) return sideBySide;
var onPath = Environment.GetEnvironmentVariable("PATH")?
.Split(';')
.Select(p => Path.Combine(p, "cloudflared.exe"))
.FirstOrDefault(File.Exists);
return onPath;
}
public async ValueTask DisposeAsync()
{
if (_process is null) return;
try
{
if (!_process.HasExited) _process.Kill(entireProcessTree: true);
await _process.WaitForExitAsync();
}
catch { }
_process.Dispose();
}
}
+71
View File
@@ -0,0 +1,71 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DorkNet.Launcher.Backend;
/// <summary>Fetches the canonical branch-to-build mapping from
/// <c>versions.json</c> on the public DorkNet repo's <c>main</c>
/// branch. Falls back to the locally-cached copy when offline, so the
/// launcher opens even with no internet (host mode just can't fetch
/// new server builds).
///
/// <para>Source URL is hardcoded — it's the entire point of the
/// launcher knowing where to look. To run against a fork's manifest,
/// edit <see cref="ManifestUrl"/>.</para></summary>
public sealed class VersionsManifest
{
public const string ManifestUrl =
"https://raw.githubusercontent.com/DorkSquadRR/DorkNet/main/versions.json";
private static readonly HttpClient Http = new()
{
Timeout = TimeSpan.FromSeconds(15),
};
[JsonPropertyName("$schema_version")] public int SchemaVersion { get; set; }
[JsonPropertyName("branches")] public List<VersionEntry> Branches { get; set; } = new();
public async Task<VersionsManifest?> FetchAsync(CancellationToken ct = default)
{
try
{
var m = await Http.GetFromJsonAsync<VersionsManifest>(ManifestUrl, ct);
if (m is not null)
{
File.WriteAllText(AppPaths.ManifestCacheFile,
JsonSerializer.Serialize(m));
}
return m;
}
catch
{
// Offline / 404 / parse failure — fall back to last-seen
// cache. Returns null only when there's no cache either
// (first launch + no internet — the launcher reports the
// failure to the user in that case).
return LoadCached();
}
}
public static VersionsManifest? LoadCached()
{
if (!File.Exists(AppPaths.ManifestCacheFile)) return null;
try
{
var json = File.ReadAllText(AppPaths.ManifestCacheFile);
return JsonSerializer.Deserialize<VersionsManifest>(json);
}
catch { return null; }
}
}
public sealed class VersionEntry
{
[JsonPropertyName("branch")] public string Branch { get; set; } = "";
[JsonPropertyName("client_build")] public string ClientBuild { get; set; } = "";
[JsonPropertyName("alt_builds")] public List<string> AltBuilds { get; set; } = new();
[JsonPropertyName("version_key")] public string VersionKey { get; set; } = "";
[JsonPropertyName("release_tag_prefix")] public string ReleaseTagPrefix { get; set; } = "";
[JsonPropertyName("supported")] public bool Supported { get; set; }
}