mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
launcher: v0.1 — PhotinoNET shell + multi-version orchestration
Single-download .NET 9 self-contained launcher for hosting / joining
DorkNet servers. Reads versions.json from main to discover supported
client builds, fetches the matching server binary + client patcher
from GitHub Releases per branch, manages the server subprocess +
Cloudflare quick-tunnel, and wraps it all in a PhotinoNET window.
Code layout:
- Program.cs PhotinoNET window + message bridge bootstrap
- Backend/ AppPaths, AppState, VersionsManifest,
ReleaseDownloader, ServerProcess, ClientPatcher,
Tunnel, RecRoomPicker, JoinCode, MessageBridge
- ui/ index.html + style.css + app.js (no build step)
- RELEASES.md GitHub Releases artifact contract per-version
branches must publish to
Per-version branches need to ship two release assets per tag:
- dorknet-server-{version_key}-win-x64.zip
- dorknet-clientpatch-{version_key}.zip
(see launcher/RELEASES.md for the full naming + arg contract)
Photon AppIds, JWT secret, and the picked Rec Room path stay
per-machine; the launcher never bundles them.
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
namespace DorkNet.Launcher.Backend;
|
||||||
|
|
||||||
|
/// <summary>Single source of truth for where the Easy app stores
|
||||||
|
/// per-user state, cached server binaries, and run-time logs. Roots at
|
||||||
|
/// <c>%APPDATA%\DorkNet</c> (roaming) for state/config, and
|
||||||
|
/// <c>%LOCALAPPDATA%\DorkNet</c> (non-roaming) for the larger cached
|
||||||
|
/// server binaries — the launcher works without either, but caching
|
||||||
|
/// downloads avoids re-fetching on every launch.</summary>
|
||||||
|
public static class AppPaths
|
||||||
|
{
|
||||||
|
public static string RoamingRoot { get; } = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"DorkNet");
|
||||||
|
|
||||||
|
public static string LocalRoot { get; } = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"DorkNet");
|
||||||
|
|
||||||
|
/// <summary>Per-user app state — mode (host/join), picked Rec Room
|
||||||
|
/// path, selected version, Photon AppIds, etc. Roaming so it
|
||||||
|
/// follows the user across machines if they use roaming profiles.</summary>
|
||||||
|
public static string StateFile => Path.Combine(RoamingRoot, "state.json");
|
||||||
|
|
||||||
|
/// <summary>Downloaded server binaries, one folder per version key
|
||||||
|
/// (e.g. <c>servers\december_2020_12_18\</c>). Big — these are
|
||||||
|
/// self-contained .NET publishes, ~50 MB each. Local (non-roaming)
|
||||||
|
/// so they don't bloat domain roaming.</summary>
|
||||||
|
public static string ServersRoot => Path.Combine(LocalRoot, "servers");
|
||||||
|
|
||||||
|
/// <summary>Downloaded client-patcher zips and the unpacked patcher
|
||||||
|
/// files, one folder per version.</summary>
|
||||||
|
public static string PatchersRoot => Path.Combine(LocalRoot, "patchers");
|
||||||
|
|
||||||
|
/// <summary>Cached version-manifest snapshot + ETag — lets the
|
||||||
|
/// launcher boot offline (using the last-seen versions.json) and
|
||||||
|
/// skips the round-trip when nothing changed.</summary>
|
||||||
|
public static string ManifestCacheFile => Path.Combine(LocalRoot, "versions.cache.json");
|
||||||
|
|
||||||
|
/// <summary>Rolling log of launcher operations + the server's
|
||||||
|
/// stdout/stderr per-session. Useful when "it didn't start" needs
|
||||||
|
/// to be debugged.</summary>
|
||||||
|
public static string LogsRoot => Path.Combine(LocalRoot, "logs");
|
||||||
|
|
||||||
|
public static void EnsureDirectoriesExist()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(RoamingRoot);
|
||||||
|
Directory.CreateDirectory(LocalRoot);
|
||||||
|
Directory.CreateDirectory(ServersRoot);
|
||||||
|
Directory.CreateDirectory(PatchersRoot);
|
||||||
|
Directory.CreateDirectory(LogsRoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Launcher.Backend;
|
||||||
|
|
||||||
|
/// <summary>Persisted user choices that should survive across launches:
|
||||||
|
/// host-vs-join mode (asked on first launch, switchable in settings),
|
||||||
|
/// the picked Rec Room install path, the selected server version, and
|
||||||
|
/// any Photon AppIds the user has configured. Serialized as JSON at
|
||||||
|
/// <see cref="AppPaths.StateFile"/>.</summary>
|
||||||
|
public sealed class AppState
|
||||||
|
{
|
||||||
|
[JsonPropertyName("mode")]
|
||||||
|
public AppMode Mode { get; set; } = AppMode.Unset;
|
||||||
|
|
||||||
|
/// <summary>Path to the user's Recroom_Release_Data folder. Picked
|
||||||
|
/// via the folder dialog; never auto-detected per project policy
|
||||||
|
/// (no Steam library scanning).</summary>
|
||||||
|
[JsonPropertyName("recRoomPath")]
|
||||||
|
public string? RecRoomPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The version key currently selected for host mode
|
||||||
|
/// (e.g. <c>december_2020_12_18</c>). For join mode this is set
|
||||||
|
/// from the parsed join code.</summary>
|
||||||
|
[JsonPropertyName("selectedVersion")]
|
||||||
|
public string? SelectedVersion { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Photon Realtime AppId for host mode. The user gets
|
||||||
|
/// their own (free at dashboard.photonengine.com); DorkNet never
|
||||||
|
/// ships one. Empty until set.</summary>
|
||||||
|
[JsonPropertyName("photonAppId")]
|
||||||
|
public string PhotonAppId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("photonVoiceAppId")]
|
||||||
|
public string PhotonVoiceAppId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("photonRegion")]
|
||||||
|
public string PhotonRegion { get; set; } = "us";
|
||||||
|
|
||||||
|
/// <summary>Host's server name shown in the join code. Empty until
|
||||||
|
/// the user fills it in on first-run host setup.</summary>
|
||||||
|
[JsonPropertyName("serverName")]
|
||||||
|
public string ServerName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||||
|
};
|
||||||
|
|
||||||
|
public static AppState Load()
|
||||||
|
{
|
||||||
|
if (!File.Exists(AppPaths.StateFile)) return new AppState();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(AppPaths.StateFile);
|
||||||
|
return JsonSerializer.Deserialize<AppState>(json, JsonOptions) ?? new AppState();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Corrupt state file — start fresh rather than crash on every
|
||||||
|
// launch. The user's mode pick + Photon config gets lost but
|
||||||
|
// they can re-enter; the alternative is unrecoverable.
|
||||||
|
return new AppState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(this, JsonOptions);
|
||||||
|
File.WriteAllText(AppPaths.StateFile, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AppMode
|
||||||
|
{
|
||||||
|
/// <summary>First launch — show the host-vs-join wizard.</summary>
|
||||||
|
Unset,
|
||||||
|
Host,
|
||||||
|
Join,
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace DorkNet.Launcher.Backend;
|
||||||
|
|
||||||
|
/// <summary>Invokes the per-version branch's <c>install-plugin.ps1</c>
|
||||||
|
/// (or <c>install-legacy-client.ps1</c> as a fallback) on the user's
|
||||||
|
/// Rec Room install. The patcher zip is unpacked by
|
||||||
|
/// <see cref="ReleaseDownloader.EnsurePatcherAsync"/> first; we just
|
||||||
|
/// run the script from inside that unpacked tree.</summary>
|
||||||
|
public sealed class ClientPatcher
|
||||||
|
{
|
||||||
|
public async Task<PatchResult> ApplyAsync(
|
||||||
|
string patcherDir,
|
||||||
|
string recRoomPath,
|
||||||
|
string photonAppId,
|
||||||
|
string photonVoiceAppId,
|
||||||
|
string apexHost,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var script = Path.Combine(patcherDir, "install-plugin.ps1");
|
||||||
|
if (!File.Exists(script))
|
||||||
|
{
|
||||||
|
// Some branches may ship only the legacy installer.
|
||||||
|
script = Path.Combine(patcherDir, "install-legacy-client.ps1");
|
||||||
|
if (!File.Exists(script))
|
||||||
|
{
|
||||||
|
return PatchResult.Failure(
|
||||||
|
"Patcher zip is missing install-plugin.ps1 and install-legacy-client.ps1. " +
|
||||||
|
"Open an issue with the version key + the contents of " +
|
||||||
|
$"{patcherDir}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "powershell.exe",
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
WorkingDirectory = patcherDir,
|
||||||
|
};
|
||||||
|
psi.ArgumentList.Add("-NoProfile");
|
||||||
|
psi.ArgumentList.Add("-ExecutionPolicy"); psi.ArgumentList.Add("Bypass");
|
||||||
|
psi.ArgumentList.Add("-File"); psi.ArgumentList.Add(script);
|
||||||
|
psi.ArgumentList.Add("-RecRoomPath"); psi.ArgumentList.Add(recRoomPath);
|
||||||
|
psi.ArgumentList.Add("-PhotonAppId"); psi.ArgumentList.Add(photonAppId);
|
||||||
|
psi.ArgumentList.Add("-PhotonVoiceAppId");
|
||||||
|
psi.ArgumentList.Add(string.IsNullOrEmpty(photonVoiceAppId) ? photonAppId : photonVoiceAppId);
|
||||||
|
if (!string.IsNullOrEmpty(apexHost))
|
||||||
|
{
|
||||||
|
psi.ArgumentList.Add("-ServerHost"); psi.ArgumentList.Add(apexHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var p = Process.Start(psi)
|
||||||
|
?? throw new InvalidOperationException("Failed to spawn powershell.exe");
|
||||||
|
var stdout = await p.StandardOutput.ReadToEndAsync(ct);
|
||||||
|
var stderr = await p.StandardError.ReadToEndAsync(ct);
|
||||||
|
await p.WaitForExitAsync(ct);
|
||||||
|
|
||||||
|
return p.ExitCode == 0
|
||||||
|
? PatchResult.Success(stdout)
|
||||||
|
: PatchResult.Failure(string.IsNullOrEmpty(stderr) ? stdout : stderr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PatchResult(bool Ok, string Log)
|
||||||
|
{
|
||||||
|
public static PatchResult Success(string log) => new(true, log);
|
||||||
|
public static PatchResult Failure(string log) => new(false, log);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Launcher.Backend;
|
||||||
|
|
||||||
|
/// <summary>Compact URL-safe encoding of the bits a joining player
|
||||||
|
/// needs: the host's public address (typically a Cloudflare-Tunnel
|
||||||
|
/// hostname), the Photon AppIds + region they used, the host's
|
||||||
|
/// server name, and the version key so the joiner's launcher fetches
|
||||||
|
/// the matching client patcher.
|
||||||
|
///
|
||||||
|
/// <para>Format: base64-url of a small JSON object. Roughly 200-300
|
||||||
|
/// chars including padding — short enough to paste in chat / Discord
|
||||||
|
/// DM without line-wrapping.</para></summary>
|
||||||
|
public static class JoinCode
|
||||||
|
{
|
||||||
|
public static string Encode(JoinPayload payload)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.SerializeToUtf8Bytes(payload);
|
||||||
|
return Convert.ToBase64String(json)
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_')
|
||||||
|
.TrimEnd('=');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JoinPayload? Decode(string code)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var padded = code.Replace('-', '+').Replace('_', '/');
|
||||||
|
switch (padded.Length % 4)
|
||||||
|
{
|
||||||
|
case 2: padded += "=="; break;
|
||||||
|
case 3: padded += "="; break;
|
||||||
|
}
|
||||||
|
var json = Convert.FromBase64String(padded);
|
||||||
|
return JsonSerializer.Deserialize<JoinPayload>(json);
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class JoinPayload
|
||||||
|
{
|
||||||
|
/// <summary>Public hostname the joining patcher rewrites
|
||||||
|
/// <c>*.rec.net</c> URIs to. Cloudflare Tunnel typically gives
|
||||||
|
/// <c>random-words.trycloudflare.com</c>.</summary>
|
||||||
|
[JsonPropertyName("host")] public string Host { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Version key from versions.json. Tells the joiner which
|
||||||
|
/// per-version branch's client patcher to fetch + install.</summary>
|
||||||
|
[JsonPropertyName("v")] public string VersionKey { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Photon Realtime AppId. The joining client patcher will
|
||||||
|
/// rewrite the in-binary AppId to this so the joiner reaches the
|
||||||
|
/// host's Photon-Cloud match instances.</summary>
|
||||||
|
[JsonPropertyName("pa")] public string PhotonAppId { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("pv")] public string PhotonVoiceAppId { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("pr")] public string PhotonRegion { get; set; } = "us";
|
||||||
|
|
||||||
|
/// <summary>Server name shown in the joiner's "you're about to
|
||||||
|
/// connect to ..." preview.</summary>
|
||||||
|
[JsonPropertyName("n")] public string Name { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Photino.NET;
|
||||||
|
|
||||||
|
namespace DorkNet.Launcher.Backend;
|
||||||
|
|
||||||
|
/// <summary>The JS ↔ C# message router. Both sides use a small JSON
|
||||||
|
/// envelope:
|
||||||
|
/// <code>{ "type": "command-or-event-name", "payload": { ... }, "requestId": "optional-string" }</code>
|
||||||
|
/// On the JS side, <c>window.external.sendMessage(JSON.stringify(envelope))</c>
|
||||||
|
/// pushes to C#; <c>window.external.receiveMessage(cb)</c> registers
|
||||||
|
/// the inbound handler. On C# side, this class consumes the inbound
|
||||||
|
/// envelopes via <see cref="HandleAsync"/> and routes by type.</summary>
|
||||||
|
public sealed class MessageBridge
|
||||||
|
{
|
||||||
|
private readonly PhotinoWindow _window;
|
||||||
|
private readonly AppState _state;
|
||||||
|
private readonly ReleaseDownloader _releases = new();
|
||||||
|
private readonly ClientPatcher _patcher = new();
|
||||||
|
private readonly ServerProcess _server = new();
|
||||||
|
private Tunnel? _tunnel;
|
||||||
|
private VersionsManifest? _manifestCache;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
public MessageBridge(PhotinoWindow window)
|
||||||
|
{
|
||||||
|
_window = window;
|
||||||
|
_state = AppState.Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task HandleAsync(string raw)
|
||||||
|
{
|
||||||
|
var envelope = JsonSerializer.Deserialize<Envelope>(raw, JsonOptions);
|
||||||
|
if (envelope is null) return;
|
||||||
|
|
||||||
|
// Dispatch table. Add new commands here. Keep handlers tight —
|
||||||
|
// long work goes in Backend/* classes, not inline.
|
||||||
|
switch (envelope.Type)
|
||||||
|
{
|
||||||
|
case "init":
|
||||||
|
await InitAsync(envelope);
|
||||||
|
break;
|
||||||
|
case "set-mode":
|
||||||
|
_state.Mode = Enum.TryParse<AppMode>(envelope.Payload?.GetProperty("mode").GetString(), true, out var m) ? m : AppMode.Unset;
|
||||||
|
_state.Save();
|
||||||
|
SendEvent("state-changed", _state);
|
||||||
|
break;
|
||||||
|
case "pick-recroom":
|
||||||
|
var picked = RecRoomPicker.Pick(_state.RecRoomPath);
|
||||||
|
if (picked is not null)
|
||||||
|
{
|
||||||
|
var (ok, reason) = RecRoomPicker.Validate(picked);
|
||||||
|
if (!ok) { SendEvent("error", new { source = "pick-recroom", message = reason }); break; }
|
||||||
|
_state.RecRoomPath = picked;
|
||||||
|
_state.Save();
|
||||||
|
SendEvent("state-changed", _state);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "set-photon":
|
||||||
|
ApplyPhoton(envelope.Payload);
|
||||||
|
_state.Save();
|
||||||
|
SendEvent("state-changed", _state);
|
||||||
|
break;
|
||||||
|
case "set-server-name":
|
||||||
|
_state.ServerName = envelope.Payload?.GetProperty("name").GetString() ?? "";
|
||||||
|
_state.Save();
|
||||||
|
SendEvent("state-changed", _state);
|
||||||
|
break;
|
||||||
|
case "fetch-versions":
|
||||||
|
_manifestCache = await new VersionsManifest().FetchAsync();
|
||||||
|
SendEvent("versions", _manifestCache);
|
||||||
|
break;
|
||||||
|
case "host-start":
|
||||||
|
await HostStartAsync(envelope);
|
||||||
|
break;
|
||||||
|
case "host-stop":
|
||||||
|
await HostStopAsync();
|
||||||
|
break;
|
||||||
|
case "decode-join-code":
|
||||||
|
var decoded = JoinCode.Decode(envelope.Payload?.GetProperty("code").GetString() ?? "");
|
||||||
|
SendEvent("join-code-decoded", decoded);
|
||||||
|
break;
|
||||||
|
case "join-apply":
|
||||||
|
await JoinApplyAsync(envelope);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
SendEvent("error", new { source = "bridge", message = $"unknown command: {envelope.Type}" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitAsync(Envelope envelope)
|
||||||
|
{
|
||||||
|
SendEvent("state-changed", _state);
|
||||||
|
_manifestCache = await new VersionsManifest().FetchAsync();
|
||||||
|
SendEvent("versions", _manifestCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyPhoton(JsonElement? payload)
|
||||||
|
{
|
||||||
|
if (payload is not { } p) return;
|
||||||
|
if (p.TryGetProperty("appId", out var a)) _state.PhotonAppId = a.GetString() ?? "";
|
||||||
|
if (p.TryGetProperty("voiceAppId", out var v)) _state.PhotonVoiceAppId = v.GetString() ?? "";
|
||||||
|
if (p.TryGetProperty("region", out var r)) _state.PhotonRegion = r.GetString() ?? "us";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HostStartAsync(Envelope envelope)
|
||||||
|
{
|
||||||
|
if (_manifestCache is null)
|
||||||
|
_manifestCache = await new VersionsManifest().FetchAsync();
|
||||||
|
var versionKey = envelope.Payload?.GetProperty("versionKey").GetString();
|
||||||
|
var version = _manifestCache?.Branches.FirstOrDefault(b => b.VersionKey == versionKey);
|
||||||
|
if (version is null) { SendEvent("error", new { source = "host-start", message = $"unknown version: {versionKey}" }); return; }
|
||||||
|
if (string.IsNullOrEmpty(_state.PhotonAppId)) { SendEvent("error", new { source = "host-start", message = "Photon AppId not set — fill it in under Settings." }); return; }
|
||||||
|
|
||||||
|
SendEvent("host-status", new { stage = "downloading-server" });
|
||||||
|
var progress = new Progress<DownloadProgress>(p =>
|
||||||
|
SendEvent("download-progress", new { fraction = p.Fraction, bytes = p.BytesRead, total = p.TotalBytes }));
|
||||||
|
var serverDir = await _releases.EnsureServerAsync(version, progress);
|
||||||
|
|
||||||
|
SendEvent("host-status", new { stage = "opening-tunnel" });
|
||||||
|
_tunnel = new Tunnel();
|
||||||
|
var publicUrl = await _tunnel.StartAsync(localPort: 5005);
|
||||||
|
var apex = new Uri(publicUrl).Host;
|
||||||
|
|
||||||
|
SendEvent("host-status", new { stage = "starting-server" });
|
||||||
|
await _server.StartAsync(serverDir, _state, apex);
|
||||||
|
_state.SelectedVersion = version.VersionKey;
|
||||||
|
_state.Save();
|
||||||
|
|
||||||
|
SendEvent("host-status", new { stage = "patching-client" });
|
||||||
|
var patcherDir = await _releases.EnsurePatcherAsync(version, progress);
|
||||||
|
var patchResult = await _patcher.ApplyAsync(
|
||||||
|
patcherDir,
|
||||||
|
_state.RecRoomPath ?? "",
|
||||||
|
_state.PhotonAppId,
|
||||||
|
_state.PhotonVoiceAppId,
|
||||||
|
apex);
|
||||||
|
|
||||||
|
if (!patchResult.Ok)
|
||||||
|
{
|
||||||
|
SendEvent("error", new { source = "patcher", message = patchResult.Log });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var joinCode = JoinCode.Encode(new JoinPayload
|
||||||
|
{
|
||||||
|
Host = apex,
|
||||||
|
VersionKey = version.VersionKey,
|
||||||
|
PhotonAppId = _state.PhotonAppId,
|
||||||
|
PhotonVoiceAppId = _state.PhotonVoiceAppId,
|
||||||
|
PhotonRegion = _state.PhotonRegion,
|
||||||
|
Name = _state.ServerName,
|
||||||
|
});
|
||||||
|
|
||||||
|
SendEvent("host-status", new { stage = "ready", publicUrl, joinCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HostStopAsync()
|
||||||
|
{
|
||||||
|
await _server.StopAsync();
|
||||||
|
if (_tunnel is not null) { await _tunnel.DisposeAsync(); _tunnel = null; }
|
||||||
|
SendEvent("host-status", new { stage = "stopped" });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task JoinApplyAsync(Envelope envelope)
|
||||||
|
{
|
||||||
|
var code = envelope.Payload?.GetProperty("code").GetString();
|
||||||
|
var payload = JoinCode.Decode(code ?? "");
|
||||||
|
if (payload is null) { SendEvent("error", new { source = "join", message = "invalid join code" }); return; }
|
||||||
|
if (string.IsNullOrEmpty(_state.RecRoomPath)) { SendEvent("error", new { source = "join", message = "pick your Rec Room install first" }); return; }
|
||||||
|
if (_manifestCache is null)
|
||||||
|
_manifestCache = await new VersionsManifest().FetchAsync();
|
||||||
|
var version = _manifestCache?.Branches.FirstOrDefault(b => b.VersionKey == payload.VersionKey);
|
||||||
|
if (version is null) { SendEvent("error", new { source = "join", message = $"host is on a version this launcher doesn't know about: {payload.VersionKey}" }); return; }
|
||||||
|
|
||||||
|
SendEvent("join-status", new { stage = "downloading-patcher" });
|
||||||
|
var progress = new Progress<DownloadProgress>(p =>
|
||||||
|
SendEvent("download-progress", new { fraction = p.Fraction, bytes = p.BytesRead, total = p.TotalBytes }));
|
||||||
|
var patcherDir = await _releases.EnsurePatcherAsync(version, progress);
|
||||||
|
|
||||||
|
SendEvent("join-status", new { stage = "patching-client" });
|
||||||
|
var patchResult = await _patcher.ApplyAsync(
|
||||||
|
patcherDir,
|
||||||
|
_state.RecRoomPath!,
|
||||||
|
payload.PhotonAppId,
|
||||||
|
payload.PhotonVoiceAppId,
|
||||||
|
payload.Host);
|
||||||
|
|
||||||
|
if (!patchResult.Ok)
|
||||||
|
{
|
||||||
|
SendEvent("error", new { source = "patcher", message = patchResult.Log });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SendEvent("join-status", new { stage = "ready" });
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SendEvent(string type, object? payload)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(
|
||||||
|
new { type, payload },
|
||||||
|
JsonOptions);
|
||||||
|
_window.SendWebMessage(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Envelope
|
||||||
|
{
|
||||||
|
[JsonPropertyName("type")] public string Type { get; set; } = "";
|
||||||
|
[JsonPropertyName("payload")] public JsonElement? Payload { get; set; }
|
||||||
|
[JsonPropertyName("requestId")] public string? RequestId { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace DorkNet.Launcher.Backend;
|
||||||
|
|
||||||
|
/// <summary>Windows folder picker for the user's
|
||||||
|
/// <c>Recroom_Release_Data</c> directory. Per project policy: never
|
||||||
|
/// auto-detect via Steam library scanning, always ask the user.</summary>
|
||||||
|
public static class RecRoomPicker
|
||||||
|
{
|
||||||
|
public static string? Pick(string? initialPath = null)
|
||||||
|
{
|
||||||
|
using var dlg = new FolderBrowserDialog
|
||||||
|
{
|
||||||
|
Description = "Select your Rec Room install's *_Data folder " +
|
||||||
|
"(e.g. Recroom_Release_Data inside the install).",
|
||||||
|
UseDescriptionForTitle = true,
|
||||||
|
ShowNewFolderButton = false,
|
||||||
|
InitialDirectory = initialPath ?? Environment.GetFolderPath(
|
||||||
|
Environment.SpecialFolder.ProgramFilesX86),
|
||||||
|
};
|
||||||
|
return dlg.ShowDialog() == DialogResult.OK ? dlg.SelectedPath : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Quick sanity check that the picked path looks like a
|
||||||
|
/// Rec Room install. Doesn't validate the build version — that's
|
||||||
|
/// the patcher's job. Just checks for the canonical files so we
|
||||||
|
/// catch "user picked their Documents folder" mistakes early.</summary>
|
||||||
|
public static (bool ok, string reason) Validate(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)) return (false, "no path selected");
|
||||||
|
if (!Directory.Exists(path)) return (false, $"folder does not exist: {path}");
|
||||||
|
|
||||||
|
// Recroom_Release_Data/StreamingAssets is the consistent
|
||||||
|
// checkable subfolder across all Rec Room builds we target.
|
||||||
|
var streaming = Path.Combine(path, "StreamingAssets");
|
||||||
|
if (!Directory.Exists(streaming))
|
||||||
|
return (false, "folder is missing StreamingAssets — pick the *_Data folder inside your Rec Room install");
|
||||||
|
|
||||||
|
return (true, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Launcher.Backend;
|
||||||
|
|
||||||
|
/// <summary>Fetches the latest server + client-patcher release
|
||||||
|
/// artifacts for a given version branch from GitHub Releases. Caches
|
||||||
|
/// under <see cref="AppPaths.ServersRoot"/> /
|
||||||
|
/// <see cref="AppPaths.PatchersRoot"/> so re-launches don't re-download
|
||||||
|
/// the same artifact.
|
||||||
|
///
|
||||||
|
/// <para>Per-branch release naming convention (see RELEASES.md):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>dorknet-server-{version_key}-win-x64.zip</c></item>
|
||||||
|
/// <item><c>dorknet-clientpatch-{version_key}.zip</c></item>
|
||||||
|
/// </list>
|
||||||
|
/// The branch's <c>release_tag_prefix</c> is the prefix for tag names
|
||||||
|
/// (e.g. <c>v1-december-2026.05.25</c>) so the launcher finds tags
|
||||||
|
/// regardless of the suffix scheme used.</para></summary>
|
||||||
|
public sealed class ReleaseDownloader
|
||||||
|
{
|
||||||
|
private const string ReleasesApi =
|
||||||
|
"https://api.github.com/repos/DorkSquadRR/DorkNet/releases";
|
||||||
|
|
||||||
|
private static readonly HttpClient Http = CreateHttpClient();
|
||||||
|
|
||||||
|
private static HttpClient CreateHttpClient()
|
||||||
|
{
|
||||||
|
var c = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
|
||||||
|
// GitHub requires a User-Agent on API calls.
|
||||||
|
c.DefaultRequestHeaders.UserAgent.ParseAdd("DorkNet-Launcher/0.1");
|
||||||
|
c.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the path to the unpacked server-binary
|
||||||
|
/// directory for <paramref name="version"/>, downloading + caching
|
||||||
|
/// it if not already present.</summary>
|
||||||
|
public async Task<string> EnsureServerAsync(
|
||||||
|
VersionEntry version,
|
||||||
|
IProgress<DownloadProgress>? progress,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(AppPaths.ServersRoot, version.VersionKey);
|
||||||
|
var marker = Path.Combine(dir, ".dorknet-version");
|
||||||
|
// If the marker file exists, the server is already unpacked
|
||||||
|
// for this tag and re-downloading would be wasted bandwidth.
|
||||||
|
if (File.Exists(marker)) return dir;
|
||||||
|
|
||||||
|
var asset = await FindAssetAsync(
|
||||||
|
tagPrefix: version.ReleaseTagPrefix,
|
||||||
|
assetName: $"dorknet-server-{version.VersionKey}-win-x64.zip",
|
||||||
|
ct);
|
||||||
|
|
||||||
|
var zipPath = Path.Combine(AppPaths.LocalRoot, $"download-{Guid.NewGuid():N}.zip");
|
||||||
|
await DownloadAsync(asset.BrowserDownloadUrl, zipPath, progress, ct);
|
||||||
|
|
||||||
|
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
ZipFile.ExtractToDirectory(zipPath, dir);
|
||||||
|
File.Delete(zipPath);
|
||||||
|
File.WriteAllText(marker, asset.NodeId);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the path to the unpacked client-patcher dir
|
||||||
|
/// for <paramref name="version"/>, downloading + caching if needed.</summary>
|
||||||
|
public async Task<string> EnsurePatcherAsync(
|
||||||
|
VersionEntry version,
|
||||||
|
IProgress<DownloadProgress>? progress,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(AppPaths.PatchersRoot, version.VersionKey);
|
||||||
|
var marker = Path.Combine(dir, ".dorknet-version");
|
||||||
|
if (File.Exists(marker)) return dir;
|
||||||
|
|
||||||
|
var asset = await FindAssetAsync(
|
||||||
|
tagPrefix: version.ReleaseTagPrefix,
|
||||||
|
assetName: $"dorknet-clientpatch-{version.VersionKey}.zip",
|
||||||
|
ct);
|
||||||
|
|
||||||
|
var zipPath = Path.Combine(AppPaths.LocalRoot, $"download-{Guid.NewGuid():N}.zip");
|
||||||
|
await DownloadAsync(asset.BrowserDownloadUrl, zipPath, progress, ct);
|
||||||
|
|
||||||
|
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
ZipFile.ExtractToDirectory(zipPath, dir);
|
||||||
|
File.Delete(zipPath);
|
||||||
|
File.WriteAllText(marker, asset.NodeId);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ReleaseAsset> FindAssetAsync(
|
||||||
|
string tagPrefix, string assetName, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// List releases (paginated, but we only need the most recent
|
||||||
|
// matching prefix). Default page size is 30 which covers all
|
||||||
|
// realistic per-version release histories.
|
||||||
|
var releases = await Http.GetFromJsonAsync<List<Release>>(ReleasesApi, ct)
|
||||||
|
?? throw new InvalidOperationException("GitHub Releases returned null");
|
||||||
|
|
||||||
|
var matching = releases
|
||||||
|
.Where(r => r.TagName.StartsWith(tagPrefix, StringComparison.Ordinal))
|
||||||
|
.OrderByDescending(r => r.PublishedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (matching.Count == 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"No release found with tag prefix '{tagPrefix}'. " +
|
||||||
|
"Run the per-version branch's release workflow before booting host mode.");
|
||||||
|
|
||||||
|
foreach (var rel in matching)
|
||||||
|
{
|
||||||
|
var asset = rel.Assets.FirstOrDefault(a =>
|
||||||
|
a.Name.Equals(assetName, StringComparison.Ordinal));
|
||||||
|
if (asset is not null) return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Found {matching.Count} release(s) matching '{tagPrefix}' but none contained " +
|
||||||
|
$"the asset '{assetName}'. Check the release-workflow asset naming.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DownloadAsync(
|
||||||
|
string url, string destPath,
|
||||||
|
IProgress<DownloadProgress>? progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var resp = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var totalBytes = resp.Content.Headers.ContentLength ?? -1L;
|
||||||
|
|
||||||
|
await using var src = await resp.Content.ReadAsStreamAsync(ct);
|
||||||
|
await using var dst = File.Create(destPath);
|
||||||
|
|
||||||
|
var buffer = new byte[81920];
|
||||||
|
long readTotal = 0;
|
||||||
|
int read;
|
||||||
|
while ((read = await src.ReadAsync(buffer, ct)) > 0)
|
||||||
|
{
|
||||||
|
await dst.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||||
|
readTotal += read;
|
||||||
|
progress?.Report(new DownloadProgress(readTotal, totalBytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Release
|
||||||
|
{
|
||||||
|
[JsonPropertyName("tag_name")] public string TagName { get; set; } = "";
|
||||||
|
[JsonPropertyName("published_at")] public DateTimeOffset PublishedAt { get; set; }
|
||||||
|
[JsonPropertyName("assets")] public List<ReleaseAsset> Assets { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ReleaseAsset
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||||
|
[JsonPropertyName("browser_download_url")] public string BrowserDownloadUrl { get; set; } = "";
|
||||||
|
[JsonPropertyName("node_id")] public string NodeId { get; set; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct DownloadProgress(long BytesRead, long TotalBytes)
|
||||||
|
{
|
||||||
|
public double Fraction => TotalBytes > 0 ? (double)BytesRead / TotalBytes : 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DorkNet.Launcher.Backend;
|
||||||
|
|
||||||
|
/// <summary>Spawns + manages the downloaded server binary as a child
|
||||||
|
/// process. Each running launcher owns one server at a time — the
|
||||||
|
/// previous one is killed before starting a new one.</summary>
|
||||||
|
public sealed class ServerProcess : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private Process? _process;
|
||||||
|
private readonly StringBuilder _stderrBuffer = new();
|
||||||
|
public string? StdoutLogPath { get; private set; }
|
||||||
|
public bool IsRunning => _process is not null && !_process.HasExited;
|
||||||
|
|
||||||
|
/// <summary>Boot the server in <paramref name="serverDir"/>.
|
||||||
|
/// Passes the user's Photon AppIds + apex domain via environment
|
||||||
|
/// so we don't need to write a per-launch appsettings.Local.json.</summary>
|
||||||
|
public Task StartAsync(
|
||||||
|
string serverDir,
|
||||||
|
AppState state,
|
||||||
|
string apex,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (IsRunning)
|
||||||
|
throw new InvalidOperationException("A server is already running; stop it first.");
|
||||||
|
|
||||||
|
var exe = FindServerExecutable(serverDir);
|
||||||
|
StdoutLogPath = Path.Combine(AppPaths.LogsRoot, $"server-{DateTime.UtcNow:yyyyMMdd-HHmmss}.log");
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = exe,
|
||||||
|
WorkingDirectory = serverDir,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
};
|
||||||
|
psi.Environment["ASPNETCORE_ENVIRONMENT"] = "Development";
|
||||||
|
psi.Environment["ASPNETCORE_URLS"] = "http://127.0.0.1:5005";
|
||||||
|
psi.Environment["Database__Provider"] = "sqlite";
|
||||||
|
psi.Environment["Jwt__Secret"] = EnsureJwtSecret();
|
||||||
|
psi.Environment["Photon__AppId"] = state.PhotonAppId;
|
||||||
|
psi.Environment["Photon__VoiceAppId"] =
|
||||||
|
string.IsNullOrEmpty(state.PhotonVoiceAppId) ? state.PhotonAppId : state.PhotonVoiceAppId;
|
||||||
|
psi.Environment["Photon__CloudRegion"] = state.PhotonRegion;
|
||||||
|
psi.Environment["Domain__Apex"] = apex;
|
||||||
|
|
||||||
|
_process = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||||
|
_process.OutputDataReceived += (_, e) => AppendLog(e.Data);
|
||||||
|
_process.ErrorDataReceived += (_, e) => { AppendLog(e.Data); if (e.Data is not null) _stderrBuffer.AppendLine(e.Data); };
|
||||||
|
_process.Start();
|
||||||
|
_process.BeginOutputReadLine();
|
||||||
|
_process.BeginErrorReadLine();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendLog(string? line)
|
||||||
|
{
|
||||||
|
if (line is null || StdoutLogPath is null) return;
|
||||||
|
try { File.AppendAllText(StdoutLogPath, line + Environment.NewLine); }
|
||||||
|
catch { /* disk full / locked / can't help here */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAsync()
|
||||||
|
{
|
||||||
|
if (_process is null || _process.HasExited) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_process.CloseMainWindow();
|
||||||
|
if (!_process.WaitForExit(2000))
|
||||||
|
{
|
||||||
|
_process.Kill(entireProcessTree: true);
|
||||||
|
}
|
||||||
|
await _process.WaitForExitAsync();
|
||||||
|
}
|
||||||
|
catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TailStderr(int lines = 30)
|
||||||
|
{
|
||||||
|
var all = _stderrBuffer.ToString().Split('\n');
|
||||||
|
return string.Join('\n', all[Math.Max(0, all.Length - lines)..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindServerExecutable(string serverDir)
|
||||||
|
{
|
||||||
|
// Self-contained .NET publish produces a single .exe per
|
||||||
|
// RuntimeIdentifier. The server's csproj sets AssemblyName so
|
||||||
|
// we know the name; fall back to scanning if the rename
|
||||||
|
// changes.
|
||||||
|
var canonical = Path.Combine(serverDir, "DorkNet.Server.exe");
|
||||||
|
if (File.Exists(canonical)) return canonical;
|
||||||
|
var any = Directory.GetFiles(serverDir, "*.exe").FirstOrDefault();
|
||||||
|
if (any is null)
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
$"No .exe found in {serverDir}. The release artifact may be corrupt.");
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>JWT signing key — random per-machine, persisted under
|
||||||
|
/// LocalAppData so re-launches keep the same key (so existing
|
||||||
|
/// tokens stay valid).</summary>
|
||||||
|
private static string EnsureJwtSecret()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(AppPaths.LocalRoot, "jwt.secret");
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
var existing = File.ReadAllText(path).Trim();
|
||||||
|
if (existing.Length >= 64) return existing;
|
||||||
|
}
|
||||||
|
var bytes = new byte[64];
|
||||||
|
System.Security.Cryptography.RandomNumberGenerator.Fill(bytes);
|
||||||
|
var secret = Convert.ToBase64String(bytes);
|
||||||
|
File.WriteAllText(path, secret);
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await StopAsync();
|
||||||
|
_process?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace DorkNet.Launcher.Backend;
|
||||||
|
|
||||||
|
/// <summary>Wraps Cloudflare's <c>cloudflared</c> quick-tunnel so the
|
||||||
|
/// host doesn't need to open ports / configure DNS. Each launch gets
|
||||||
|
/// a fresh random <c>*.trycloudflare.com</c> URL.
|
||||||
|
///
|
||||||
|
/// <para>Requires <c>cloudflared.exe</c> on PATH or alongside the
|
||||||
|
/// launcher binary. If absent, returns a clear error and the host
|
||||||
|
/// can fall back to LAN-only play.</para></summary>
|
||||||
|
public sealed class Tunnel : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private Process? _process;
|
||||||
|
private TaskCompletionSource<string>? _urlReady;
|
||||||
|
public string? PublicUrl { get; private set; }
|
||||||
|
|
||||||
|
public async Task<string> StartAsync(int localPort, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var exe = FindCloudflared() ??
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
"cloudflared not found. Install from " +
|
||||||
|
"https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ " +
|
||||||
|
"and put cloudflared.exe on PATH (or next to the DorkNet launcher).");
|
||||||
|
|
||||||
|
_urlReady = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = exe,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
};
|
||||||
|
psi.ArgumentList.Add("tunnel");
|
||||||
|
psi.ArgumentList.Add("--no-autoupdate");
|
||||||
|
psi.ArgumentList.Add("--url");
|
||||||
|
psi.ArgumentList.Add($"http://127.0.0.1:{localPort}");
|
||||||
|
|
||||||
|
_process = Process.Start(psi) ??
|
||||||
|
throw new InvalidOperationException("Failed to spawn cloudflared.exe");
|
||||||
|
_process.OutputDataReceived += (_, e) => ParseLine(e.Data);
|
||||||
|
_process.ErrorDataReceived += (_, e) => ParseLine(e.Data);
|
||||||
|
_process.BeginOutputReadLine();
|
||||||
|
_process.BeginErrorReadLine();
|
||||||
|
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
|
||||||
|
PublicUrl = await _urlReady.Task.WaitAsync(timeoutCts.Token);
|
||||||
|
return PublicUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Regex UrlRegex = new(
|
||||||
|
@"https://[a-z0-9\-]+\.trycloudflare\.com",
|
||||||
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
private void ParseLine(string? line)
|
||||||
|
{
|
||||||
|
if (line is null || _urlReady is null || _urlReady.Task.IsCompleted) return;
|
||||||
|
var match = UrlRegex.Match(line);
|
||||||
|
if (match.Success) _urlReady.TrySetResult(match.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindCloudflared()
|
||||||
|
{
|
||||||
|
var sideBySide = Path.Combine(AppContext.BaseDirectory, "cloudflared.exe");
|
||||||
|
if (File.Exists(sideBySide)) return sideBySide;
|
||||||
|
var onPath = Environment.GetEnvironmentVariable("PATH")?
|
||||||
|
.Split(';')
|
||||||
|
.Select(p => Path.Combine(p, "cloudflared.exe"))
|
||||||
|
.FirstOrDefault(File.Exists);
|
||||||
|
return onPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_process is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_process.HasExited) _process.Kill(entireProcessTree: true);
|
||||||
|
await _process.WaitForExitAsync();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
_process.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DorkNet.Launcher.Backend;
|
||||||
|
|
||||||
|
/// <summary>Fetches the canonical branch-to-build mapping from
|
||||||
|
/// <c>versions.json</c> on the public DorkNet repo's <c>main</c>
|
||||||
|
/// branch. Falls back to the locally-cached copy when offline, so the
|
||||||
|
/// launcher opens even with no internet (host mode just can't fetch
|
||||||
|
/// new server builds).
|
||||||
|
///
|
||||||
|
/// <para>Source URL is hardcoded — it's the entire point of the
|
||||||
|
/// launcher knowing where to look. To run against a fork's manifest,
|
||||||
|
/// edit <see cref="ManifestUrl"/>.</para></summary>
|
||||||
|
public sealed class VersionsManifest
|
||||||
|
{
|
||||||
|
public const string ManifestUrl =
|
||||||
|
"https://raw.githubusercontent.com/DorkSquadRR/DorkNet/main/versions.json";
|
||||||
|
|
||||||
|
private static readonly HttpClient Http = new()
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(15),
|
||||||
|
};
|
||||||
|
|
||||||
|
[JsonPropertyName("$schema_version")] public int SchemaVersion { get; set; }
|
||||||
|
[JsonPropertyName("branches")] public List<VersionEntry> Branches { get; set; } = new();
|
||||||
|
|
||||||
|
public async Task<VersionsManifest?> FetchAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var m = await Http.GetFromJsonAsync<VersionsManifest>(ManifestUrl, ct);
|
||||||
|
if (m is not null)
|
||||||
|
{
|
||||||
|
File.WriteAllText(AppPaths.ManifestCacheFile,
|
||||||
|
JsonSerializer.Serialize(m));
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Offline / 404 / parse failure — fall back to last-seen
|
||||||
|
// cache. Returns null only when there's no cache either
|
||||||
|
// (first launch + no internet — the launcher reports the
|
||||||
|
// failure to the user in that case).
|
||||||
|
return LoadCached();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static VersionsManifest? LoadCached()
|
||||||
|
{
|
||||||
|
if (!File.Exists(AppPaths.ManifestCacheFile)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(AppPaths.ManifestCacheFile);
|
||||||
|
return JsonSerializer.Deserialize<VersionsManifest>(json);
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class VersionEntry
|
||||||
|
{
|
||||||
|
[JsonPropertyName("branch")] public string Branch { get; set; } = "";
|
||||||
|
[JsonPropertyName("client_build")] public string ClientBuild { get; set; } = "";
|
||||||
|
[JsonPropertyName("alt_builds")] public List<string> AltBuilds { get; set; } = new();
|
||||||
|
[JsonPropertyName("version_key")] public string VersionKey { get; set; } = "";
|
||||||
|
[JsonPropertyName("release_tag_prefix")] public string ReleaseTagPrefix { get; set; } = "";
|
||||||
|
[JsonPropertyName("supported")] public bool Supported { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net9.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<RootNamespace>DorkNet.Launcher</RootNamespace>
|
||||||
|
<AssemblyName>dorknet</AssemblyName>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- PhotinoNET wraps WebView2 with a tiny .NET host. The WebView2 runtime
|
||||||
|
ships in Windows 11 + modern Windows 10 (Edge auto-installs it). -->
|
||||||
|
<PackageReference Include="Photino.NET" Version="4.0.16" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- UI assets get copied next to the binary at publish time so the
|
||||||
|
PhotinoNET window can load them from disk. Keeps the binary
|
||||||
|
smaller than embedding base64; the user can also patch the UI
|
||||||
|
in-place without rebuilding the .exe. -->
|
||||||
|
<None Update="ui\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using Photino.NET;
|
||||||
|
using DorkNet.Launcher.Backend;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace DorkNet.Launcher;
|
||||||
|
|
||||||
|
/// <summary>Entry point + the host↔JS message bridge. The window is a
|
||||||
|
/// PhotinoNET wrapper around WebView2, loading ui/index.html from the
|
||||||
|
/// app's directory. Every JS ↔ C# call goes through one JSON-tagged
|
||||||
|
/// envelope so we can route by command/event name without per-call
|
||||||
|
/// glue.</summary>
|
||||||
|
public static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
AppPaths.EnsureDirectoriesExist();
|
||||||
|
|
||||||
|
var indexPath = Path.Combine(AppContext.BaseDirectory, "ui", "index.html");
|
||||||
|
if (!File.Exists(indexPath))
|
||||||
|
{
|
||||||
|
// First-aid: if the UI files didn't ship next to the binary,
|
||||||
|
// the window would be blank with no hint why. Surface the
|
||||||
|
// missing-file path before opening anything.
|
||||||
|
MessageBox.Error(
|
||||||
|
"DorkNet UI files missing",
|
||||||
|
$"Could not find {indexPath}. Reinstall the launcher.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var window = new PhotinoWindow()
|
||||||
|
.SetTitle("DorkNet")
|
||||||
|
.SetUseOsDefaultSize(false)
|
||||||
|
.SetSize(900, 640)
|
||||||
|
.SetResizable(true)
|
||||||
|
.Center()
|
||||||
|
.Load(new Uri(indexPath));
|
||||||
|
|
||||||
|
var bridge = new MessageBridge(window);
|
||||||
|
window.RegisterWebMessageReceivedHandler((sender, message) =>
|
||||||
|
{
|
||||||
|
// JS sends JSON envelopes: {"type":"command-name","payload":{...},"requestId":"..."}
|
||||||
|
// Reply path is bridge.SendEvent — fire-and-forget unless requestId is set.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bridge.HandleAsync(message).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
bridge.SendEvent("error", new { source = "bridge", message = ex.Message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.WaitForClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal MessageBox helper — DorkNet won't pop these often,
|
||||||
|
/// but a missing-UI-file failure mode needs a visible signal before the
|
||||||
|
/// PhotinoNET window opens.</summary>
|
||||||
|
internal static class MessageBox
|
||||||
|
{
|
||||||
|
public static void Error(string title, string body)
|
||||||
|
{
|
||||||
|
System.Windows.Forms.MessageBox.Show(
|
||||||
|
body, title,
|
||||||
|
System.Windows.Forms.MessageBoxButtons.OK,
|
||||||
|
System.Windows.Forms.MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/*`.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Release artifact contract
|
||||||
|
|
||||||
|
The DorkNet Launcher (on `main`) reads
|
||||||
|
[`/versions.json`](../versions.json) to find every supported per-version
|
||||||
|
branch, then fetches release artifacts from each branch's GitHub
|
||||||
|
Releases.
|
||||||
|
|
||||||
|
Per-version branches must publish releases that follow this contract,
|
||||||
|
otherwise the launcher reports "no release found" to users on host /
|
||||||
|
join.
|
||||||
|
|
||||||
|
## Tag naming
|
||||||
|
|
||||||
|
A release's tag must start with the `release_tag_prefix` declared for
|
||||||
|
the branch in `versions.json`. Recommended scheme:
|
||||||
|
|
||||||
|
```
|
||||||
|
v1-{month}-{YYYY.MM.DD}
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples (matching the current `release_tag_prefix` values):
|
||||||
|
|
||||||
|
| Branch | `release_tag_prefix` | Example full tag |
|
||||||
|
|---|---|---|
|
||||||
|
| `december-2020-12-18` | `v1-december` | `v1-december-2026.06.15` |
|
||||||
|
| `march-2020-03-10` | `v1-march` | `v1-march-2026.06.15` |
|
||||||
|
|
||||||
|
The launcher picks the most-recent release (by `published_at`) whose
|
||||||
|
tag starts with the prefix, then looks for the asset names below.
|
||||||
|
|
||||||
|
## Required assets
|
||||||
|
|
||||||
|
Each release must attach these two files:
|
||||||
|
|
||||||
|
### 1. `dorknet-server-{version_key}-win-x64.zip`
|
||||||
|
|
||||||
|
A self-contained .NET 9 publish of `DorkNet.Server` for `win-x64`.
|
||||||
|
The launcher unpacks this and spawns the executable directly.
|
||||||
|
|
||||||
|
Build with:
|
||||||
|
|
||||||
|
```pwsh
|
||||||
|
cd DorkNet.Server
|
||||||
|
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
|
||||||
|
# Then zip the publish dir's contents (NOT the dir itself):
|
||||||
|
cd bin/Release/net9.0/win-x64/publish
|
||||||
|
Compress-Archive -Path * -DestinationPath ../../../../../dorknet-server-{version_key}-win-x64.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
The zip's root must contain `DorkNet.Server.exe` (or any `.exe`; the
|
||||||
|
launcher scans for it). Folder layout below that is preserved as-is.
|
||||||
|
|
||||||
|
### 2. `dorknet-clientpatch-{version_key}.zip`
|
||||||
|
|
||||||
|
The contents of the per-version branch's `tools/` directory limited
|
||||||
|
to the patcher scripts the launcher invokes:
|
||||||
|
|
||||||
|
- `install-plugin.ps1` (preferred — BepInEx route)
|
||||||
|
- `install-legacy-client.ps1` (fallback — direct byte-patch route)
|
||||||
|
- Any helper scripts these reference
|
||||||
|
- The compiled `DorkNet.ClientPatch.dll` if `install-plugin.ps1`
|
||||||
|
expects a prebuilt DLL
|
||||||
|
|
||||||
|
The launcher invokes the patcher script with these arguments:
|
||||||
|
|
||||||
|
```
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File install-plugin.ps1 `
|
||||||
|
-RecRoomPath <user's path> `
|
||||||
|
-PhotonAppId <user's> `
|
||||||
|
-PhotonVoiceAppId <user's> `
|
||||||
|
-ServerHost <apex host or join-code host>
|
||||||
|
```
|
||||||
|
|
||||||
|
So the script must accept those four parameters. If your branch's
|
||||||
|
patcher needs additional config, default to safe values.
|
||||||
|
|
||||||
|
## Verifying a release before publishing
|
||||||
|
|
||||||
|
```pwsh
|
||||||
|
# Locally simulate the launcher's fetch.
|
||||||
|
$tag = "v1-december-2026.06.15"
|
||||||
|
gh release view $tag --repo DorkSquadRR/DorkNet
|
||||||
|
gh release download $tag --repo DorkSquadRR/DorkNet `
|
||||||
|
--pattern "dorknet-server-december_2020_12_18-win-x64.zip" `
|
||||||
|
--pattern "dorknet-clientpatch-december_2020_12_18.zip"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then unzip + boot the server locally to confirm it starts, and
|
||||||
|
unzip + run the patcher against a clean Rec Room install to confirm
|
||||||
|
patching works.
|
||||||
|
|
||||||
|
## Future: GitHub Actions workflow
|
||||||
|
|
||||||
|
Each per-version branch should land a `.github/workflows/release.yml`
|
||||||
|
that builds + zips + publishes the two artifacts on tag push. Not yet
|
||||||
|
implemented; releases are manual for now.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="0.1.0.0" name="DorkNet.EasyApp"/>
|
||||||
|
<!-- High-DPI awareness so the launcher window doesn't render blurry on
|
||||||
|
4K displays. PerMonitorV2 = correct scaling when the user moves the
|
||||||
|
window between monitors with different DPIs. -->
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
<!-- No requestedExecutionLevel — DorkNet doesn't need admin. The
|
||||||
|
Rec Room install path is user-readable, the patcher writes into
|
||||||
|
it without elevation, and the server binds to high ports. -->
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// DorkNet Launcher UI — single-file JS, no framework. State held in
|
||||||
|
// `appState`; views toggled via `showView(name)`. C# is reached through
|
||||||
|
// `bridge.send({type, payload})`; C# pushes events via
|
||||||
|
// `window.external.receiveMessage(handler)`.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let appState = null; // mirrors the C# AppState payload
|
||||||
|
let versions = null; // versions.json contents
|
||||||
|
let previousView = null; // for the settings 'back' button
|
||||||
|
|
||||||
|
// ── Message bridge ───────────────────────────────────────────────────
|
||||||
|
const bridge = {
|
||||||
|
send(envelope) {
|
||||||
|
// PhotinoNET exposes window.external.sendMessage(string) — the C#
|
||||||
|
// side parses the JSON envelope and routes by `type`.
|
||||||
|
window.external.sendMessage(JSON.stringify(envelope));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.external.receiveMessage(raw => {
|
||||||
|
let env;
|
||||||
|
try { env = JSON.parse(raw); }
|
||||||
|
catch { console.error('bridge: non-JSON message', raw); return; }
|
||||||
|
handleEvent(env.type, env.payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleEvent(type, payload) {
|
||||||
|
switch (type) {
|
||||||
|
case 'state-changed':
|
||||||
|
appState = payload;
|
||||||
|
reflectStateInUi();
|
||||||
|
break;
|
||||||
|
case 'versions':
|
||||||
|
versions = payload;
|
||||||
|
reflectVersionsInUi();
|
||||||
|
break;
|
||||||
|
case 'host-status':
|
||||||
|
renderHostStatus(payload);
|
||||||
|
break;
|
||||||
|
case 'join-status':
|
||||||
|
renderJoinStatus(payload);
|
||||||
|
break;
|
||||||
|
case 'download-progress':
|
||||||
|
renderDownloadProgress(payload);
|
||||||
|
break;
|
||||||
|
case 'join-code-decoded':
|
||||||
|
renderJoinPreview(payload);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
showToast(payload?.message ?? 'Unknown error', 'error');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn('bridge: unhandled event', type, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View routing ─────────────────────────────────────────────────────
|
||||||
|
function showView(name) {
|
||||||
|
for (const sec of document.querySelectorAll('section[data-view]')) {
|
||||||
|
const matches = sec.dataset.view === name;
|
||||||
|
sec.hidden = !matches;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reflectStateInUi() {
|
||||||
|
if (!appState) return;
|
||||||
|
|
||||||
|
// First-run check.
|
||||||
|
if (appState.mode === 'unset') { showView('first-run'); return; }
|
||||||
|
|
||||||
|
// Mirror persisted values into the right view's inputs.
|
||||||
|
const recDisplay = document.getElementById('recroom-path-display');
|
||||||
|
const recDisplayJoin = document.getElementById('recroom-path-display-join');
|
||||||
|
const path = appState.recRoomPath || '(not picked)';
|
||||||
|
if (recDisplay) recDisplay.textContent = path;
|
||||||
|
if (recDisplayJoin) recDisplayJoin.textContent = path;
|
||||||
|
|
||||||
|
document.getElementById('photon-app-id').value = appState.photonAppId || '';
|
||||||
|
document.getElementById('photon-voice-app-id').value = appState.photonVoiceAppId || '';
|
||||||
|
document.getElementById('photon-region').value = appState.photonRegion || 'us';
|
||||||
|
document.getElementById('server-name').value = appState.serverName || '';
|
||||||
|
|
||||||
|
// Enable join button only when prerequisites are met.
|
||||||
|
refreshJoinReadiness();
|
||||||
|
|
||||||
|
// Show the mode the user picked unless they're in settings.
|
||||||
|
if (!isSettingsOpen()) showView(appState.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reflectVersionsInUi() {
|
||||||
|
const sel = document.getElementById('version-select');
|
||||||
|
if (!sel || !versions) return;
|
||||||
|
sel.innerHTML = '';
|
||||||
|
for (const v of versions.branches.filter(b => b.supported)) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = v.versionKey;
|
||||||
|
opt.textContent = `Rec Room ${v.clientBuild} (${v.branch})`;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
if (appState?.selectedVersion) sel.value = appState.selectedVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSettingsOpen() {
|
||||||
|
const s = document.querySelector('section[data-view="settings"]');
|
||||||
|
return s && !s.hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toasts ───────────────────────────────────────────────────────────
|
||||||
|
function showToast(msg, kind = '') {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = `toast ${kind}`.trim();
|
||||||
|
el.textContent = msg;
|
||||||
|
document.getElementById('toasts').appendChild(el);
|
||||||
|
setTimeout(() => el.remove(), 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event wiring ─────────────────────────────────────────────────────
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const t = e.target.closest('[data-action], [data-mode-pick]');
|
||||||
|
if (!t) return;
|
||||||
|
|
||||||
|
if (t.dataset.modePick) {
|
||||||
|
bridge.send({ type: 'set-mode', payload: { mode: t.dataset.modePick } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (t.dataset.action) {
|
||||||
|
case 'pick-recroom':
|
||||||
|
bridge.send({ type: 'pick-recroom' });
|
||||||
|
break;
|
||||||
|
case 'open-settings':
|
||||||
|
previousView = document.querySelector('section[data-view]:not([hidden])')?.dataset.view;
|
||||||
|
showView('settings');
|
||||||
|
break;
|
||||||
|
case 'back-from-settings':
|
||||||
|
showView(previousView || appState?.mode || 'first-run');
|
||||||
|
break;
|
||||||
|
case 'copy-join-code':
|
||||||
|
const code = document.getElementById('join-code-value').textContent;
|
||||||
|
navigator.clipboard.writeText(code).then(
|
||||||
|
() => showToast('Join code copied', 'ok'),
|
||||||
|
() => showToast('Copy failed — select + Ctrl+C manually', 'error'));
|
||||||
|
break;
|
||||||
|
case 'decode-join-code':
|
||||||
|
const codeRaw = document.getElementById('join-code-input').value.trim();
|
||||||
|
bridge.send({ type: 'decode-join-code', payload: { code: codeRaw } });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Photon inputs auto-persist on blur.
|
||||||
|
['photon-app-id', 'photon-voice-app-id', 'photon-region'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('change', () => {
|
||||||
|
bridge.send({
|
||||||
|
type: 'set-photon',
|
||||||
|
payload: {
|
||||||
|
appId: document.getElementById('photon-app-id').value.trim(),
|
||||||
|
voiceAppId: document.getElementById('photon-voice-app-id').value.trim(),
|
||||||
|
region: document.getElementById('photon-region').value,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('server-name').addEventListener('change', e => {
|
||||||
|
bridge.send({ type: 'set-server-name', payload: { name: e.target.value.trim() } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Host start/stop.
|
||||||
|
document.getElementById('host-start').addEventListener('click', () => {
|
||||||
|
if (!appState?.recRoomPath) { showToast('Pick your Rec Room install first', 'error'); return; }
|
||||||
|
if (!appState?.photonAppId) { showToast('Photon AppId required (free at dashboard.photonengine.com)', 'error'); return; }
|
||||||
|
const versionKey = document.getElementById('version-select').value;
|
||||||
|
document.getElementById('host-start').disabled = true;
|
||||||
|
document.getElementById('host-stop').hidden = false;
|
||||||
|
bridge.send({ type: 'host-start', payload: { versionKey } });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('host-stop').addEventListener('click', () => {
|
||||||
|
bridge.send({ type: 'host-stop' });
|
||||||
|
document.getElementById('host-stop').hidden = true;
|
||||||
|
document.getElementById('host-start').disabled = false;
|
||||||
|
document.getElementById('join-code-row').hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Join apply.
|
||||||
|
document.getElementById('join-apply').addEventListener('click', () => {
|
||||||
|
const code = document.getElementById('join-code-input').value.trim();
|
||||||
|
bridge.send({ type: 'join-apply', payload: { code } });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('join-code-input').addEventListener('input', refreshJoinReadiness);
|
||||||
|
|
||||||
|
function refreshJoinReadiness() {
|
||||||
|
const btn = document.getElementById('join-apply');
|
||||||
|
if (!btn) return;
|
||||||
|
const hasCode = document.getElementById('join-code-input').value.trim().length > 10;
|
||||||
|
const hasPath = !!appState?.recRoomPath;
|
||||||
|
btn.disabled = !(hasCode && hasPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render helpers for incoming events ───────────────────────────────
|
||||||
|
function renderHostStatus(p) {
|
||||||
|
const el = document.getElementById('host-status');
|
||||||
|
if (!p) return;
|
||||||
|
if (p.stage === 'ready') {
|
||||||
|
el.className = 'status ok';
|
||||||
|
el.textContent = `Hosting at ${p.publicUrl}`;
|
||||||
|
document.getElementById('join-code-value').textContent = p.joinCode;
|
||||||
|
document.getElementById('join-code-row').hidden = false;
|
||||||
|
} else if (p.stage === 'stopped') {
|
||||||
|
el.className = 'status';
|
||||||
|
el.textContent = 'Stopped.';
|
||||||
|
document.getElementById('host-stop').hidden = true;
|
||||||
|
document.getElementById('host-start').disabled = false;
|
||||||
|
document.getElementById('join-code-row').hidden = true;
|
||||||
|
} else {
|
||||||
|
el.className = 'status';
|
||||||
|
el.textContent = humanStage(p.stage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJoinStatus(p) {
|
||||||
|
const el = document.getElementById('join-status');
|
||||||
|
if (!p) return;
|
||||||
|
el.className = p.stage === 'ready' ? 'status ok' : 'status';
|
||||||
|
el.textContent = p.stage === 'ready'
|
||||||
|
? 'Patched. Launch Rec Room from Steam to connect.'
|
||||||
|
: humanStage(p.stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJoinPreview(p) {
|
||||||
|
const el = document.getElementById('join-preview');
|
||||||
|
if (!el) return;
|
||||||
|
if (!p) {
|
||||||
|
el.className = 'status error';
|
||||||
|
el.textContent = 'Invalid join code.';
|
||||||
|
el.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.className = 'status';
|
||||||
|
el.innerHTML = `Connecting to <b>${escapeHtml(p.name || '(unnamed server)')}</b><br>` +
|
||||||
|
`Host: <code>${escapeHtml(p.host)}</code><br>` +
|
||||||
|
`Version: <code>${escapeHtml(p.v)}</code>`;
|
||||||
|
el.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDownloadProgress(p) {
|
||||||
|
// Could render a progress bar; for v0.1 just flash a status line.
|
||||||
|
if (!p || !p.total || p.total <= 0) return;
|
||||||
|
const pct = Math.round(p.fraction * 100);
|
||||||
|
const mb = (p.bytes / (1024 * 1024)).toFixed(1);
|
||||||
|
const totalMb = (p.total / (1024 * 1024)).toFixed(1);
|
||||||
|
const hostEl = document.getElementById('host-status');
|
||||||
|
const joinEl = document.getElementById('join-status');
|
||||||
|
const text = `Downloading… ${mb} / ${totalMb} MB (${pct}%)`;
|
||||||
|
if (hostEl && !hostEl.classList.contains('ok')) hostEl.textContent = text;
|
||||||
|
if (joinEl && !joinEl.classList.contains('ok')) joinEl.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanStage(stage) {
|
||||||
|
return ({
|
||||||
|
'downloading-server': 'Downloading server binary…',
|
||||||
|
'opening-tunnel': 'Opening Cloudflare tunnel…',
|
||||||
|
'starting-server': 'Starting the server…',
|
||||||
|
'patching-client': 'Patching your Rec Room client…',
|
||||||
|
'downloading-patcher': 'Downloading client patcher…',
|
||||||
|
'ready': 'Ready.',
|
||||||
|
'stopped': 'Stopped.',
|
||||||
|
}[stage] || stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&').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' });
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>DorkNet</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="app">
|
||||||
|
<!-- First-run wizard. Hidden once state.mode flips to host or join. -->
|
||||||
|
<section data-view="first-run" hidden>
|
||||||
|
<header>
|
||||||
|
<div class="logo">DorkNet</div>
|
||||||
|
<p class="tagline">Self-hostable 2020 Rec Room backend</p>
|
||||||
|
</header>
|
||||||
|
<div class="card-row">
|
||||||
|
<button class="card" data-mode-pick="host">
|
||||||
|
<span class="card-emoji">🛠️</span>
|
||||||
|
<span class="card-title">Host a server</span>
|
||||||
|
<span class="card-body">Run a server for you and your friends. Picks a Rec Room version, downloads the matching server, opens a tunnel, shares a join code.</span>
|
||||||
|
</button>
|
||||||
|
<button class="card" data-mode-pick="join">
|
||||||
|
<span class="card-emoji">🎮</span>
|
||||||
|
<span class="card-title">Join a friend</span>
|
||||||
|
<span class="card-body">Paste a join code, point at your Rec Room install. The launcher fetches the matching patcher.</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="footnote">You can switch modes later under Settings.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Host view: pick version → server downloads → tunnel + patcher → share code. -->
|
||||||
|
<section data-view="host" hidden>
|
||||||
|
<nav class="topbar">
|
||||||
|
<span class="topbar-title">Host</span>
|
||||||
|
<button class="link" data-action="open-settings">Settings</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>Rec Room install</label>
|
||||||
|
<div class="path-row">
|
||||||
|
<code id="recroom-path-display">(not picked)</code>
|
||||||
|
<button data-action="pick-recroom">Pick…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label for="server-name">Server name (shown to joiners)</label>
|
||||||
|
<input id="server-name" type="text" placeholder="e.g. Saturday Hangout" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label for="version-select">Rec Room version to host for</label>
|
||||||
|
<select id="version-select"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" id="photon-config">
|
||||||
|
<label>Photon AppIds (free at <a href="https://dashboard.photonengine.com" target="_blank">dashboard.photonengine.com</a>)</label>
|
||||||
|
<input id="photon-app-id" type="text" placeholder="Realtime AppId (UUID)" />
|
||||||
|
<input id="photon-voice-app-id" type="text" placeholder="Voice AppId (UUID, optional)" />
|
||||||
|
<select id="photon-region">
|
||||||
|
<option value="us">US</option>
|
||||||
|
<option value="eu">EU</option>
|
||||||
|
<option value="asia">Asia</option>
|
||||||
|
<option value="jp">Japan</option>
|
||||||
|
<option value="sa">South America</option>
|
||||||
|
<option value="kr">Korea</option>
|
||||||
|
<option value="in">India</option>
|
||||||
|
<option value="au">Australia</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="host-start" class="primary">Start hosting</button>
|
||||||
|
<button id="host-stop" class="secondary" hidden>Stop</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="host-status" class="status"></div>
|
||||||
|
|
||||||
|
<div id="join-code-row" class="row" hidden>
|
||||||
|
<label>Join code (share this)</label>
|
||||||
|
<div class="join-code-display">
|
||||||
|
<code id="join-code-value"></code>
|
||||||
|
<button data-action="copy-join-code">Copy</button>
|
||||||
|
</div>
|
||||||
|
<small>Joiners paste this into their DorkNet launcher's Join screen.</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Join view: paste code → patcher downloads + installs → launch Rec Room. -->
|
||||||
|
<section data-view="join" hidden>
|
||||||
|
<nav class="topbar">
|
||||||
|
<span class="topbar-title">Join</span>
|
||||||
|
<button class="link" data-action="open-settings">Settings</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label for="join-code-input">Paste the join code your host shared</label>
|
||||||
|
<textarea id="join-code-input" rows="3" placeholder="A long base64-looking string"></textarea>
|
||||||
|
<button data-action="decode-join-code">Preview</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="join-preview" class="status" hidden></div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>Rec Room install</label>
|
||||||
|
<div class="path-row">
|
||||||
|
<code id="recroom-path-display-join">(not picked)</code>
|
||||||
|
<button data-action="pick-recroom">Pick…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="join-apply" class="primary" disabled>Patch and launch</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="join-status" class="status"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Settings drawer (overlay). -->
|
||||||
|
<section data-view="settings" hidden>
|
||||||
|
<nav class="topbar">
|
||||||
|
<span class="topbar-title">Settings</span>
|
||||||
|
<button class="link" data-action="back-from-settings">← back</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>Mode</label>
|
||||||
|
<button data-mode-pick="host">Switch to Host</button>
|
||||||
|
<button data-mode-pick="join">Switch to Join</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>Cache location</label>
|
||||||
|
<small id="cache-paths"></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>About</label>
|
||||||
|
<small>DorkNet Launcher v0.1 — <a href="https://github.com/DorkSquadRR/DorkNet" target="_blank">GitHub</a> — AGPL-3.0</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Toast container for transient errors / progress flashes. -->
|
||||||
|
<div id="toasts"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user