From b155ae5dbe5f1a345c6cd7fb25422e9fe28fd590 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Wed, 27 May 2026 00:55:22 +0300 Subject: [PATCH] launcher: Rec-Room-styled UI; PowerShell-free patcher; Launch button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI redesign: - Warm coral + teal palette over deep purple-navy (closer to Rec Room's marketing aesthetic). New wordmark gradient (coral -> pink -> teal). - Chunky 18px rounded cards on the first-run wizard with per-mode accent tinting (coral for host, teal for join) and clear CTAs. - Form fields wrapped in elevated panels with sectional titles + pill status chips in the header (HOST MODE / JOIN MODE / SETTINGS). - Drop-shadow effects on primary buttons + cards for depth. Patcher rewrite: - ClientPatcher.cs no longer shells out to PowerShell; the launcher unzips MelonLoader, copies the DorkNet plugin DLL, and renders the user-data config template — all in C# in-process. - Patcher zip now driven by a manifest.json at root (schema v1). Per-version branches publish: manifest.json + MelonLoader.zip + DorkNet.ClientMod.dll + config template. - RELEASES.md updated with the new MelonLoader manifest contract and the placeholder substitution rules. Rec Room launch path: - New Backend/RecRoomLauncher.cs runs Recroom_Release.exe directly from the picked install folder's parent dir (no Steam). - 'Launch Rec Room' button appears after a successful patch in both host + join modes. --- launcher/App.xaml | 203 +++++++-- launcher/Backend/ClientPatcher.cs | 213 +++++++-- launcher/Backend/RecRoomLauncher.cs | 51 +++ launcher/MainWindow.xaml | 657 ++++++++++++++++------------ launcher/MainWindow.xaml.cs | 16 +- launcher/RELEASES.md | 107 +++-- 6 files changed, 864 insertions(+), 383 deletions(-) create mode 100644 launcher/Backend/RecRoomLauncher.cs diff --git a/launcher/App.xaml b/launcher/App.xaml index 1127dfd..002d4c0 100644 --- a/launcher/App.xaml +++ b/launcher/App.xaml @@ -4,47 +4,118 @@ StartupUri="MainWindow.xaml"> - - - - - - - - - - - - + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + - - + + + + + - + + + + + + + + + diff --git a/launcher/Backend/ClientPatcher.cs b/launcher/Backend/ClientPatcher.cs index 6832c0c..c4285f0 100644 --- a/launcher/Backend/ClientPatcher.cs +++ b/launcher/Backend/ClientPatcher.cs @@ -1,67 +1,192 @@ using System.IO; -using System.Diagnostics; +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; 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. +/// Applies a DorkNet client patch to the user's Rec Room +/// install entirely in-process — no PowerShell scripts. The patcher +/// zip fetched from a per-version branch's release MUST contain a +/// manifest.json at root describing what to do; the launcher +/// reads it and performs each step in C#. +/// +/// DorkNet ships against MelonLoader. The manifest's +/// default paths target a MelonLoader install layout; per-version +/// branches can override any path explicitly. +/// +/// Manifest schema (v1): +/// +/// { +/// "$schema_version": 1, +/// "loader_archive": "MelonLoader.zip", // optional — unzipped into <recroom-root>\ +/// "plugin_dll": "DorkNet.ClientMod.dll", // required — copied to plugin_dest +/// "plugin_dest": "MelonLoader/Mods", // optional — defaults to "MelonLoader/Mods" +/// "config_template": "dorknet-clientmod.json.template", // optional — rendered with {HOST}/{PHOTON_APPID}/{PHOTON_VOICE_APPID} +/// "config_dest": "MelonLoader/UserData/dorknet-clientmod.json", // optional — defaults shown +/// "old_plugin_paths": ["BepInEx/plugins/DorkNet.ClientPatch.dll"] // optional — deleted before install +/// } +/// +/// Config template placeholders: {HOST}, {PHOTON_APPID}, +/// {PHOTON_VOICE_APPID}. Anything else passes through unchanged. public sealed class ClientPatcher { - public async Task ApplyAsync( + private const string DefaultPluginDest = "MelonLoader/Mods"; + private const string DefaultConfigDest = "MelonLoader/UserData/dorknet-clientmod.json"; + + public Task ApplyAsync( string patcherDir, - string recRoomPath, + string recRoomDataPath, string photonAppId, string photonVoiceAppId, string apexHost, CancellationToken ct = default) { - var script = Path.Combine(patcherDir, "install-plugin.ps1"); - if (!File.Exists(script)) + return Task.Run(() => ApplyCore(patcherDir, recRoomDataPath, + photonAppId, photonVoiceAppId, apexHost), ct); + } + + private PatchResult ApplyCore( + string patcherDir, + string recRoomDataPath, + string photonAppId, + string photonVoiceAppId, + string apexHost) + { + var log = new System.Text.StringBuilder(); + try { - // Some branches may ship only the legacy installer. - script = Path.Combine(patcherDir, "install-legacy-client.ps1"); - if (!File.Exists(script)) - { + if (string.IsNullOrEmpty(recRoomDataPath)) + return PatchResult.Failure("Rec Room install path is empty."); + var recRoomRoot = Path.GetDirectoryName(recRoomDataPath.TrimEnd(Path.DirectorySeparatorChar)) + ?? throw new InvalidOperationException("Couldn't resolve parent dir of " + recRoomDataPath); + if (!Directory.Exists(recRoomRoot)) + return PatchResult.Failure($"Rec Room root does not exist: {recRoomRoot}"); + + var manifestPath = Path.Combine(patcherDir, "manifest.json"); + if (!File.Exists(manifestPath)) 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}."); + $"Patcher zip is missing manifest.json — the per-version " + + $"branch's release is on an older PowerShell-based format. " + + $"Looked in: {patcherDir}"); + var manifest = JsonSerializer.Deserialize( + File.ReadAllText(manifestPath), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (manifest is null) + return PatchResult.Failure("manifest.json parse failed."); + + log.AppendLine($"[patch] manifest schema v{manifest.SchemaVersion}"); + log.AppendLine($"[patch] target: {recRoomRoot}"); + + // 1. Unpack MelonLoader (or whatever loader the branch chose). + if (!string.IsNullOrEmpty(manifest.LoaderArchive)) + { + var zipPath = Path.Combine(patcherDir, manifest.LoaderArchive); + if (!File.Exists(zipPath)) + return PatchResult.Failure($"Loader archive listed in manifest but missing on disk: {zipPath}"); + log.AppendLine($"[patch] extracting loader from {Path.GetFileName(zipPath)} → {recRoomRoot}"); + ExtractZipOverwrite(zipPath, recRoomRoot); } + else + { + log.AppendLine("[patch] no loader archive in manifest — assuming user already has MelonLoader installed"); + } + + // 2. Remove stale plugin paths (e.g. a previous BepInEx-era DLL). + foreach (var old in manifest.OldPluginPaths ?? Array.Empty()) + { + var stale = Path.Combine(recRoomRoot, old.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(stale)) + { + File.Delete(stale); + log.AppendLine($"[patch] removed stale plugin: {old}"); + } + } + + // 3. Copy the DorkNet plugin DLL. + if (string.IsNullOrEmpty(manifest.PluginDll)) + return PatchResult.Failure("manifest.plugin_dll is required."); + var dllSrc = Path.Combine(patcherDir, manifest.PluginDll); + if (!File.Exists(dllSrc)) + return PatchResult.Failure($"plugin DLL listed in manifest but missing on disk: {dllSrc}"); + var pluginDestDir = Path.Combine(recRoomRoot, + (manifest.PluginDest ?? DefaultPluginDest).Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(pluginDestDir); + var dllDest = Path.Combine(pluginDestDir, Path.GetFileName(manifest.PluginDll)); + File.Copy(dllSrc, dllDest, overwrite: true); + log.AppendLine($"[patch] installed plugin: {Path.GetFileName(manifest.PluginDll)} → {pluginDestDir}"); + + // 4. Render the config template. + if (!string.IsNullOrEmpty(manifest.ConfigTemplate)) + { + var templatePath = Path.Combine(patcherDir, manifest.ConfigTemplate); + if (!File.Exists(templatePath)) + return PatchResult.Failure($"config template listed in manifest but missing: {templatePath}"); + + var configDestRel = (manifest.ConfigDest ?? DefaultConfigDest) + .Replace('/', Path.DirectorySeparatorChar); + var configDest = Path.Combine(recRoomRoot, configDestRel); + var configDestDir = Path.GetDirectoryName(configDest); + if (!string.IsNullOrEmpty(configDestDir)) Directory.CreateDirectory(configDestDir); + + var rendered = File.ReadAllText(templatePath) + .Replace("{HOST}", apexHost ?? "") + .Replace("{PHOTON_APPID}", photonAppId ?? "") + .Replace("{PHOTON_VOICE_APPID}", + string.IsNullOrEmpty(photonVoiceAppId) ? photonAppId ?? "" : photonVoiceAppId); + File.WriteAllText(configDest, rendered); + log.AppendLine($"[patch] wrote config: {Path.GetFileName(configDest)}"); + } + + log.AppendLine("[patch] done."); + return PatchResult.Success(log.ToString()); } - - var psi = new ProcessStartInfo + catch (Exception ex) { - 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); + log.AppendLine(); + log.AppendLine("[patch] FAILED: " + ex.Message); + return PatchResult.Failure(log.ToString()); } + } - 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); + /// Extracts a zip on top of an existing directory, replacing + /// any files that collide. + /// throws on existing files; we walk entries ourselves. Also guards + /// against zip-slip path-traversal entries (file names that try to + /// escape destRoot via ../). + private static void ExtractZipOverwrite(string zipPath, string destRoot) + { + var destFull = Path.GetFullPath(destRoot); + using var zip = ZipFile.OpenRead(zipPath); + foreach (var entry in zip.Entries) + { + var safeName = entry.FullName.Replace('/', Path.DirectorySeparatorChar); + var fullDest = Path.GetFullPath(Path.Combine(destRoot, safeName)); + if (!fullDest.StartsWith(destFull + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + && fullDest != destFull) + throw new InvalidOperationException( + $"Refusing to extract '{entry.FullName}' — path escapes destination root."); - return p.ExitCode == 0 - ? PatchResult.Success(stdout) - : PatchResult.Failure(string.IsNullOrEmpty(stderr) ? stdout : stderr); + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(fullDest); + continue; + } + var dir = Path.GetDirectoryName(fullDest); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + entry.ExtractToFile(fullDest, overwrite: true); + } + } + + private sealed class PatcherManifest + { + [JsonPropertyName("$schema_version")] public int SchemaVersion { get; set; } + [JsonPropertyName("loader_archive")] public string? LoaderArchive { get; set; } + [JsonPropertyName("plugin_dll")] public string? PluginDll { get; set; } + [JsonPropertyName("plugin_dest")] public string? PluginDest { get; set; } + [JsonPropertyName("config_template")] public string? ConfigTemplate { get; set; } + [JsonPropertyName("config_dest")] public string? ConfigDest { get; set; } + [JsonPropertyName("old_plugin_paths")] public string[]? OldPluginPaths { get; set; } } } diff --git a/launcher/Backend/RecRoomLauncher.cs b/launcher/Backend/RecRoomLauncher.cs new file mode 100644 index 0000000..4f12d86 --- /dev/null +++ b/launcher/Backend/RecRoomLauncher.cs @@ -0,0 +1,51 @@ +using System.Diagnostics; +using System.IO; + +namespace DorkNet.Launcher.Backend; + +/// Launches the user's already-patched Rec Room install +/// directly via Recroom_Release.exe. Never goes through Steam — +/// DorkNet users may not own the game on Steam. +public static class RecRoomLauncher +{ + /// The picked install path is the Recroom_Release_Data + /// folder; the executable lives at <parent>\Recroom_Release.exe. + /// Returns true if Process.Start succeeded. + public static bool TryLaunch(string recRoomDataPath, out string error) + { + error = ""; + if (string.IsNullOrEmpty(recRoomDataPath)) + { + error = "No Rec Room install picked."; + return false; + } + var parent = Path.GetDirectoryName(recRoomDataPath.TrimEnd(Path.DirectorySeparatorChar)); + if (string.IsNullOrEmpty(parent)) + { + error = $"Couldn't resolve the parent directory of '{recRoomDataPath}'."; + return false; + } + var exe = Path.Combine(parent, "Recroom_Release.exe"); + if (!File.Exists(exe)) + { + error = $"Recroom_Release.exe not found at '{exe}'. " + + "Pick the *_Data folder that sits next to it."; + return false; + } + try + { + Process.Start(new ProcessStartInfo + { + FileName = exe, + WorkingDirectory = parent, + UseShellExecute = true, + }); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } +} diff --git a/launcher/MainWindow.xaml b/launcher/MainWindow.xaml index 1b479d9..ba93d40 100644 --- a/launcher/MainWindow.xaml +++ b/launcher/MainWindow.xaml @@ -2,284 +2,405 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="DorkNet" - Width="900" Height="640" - MinWidth="640" MinHeight="480" + Width="960" Height="700" + MinWidth="720" MinHeight="540" WindowStartupLocation="CenterScreen" Background="{StaticResource Bg}" TextElement.Foreground="{StaticResource Ink}" - FontFamily="Segoe UI"> + FontFamily="Segoe UI Variable, Segoe UI" + TextOptions.TextFormattingMode="Ideal" + TextOptions.TextRenderingMode="ClearType"> - - + + + + + + + + + - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + +