mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 23:48:45 -07:00
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:
@@ -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 <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
|
||||
/// }
|
||||
/// </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; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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><parent>\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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user