From df9dac053dcd09ccf6028be21444846c2d3d4345 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Tue, 26 May 2026 20:54:16 +0300 Subject: [PATCH] =?UTF-8?q?launcher:=20v0.1=20=E2=80=94=20PhotinoNET=20she?= =?UTF-8?q?ll=20+=20multi-version=20orchestration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- launcher/Backend/AppPaths.cs | 52 +++++ launcher/Backend/AppState.cs | 82 ++++++++ launcher/Backend/ClientPatcher.cs | 71 +++++++ launcher/Backend/JoinCode.cs | 67 ++++++ launcher/Backend/MessageBridge.cs | 218 ++++++++++++++++++++ launcher/Backend/RecRoomPicker.cs | 41 ++++ launcher/Backend/ReleaseDownloader.cs | 166 +++++++++++++++ launcher/Backend/ServerProcess.cs | 125 +++++++++++ launcher/Backend/Tunnel.cs | 88 ++++++++ launcher/Backend/VersionsManifest.cs | 71 +++++++ launcher/DorkNet.Launcher.csproj | 35 ++++ launcher/Program.cs | 70 +++++++ launcher/README.md | 87 ++++++++ launcher/RELEASES.md | 96 +++++++++ launcher/app.manifest | 16 ++ launcher/ui/app.js | 285 ++++++++++++++++++++++++++ launcher/ui/index.html | 149 ++++++++++++++ launcher/ui/style.css | 151 ++++++++++++++ 18 files changed, 1870 insertions(+) create mode 100644 launcher/Backend/AppPaths.cs create mode 100644 launcher/Backend/AppState.cs create mode 100644 launcher/Backend/ClientPatcher.cs create mode 100644 launcher/Backend/JoinCode.cs create mode 100644 launcher/Backend/MessageBridge.cs create mode 100644 launcher/Backend/RecRoomPicker.cs create mode 100644 launcher/Backend/ReleaseDownloader.cs create mode 100644 launcher/Backend/ServerProcess.cs create mode 100644 launcher/Backend/Tunnel.cs create mode 100644 launcher/Backend/VersionsManifest.cs create mode 100644 launcher/DorkNet.Launcher.csproj create mode 100644 launcher/Program.cs create mode 100644 launcher/README.md create mode 100644 launcher/RELEASES.md create mode 100644 launcher/app.manifest create mode 100644 launcher/ui/app.js create mode 100644 launcher/ui/index.html create mode 100644 launcher/ui/style.css diff --git a/launcher/Backend/AppPaths.cs b/launcher/Backend/AppPaths.cs new file mode 100644 index 0000000..b89eaa0 --- /dev/null +++ b/launcher/Backend/AppPaths.cs @@ -0,0 +1,52 @@ +namespace DorkNet.Launcher.Backend; + +/// Single source of truth for where the Easy app stores +/// per-user state, cached server binaries, and run-time logs. Roots at +/// %APPDATA%\DorkNet (roaming) for state/config, and +/// %LOCALAPPDATA%\DorkNet (non-roaming) for the larger cached +/// server binaries — the launcher works without either, but caching +/// downloads avoids re-fetching on every launch. +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"); + + /// 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. + public static string StateFile => Path.Combine(RoamingRoot, "state.json"); + + /// Downloaded server binaries, one folder per version key + /// (e.g. servers\december_2020_12_18\). Big — these are + /// self-contained .NET publishes, ~50 MB each. Local (non-roaming) + /// so they don't bloat domain roaming. + public static string ServersRoot => Path.Combine(LocalRoot, "servers"); + + /// Downloaded client-patcher zips and the unpacked patcher + /// files, one folder per version. + public static string PatchersRoot => Path.Combine(LocalRoot, "patchers"); + + /// Cached version-manifest snapshot + ETag — lets the + /// launcher boot offline (using the last-seen versions.json) and + /// skips the round-trip when nothing changed. + public static string ManifestCacheFile => Path.Combine(LocalRoot, "versions.cache.json"); + + /// Rolling log of launcher operations + the server's + /// stdout/stderr per-session. Useful when "it didn't start" needs + /// to be debugged. + 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); + } +} diff --git a/launcher/Backend/AppState.cs b/launcher/Backend/AppState.cs new file mode 100644 index 0000000..5961fbf --- /dev/null +++ b/launcher/Backend/AppState.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DorkNet.Launcher.Backend; + +/// 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 +/// . +public sealed class AppState +{ + [JsonPropertyName("mode")] + public AppMode Mode { get; set; } = AppMode.Unset; + + /// Path to the user's Recroom_Release_Data folder. Picked + /// via the folder dialog; never auto-detected per project policy + /// (no Steam library scanning). + [JsonPropertyName("recRoomPath")] + public string? RecRoomPath { get; set; } + + /// The version key currently selected for host mode + /// (e.g. december_2020_12_18). For join mode this is set + /// from the parsed join code. + [JsonPropertyName("selectedVersion")] + public string? SelectedVersion { get; set; } + + /// Photon Realtime AppId for host mode. The user gets + /// their own (free at dashboard.photonengine.com); DorkNet never + /// ships one. Empty until set. + [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"; + + /// Host's server name shown in the join code. Empty until + /// the user fills it in on first-run host setup. + [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(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 +{ + /// First launch — show the host-vs-join wizard. + Unset, + Host, + Join, +} diff --git a/launcher/Backend/ClientPatcher.cs b/launcher/Backend/ClientPatcher.cs new file mode 100644 index 0000000..4bf125a --- /dev/null +++ b/launcher/Backend/ClientPatcher.cs @@ -0,0 +1,71 @@ +using System.Diagnostics; + +namespace DorkNet.Launcher.Backend; + +/// Invokes the per-version branch's install-plugin.ps1 +/// (or install-legacy-client.ps1 as a fallback) on the user's +/// Rec Room install. The patcher zip is unpacked by +/// first; we just +/// run the script from inside that unpacked tree. +public sealed class ClientPatcher +{ + public async Task 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); +} diff --git a/launcher/Backend/JoinCode.cs b/launcher/Backend/JoinCode.cs new file mode 100644 index 0000000..e10d355 --- /dev/null +++ b/launcher/Backend/JoinCode.cs @@ -0,0 +1,67 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DorkNet.Launcher.Backend; + +/// 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. +/// +/// 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. +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(json); + } + catch { return null; } + } +} + +public sealed class JoinPayload +{ + /// Public hostname the joining patcher rewrites + /// *.rec.net URIs to. Cloudflare Tunnel typically gives + /// random-words.trycloudflare.com. + [JsonPropertyName("host")] public string Host { get; set; } = ""; + + /// Version key from versions.json. Tells the joiner which + /// per-version branch's client patcher to fetch + install. + [JsonPropertyName("v")] public string VersionKey { get; set; } = ""; + + /// 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. + [JsonPropertyName("pa")] public string PhotonAppId { get; set; } = ""; + + [JsonPropertyName("pv")] public string PhotonVoiceAppId { get; set; } = ""; + + [JsonPropertyName("pr")] public string PhotonRegion { get; set; } = "us"; + + /// Server name shown in the joiner's "you're about to + /// connect to ..." preview. + [JsonPropertyName("n")] public string Name { get; set; } = ""; +} diff --git a/launcher/Backend/MessageBridge.cs b/launcher/Backend/MessageBridge.cs new file mode 100644 index 0000000..638d626 --- /dev/null +++ b/launcher/Backend/MessageBridge.cs @@ -0,0 +1,218 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Photino.NET; + +namespace DorkNet.Launcher.Backend; + +/// The JS ↔ C# message router. Both sides use a small JSON +/// envelope: +/// { "type": "command-or-event-name", "payload": { ... }, "requestId": "optional-string" } +/// On the JS side, window.external.sendMessage(JSON.stringify(envelope)) +/// pushes to C#; window.external.receiveMessage(cb) registers +/// the inbound handler. On C# side, this class consumes the inbound +/// envelopes via and routes by type. +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(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(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(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(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; } + } +} diff --git a/launcher/Backend/RecRoomPicker.cs b/launcher/Backend/RecRoomPicker.cs new file mode 100644 index 0000000..3667c7f --- /dev/null +++ b/launcher/Backend/RecRoomPicker.cs @@ -0,0 +1,41 @@ +using System.Windows.Forms; + +namespace DorkNet.Launcher.Backend; + +/// Windows folder picker for the user's +/// Recroom_Release_Data directory. Per project policy: never +/// auto-detect via Steam library scanning, always ask the user. +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; + } + + /// 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. + 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, ""); + } +} diff --git a/launcher/Backend/ReleaseDownloader.cs b/launcher/Backend/ReleaseDownloader.cs new file mode 100644 index 0000000..78eb322 --- /dev/null +++ b/launcher/Backend/ReleaseDownloader.cs @@ -0,0 +1,166 @@ +using System.IO.Compression; +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace DorkNet.Launcher.Backend; + +/// Fetches the latest server + client-patcher release +/// artifacts for a given version branch from GitHub Releases. Caches +/// under / +/// so re-launches don't re-download +/// the same artifact. +/// +/// Per-branch release naming convention (see RELEASES.md): +/// +/// dorknet-server-{version_key}-win-x64.zip +/// dorknet-clientpatch-{version_key}.zip +/// +/// The branch's release_tag_prefix is the prefix for tag names +/// (e.g. v1-december-2026.05.25) so the launcher finds tags +/// regardless of the suffix scheme used. +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; + } + + /// Returns the path to the unpacked server-binary + /// directory for , downloading + caching + /// it if not already present. + public async Task EnsureServerAsync( + VersionEntry version, + IProgress? 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; + } + + /// Returns the path to the unpacked client-patcher dir + /// for , downloading + caching if needed. + public async Task EnsurePatcherAsync( + VersionEntry version, + IProgress? 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 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>(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? 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 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; +} diff --git a/launcher/Backend/ServerProcess.cs b/launcher/Backend/ServerProcess.cs new file mode 100644 index 0000000..65fabba --- /dev/null +++ b/launcher/Backend/ServerProcess.cs @@ -0,0 +1,125 @@ +using System.Diagnostics; +using System.Text; + +namespace DorkNet.Launcher.Backend; + +/// 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. +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; + + /// Boot the server in . + /// Passes the user's Photon AppIds + apex domain via environment + /// so we don't need to write a per-launch appsettings.Local.json. + 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; + } + + /// JWT signing key — random per-machine, persisted under + /// LocalAppData so re-launches keep the same key (so existing + /// tokens stay valid). + 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(); + } +} diff --git a/launcher/Backend/Tunnel.cs b/launcher/Backend/Tunnel.cs new file mode 100644 index 0000000..4a0d8b5 --- /dev/null +++ b/launcher/Backend/Tunnel.cs @@ -0,0 +1,88 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; + +namespace DorkNet.Launcher.Backend; + +/// Wraps Cloudflare's cloudflared quick-tunnel so the +/// host doesn't need to open ports / configure DNS. Each launch gets +/// a fresh random *.trycloudflare.com URL. +/// +/// Requires cloudflared.exe on PATH or alongside the +/// launcher binary. If absent, returns a clear error and the host +/// can fall back to LAN-only play. +public sealed class Tunnel : IAsyncDisposable +{ + private Process? _process; + private TaskCompletionSource? _urlReady; + public string? PublicUrl { get; private set; } + + public async Task 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(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(); + } +} diff --git a/launcher/Backend/VersionsManifest.cs b/launcher/Backend/VersionsManifest.cs new file mode 100644 index 0000000..68f87bb --- /dev/null +++ b/launcher/Backend/VersionsManifest.cs @@ -0,0 +1,71 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DorkNet.Launcher.Backend; + +/// Fetches the canonical branch-to-build mapping from +/// versions.json on the public DorkNet repo's main +/// 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). +/// +/// Source URL is hardcoded — it's the entire point of the +/// launcher knowing where to look. To run against a fork's manifest, +/// edit . +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 Branches { get; set; } = new(); + + public async Task FetchAsync(CancellationToken ct = default) + { + try + { + var m = await Http.GetFromJsonAsync(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(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 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; } +} diff --git a/launcher/DorkNet.Launcher.csproj b/launcher/DorkNet.Launcher.csproj new file mode 100644 index 0000000..9abcd4f --- /dev/null +++ b/launcher/DorkNet.Launcher.csproj @@ -0,0 +1,35 @@ + + + + WinExe + net9.0-windows + enable + enable + true + DorkNet.Launcher + dorknet + app.manifest + true + true + win-x64 + true + true + + + + + + + + + + + PreserveNewest + + + + diff --git a/launcher/Program.cs b/launcher/Program.cs new file mode 100644 index 0000000..f3d218c --- /dev/null +++ b/launcher/Program.cs @@ -0,0 +1,70 @@ +using Photino.NET; +using DorkNet.Launcher.Backend; +using System.Text.Json; + +namespace DorkNet.Launcher; + +/// 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. +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(); + } +} + +/// 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. +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); + } +} diff --git a/launcher/README.md b/launcher/README.md new file mode 100644 index 0000000..c18d226 --- /dev/null +++ b/launcher/README.md @@ -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/*`. diff --git a/launcher/RELEASES.md b/launcher/RELEASES.md new file mode 100644 index 0000000..8328e08 --- /dev/null +++ b/launcher/RELEASES.md @@ -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 ` + -PhotonAppId ` + -PhotonVoiceAppId ` + -ServerHost +``` + +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. diff --git a/launcher/app.manifest b/launcher/app.manifest new file mode 100644 index 0000000..6d8df79 --- /dev/null +++ b/launcher/app.manifest @@ -0,0 +1,16 @@ + + + + + + + true/pm + PerMonitorV2 + + + + diff --git a/launcher/ui/app.js b/launcher/ui/app.js new file mode 100644 index 0000000..66137fc --- /dev/null +++ b/launcher/ui/app.js @@ -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 ${escapeHtml(p.name || '(unnamed server)')}
` + + `Host: ${escapeHtml(p.host)}
` + + `Version: ${escapeHtml(p.v)}`; + 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, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +// ── Boot ───────────────────────────────────────────────────────────── +// Render an empty 'first-run' until the C# init reply arrives. +showView('first-run'); +bridge.send({ type: 'init' }); diff --git a/launcher/ui/index.html b/launcher/ui/index.html new file mode 100644 index 0000000..37d0479 --- /dev/null +++ b/launcher/ui/index.html @@ -0,0 +1,149 @@ + + + + + DorkNet + + + +
+ + + + + + + + + + + + + +
+
+ + + + diff --git a/launcher/ui/style.css b/launcher/ui/style.css new file mode 100644 index 0000000..7ed8253 --- /dev/null +++ b/launcher/ui/style.css @@ -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; } +}