mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 15:38:45 -07:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user