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; }
}
+35
View File
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>DorkNet.Launcher</RootNamespace>
<AssemblyName>dorknet</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
</PropertyGroup>
<ItemGroup>
<!-- PhotinoNET wraps WebView2 with a tiny .NET host. The WebView2 runtime
ships in Windows 11 + modern Windows 10 (Edge auto-installs it). -->
<PackageReference Include="Photino.NET" Version="4.0.16" />
</ItemGroup>
<ItemGroup>
<!-- UI assets get copied next to the binary at publish time so the
PhotinoNET window can load them from disk. Keeps the binary
smaller than embedding base64; the user can also patch the UI
in-place without rebuilding the .exe. -->
<None Update="ui\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+70
View File
@@ -0,0 +1,70 @@
using Photino.NET;
using DorkNet.Launcher.Backend;
using System.Text.Json;
namespace DorkNet.Launcher;
/// <summary>Entry point + the host↔JS message bridge. The window is a
/// PhotinoNET wrapper around WebView2, loading ui/index.html from the
/// app's directory. Every JS ↔ C# call goes through one JSON-tagged
/// envelope so we can route by command/event name without per-call
/// glue.</summary>
public static class Program
{
[STAThread]
public static void Main(string[] args)
{
AppPaths.EnsureDirectoriesExist();
var indexPath = Path.Combine(AppContext.BaseDirectory, "ui", "index.html");
if (!File.Exists(indexPath))
{
// First-aid: if the UI files didn't ship next to the binary,
// the window would be blank with no hint why. Surface the
// missing-file path before opening anything.
MessageBox.Error(
"DorkNet UI files missing",
$"Could not find {indexPath}. Reinstall the launcher.");
return;
}
var window = new PhotinoWindow()
.SetTitle("DorkNet")
.SetUseOsDefaultSize(false)
.SetSize(900, 640)
.SetResizable(true)
.Center()
.Load(new Uri(indexPath));
var bridge = new MessageBridge(window);
window.RegisterWebMessageReceivedHandler((sender, message) =>
{
// JS sends JSON envelopes: {"type":"command-name","payload":{...},"requestId":"..."}
// Reply path is bridge.SendEvent — fire-and-forget unless requestId is set.
try
{
bridge.HandleAsync(message).GetAwaiter().GetResult();
}
catch (Exception ex)
{
bridge.SendEvent("error", new { source = "bridge", message = ex.Message });
}
});
window.WaitForClose();
}
}
/// <summary>Minimal MessageBox helper — DorkNet won't pop these often,
/// but a missing-UI-file failure mode needs a visible signal before the
/// PhotinoNET window opens.</summary>
internal static class MessageBox
{
public static void Error(string title, string body)
{
System.Windows.Forms.MessageBox.Show(
body, title,
System.Windows.Forms.MessageBoxButtons.OK,
System.Windows.Forms.MessageBoxIcon.Error);
}
}
+87
View File
@@ -0,0 +1,87 @@
# DorkNet Launcher
Single-download desktop launcher for self-hosting / joining DorkNet
servers. PhotinoNET-wrapped WebView2 with the orchestration logic in
C#.
This README is for **developers** of the launcher. End-users see
[the main README](../README.md) and download a prebuilt `.exe` from
[Releases](https://github.com/DorkSquadRR/DorkNet/releases).
## What it does
- **Host mode:** fetches the matching server binary from GitHub
Releases for the user's picked Rec Room version, runs it as a
subprocess, opens a Cloudflare quick-tunnel, patches the user's
Rec Room install, hands the user a shareable join code.
- **Join mode:** parses the join code, fetches the matching client
patcher from GitHub Releases, patches the user's Rec Room install
to point at the host.
## How it's wired
```
launcher/
├── DorkNet.Launcher.csproj .NET 9 single-file self-contained exe (Photino.NET dep)
├── Program.cs PhotinoNET window + message-bridge wiring
├── app.manifest Windows DPI-awareness manifest
├── Backend/ Orchestration (C#)
│ ├── AppPaths.cs %APPDATA%\DorkNet layout
│ ├── AppState.cs Persisted user choices (mode, paths, Photon AppId)
│ ├── VersionsManifest.cs Fetches versions.json from main
│ ├── ReleaseDownloader.cs GitHub Releases API + artifact unpack
│ ├── ServerProcess.cs Spawns the downloaded server binary
│ ├── ClientPatcher.cs Invokes install-plugin.ps1 from the unpacked patcher zip
│ ├── Tunnel.cs Wraps `cloudflared tunnel --url …`
│ ├── RecRoomPicker.cs Windows folder dialog
│ ├── JoinCode.cs base64url-encoded {host, version, photonAppId, …}
│ └── MessageBridge.cs JSON envelope router between JS ↔ C#
└── ui/ Single-file frontend (no build step)
├── index.html
├── style.css
└── app.js
```
Message protocol: both directions use
`{ "type": "command-or-event-name", "payload": { ... } }`. New
commands go in `MessageBridge.HandleAsync`'s switch; new outbound
events use `bridge.SendEvent(name, payload)`.
## Build + run
```pwsh
cd launcher
dotnet run
```
Watch the launcher window open. C# stderr lands in the console;
the WebView2 devtools open with F12 (PhotinoNET passes them through).
## Publish a self-contained .exe
```pwsh
dotnet publish -c Release
# Output: bin\Release\net9.0-windows\win-x64\publish\dorknet.exe (~80 MB)
```
The published exe is what ships in GitHub Releases on `main` (use the
`Easy launcher release` workflow when it exists).
## Dependencies on per-version branches
The launcher fetches release artifacts from GitHub Releases tagged on
the per-version branches. See [RELEASES.md](RELEASES.md) for the
exact naming convention each branch must follow.
## Adding a new command
1. Add a new `case` in `MessageBridge.HandleAsync`.
2. Implement the work in a `Backend/*` class.
3. In `ui/app.js`, call `bridge.send({ type: 'your-command', payload: {...} })`
from wherever the user triggers it.
4. If the command needs to push events back, call
`bridge.SendEvent('your-event-name', { ... })` from the handler
and add a case in `app.js`'s `handleEvent` switch.
Keep the bridge handlers tight — they're routing + state mutation;
long work goes in `Backend/*`.
+96
View File
@@ -0,0 +1,96 @@
# Release artifact contract
The DorkNet Launcher (on `main`) reads
[`/versions.json`](../versions.json) to find every supported per-version
branch, then fetches release artifacts from each branch's GitHub
Releases.
Per-version branches must publish releases that follow this contract,
otherwise the launcher reports "no release found" to users on host /
join.
## Tag naming
A release's tag must start with the `release_tag_prefix` declared for
the branch in `versions.json`. Recommended scheme:
```
v1-{month}-{YYYY.MM.DD}
```
Examples (matching the current `release_tag_prefix` values):
| Branch | `release_tag_prefix` | Example full tag |
|---|---|---|
| `december-2020-12-18` | `v1-december` | `v1-december-2026.06.15` |
| `march-2020-03-10` | `v1-march` | `v1-march-2026.06.15` |
The launcher picks the most-recent release (by `published_at`) whose
tag starts with the prefix, then looks for the asset names below.
## Required assets
Each release must attach these two files:
### 1. `dorknet-server-{version_key}-win-x64.zip`
A self-contained .NET 9 publish of `DorkNet.Server` for `win-x64`.
The launcher unpacks this and spawns the executable directly.
Build with:
```pwsh
cd DorkNet.Server
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
# Then zip the publish dir's contents (NOT the dir itself):
cd bin/Release/net9.0/win-x64/publish
Compress-Archive -Path * -DestinationPath ../../../../../dorknet-server-{version_key}-win-x64.zip
```
The zip's root must contain `DorkNet.Server.exe` (or any `.exe`; the
launcher scans for it). Folder layout below that is preserved as-is.
### 2. `dorknet-clientpatch-{version_key}.zip`
The contents of the per-version branch's `tools/` directory limited
to the patcher scripts the launcher invokes:
- `install-plugin.ps1` (preferred — BepInEx route)
- `install-legacy-client.ps1` (fallback — direct byte-patch route)
- Any helper scripts these reference
- The compiled `DorkNet.ClientPatch.dll` if `install-plugin.ps1`
expects a prebuilt DLL
The launcher invokes the patcher script with these arguments:
```
powershell -NoProfile -ExecutionPolicy Bypass -File install-plugin.ps1 `
-RecRoomPath <user's path> `
-PhotonAppId <user's> `
-PhotonVoiceAppId <user's> `
-ServerHost <apex host or join-code host>
```
So the script must accept those four parameters. If your branch's
patcher needs additional config, default to safe values.
## Verifying a release before publishing
```pwsh
# Locally simulate the launcher's fetch.
$tag = "v1-december-2026.06.15"
gh release view $tag --repo DorkSquadRR/DorkNet
gh release download $tag --repo DorkSquadRR/DorkNet `
--pattern "dorknet-server-december_2020_12_18-win-x64.zip" `
--pattern "dorknet-clientpatch-december_2020_12_18.zip"
```
Then unzip + boot the server locally to confirm it starts, and
unzip + run the patcher against a clean Rec Room install to confirm
patching works.
## Future: GitHub Actions workflow
Each per-version branch should land a `.github/workflows/release.yml`
that builds + zips + publishes the two artifacts on tag push. Not yet
implemented; releases are manual for now.
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="0.1.0.0" name="DorkNet.EasyApp"/>
<!-- High-DPI awareness so the launcher window doesn't render blurry on
4K displays. PerMonitorV2 = correct scaling when the user moves the
window between monitors with different DPIs. -->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<!-- No requestedExecutionLevel — DorkNet doesn't need admin. The
Rec Room install path is user-readable, the patcher writes into
it without elevation, and the server binds to high ports. -->
</assembly>
+285
View File
@@ -0,0 +1,285 @@
// DorkNet Launcher UI — single-file JS, no framework. State held in
// `appState`; views toggled via `showView(name)`. C# is reached through
// `bridge.send({type, payload})`; C# pushes events via
// `window.external.receiveMessage(handler)`.
'use strict';
let appState = null; // mirrors the C# AppState payload
let versions = null; // versions.json contents
let previousView = null; // for the settings 'back' button
// ── Message bridge ───────────────────────────────────────────────────
const bridge = {
send(envelope) {
// PhotinoNET exposes window.external.sendMessage(string) — the C#
// side parses the JSON envelope and routes by `type`.
window.external.sendMessage(JSON.stringify(envelope));
}
};
window.external.receiveMessage(raw => {
let env;
try { env = JSON.parse(raw); }
catch { console.error('bridge: non-JSON message', raw); return; }
handleEvent(env.type, env.payload);
});
function handleEvent(type, payload) {
switch (type) {
case 'state-changed':
appState = payload;
reflectStateInUi();
break;
case 'versions':
versions = payload;
reflectVersionsInUi();
break;
case 'host-status':
renderHostStatus(payload);
break;
case 'join-status':
renderJoinStatus(payload);
break;
case 'download-progress':
renderDownloadProgress(payload);
break;
case 'join-code-decoded':
renderJoinPreview(payload);
break;
case 'error':
showToast(payload?.message ?? 'Unknown error', 'error');
break;
default:
console.warn('bridge: unhandled event', type, payload);
}
}
// ── View routing ─────────────────────────────────────────────────────
function showView(name) {
for (const sec of document.querySelectorAll('section[data-view]')) {
const matches = sec.dataset.view === name;
sec.hidden = !matches;
}
}
function reflectStateInUi() {
if (!appState) return;
// First-run check.
if (appState.mode === 'unset') { showView('first-run'); return; }
// Mirror persisted values into the right view's inputs.
const recDisplay = document.getElementById('recroom-path-display');
const recDisplayJoin = document.getElementById('recroom-path-display-join');
const path = appState.recRoomPath || '(not picked)';
if (recDisplay) recDisplay.textContent = path;
if (recDisplayJoin) recDisplayJoin.textContent = path;
document.getElementById('photon-app-id').value = appState.photonAppId || '';
document.getElementById('photon-voice-app-id').value = appState.photonVoiceAppId || '';
document.getElementById('photon-region').value = appState.photonRegion || 'us';
document.getElementById('server-name').value = appState.serverName || '';
// Enable join button only when prerequisites are met.
refreshJoinReadiness();
// Show the mode the user picked unless they're in settings.
if (!isSettingsOpen()) showView(appState.mode);
}
function reflectVersionsInUi() {
const sel = document.getElementById('version-select');
if (!sel || !versions) return;
sel.innerHTML = '';
for (const v of versions.branches.filter(b => b.supported)) {
const opt = document.createElement('option');
opt.value = v.versionKey;
opt.textContent = `Rec Room ${v.clientBuild} (${v.branch})`;
sel.appendChild(opt);
}
if (appState?.selectedVersion) sel.value = appState.selectedVersion;
}
function isSettingsOpen() {
const s = document.querySelector('section[data-view="settings"]');
return s && !s.hidden;
}
// ── Toasts ───────────────────────────────────────────────────────────
function showToast(msg, kind = '') {
const el = document.createElement('div');
el.className = `toast ${kind}`.trim();
el.textContent = msg;
document.getElementById('toasts').appendChild(el);
setTimeout(() => el.remove(), 6000);
}
// ── Event wiring ─────────────────────────────────────────────────────
document.addEventListener('click', e => {
const t = e.target.closest('[data-action], [data-mode-pick]');
if (!t) return;
if (t.dataset.modePick) {
bridge.send({ type: 'set-mode', payload: { mode: t.dataset.modePick } });
return;
}
switch (t.dataset.action) {
case 'pick-recroom':
bridge.send({ type: 'pick-recroom' });
break;
case 'open-settings':
previousView = document.querySelector('section[data-view]:not([hidden])')?.dataset.view;
showView('settings');
break;
case 'back-from-settings':
showView(previousView || appState?.mode || 'first-run');
break;
case 'copy-join-code':
const code = document.getElementById('join-code-value').textContent;
navigator.clipboard.writeText(code).then(
() => showToast('Join code copied', 'ok'),
() => showToast('Copy failed — select + Ctrl+C manually', 'error'));
break;
case 'decode-join-code':
const codeRaw = document.getElementById('join-code-input').value.trim();
bridge.send({ type: 'decode-join-code', payload: { code: codeRaw } });
break;
}
});
// Photon inputs auto-persist on blur.
['photon-app-id', 'photon-voice-app-id', 'photon-region'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('change', () => {
bridge.send({
type: 'set-photon',
payload: {
appId: document.getElementById('photon-app-id').value.trim(),
voiceAppId: document.getElementById('photon-voice-app-id').value.trim(),
region: document.getElementById('photon-region').value,
}
});
});
});
document.getElementById('server-name').addEventListener('change', e => {
bridge.send({ type: 'set-server-name', payload: { name: e.target.value.trim() } });
});
// Host start/stop.
document.getElementById('host-start').addEventListener('click', () => {
if (!appState?.recRoomPath) { showToast('Pick your Rec Room install first', 'error'); return; }
if (!appState?.photonAppId) { showToast('Photon AppId required (free at dashboard.photonengine.com)', 'error'); return; }
const versionKey = document.getElementById('version-select').value;
document.getElementById('host-start').disabled = true;
document.getElementById('host-stop').hidden = false;
bridge.send({ type: 'host-start', payload: { versionKey } });
});
document.getElementById('host-stop').addEventListener('click', () => {
bridge.send({ type: 'host-stop' });
document.getElementById('host-stop').hidden = true;
document.getElementById('host-start').disabled = false;
document.getElementById('join-code-row').hidden = true;
});
// Join apply.
document.getElementById('join-apply').addEventListener('click', () => {
const code = document.getElementById('join-code-input').value.trim();
bridge.send({ type: 'join-apply', payload: { code } });
});
document.getElementById('join-code-input').addEventListener('input', refreshJoinReadiness);
function refreshJoinReadiness() {
const btn = document.getElementById('join-apply');
if (!btn) return;
const hasCode = document.getElementById('join-code-input').value.trim().length > 10;
const hasPath = !!appState?.recRoomPath;
btn.disabled = !(hasCode && hasPath);
}
// ── Render helpers for incoming events ───────────────────────────────
function renderHostStatus(p) {
const el = document.getElementById('host-status');
if (!p) return;
if (p.stage === 'ready') {
el.className = 'status ok';
el.textContent = `Hosting at ${p.publicUrl}`;
document.getElementById('join-code-value').textContent = p.joinCode;
document.getElementById('join-code-row').hidden = false;
} else if (p.stage === 'stopped') {
el.className = 'status';
el.textContent = 'Stopped.';
document.getElementById('host-stop').hidden = true;
document.getElementById('host-start').disabled = false;
document.getElementById('join-code-row').hidden = true;
} else {
el.className = 'status';
el.textContent = humanStage(p.stage);
}
}
function renderJoinStatus(p) {
const el = document.getElementById('join-status');
if (!p) return;
el.className = p.stage === 'ready' ? 'status ok' : 'status';
el.textContent = p.stage === 'ready'
? 'Patched. Launch Rec Room from Steam to connect.'
: humanStage(p.stage);
}
function renderJoinPreview(p) {
const el = document.getElementById('join-preview');
if (!el) return;
if (!p) {
el.className = 'status error';
el.textContent = 'Invalid join code.';
el.hidden = false;
return;
}
el.className = 'status';
el.innerHTML = `Connecting to <b>${escapeHtml(p.name || '(unnamed server)')}</b><br>` +
`Host: <code>${escapeHtml(p.host)}</code><br>` +
`Version: <code>${escapeHtml(p.v)}</code>`;
el.hidden = false;
}
function renderDownloadProgress(p) {
// Could render a progress bar; for v0.1 just flash a status line.
if (!p || !p.total || p.total <= 0) return;
const pct = Math.round(p.fraction * 100);
const mb = (p.bytes / (1024 * 1024)).toFixed(1);
const totalMb = (p.total / (1024 * 1024)).toFixed(1);
const hostEl = document.getElementById('host-status');
const joinEl = document.getElementById('join-status');
const text = `Downloading… ${mb} / ${totalMb} MB (${pct}%)`;
if (hostEl && !hostEl.classList.contains('ok')) hostEl.textContent = text;
if (joinEl && !joinEl.classList.contains('ok')) joinEl.textContent = text;
}
function humanStage(stage) {
return ({
'downloading-server': 'Downloading server binary…',
'opening-tunnel': 'Opening Cloudflare tunnel…',
'starting-server': 'Starting the server…',
'patching-client': 'Patching your Rec Room client…',
'downloading-patcher': 'Downloading client patcher…',
'ready': 'Ready.',
'stopped': 'Stopped.',
}[stage] || stage);
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ── Boot ─────────────────────────────────────────────────────────────
// Render an empty 'first-run' until the C# init reply arrives.
showView('first-run');
bridge.send({ type: 'init' });
+149
View File
@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>DorkNet</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main id="app">
<!-- First-run wizard. Hidden once state.mode flips to host or join. -->
<section data-view="first-run" hidden>
<header>
<div class="logo">DorkNet</div>
<p class="tagline">Self-hostable 2020 Rec Room backend</p>
</header>
<div class="card-row">
<button class="card" data-mode-pick="host">
<span class="card-emoji">🛠️</span>
<span class="card-title">Host a server</span>
<span class="card-body">Run a server for you and your friends. Picks a Rec Room version, downloads the matching server, opens a tunnel, shares a join code.</span>
</button>
<button class="card" data-mode-pick="join">
<span class="card-emoji">🎮</span>
<span class="card-title">Join a friend</span>
<span class="card-body">Paste a join code, point at your Rec Room install. The launcher fetches the matching patcher.</span>
</button>
</div>
<p class="footnote">You can switch modes later under Settings.</p>
</section>
<!-- Host view: pick version → server downloads → tunnel + patcher → share code. -->
<section data-view="host" hidden>
<nav class="topbar">
<span class="topbar-title">Host</span>
<button class="link" data-action="open-settings">Settings</button>
</nav>
<div class="row">
<label>Rec Room install</label>
<div class="path-row">
<code id="recroom-path-display">(not picked)</code>
<button data-action="pick-recroom">Pick…</button>
</div>
</div>
<div class="row">
<label for="server-name">Server name (shown to joiners)</label>
<input id="server-name" type="text" placeholder="e.g. Saturday Hangout" />
</div>
<div class="row">
<label for="version-select">Rec Room version to host for</label>
<select id="version-select"></select>
</div>
<div class="row" id="photon-config">
<label>Photon AppIds (free at <a href="https://dashboard.photonengine.com" target="_blank">dashboard.photonengine.com</a>)</label>
<input id="photon-app-id" type="text" placeholder="Realtime AppId (UUID)" />
<input id="photon-voice-app-id" type="text" placeholder="Voice AppId (UUID, optional)" />
<select id="photon-region">
<option value="us">US</option>
<option value="eu">EU</option>
<option value="asia">Asia</option>
<option value="jp">Japan</option>
<option value="sa">South America</option>
<option value="kr">Korea</option>
<option value="in">India</option>
<option value="au">Australia</option>
</select>
</div>
<div class="actions">
<button id="host-start" class="primary">Start hosting</button>
<button id="host-stop" class="secondary" hidden>Stop</button>
</div>
<div id="host-status" class="status"></div>
<div id="join-code-row" class="row" hidden>
<label>Join code (share this)</label>
<div class="join-code-display">
<code id="join-code-value"></code>
<button data-action="copy-join-code">Copy</button>
</div>
<small>Joiners paste this into their DorkNet launcher's Join screen.</small>
</div>
</section>
<!-- Join view: paste code → patcher downloads + installs → launch Rec Room. -->
<section data-view="join" hidden>
<nav class="topbar">
<span class="topbar-title">Join</span>
<button class="link" data-action="open-settings">Settings</button>
</nav>
<div class="row">
<label for="join-code-input">Paste the join code your host shared</label>
<textarea id="join-code-input" rows="3" placeholder="A long base64-looking string"></textarea>
<button data-action="decode-join-code">Preview</button>
</div>
<div id="join-preview" class="status" hidden></div>
<div class="row">
<label>Rec Room install</label>
<div class="path-row">
<code id="recroom-path-display-join">(not picked)</code>
<button data-action="pick-recroom">Pick…</button>
</div>
</div>
<div class="actions">
<button id="join-apply" class="primary" disabled>Patch and launch</button>
</div>
<div id="join-status" class="status"></div>
</section>
<!-- Settings drawer (overlay). -->
<section data-view="settings" hidden>
<nav class="topbar">
<span class="topbar-title">Settings</span>
<button class="link" data-action="back-from-settings">← back</button>
</nav>
<div class="row">
<label>Mode</label>
<button data-mode-pick="host">Switch to Host</button>
<button data-mode-pick="join">Switch to Join</button>
</div>
<div class="row">
<label>Cache location</label>
<small id="cache-paths"></small>
</div>
<div class="row">
<label>About</label>
<small>DorkNet Launcher v0.1 — <a href="https://github.com/DorkSquadRR/DorkNet" target="_blank">GitHub</a> — AGPL-3.0</small>
</div>
</section>
<!-- Toast container for transient errors / progress flashes. -->
<div id="toasts"></div>
</main>
<script src="app.js"></script>
</body>
</html>
+151
View File
@@ -0,0 +1,151 @@
/* DorkNet Launcher UI — small surface, opinionated styling.
No framework. Stays browser-portable (PhotinoNET is WebView2). */
:root {
--bg: #0e0f13;
--bg-elev: #181a21;
--bg-elev-2: #21232d;
--border: #2a2d3a;
--ink: #e8ebf3;
--ink-soft: #9aa0b4;
--ink-faint: #5e6478;
--brand: #7c5cff;
--brand-hover: #9a7eff;
--danger: #ff6b6b;
--ok: #44d68a;
--radius: 10px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--ink);
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
height: 100%;
overflow: hidden;
}
a { color: var(--brand-hover); }
a:hover { color: var(--brand); }
#app {
height: 100vh;
padding: 24px 32px;
overflow-y: auto;
}
section[data-view] { animation: fade-in 160ms ease-out; }
@keyframes fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
/* First-run wizard */
section[data-view="first-run"] header { text-align: center; margin-bottom: 28px; }
.logo {
font-weight: 700;
font-size: 32px;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--brand) 0%, #ff6bd6 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.tagline { color: var(--ink-soft); margin: 4px 0 0; }
.card-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 24px 0; }
.card {
display: flex; flex-direction: column; align-items: flex-start; gap: 8px;
text-align: left; padding: 20px; cursor: pointer;
background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--ink); transition: border-color 100ms, transform 100ms;
}
.card:hover { border-color: var(--brand); transform: translateY(-2px); }
.card-emoji { font-size: 28px; }
.card-title { font-weight: 600; font-size: 16px; }
.card-body { color: var(--ink-soft); font-size: 13px; }
.footnote { text-align: center; color: var(--ink-faint); font-size: 12px; }
/* Top bar */
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 0 0 16px; margin-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.topbar-title { font-weight: 600; font-size: 18px; }
.link {
background: none; border: 0; color: var(--ink-soft); cursor: pointer; font-size: 13px;
}
.link:hover { color: var(--ink); }
/* Form rows */
.row { margin-bottom: 18px; }
.row > label {
display: block; color: var(--ink-soft); font-size: 12px;
text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 6px;
}
.row input, .row select, .row textarea {
width: 100%; padding: 8px 10px; margin-bottom: 6px;
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 6px;
color: var(--ink); font: inherit;
}
.row input:focus, .row select:focus, .row textarea:focus {
outline: 0; border-color: var(--brand);
}
.path-row { display: flex; align-items: center; gap: 8px; }
.path-row code {
flex: 1; padding: 8px 10px; background: var(--bg-elev); border-radius: 6px;
color: var(--ink-soft); font-size: 12px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap;
}
/* Buttons */
button:not(.card):not(.link) {
padding: 8px 14px;
background: var(--bg-elev-2); color: var(--ink);
border: 1px solid var(--border); border-radius: 6px;
cursor: pointer; font: inherit;
transition: background 100ms, border-color 100ms;
}
button:not(.card):not(.link):hover { background: var(--border); }
button.primary { background: var(--brand); border-color: var(--brand); color: white; }
button.primary:hover { background: var(--brand-hover); border-color: var(--brand-hover); }
button.primary:disabled { background: var(--bg-elev-2); border-color: var(--border); color: var(--ink-faint); cursor: not-allowed; }
button.secondary { background: var(--bg-elev); }
.actions { display: flex; gap: 8px; margin: 24px 0 16px; }
/* Status panel */
.status {
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 6px;
padding: 12px 14px; margin-top: 8px; min-height: 40px;
font-size: 13px; color: var(--ink-soft);
}
.status.ok { border-color: var(--ok); color: var(--ok); }
.status.error { border-color: var(--danger); color: var(--danger); }
.join-code-display { display: flex; gap: 8px; align-items: stretch; }
.join-code-display code {
flex: 1; padding: 8px 10px; background: var(--bg-elev-2); border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
word-break: break-all; font-size: 12px;
}
/* Toasts */
#toasts {
position: fixed; bottom: 20px; right: 20px; display: flex; flex-direction: column;
gap: 8px; z-index: 100; pointer-events: none;
}
.toast {
padding: 10px 14px; background: var(--bg-elev-2); border: 1px solid var(--border);
border-radius: 6px; min-width: 240px; max-width: 360px;
animation: toast-in 200ms ease-out;
}
.toast.error { border-color: var(--danger); }
.toast.ok { border-color: var(--ok); }
@keyframes toast-in {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: none; }
}