launcher: Rec-Room-styled UI; PowerShell-free patcher; Launch button

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.
This commit is contained in:
Alexa
2026-05-27 00:55:22 +03:00
parent 446a23da2b
commit b155ae5dbe
6 changed files with 864 additions and 383 deletions
+163 -40
View File
@@ -4,47 +4,118 @@
StartupUri="MainWindow.xaml">
<Application.Resources>
<!-- ── Dark theme palette ─────────────────────────────────────── -->
<SolidColorBrush x:Key="Bg" Color="#0E0F13" />
<SolidColorBrush x:Key="BgElev" Color="#181A21" />
<SolidColorBrush x:Key="BgElev2" Color="#21232D" />
<SolidColorBrush x:Key="Border" Color="#2A2D3A" />
<SolidColorBrush x:Key="Ink" Color="#E8EBF3" />
<SolidColorBrush x:Key="InkSoft" Color="#9AA0B4" />
<SolidColorBrush x:Key="InkFaint" Color="#5E6478" />
<SolidColorBrush x:Key="Brand" Color="#7C5CFF" />
<SolidColorBrush x:Key="BrandHover" Color="#9A7EFF" />
<SolidColorBrush x:Key="Danger" Color="#FF6B6B" />
<SolidColorBrush x:Key="Ok" Color="#44D68A" />
<!-- ── Palette ─────────────────────────────────────────────────
Rec Room-adjacent — warm coral + teal on a deep purple-navy.
Avoids the cold-grey "developer tool" look in favour of the
friendly game-launcher aesthetic. -->
<SolidColorBrush x:Key="Bg" Color="#16102A" />
<SolidColorBrush x:Key="BgDeeper" Color="#0E0A1F" />
<SolidColorBrush x:Key="BgElev" Color="#221833" />
<SolidColorBrush x:Key="BgElev2" Color="#2E2245" />
<SolidColorBrush x:Key="Border" Color="#3A2C56" />
<SolidColorBrush x:Key="BorderSoft" Color="#2A2042" />
<SolidColorBrush x:Key="Ink" Color="#F7F3FF" />
<SolidColorBrush x:Key="InkSoft" Color="#B8AED4" />
<SolidColorBrush x:Key="InkFaint" Color="#6E608E" />
<!-- ── TextBox / PasswordBox base ──────────────────────────────── -->
<!-- Brand: warm coral, with teal as the secondary accent. -->
<SolidColorBrush x:Key="Brand" Color="#FF7B5C" />
<SolidColorBrush x:Key="BrandHover" Color="#FF9277" />
<SolidColorBrush x:Key="BrandDeep" Color="#E55F40" />
<SolidColorBrush x:Key="Teal" Color="#44D6C4" />
<SolidColorBrush x:Key="TealDeep" Color="#2DB8A6" />
<SolidColorBrush x:Key="Sunshine" Color="#FFD555" />
<SolidColorBrush x:Key="Ok" Color="#5BE0A6" />
<SolidColorBrush x:Key="Danger" Color="#FF6B8A" />
<!-- Wordmark gradient: coral → pink → teal across the "DorkNet" letters. -->
<LinearGradientBrush x:Key="WordmarkBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FF7B5C" Offset="0" />
<GradientStop Color="#FF5C95" Offset="0.55" />
<GradientStop Color="#44D6C4" Offset="1" />
</LinearGradientBrush>
<!-- Subtle card-back gradient — radial illusion via linear top-left
tint into the base elev colour. -->
<LinearGradientBrush x:Key="CardBg" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#2A1F40" Offset="0" />
<GradientStop Color="#221833" Offset="1" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="CardBgHost" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#3A1F35" Offset="0" />
<GradientStop Color="#221833" Offset="1" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="CardBgJoin" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#1F3540" Offset="0" />
<GradientStop Color="#221833" Offset="1" />
</LinearGradientBrush>
<!-- Soft drop-shadow effect used on cards / primary buttons. -->
<DropShadowEffect x:Key="CardShadow" BlurRadius="24" ShadowDepth="0"
Color="#000000" Opacity="0.45" />
<DropShadowEffect x:Key="BtnShadow" BlurRadius="16" ShadowDepth="2"
Color="#FF5C40" Opacity="0.35" />
<!-- ── TextBox ─────────────────────────────────────────────────── -->
<Style TargetType="TextBox">
<Setter Property="Background" Value="{StaticResource BgElev}" />
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="CaretBrush" Value="{StaticResource Ink}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="8,6" />
<Setter Property="FontFamily" Value="Segoe UI" />
<Setter Property="FontSize" Value="13" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="Padding" Value="12,10" />
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="10">
<ScrollViewer x:Name="PART_ContentHost"
Margin="{TemplateBinding Padding}"
VerticalScrollBarVisibility="Auto" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Brand}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ── ComboBox ────────────────────────────────────────────────── -->
<Style TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource BgElev}" />
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="Padding" Value="8,6" />
<Setter Property="FontSize" Value="13" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="Padding" Value="12,10" />
<Setter Property="FontSize" Value="14" />
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
</Style>
<!-- ── Button styles ───────────────────────────────────────────── -->
<Style TargetType="ComboBoxItem">
<Setter Property="Background" Value="{StaticResource BgElev}" />
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="Padding" Value="10,8" />
</Style>
<!-- ── Buttons ─────────────────────────────────────────────────── -->
<Style x:Key="Btn" TargetType="Button">
<Setter Property="Background" Value="{StaticResource BgElev2}" />
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="14,8" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="Padding" Value="16,10" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
@@ -52,7 +123,7 @@
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="6">
CornerRadius="10">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="{TemplateBinding Padding}" />
@@ -63,64 +134,116 @@
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource Border}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderSoft}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{StaticResource InkFaint}" />
<Setter Property="Background" Value="{StaticResource BgElev}" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="PrimaryBtn" TargetType="Button" BasedOn="{StaticResource Btn}">
<Setter Property="Background" Value="{StaticResource Brand}" />
<Setter Property="BorderBrush" Value="{StaticResource Brand}" />
<Setter Property="BorderBrush" Value="{StaticResource BrandDeep}" />
<Setter Property="Foreground" Value="White" />
<Setter Property="Padding" Value="22,12" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Effect" Value="{StaticResource BtnShadow}" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource BrandHover}" />
<Setter Property="BorderBrush" Value="{StaticResource BrandHover}" />
<Setter Property="BorderBrush" Value="{StaticResource Brand}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="{StaticResource BgElev2}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="Foreground" Value="{StaticResource InkFaint}" />
<Setter Property="Effect" Value="{x:Null}" />
</Trigger>
</Style.Triggers>
</Style>
<!-- ── Card button for first-run wizard ────────────────────────── -->
<Style x:Key="CardBtn" TargetType="Button">
<Setter Property="Background" Value="{StaticResource BgElev}" />
<Style x:Key="TealBtn" TargetType="Button" BasedOn="{StaticResource Btn}">
<Setter Property="Background" Value="{StaticResource Teal}" />
<Setter Property="BorderBrush" Value="{StaticResource TealDeep}" />
<Setter Property="Foreground" Value="#0E0A1F" />
<Setter Property="Padding" Value="22,12" />
<Setter Property="FontSize" Value="14" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#5DE8D6" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="GhostBtn" TargetType="Button" BasedOn="{StaticResource Btn}">
<Setter Property="Background" Value="Transparent" />
</Style>
<!-- ── Card buttons for first-run wizard ───────────────────────── -->
<Style x:Key="ChoiceCardBtn" TargetType="Button">
<Setter Property="Background" Value="{StaticResource CardBg}" />
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="20" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="Padding" Value="28" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Effect" Value="{StaticResource CardShadow}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
<Border x:Name="cardBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="10">
CornerRadius="18">
<ContentPresenter Margin="{TemplateBinding Padding}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="cardBorder" Property="BorderBrush"
Value="{StaticResource Brand}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Brand}" />
</Trigger>
</Style.Triggers>
</Style>
<!-- ── Section header text ─────────────────────────────────────── -->
<!-- ── Section panel — used to group form fields ───────────────── -->
<Style x:Key="Panel" TargetType="Border">
<Setter Property="Background" Value="{StaticResource BgElev}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderSoft}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Padding" Value="20" />
</Style>
<!-- ── Field label text ────────────────────────────────────────── -->
<Style x:Key="FieldLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource InkSoft}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Margin" Value="0,0,0,8" />
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
</Style>
<Style x:Key="SectionTitle" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,0,0,4" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="Margin" Value="0,0,0,12" />
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
</Style>
<!-- Pill-style "status chip" used in section headers. -->
<Style x:Key="StatusChip" TargetType="Border">
<Setter Property="Background" Value="{StaticResource BgElev2}" />
<Setter Property="CornerRadius" Value="999" />
<Setter Property="Padding" Value="10,4" />
</Style>
</Application.Resources>
</Application>
+169 -44
View File
@@ -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;
/// <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>
/// <summary>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
/// <c>manifest.json</c> at root describing what to do; the launcher
/// reads it and performs each step in C#.
///
/// <para>DorkNet ships against <b>MelonLoader</b>. The manifest's
/// default paths target a MelonLoader install layout; per-version
/// branches can override any path explicitly.</para>
///
/// <para>Manifest schema (v1):
/// <code>
/// {
/// "$schema_version": 1,
/// "loader_archive": "MelonLoader.zip", // optional — unzipped into &lt;recroom-root&gt;\
/// "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
/// }
/// </code>
/// Config template placeholders: <c>{HOST}</c>, <c>{PHOTON_APPID}</c>,
/// <c>{PHOTON_VOICE_APPID}</c>. Anything else passes through unchanged.</para></summary>
public sealed class ClientPatcher
{
public async Task<PatchResult> ApplyAsync(
private const string DefaultPluginDest = "MelonLoader/Mods";
private const string DefaultConfigDest = "MelonLoader/UserData/dorknet-clientmod.json";
public Task<PatchResult> 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<PatcherManifest>(
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<string>())
{
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);
/// <summary>Extracts a zip on top of an existing directory, replacing
/// any files that collide. <see cref="ZipFile.ExtractToDirectory"/>
/// throws on existing files; we walk entries ourselves. Also guards
/// against zip-slip path-traversal entries (file names that try to
/// escape destRoot via <c>../</c>).</summary>
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; }
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Diagnostics;
using System.IO;
namespace DorkNet.Launcher.Backend;
/// <summary>Launches the user's already-patched Rec Room install
/// directly via <c>Recroom_Release.exe</c>. Never goes through Steam —
/// DorkNet users may not own the game on Steam.</summary>
public static class RecRoomLauncher
{
/// <summary>The picked install path is the <c>Recroom_Release_Data</c>
/// folder; the executable lives at <c>&lt;parent&gt;\Recroom_Release.exe</c>.
/// Returns true if Process.Start succeeded.</summary>
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;
}
}
}
+389 -268
View File
@@ -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">
<Grid Margin="32,24">
<!-- View slots — only one is visible at a time. CodeBehind toggles
Visibility based on AppState.Mode + the settings drawer flag. -->
<!-- Soft radial-ish glow in the top-left corner of the window for
atmosphere. WPF doesn't have a true radial gradient on Background
without nested Border tricks, so we stack a linear-gradient
overlay on top of the solid Bg. -->
<Grid>
<Grid.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#2A1E48" Offset="0" />
<GradientStop Color="#16102A" Offset="0.6" />
<GradientStop Color="#16102A" Offset="1" />
</LinearGradientBrush>
</Grid.Background>
<!-- ── First-run wizard ───────────────────────────────────────── -->
<StackPanel x:Name="FirstRunView" Visibility="Visible">
<StackPanel HorizontalAlignment="Center" Margin="0,16,0,24">
<TextBlock Text="DorkNet"
FontSize="36" FontWeight="Bold"
Foreground="{StaticResource Brand}"
HorizontalAlignment="Center" />
<TextBlock Text="Self-hostable 2020 Rec Room backend"
Foreground="{StaticResource InkSoft}"
HorizontalAlignment="Center" Margin="0,4,0,0" />
</StackPanel>
<Grid Margin="36,28">
<!-- View slots — only one Visible at a time; code-behind toggles. -->
<Grid Margin="0,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="16" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Style="{StaticResource CardBtn}"
Click="OnPickHost">
<StackPanel>
<TextBlock Text="🛠" FontSize="28" Margin="0,0,0,8" />
<TextBlock Text="Host a server" FontSize="16"
FontWeight="SemiBold" Margin="0,0,0,8" />
<TextBlock Foreground="{StaticResource InkSoft}"
FontSize="12" TextWrapping="Wrap"
Text="Run a server for you and your friends. Picks a Rec Room version, downloads the matching server, opens a tunnel, shares a join code." />
</StackPanel>
</Button>
<Button Grid.Column="2" Style="{StaticResource CardBtn}"
Click="OnPickJoin">
<StackPanel>
<TextBlock Text="🎮" FontSize="28" Margin="0,0,0,8" />
<TextBlock Text="Join a friend" FontSize="16"
FontWeight="SemiBold" Margin="0,0,0,8" />
<TextBlock Foreground="{StaticResource InkSoft}"
FontSize="12" TextWrapping="Wrap"
Text="Paste a join code, point at your Rec Room install. The launcher fetches the matching patcher." />
</StackPanel>
</Button>
</Grid>
<TextBlock Text="You can switch modes later under Settings."
HorizontalAlignment="Center"
Foreground="{StaticResource InkFaint}"
FontSize="11" Margin="0,24,0,0" />
</StackPanel>
<!-- ── Host view ──────────────────────────────────────────────── -->
<Grid x:Name="HostView" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,16">
<TextBlock Text="Host" FontSize="20" FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button HorizontalAlignment="Right" Style="{StaticResource Btn}"
Content="Settings" Click="OnOpenSettings" />
</Grid>
<Border Grid.Row="0" VerticalAlignment="Bottom"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" />
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel>
<StackPanel Margin="0,0,0,16">
<TextBlock Text="REC ROOM INSTALL" Style="{StaticResource FieldLabel}" />
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource Btn}"
Content="Pick..." Click="OnPickRecRoom" Margin="8,0,0,0" />
<Border Background="{StaticResource BgElev}"
BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="6" Padding="8,6">
<TextBlock x:Name="HostRecRoomPath"
Text="(not picked)"
Foreground="{StaticResource InkSoft}"
TextTrimming="CharacterEllipsis" />
</Border>
</DockPanel>
</StackPanel>
<StackPanel Margin="0,0,0,16">
<TextBlock Text="SERVER NAME (SHOWN TO JOINERS)" Style="{StaticResource FieldLabel}" />
<TextBox x:Name="ServerNameInput"
LostFocus="OnServerNameChanged" />
</StackPanel>
<StackPanel Margin="0,0,0,16">
<TextBlock Text="REC ROOM VERSION TO HOST FOR" Style="{StaticResource FieldLabel}" />
<ComboBox x:Name="VersionSelect" />
</StackPanel>
<StackPanel Margin="0,0,0,16">
<TextBlock Text="PHOTON APPIDS (FREE AT DASHBOARD.PHOTONENGINE.COM)" Style="{StaticResource FieldLabel}" />
<TextBox x:Name="PhotonAppIdInput"
Margin="0,0,0,6"
LostFocus="OnPhotonChanged" />
<TextBox x:Name="PhotonVoiceAppIdInput"
Margin="0,0,0,6"
LostFocus="OnPhotonChanged" />
<ComboBox x:Name="PhotonRegionSelect"
SelectionChanged="OnPhotonChanged">
<ComboBoxItem Content="US" Tag="us" />
<ComboBoxItem Content="EU" Tag="eu" />
<ComboBoxItem Content="Asia" Tag="asia" />
<ComboBoxItem Content="Japan" Tag="jp" />
<ComboBoxItem Content="South America" Tag="sa" />
<ComboBoxItem Content="Korea" Tag="kr" />
<ComboBoxItem Content="India" Tag="in" />
<ComboBoxItem Content="Australia" Tag="au" />
</ComboBox>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,8,0,8">
<Button x:Name="HostStartBtn" Style="{StaticResource PrimaryBtn}"
Content="Start hosting" Click="OnHostStart" />
<Button x:Name="HostStopBtn" Style="{StaticResource Btn}"
Content="Stop" Click="OnHostStop"
Margin="8,0,0,0" Visibility="Collapsed" />
</StackPanel>
<Border Background="{StaticResource BgElev}"
BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="6" Padding="12" MinHeight="40" Margin="0,8,0,16">
<TextBlock x:Name="HostStatusText"
Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" Text="Idle." />
</Border>
<StackPanel x:Name="JoinCodePanel" Visibility="Collapsed">
<TextBlock Text="JOIN CODE (SHARE THIS)" Style="{StaticResource FieldLabel}" />
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource Btn}"
Content="Copy" Click="OnCopyJoinCode" Margin="8,0,0,0" />
<Border Background="{StaticResource BgElev2}"
BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="6" Padding="8,6">
<TextBox x:Name="JoinCodeText" IsReadOnly="True"
FontFamily="Consolas" FontSize="11"
Background="Transparent" BorderThickness="0"
TextWrapping="Wrap" />
</Border>
</DockPanel>
<TextBlock Foreground="{StaticResource InkFaint}" FontSize="11"
Text="Joiners paste this into their DorkNet launcher's Join screen."
Margin="0,4,0,0" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
<!-- ── Join view ──────────────────────────────────────────────── -->
<Grid x:Name="JoinView" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,16">
<TextBlock Text="Join" FontSize="20" FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button HorizontalAlignment="Right" Style="{StaticResource Btn}"
Content="Settings" Click="OnOpenSettings" />
</Grid>
<Border Grid.Row="0" VerticalAlignment="Bottom"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" />
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel>
<StackPanel Margin="0,0,0,16">
<TextBlock Text="PASTE THE JOIN CODE YOUR HOST SHARED" Style="{StaticResource FieldLabel}" />
<TextBox x:Name="JoinCodeInput"
AcceptsReturn="True" TextWrapping="Wrap"
Height="60" VerticalContentAlignment="Top"
TextChanged="OnJoinCodeInputChanged" />
<Button Style="{StaticResource Btn}"
Content="Preview" Click="OnDecodeJoinCode"
HorizontalAlignment="Left" Margin="0,8,0,0" />
</StackPanel>
<Border x:Name="JoinPreviewBorder" Visibility="Collapsed"
Background="{StaticResource BgElev}"
BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="6" Padding="12" Margin="0,0,0,16">
<TextBlock x:Name="JoinPreviewText"
Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" />
</Border>
<StackPanel Margin="0,0,0,16">
<TextBlock Text="REC ROOM INSTALL" Style="{StaticResource FieldLabel}" />
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource Btn}"
Content="Pick..." Click="OnPickRecRoom" Margin="8,0,0,0" />
<Border Background="{StaticResource BgElev}"
BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="6" Padding="8,6">
<TextBlock x:Name="JoinRecRoomPath"
Text="(not picked)"
Foreground="{StaticResource InkSoft}"
TextTrimming="CharacterEllipsis" />
</Border>
</DockPanel>
</StackPanel>
<Button x:Name="JoinApplyBtn" Style="{StaticResource PrimaryBtn}"
Content="Patch and launch" Click="OnJoinApply"
HorizontalAlignment="Left" IsEnabled="False"
Margin="0,0,0,16" />
<Border Background="{StaticResource BgElev}"
BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="6" Padding="12" MinHeight="40">
<TextBlock x:Name="JoinStatusText"
Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" Text="Idle." />
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
<!-- ── Settings view ──────────────────────────────────────────── -->
<Grid x:Name="SettingsView" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,16">
<TextBlock Text="Settings" FontSize="20" FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button HorizontalAlignment="Right" Style="{StaticResource Btn}"
Content="← Back" Click="OnBackFromSettings" />
</Grid>
<Border Grid.Row="0" VerticalAlignment="Bottom"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" />
<StackPanel Grid.Row="1">
<StackPanel Margin="0,0,0,16">
<TextBlock Text="MODE" Style="{StaticResource FieldLabel}" />
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource Btn}"
Content="Switch to Host" Click="OnPickHost" />
<Button Style="{StaticResource Btn}"
Content="Switch to Join" Click="OnPickJoin"
Margin="8,0,0,0" />
</StackPanel>
</StackPanel>
<StackPanel Margin="0,0,0,16">
<TextBlock Text="CACHE LOCATIONS" Style="{StaticResource FieldLabel}" />
<TextBlock x:Name="CachePathsText"
<!-- ── First-run wizard ───────────────────────────────────── -->
<StackPanel x:Name="FirstRunView" Visibility="Visible"
VerticalAlignment="Center">
<StackPanel HorizontalAlignment="Center" Margin="0,0,0,40">
<TextBlock Text="DorkNet"
FontSize="56" FontWeight="Bold"
Foreground="{StaticResource WordmarkBrush}"
HorizontalAlignment="Center"
FontFamily="Segoe UI Variable, Segoe UI" />
<TextBlock Text="Play together on your own Rec Room server"
Foreground="{StaticResource InkSoft}"
FontSize="11" TextWrapping="Wrap" />
FontSize="16"
HorizontalAlignment="Center" Margin="0,6,0,0" />
</StackPanel>
<StackPanel>
<TextBlock Text="ABOUT" Style="{StaticResource FieldLabel}" />
<TextBlock Foreground="{StaticResource InkSoft}" FontSize="11">
DorkNet Launcher v0.1 —
<Hyperlink NavigateUri="https://github.com/DorkSquadRR/DorkNet"
Foreground="{StaticResource BrandHover}"
RequestNavigate="OnNavigate">GitHub</Hyperlink>
— AGPL-3.0
</TextBlock>
</StackPanel>
<Grid Margin="40,0" MaxWidth="780" HorizontalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="20" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Style="{StaticResource ChoiceCardBtn}"
Background="{StaticResource CardBgHost}"
Click="OnPickHost">
<StackPanel>
<Border CornerRadius="14" Width="56" Height="56"
Background="#3D2B57" Margin="0,0,0,18"
HorizontalAlignment="Left">
<TextBlock Text="🛠" FontSize="32"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<TextBlock Text="Host a server" FontSize="22"
FontWeight="Bold" Margin="0,0,0,10" />
<TextBlock Foreground="{StaticResource InkSoft}"
FontSize="13" TextWrapping="Wrap" LineHeight="20"
Text="Run a server for you and your friends. Pick a Rec Room version, the launcher downloads the matching server, opens a tunnel, and hands you a shareable join code." />
<Border Margin="0,18,0,0" CornerRadius="8"
Background="{StaticResource Brand}" Padding="14,8"
HorizontalAlignment="Left">
<TextBlock Text="Get started →" Foreground="White"
FontWeight="SemiBold" FontSize="13" />
</Border>
</StackPanel>
</Button>
<Button Grid.Column="2" Style="{StaticResource ChoiceCardBtn}"
Background="{StaticResource CardBgJoin}"
Click="OnPickJoin">
<StackPanel>
<Border CornerRadius="14" Width="56" Height="56"
Background="#1C3C4A" Margin="0,0,0,18"
HorizontalAlignment="Left">
<TextBlock Text="🎮" FontSize="32"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<TextBlock Text="Join a friend" FontSize="22"
FontWeight="Bold" Margin="0,0,0,10" />
<TextBlock Foreground="{StaticResource InkSoft}"
FontSize="13" TextWrapping="Wrap" LineHeight="20"
Text="Paste a join code from your host, point at your Rec Room install. The launcher fetches the matching patcher and gets you connected." />
<Border Margin="0,18,0,0" CornerRadius="8"
Background="{StaticResource Teal}" Padding="14,8"
HorizontalAlignment="Left">
<TextBlock Text="Get started →" Foreground="#0E0A1F"
FontWeight="SemiBold" FontSize="13" />
</Border>
</StackPanel>
</Button>
</Grid>
<TextBlock Text="You can switch modes anytime under Settings."
HorizontalAlignment="Center"
Foreground="{StaticResource InkFaint}"
FontSize="12" Margin="0,32,0,0" />
</StackPanel>
<!-- ── Host view ──────────────────────────────────────────── -->
<Grid x:Name="HostView" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Header bar with wordmark + view label + settings button -->
<Grid Grid.Row="0" Margin="0,0,0,28">
<StackPanel Orientation="Horizontal">
<TextBlock Text="DorkNet" FontSize="22" FontWeight="Bold"
Foreground="{StaticResource WordmarkBrush}" />
<Border Style="{StaticResource StatusChip}" Margin="14,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="HOST MODE" FontSize="10"
FontWeight="Bold" Foreground="{StaticResource Brand}" />
</Border>
</StackPanel>
<Button HorizontalAlignment="Right" Style="{StaticResource GhostBtn}"
Content="Settings" Click="OnOpenSettings" />
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"
Padding="0,0,8,0">
<StackPanel>
<!-- Setup panel -->
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Setup" />
<TextBlock Style="{StaticResource FieldLabel}" Text="REC ROOM INSTALL" />
<DockPanel Margin="0,0,0,16">
<Button DockPanel.Dock="Right" Style="{StaticResource Btn}"
Content="Pick…" Click="OnPickRecRoom" Margin="8,0,0,0" />
<Border Background="{StaticResource BgDeeper}"
BorderBrush="{StaticResource BorderSoft}" BorderThickness="1"
CornerRadius="10" Padding="12,10">
<TextBlock x:Name="HostRecRoomPath"
Text="(not picked)"
Foreground="{StaticResource InkSoft}"
TextTrimming="CharacterEllipsis" FontSize="13" />
</Border>
</DockPanel>
<TextBlock Style="{StaticResource FieldLabel}" Text="SERVER NAME (SHOWN TO JOINERS)" />
<TextBox x:Name="ServerNameInput" Margin="0,0,0,16"
LostFocus="OnServerNameChanged" />
<TextBlock Style="{StaticResource FieldLabel}" Text="REC ROOM VERSION TO HOST FOR" />
<ComboBox x:Name="VersionSelect" />
</StackPanel>
</Border>
<!-- Photon panel -->
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Photon AppIds" />
<TextBlock Foreground="{StaticResource InkSoft}" FontSize="12"
Margin="0,-8,0,12" TextWrapping="Wrap">
Photon Cloud powers in-room realtime sync. Free to register at
<Hyperlink NavigateUri="https://dashboard.photonengine.com"
Foreground="{StaticResource Brand}"
RequestNavigate="OnNavigate">dashboard.photonengine.com</Hyperlink>;
DorkNet never bundles one.
</TextBlock>
<TextBlock Style="{StaticResource FieldLabel}" Text="REALTIME APPID" />
<TextBox x:Name="PhotonAppIdInput" Margin="0,0,0,12"
LostFocus="OnPhotonChanged" />
<TextBlock Style="{StaticResource FieldLabel}" Text="VOICE APPID (OPTIONAL)" />
<TextBox x:Name="PhotonVoiceAppIdInput" Margin="0,0,0,12"
LostFocus="OnPhotonChanged" />
<TextBlock Style="{StaticResource FieldLabel}" Text="CLOUD REGION" />
<ComboBox x:Name="PhotonRegionSelect"
SelectionChanged="OnPhotonChanged">
<ComboBoxItem Content="US" Tag="us" />
<ComboBoxItem Content="EU" Tag="eu" />
<ComboBoxItem Content="Asia" Tag="asia" />
<ComboBoxItem Content="Japan" Tag="jp" />
<ComboBoxItem Content="South America" Tag="sa" />
<ComboBoxItem Content="Korea" Tag="kr" />
<ComboBoxItem Content="India" Tag="in" />
<ComboBoxItem Content="Australia" Tag="au" />
</ComboBox>
</StackPanel>
</Border>
<!-- Action area -->
<StackPanel Orientation="Horizontal" Margin="0,4,0,16">
<Button x:Name="HostStartBtn" Style="{StaticResource PrimaryBtn}"
Content="Start hosting" Click="OnHostStart" />
<Button x:Name="HostStopBtn" Style="{StaticResource Btn}"
Content="Stop" Click="OnHostStop"
Margin="10,0,0,0" Visibility="Collapsed" />
<Button x:Name="HostLaunchBtn" Style="{StaticResource TealBtn}"
Content="Launch Rec Room" Click="OnLaunchRecRoom"
Margin="10,0,0,0" Visibility="Collapsed" />
</StackPanel>
<!-- Status -->
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Status" />
<TextBlock x:Name="HostStatusText"
Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" Text="Idle." FontSize="14" />
</StackPanel>
</Border>
<!-- Join code -->
<Border x:Name="JoinCodePanel" Visibility="Collapsed"
Style="{StaticResource Panel}"
Background="{StaticResource CardBgHost}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}"
Text="🎉 Share this with friends" />
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource PrimaryBtn}"
Content="Copy" Click="OnCopyJoinCode" Margin="10,0,0,0" />
<Border Background="{StaticResource BgDeeper}"
BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="10" Padding="12,10">
<TextBox x:Name="JoinCodeText" IsReadOnly="True"
FontFamily="Cascadia Mono, Consolas" FontSize="12"
Background="Transparent" BorderThickness="0"
TextWrapping="Wrap" />
</Border>
</DockPanel>
<TextBlock Foreground="{StaticResource InkFaint}" FontSize="11"
Text="Paste this in their DorkNet launcher's Join screen."
Margin="0,8,0,0" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
<!-- ── Join view ──────────────────────────────────────────── -->
<Grid x:Name="JoinView" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,28">
<StackPanel Orientation="Horizontal">
<TextBlock Text="DorkNet" FontSize="22" FontWeight="Bold"
Foreground="{StaticResource WordmarkBrush}" />
<Border Style="{StaticResource StatusChip}" Margin="14,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="JOIN MODE" FontSize="10"
FontWeight="Bold" Foreground="{StaticResource Teal}" />
</Border>
</StackPanel>
<Button HorizontalAlignment="Right" Style="{StaticResource GhostBtn}"
Content="Settings" Click="OnOpenSettings" />
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"
Padding="0,0,8,0">
<StackPanel>
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Join code" />
<TextBlock Style="{StaticResource FieldLabel}"
Text="PASTE THE JOIN CODE YOUR HOST SHARED" />
<TextBox x:Name="JoinCodeInput"
AcceptsReturn="True" TextWrapping="Wrap"
Height="80" VerticalContentAlignment="Top"
FontFamily="Cascadia Mono, Consolas" FontSize="12"
TextChanged="OnJoinCodeInputChanged" />
<Button Style="{StaticResource Btn}"
Content="Preview" Click="OnDecodeJoinCode"
HorizontalAlignment="Left" Margin="0,10,0,0" />
</StackPanel>
</Border>
<Border x:Name="JoinPreviewBorder" Visibility="Collapsed"
Style="{StaticResource Panel}"
Background="{StaticResource CardBgJoin}"
Margin="0,0,0,16">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Server preview" />
<TextBlock x:Name="JoinPreviewText"
Foreground="{StaticResource Ink}"
TextWrapping="Wrap" FontSize="14" />
</StackPanel>
</Border>
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Your Rec Room install" />
<TextBlock Style="{StaticResource FieldLabel}" Text="LOCATION" />
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource Btn}"
Content="Pick…" Click="OnPickRecRoom" Margin="8,0,0,0" />
<Border Background="{StaticResource BgDeeper}"
BorderBrush="{StaticResource BorderSoft}" BorderThickness="1"
CornerRadius="10" Padding="12,10">
<TextBlock x:Name="JoinRecRoomPath"
Text="(not picked)"
Foreground="{StaticResource InkSoft}"
TextTrimming="CharacterEllipsis" FontSize="13" />
</Border>
</DockPanel>
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal" Margin="0,4,0,16">
<Button x:Name="JoinApplyBtn" Style="{StaticResource PrimaryBtn}"
Content="Patch" Click="OnJoinApply"
IsEnabled="False" />
<Button x:Name="JoinLaunchBtn" Style="{StaticResource TealBtn}"
Content="Launch Rec Room" Click="OnLaunchRecRoom"
Margin="10,0,0,0" Visibility="Collapsed" />
</StackPanel>
<Border Style="{StaticResource Panel}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Status" />
<TextBlock x:Name="JoinStatusText"
Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" Text="Idle." FontSize="14" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
<!-- ── Settings view ──────────────────────────────────────── -->
<Grid x:Name="SettingsView" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,28">
<StackPanel Orientation="Horizontal">
<TextBlock Text="DorkNet" FontSize="22" FontWeight="Bold"
Foreground="{StaticResource WordmarkBrush}" />
<Border Style="{StaticResource StatusChip}" Margin="14,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="SETTINGS" FontSize="10"
FontWeight="Bold" Foreground="{StaticResource Sunshine}" />
</Border>
</StackPanel>
<Button HorizontalAlignment="Right" Style="{StaticResource GhostBtn}"
Content="← Back" Click="OnBackFromSettings" />
</Grid>
<StackPanel Grid.Row="1">
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Switch mode" />
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource Btn}"
Content="Host" Click="OnPickHost" />
<Button Style="{StaticResource Btn}"
Content="Join" Click="OnPickJoin"
Margin="10,0,0,0" />
</StackPanel>
</StackPanel>
</Border>
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Cache locations" />
<TextBlock x:Name="CachePathsText"
Foreground="{StaticResource InkSoft}"
FontSize="12" TextWrapping="Wrap"
FontFamily="Cascadia Mono, Consolas" />
</StackPanel>
</Border>
<Border Style="{StaticResource Panel}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="About" />
<TextBlock Foreground="{StaticResource InkSoft}" FontSize="13">
DorkNet Launcher v0.1 ·
<Hyperlink NavigateUri="https://github.com/DorkSquadRR/DorkNet"
Foreground="{StaticResource Brand}"
RequestNavigate="OnNavigate">github.com/DorkSquadRR/DorkNet</Hyperlink>
· AGPL-3.0
</TextBlock>
<TextBlock Foreground="{StaticResource InkFaint}" FontSize="11"
Margin="0,8,0,0">
Not affiliated with Rec Room Inc. or Against Gravity.
</TextBlock>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</Grid>
</Grid>
</Window>
+14 -2
View File
@@ -238,7 +238,8 @@ public partial class MainWindow : Window
});
JoinCodeText.Text = code;
JoinCodePanel.Visibility = Visibility.Visible;
HostStatusText.Text = $"Hosting at {publicUrl}";
HostStatusText.Text = $"Hosting at {publicUrl}. Share the join code below or launch Rec Room to test.";
HostLaunchBtn.Visibility = Visibility.Visible;
}
catch (Exception ex)
{
@@ -253,6 +254,7 @@ public partial class MainWindow : Window
await ShutdownServerAsync();
HostStartBtn.IsEnabled = true;
HostStopBtn.Visibility = Visibility.Collapsed;
HostLaunchBtn.Visibility = Visibility.Collapsed;
JoinCodePanel.Visibility = Visibility.Collapsed;
HostStatusText.Text = "Stopped.";
}
@@ -325,12 +327,22 @@ public partial class MainWindow : Window
payload.PhotonAppId, payload.PhotonVoiceAppId, payload.Host);
if (!patch.Ok) { ShowError("Patcher failed: " + Truncate(patch.Log, 500)); return; }
JoinStatusText.Text = "Patched. Launch Rec Room from Steam to connect.";
JoinStatusText.Text = $"Patched and ready. Connecting to {payload.Host}. " +
"Click Launch Rec Room when you're ready to play.";
JoinLaunchBtn.Visibility = Visibility.Visible;
}
catch (Exception ex) { ShowError(ex.Message); }
finally { RefreshJoinReadiness(); }
}
private void OnLaunchRecRoom(object sender, RoutedEventArgs e)
{
if (string.IsNullOrEmpty(_state.RecRoomPath))
{ ShowError("Pick your Rec Room install first."); return; }
if (!RecRoomLauncher.TryLaunch(_state.RecRoomPath, out var error))
ShowError(error);
}
// ── Helpers ─────────────────────────────────────────────────────────
private static string FormatProgress(string label, DownloadProgress p)
{
+78 -29
View File
@@ -6,8 +6,8 @@ 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.
otherwise the launcher reports "no release found" / "manifest missing"
to users on host / join.
## Tag naming
@@ -26,12 +26,10 @@ Examples (matching the current `release_tag_prefix` values):
| `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.
tag starts with the prefix, then looks for the two 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`.
@@ -42,42 +40,93 @@ 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.
launcher scans for it).
### 2. `dorknet-clientpatch-{version_key}.zip`
The contents of the per-version branch's `tools/` directory limited
to the patcher scripts the launcher invokes:
The MelonLoader-based client patcher, packed into a single zip with a
**manifest.json** at the zip root telling the launcher what to do.
The launcher performs every step in C# — no PowerShell scripts run.
- `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:
**Required layout inside the zip:**
```
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>
manifest.json (described below)
MelonLoader.zip (optional — the MelonLoader install tree)
DorkNet.ClientMod.dll (required — the DorkNet MelonLoader plugin)
dorknet-clientmod.json.template (optional — UserData config template)
```
So the script must accept those four parameters. If your branch's
patcher needs additional config, default to safe values.
**`manifest.json` schema (v1):**
```json
{
"$schema_version": 1,
"loader_archive": "MelonLoader.zip",
"plugin_dll": "DorkNet.ClientMod.dll",
"plugin_dest": "MelonLoader/Mods",
"config_template": "dorknet-clientmod.json.template",
"config_dest": "MelonLoader/UserData/dorknet-clientmod.json",
"old_plugin_paths": [
"BepInEx/plugins/DorkNet.ClientPatch.dll"
]
}
```
All fields except `$schema_version` and `plugin_dll` are optional.
Defaults:
| Field | Default |
|---|---|
| `plugin_dest` | `MelonLoader/Mods` |
| `config_dest` | `MelonLoader/UserData/dorknet-clientmod.json` |
| `loader_archive` | (none — assumes user has MelonLoader installed) |
| `config_template` | (none — no config written) |
**`config_template` placeholders** rendered before write:
| Placeholder | Source |
|---|---|
| `{HOST}` | Host's tunnel hostname (or join code's `host` field) |
| `{PHOTON_APPID}` | Host's Photon Realtime AppId |
| `{PHOTON_VOICE_APPID}` | Host's Photon Voice AppId (falls back to Realtime if empty) |
Example template:
```json
{
"ServerHost": "{HOST}",
"PhotonAppId": "{PHOTON_APPID}",
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}"
}
```
## What the launcher does on Apply
Given the manifest above, the launcher does (in this order):
1. Extracts `loader_archive` over the user's Rec Room install root
(the parent of `Recroom_Release_Data`). MelonLoader's `version.dll`
and `MelonLoader/` tree land in the right places.
2. Deletes any `old_plugin_paths` (cleans up an old BepInEx-era DLL,
a previous plugin filename, etc.).
3. Copies `plugin_dll` to `<recroom-root>/<plugin_dest>/`.
4. Renders `config_template` (placeholder substitution) and writes
to `<recroom-root>/<config_dest>`.
No PowerShell, no separate Steamless invocation, no byte-level
metadata patching in v1 — keep the patcher zip declarative. Future
schema versions can add fields for those without breaking older
launchers (they ignore unknown fields).
## 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 `
@@ -85,12 +134,12 @@ gh release download $tag --repo DorkSquadRR/DorkNet `
--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.
Then unzip + inspect: the patcher zip should have `manifest.json` at
root, the named DLL should exist, the `config_template` (if listed)
should exist, etc.
## 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.
that builds + packages the artifacts on tag push. Not yet implemented;
releases are manual for now.