mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
launcher: rewrite as native WPF, drop PhotinoNET/WebView2
Initial v0.1 (commit df9dac0) used PhotinoNET wrapping WebView2 + HTML/JS,
but rendered as a blank window on first run — WebView2 + file:// URI
quirks. Switching to native WPF: built into the .NET 9 Windows SDK
(no NuGet), single-file self-contained publish, instant window open.
All Backend/*.cs orchestration logic carried over unchanged (they're
UI-framework-agnostic). Replaced:
- Program.cs (PhotinoNET bootstrap) -> App.xaml(.cs) + MainWindow.xaml(.cs)
- ui/index.html + style.css + app.js -> MainWindow.xaml (XAML views) +
App.xaml resource styles
- Backend/MessageBridge.cs (JS<->C# router) -> direct method calls in
MainWindow.xaml.cs code-behind
RecRoomPicker now uses Microsoft.Win32.OpenFolderDialog (added in .NET 8)
instead of WinForms FolderBrowserDialog — keeps the project WPF-only.
Backend files needed explicit 'using System.IO;' since the WPF SDK
doesn't include it in implicit usings.
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
<Application x:Class="DorkNet.Launcher.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
StartupUri="MainWindow.xaml">
|
||||||
|
|
||||||
|
<Application.Resources>
|
||||||
|
<!-- ── Dark theme palette ─────────────────────────────────────── -->
|
||||||
|
<SolidColorBrush x:Key="Bg" Color="#0E0F13" />
|
||||||
|
<SolidColorBrush x:Key="BgElev" Color="#181A21" />
|
||||||
|
<SolidColorBrush x:Key="BgElev2" Color="#21232D" />
|
||||||
|
<SolidColorBrush x:Key="Border" Color="#2A2D3A" />
|
||||||
|
<SolidColorBrush x:Key="Ink" Color="#E8EBF3" />
|
||||||
|
<SolidColorBrush x:Key="InkSoft" Color="#9AA0B4" />
|
||||||
|
<SolidColorBrush x:Key="InkFaint" Color="#5E6478" />
|
||||||
|
<SolidColorBrush x:Key="Brand" Color="#7C5CFF" />
|
||||||
|
<SolidColorBrush x:Key="BrandHover" Color="#9A7EFF" />
|
||||||
|
<SolidColorBrush x:Key="Danger" Color="#FF6B6B" />
|
||||||
|
<SolidColorBrush x:Key="Ok" Color="#44D68A" />
|
||||||
|
|
||||||
|
<!-- ── TextBox / PasswordBox base ──────────────────────────────── -->
|
||||||
|
<Style TargetType="TextBox">
|
||||||
|
<Setter Property="Background" Value="{StaticResource BgElev}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Ink}" />
|
||||||
|
<Setter Property="CaretBrush" Value="{StaticResource Ink}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Padding" Value="8,6" />
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI" />
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="ComboBox">
|
||||||
|
<Setter Property="Background" Value="{StaticResource BgElev}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Ink}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||||
|
<Setter Property="Padding" Value="8,6" />
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ── Button styles ───────────────────────────────────────────── -->
|
||||||
|
<Style x:Key="Btn" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="{StaticResource BgElev2}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Ink}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Padding" Value="14,8" />
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="6">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="{TemplateBinding Padding}" />
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Border}" />
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource InkFaint}" />
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="PrimaryBtn" TargetType="Button" BasedOn="{StaticResource Btn}">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Brand}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Brand}" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="{StaticResource BrandHover}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BrandHover}" />
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter Property="Background" Value="{StaticResource BgElev2}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ── Card button for first-run wizard ────────────────────────── -->
|
||||||
|
<Style x:Key="CardBtn" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="{StaticResource BgElev}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Ink}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Padding" Value="20" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="10">
|
||||||
|
<ContentPresenter Margin="{TemplateBinding Padding}" />
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Brand}" />
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ── Section header text ─────────────────────────────────────── -->
|
||||||
|
<Style x:Key="FieldLabel" TargetType="TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource InkSoft}" />
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Margin" Value="0,0,0,4" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
</Style>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using DorkNet.Launcher.Backend;
|
||||||
|
|
||||||
|
namespace DorkNet.Launcher;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
|
{
|
||||||
|
AppPaths.EnsureDirectoriesExist();
|
||||||
|
base.OnStartup(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.IO;
|
||||||
namespace DorkNet.Launcher.Backend;
|
namespace DorkNet.Launcher.Backend;
|
||||||
|
|
||||||
/// <summary>Single source of truth for where the Easy app stores
|
/// <summary>Single source of truth for where the Easy app stores
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.IO;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.IO;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace DorkNet.Launcher.Backend;
|
namespace DorkNet.Launcher.Backend;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
using System.Windows.Forms;
|
using System.IO;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
namespace DorkNet.Launcher.Backend;
|
namespace DorkNet.Launcher.Backend;
|
||||||
|
|
||||||
/// <summary>Windows folder picker for the user's
|
/// <summary>Windows folder picker for the user's
|
||||||
/// <c>Recroom_Release_Data</c> directory. Per project policy: never
|
/// <c>Recroom_Release_Data</c> directory. Per project policy: never
|
||||||
/// auto-detect via Steam library scanning, always ask the user.</summary>
|
/// auto-detect via Steam library scanning, always ask the user.
|
||||||
|
///
|
||||||
|
/// <para>Uses <see cref="OpenFolderDialog"/> (added in .NET 8) so we
|
||||||
|
/// don't need to drag WinForms into a WPF project for one dialog.</para></summary>
|
||||||
public static class RecRoomPicker
|
public static class RecRoomPicker
|
||||||
{
|
{
|
||||||
public static string? Pick(string? initialPath = null)
|
public static string? Pick(string? initialPath = null)
|
||||||
{
|
{
|
||||||
using var dlg = new FolderBrowserDialog
|
var dlg = new OpenFolderDialog
|
||||||
{
|
{
|
||||||
Description = "Select your Rec Room install's *_Data folder " +
|
Title = "Select your Rec Room install's *_Data folder " +
|
||||||
"(e.g. Recroom_Release_Data inside the install).",
|
"(e.g. Recroom_Release_Data inside the install).",
|
||||||
UseDescriptionForTitle = true,
|
|
||||||
ShowNewFolderButton = false,
|
|
||||||
InitialDirectory = initialPath ?? Environment.GetFolderPath(
|
InitialDirectory = initialPath ?? Environment.GetFolderPath(
|
||||||
Environment.SpecialFolder.ProgramFilesX86),
|
Environment.SpecialFolder.ProgramFilesX86),
|
||||||
|
Multiselect = false,
|
||||||
};
|
};
|
||||||
return dlg.ShowDialog() == DialogResult.OK ? dlg.SelectedPath : null;
|
return dlg.ShowDialog() == true ? dlg.FolderName : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Quick sanity check that the picked path looks like a
|
/// <summary>Quick sanity check that the picked path looks like a
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using System.IO;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.IO;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.IO;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<TargetFramework>net9.0-windows</TargetFramework>
|
<TargetFramework>net9.0-windows</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UseWindowsForms>true</UseWindowsForms>
|
<UseWPF>true</UseWPF>
|
||||||
<RootNamespace>DorkNet.Launcher</RootNamespace>
|
<RootNamespace>DorkNet.Launcher</RootNamespace>
|
||||||
<AssemblyName>dorknet</AssemblyName>
|
<AssemblyName>dorknet</AssemblyName>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
@@ -16,20 +16,8 @@
|
|||||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<!-- WPF is built into the .NET 9 Windows SDK; no NuGet package needed.
|
||||||
<!-- PhotinoNET wraps WebView2 with a tiny .NET host. The WebView2 runtime
|
The Backend/* classes (release downloader, server process, etc.) are
|
||||||
ships in Windows 11 + modern Windows 10 (Edge auto-installs it). -->
|
UI-framework-agnostic and stay unchanged from the PhotinoNET draft. -->
|
||||||
<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>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<Window x:Class="DorkNet.Launcher.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="DorkNet"
|
||||||
|
Width="900" Height="640"
|
||||||
|
MinWidth="640" MinHeight="480"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
Background="{StaticResource Bg}"
|
||||||
|
TextElement.Foreground="{StaticResource Ink}"
|
||||||
|
FontFamily="Segoe UI">
|
||||||
|
|
||||||
|
<Grid Margin="32,24">
|
||||||
|
<!-- View slots — only one is visible at a time. CodeBehind toggles
|
||||||
|
Visibility based on AppState.Mode + the settings drawer flag. -->
|
||||||
|
|
||||||
|
<!-- ── First-run wizard ───────────────────────────────────────── -->
|
||||||
|
<StackPanel x:Name="FirstRunView" Visibility="Visible">
|
||||||
|
<StackPanel HorizontalAlignment="Center" Margin="0,16,0,24">
|
||||||
|
<TextBlock Text="DorkNet"
|
||||||
|
FontSize="36" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brand}"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
<TextBlock Text="Self-hostable 2020 Rec Room backend"
|
||||||
|
Foreground="{StaticResource InkSoft}"
|
||||||
|
HorizontalAlignment="Center" Margin="0,4,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid Margin="0,16">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="16" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<Button Grid.Column="0" Style="{StaticResource CardBtn}"
|
||||||
|
Click="OnPickHost">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="🛠" FontSize="28" Margin="0,0,0,8" />
|
||||||
|
<TextBlock Text="Host a server" FontSize="16"
|
||||||
|
FontWeight="SemiBold" Margin="0,0,0,8" />
|
||||||
|
<TextBlock Foreground="{StaticResource InkSoft}"
|
||||||
|
FontSize="12" TextWrapping="Wrap"
|
||||||
|
Text="Run a server for you and your friends. Picks a Rec Room version, downloads the matching server, opens a tunnel, shares a join code." />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Grid.Column="2" Style="{StaticResource CardBtn}"
|
||||||
|
Click="OnPickJoin">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="🎮" FontSize="28" Margin="0,0,0,8" />
|
||||||
|
<TextBlock Text="Join a friend" FontSize="16"
|
||||||
|
FontWeight="SemiBold" Margin="0,0,0,8" />
|
||||||
|
<TextBlock Foreground="{StaticResource InkSoft}"
|
||||||
|
FontSize="12" TextWrapping="Wrap"
|
||||||
|
Text="Paste a join code, point at your Rec Room install. The launcher fetches the matching patcher." />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="You can switch modes later under Settings."
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Foreground="{StaticResource InkFaint}"
|
||||||
|
FontSize="11" Margin="0,24,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- ── Host view ──────────────────────────────────────────────── -->
|
||||||
|
<Grid x:Name="HostView" Visibility="Collapsed">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid Grid.Row="0" Margin="0,0,0,16">
|
||||||
|
<TextBlock Text="Host" FontSize="20" FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Button HorizontalAlignment="Right" Style="{StaticResource Btn}"
|
||||||
|
Content="Settings" Click="OnOpenSettings" />
|
||||||
|
</Grid>
|
||||||
|
<Border Grid.Row="0" VerticalAlignment="Bottom"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" />
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Margin="0,0,0,16">
|
||||||
|
<TextBlock Text="REC ROOM INSTALL" Style="{StaticResource FieldLabel}" />
|
||||||
|
<DockPanel>
|
||||||
|
<Button DockPanel.Dock="Right" Style="{StaticResource Btn}"
|
||||||
|
Content="Pick..." Click="OnPickRecRoom" Margin="8,0,0,0" />
|
||||||
|
<Border Background="{StaticResource BgElev}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="8,6">
|
||||||
|
<TextBlock x:Name="HostRecRoomPath"
|
||||||
|
Text="(not picked)"
|
||||||
|
Foreground="{StaticResource InkSoft}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
</Border>
|
||||||
|
</DockPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Margin="0,0,0,16">
|
||||||
|
<TextBlock Text="SERVER NAME (SHOWN TO JOINERS)" Style="{StaticResource FieldLabel}" />
|
||||||
|
<TextBox x:Name="ServerNameInput"
|
||||||
|
LostFocus="OnServerNameChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Margin="0,0,0,16">
|
||||||
|
<TextBlock Text="REC ROOM VERSION TO HOST FOR" Style="{StaticResource FieldLabel}" />
|
||||||
|
<ComboBox x:Name="VersionSelect" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Margin="0,0,0,16">
|
||||||
|
<TextBlock Text="PHOTON APPIDS (FREE AT DASHBOARD.PHOTONENGINE.COM)" Style="{StaticResource FieldLabel}" />
|
||||||
|
<TextBox x:Name="PhotonAppIdInput"
|
||||||
|
Margin="0,0,0,6"
|
||||||
|
LostFocus="OnPhotonChanged" />
|
||||||
|
<TextBox x:Name="PhotonVoiceAppIdInput"
|
||||||
|
Margin="0,0,0,6"
|
||||||
|
LostFocus="OnPhotonChanged" />
|
||||||
|
<ComboBox x:Name="PhotonRegionSelect"
|
||||||
|
SelectionChanged="OnPhotonChanged">
|
||||||
|
<ComboBoxItem Content="US" Tag="us" />
|
||||||
|
<ComboBoxItem Content="EU" Tag="eu" />
|
||||||
|
<ComboBoxItem Content="Asia" Tag="asia" />
|
||||||
|
<ComboBoxItem Content="Japan" Tag="jp" />
|
||||||
|
<ComboBoxItem Content="South America" Tag="sa" />
|
||||||
|
<ComboBoxItem Content="Korea" Tag="kr" />
|
||||||
|
<ComboBoxItem Content="India" Tag="in" />
|
||||||
|
<ComboBoxItem Content="Australia" Tag="au" />
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,8,0,8">
|
||||||
|
<Button x:Name="HostStartBtn" Style="{StaticResource PrimaryBtn}"
|
||||||
|
Content="Start hosting" Click="OnHostStart" />
|
||||||
|
<Button x:Name="HostStopBtn" Style="{StaticResource Btn}"
|
||||||
|
Content="Stop" Click="OnHostStop"
|
||||||
|
Margin="8,0,0,0" Visibility="Collapsed" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Border Background="{StaticResource BgElev}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="12" MinHeight="40" Margin="0,8,0,16">
|
||||||
|
<TextBlock x:Name="HostStatusText"
|
||||||
|
Foreground="{StaticResource InkSoft}"
|
||||||
|
TextWrapping="Wrap" Text="Idle." />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel x:Name="JoinCodePanel" Visibility="Collapsed">
|
||||||
|
<TextBlock Text="JOIN CODE (SHARE THIS)" Style="{StaticResource FieldLabel}" />
|
||||||
|
<DockPanel>
|
||||||
|
<Button DockPanel.Dock="Right" Style="{StaticResource Btn}"
|
||||||
|
Content="Copy" Click="OnCopyJoinCode" Margin="8,0,0,0" />
|
||||||
|
<Border Background="{StaticResource BgElev2}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="8,6">
|
||||||
|
<TextBox x:Name="JoinCodeText" IsReadOnly="True"
|
||||||
|
FontFamily="Consolas" FontSize="11"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</Border>
|
||||||
|
</DockPanel>
|
||||||
|
<TextBlock Foreground="{StaticResource InkFaint}" FontSize="11"
|
||||||
|
Text="Joiners paste this into their DorkNet launcher's Join screen."
|
||||||
|
Margin="0,4,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- ── Join view ──────────────────────────────────────────────── -->
|
||||||
|
<Grid x:Name="JoinView" Visibility="Collapsed">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid Grid.Row="0" Margin="0,0,0,16">
|
||||||
|
<TextBlock Text="Join" FontSize="20" FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Button HorizontalAlignment="Right" Style="{StaticResource Btn}"
|
||||||
|
Content="Settings" Click="OnOpenSettings" />
|
||||||
|
</Grid>
|
||||||
|
<Border Grid.Row="0" VerticalAlignment="Bottom"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" />
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Margin="0,0,0,16">
|
||||||
|
<TextBlock Text="PASTE THE JOIN CODE YOUR HOST SHARED" Style="{StaticResource FieldLabel}" />
|
||||||
|
<TextBox x:Name="JoinCodeInput"
|
||||||
|
AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
|
Height="60" VerticalContentAlignment="Top"
|
||||||
|
TextChanged="OnJoinCodeInputChanged" />
|
||||||
|
<Button Style="{StaticResource Btn}"
|
||||||
|
Content="Preview" Click="OnDecodeJoinCode"
|
||||||
|
HorizontalAlignment="Left" Margin="0,8,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Border x:Name="JoinPreviewBorder" Visibility="Collapsed"
|
||||||
|
Background="{StaticResource BgElev}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="12" Margin="0,0,0,16">
|
||||||
|
<TextBlock x:Name="JoinPreviewText"
|
||||||
|
Foreground="{StaticResource InkSoft}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Margin="0,0,0,16">
|
||||||
|
<TextBlock Text="REC ROOM INSTALL" Style="{StaticResource FieldLabel}" />
|
||||||
|
<DockPanel>
|
||||||
|
<Button DockPanel.Dock="Right" Style="{StaticResource Btn}"
|
||||||
|
Content="Pick..." Click="OnPickRecRoom" Margin="8,0,0,0" />
|
||||||
|
<Border Background="{StaticResource BgElev}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="8,6">
|
||||||
|
<TextBlock x:Name="JoinRecRoomPath"
|
||||||
|
Text="(not picked)"
|
||||||
|
Foreground="{StaticResource InkSoft}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
</Border>
|
||||||
|
</DockPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button x:Name="JoinApplyBtn" Style="{StaticResource PrimaryBtn}"
|
||||||
|
Content="Patch and launch" Click="OnJoinApply"
|
||||||
|
HorizontalAlignment="Left" IsEnabled="False"
|
||||||
|
Margin="0,0,0,16" />
|
||||||
|
|
||||||
|
<Border Background="{StaticResource BgElev}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="12" MinHeight="40">
|
||||||
|
<TextBlock x:Name="JoinStatusText"
|
||||||
|
Foreground="{StaticResource InkSoft}"
|
||||||
|
TextWrapping="Wrap" Text="Idle." />
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- ── Settings view ──────────────────────────────────────────── -->
|
||||||
|
<Grid x:Name="SettingsView" Visibility="Collapsed">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid Grid.Row="0" Margin="0,0,0,16">
|
||||||
|
<TextBlock Text="Settings" FontSize="20" FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Button HorizontalAlignment="Right" Style="{StaticResource Btn}"
|
||||||
|
Content="← Back" Click="OnBackFromSettings" />
|
||||||
|
</Grid>
|
||||||
|
<Border Grid.Row="0" VerticalAlignment="Bottom"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1" />
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="1">
|
||||||
|
<StackPanel Margin="0,0,0,16">
|
||||||
|
<TextBlock Text="MODE" Style="{StaticResource FieldLabel}" />
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<Button Style="{StaticResource Btn}"
|
||||||
|
Content="Switch to Host" Click="OnPickHost" />
|
||||||
|
<Button Style="{StaticResource Btn}"
|
||||||
|
Content="Switch to Join" Click="OnPickJoin"
|
||||||
|
Margin="8,0,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Margin="0,0,0,16">
|
||||||
|
<TextBlock Text="CACHE LOCATIONS" Style="{StaticResource FieldLabel}" />
|
||||||
|
<TextBlock x:Name="CachePathsText"
|
||||||
|
Foreground="{StaticResource InkSoft}"
|
||||||
|
FontSize="11" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="ABOUT" Style="{StaticResource FieldLabel}" />
|
||||||
|
<TextBlock Foreground="{StaticResource InkSoft}" FontSize="11">
|
||||||
|
DorkNet Launcher v0.1 —
|
||||||
|
<Hyperlink NavigateUri="https://github.com/DorkSquadRR/DorkNet"
|
||||||
|
Foreground="{StaticResource BrandHover}"
|
||||||
|
RequestNavigate="OnNavigate">GitHub</Hyperlink>
|
||||||
|
— AGPL-3.0
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Navigation;
|
||||||
|
using DorkNet.Launcher.Backend;
|
||||||
|
|
||||||
|
namespace DorkNet.Launcher;
|
||||||
|
|
||||||
|
/// <summary>The launcher's only Window. Code-behind directly drives the
|
||||||
|
/// XAML controls — no MVVM framework. The <c>Backend/*</c> classes do
|
||||||
|
/// the work; the code-behind is glue + UI updates marshalled back to
|
||||||
|
/// the dispatcher thread.</summary>
|
||||||
|
public partial class MainWindow : 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? _manifest;
|
||||||
|
private string? _hostApex;
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_state = AppState.Load();
|
||||||
|
Loaded += OnWindowLoaded;
|
||||||
|
Closed += async (_, _) => await ShutdownServerAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnWindowLoaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
// Hydrate UI from persisted state, then fetch the manifest in the
|
||||||
|
// background so the user can start clicking immediately even if the
|
||||||
|
// version list is slow.
|
||||||
|
ReflectStateInUi();
|
||||||
|
await RefreshManifestAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View routing ────────────────────────────────────────────────────
|
||||||
|
private void ShowView(string name)
|
||||||
|
{
|
||||||
|
FirstRunView.Visibility = name == "first-run" ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
HostView.Visibility = name == "host" ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
JoinView.Visibility = name == "join" ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
SettingsView.Visibility = name == "settings" ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CurrentViewName()
|
||||||
|
{
|
||||||
|
if (SettingsView.Visibility == Visibility.Visible) return "settings";
|
||||||
|
if (HostView.Visibility == Visibility.Visible) return "host";
|
||||||
|
if (JoinView.Visibility == Visibility.Visible) return "join";
|
||||||
|
return "first-run";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? _previousView;
|
||||||
|
|
||||||
|
private void ReflectStateInUi()
|
||||||
|
{
|
||||||
|
// Mirror persisted values into the host/join input fields so the
|
||||||
|
// user doesn't re-type them every launch.
|
||||||
|
var path = _state.RecRoomPath ?? "(not picked)";
|
||||||
|
HostRecRoomPath.Text = path;
|
||||||
|
JoinRecRoomPath.Text = path;
|
||||||
|
|
||||||
|
PhotonAppIdInput.Text = _state.PhotonAppId;
|
||||||
|
PhotonVoiceAppIdInput.Text = _state.PhotonVoiceAppId;
|
||||||
|
SelectComboBoxByTag(PhotonRegionSelect, _state.PhotonRegion);
|
||||||
|
ServerNameInput.Text = _state.ServerName;
|
||||||
|
|
||||||
|
CachePathsText.Text =
|
||||||
|
$"State: {AppPaths.StateFile}\n" +
|
||||||
|
$"Servers: {AppPaths.ServersRoot}\n" +
|
||||||
|
$"Patchers: {AppPaths.PatchersRoot}\n" +
|
||||||
|
$"Logs: {AppPaths.LogsRoot}";
|
||||||
|
|
||||||
|
RefreshJoinReadiness();
|
||||||
|
|
||||||
|
ShowView(_state.Mode switch
|
||||||
|
{
|
||||||
|
AppMode.Host => "host",
|
||||||
|
AppMode.Join => "join",
|
||||||
|
_ => "first-run",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshManifestAsync()
|
||||||
|
{
|
||||||
|
_manifest = await new VersionsManifest().FetchAsync();
|
||||||
|
if (_manifest is null)
|
||||||
|
{
|
||||||
|
HostStatusText.Text = "Couldn't load versions.json from GitHub. " +
|
||||||
|
"Check internet, then reopen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
VersionSelect.Items.Clear();
|
||||||
|
foreach (var v in _manifest.Branches.Where(b => b.Supported))
|
||||||
|
{
|
||||||
|
var item = new ComboBoxItem
|
||||||
|
{
|
||||||
|
Content = $"Rec Room {v.ClientBuild} ({v.Branch})",
|
||||||
|
Tag = v.VersionKey,
|
||||||
|
};
|
||||||
|
VersionSelect.Items.Add(item);
|
||||||
|
if (v.VersionKey == _state.SelectedVersion) item.IsSelected = true;
|
||||||
|
}
|
||||||
|
if (VersionSelect.SelectedItem is null && VersionSelect.Items.Count > 0)
|
||||||
|
VersionSelect.SelectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SelectComboBoxByTag(ComboBox cb, string tag)
|
||||||
|
{
|
||||||
|
foreach (var item in cb.Items.OfType<ComboBoxItem>())
|
||||||
|
{
|
||||||
|
if (string.Equals(item.Tag?.ToString(), tag, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
item.IsSelected = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── First-run wizard / mode picks ───────────────────────────────────
|
||||||
|
private void OnPickHost(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_state.Mode = AppMode.Host;
|
||||||
|
_state.Save();
|
||||||
|
ReflectStateInUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPickJoin(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_state.Mode = AppMode.Join;
|
||||||
|
_state.Save();
|
||||||
|
ReflectStateInUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOpenSettings(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_previousView = CurrentViewName();
|
||||||
|
ShowView("settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnBackFromSettings(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ShowView(_previousView ?? (_state.Mode == AppMode.Join ? "join" : "host"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNavigate(object sender, RequestNavigateEventArgs e)
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared field handlers ───────────────────────────────────────────
|
||||||
|
private void OnPickRecRoom(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var picked = RecRoomPicker.Pick(_state.RecRoomPath);
|
||||||
|
if (picked is null) return;
|
||||||
|
var (ok, reason) = RecRoomPicker.Validate(picked);
|
||||||
|
if (!ok) { ShowError(reason); return; }
|
||||||
|
_state.RecRoomPath = picked;
|
||||||
|
_state.Save();
|
||||||
|
ReflectStateInUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnServerNameChanged(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_state.ServerName = ServerNameInput.Text.Trim();
|
||||||
|
_state.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPhotonChanged(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_state.PhotonAppId = PhotonAppIdInput.Text.Trim();
|
||||||
|
_state.PhotonVoiceAppId = PhotonVoiceAppIdInput.Text.Trim();
|
||||||
|
if (PhotonRegionSelect.SelectedItem is ComboBoxItem item)
|
||||||
|
_state.PhotonRegion = item.Tag?.ToString() ?? "us";
|
||||||
|
_state.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Host flow ───────────────────────────────────────────────────────
|
||||||
|
private async void OnHostStart(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_state.RecRoomPath))
|
||||||
|
{ ShowError("Pick your Rec Room install first."); return; }
|
||||||
|
if (string.IsNullOrEmpty(_state.PhotonAppId))
|
||||||
|
{ ShowError("Photon AppId required (free at dashboard.photonengine.com)."); return; }
|
||||||
|
|
||||||
|
if (_manifest is null) await RefreshManifestAsync();
|
||||||
|
if (VersionSelect.SelectedItem is not ComboBoxItem item || item.Tag is null)
|
||||||
|
{ ShowError("Pick a version to host."); return; }
|
||||||
|
var versionKey = item.Tag.ToString();
|
||||||
|
var version = _manifest?.Branches.FirstOrDefault(b => b.VersionKey == versionKey);
|
||||||
|
if (version is null)
|
||||||
|
{ ShowError($"Unknown version: {versionKey}"); return; }
|
||||||
|
|
||||||
|
HostStartBtn.IsEnabled = false;
|
||||||
|
HostStopBtn.Visibility = Visibility.Visible;
|
||||||
|
JoinCodePanel.Visibility = Visibility.Collapsed;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var progress = new Progress<DownloadProgress>(p =>
|
||||||
|
HostStatusText.Text = FormatProgress("Downloading server", p));
|
||||||
|
|
||||||
|
HostStatusText.Text = "Downloading server binary…";
|
||||||
|
var serverDir = await _releases.EnsureServerAsync(version, progress);
|
||||||
|
|
||||||
|
HostStatusText.Text = "Opening Cloudflare tunnel…";
|
||||||
|
_tunnel = new Tunnel();
|
||||||
|
var publicUrl = await _tunnel.StartAsync(localPort: 5005);
|
||||||
|
_hostApex = new Uri(publicUrl).Host;
|
||||||
|
|
||||||
|
HostStatusText.Text = "Starting the server…";
|
||||||
|
await _server.StartAsync(serverDir, _state, _hostApex);
|
||||||
|
_state.SelectedVersion = version.VersionKey;
|
||||||
|
_state.Save();
|
||||||
|
|
||||||
|
HostStatusText.Text = "Downloading client patcher…";
|
||||||
|
var patcherDir = await _releases.EnsurePatcherAsync(version, progress);
|
||||||
|
|
||||||
|
HostStatusText.Text = "Patching your Rec Room client…";
|
||||||
|
var patch = await _patcher.ApplyAsync(
|
||||||
|
patcherDir, _state.RecRoomPath!,
|
||||||
|
_state.PhotonAppId, _state.PhotonVoiceAppId, _hostApex);
|
||||||
|
if (!patch.Ok) { ShowError("Patcher failed: " + Truncate(patch.Log, 500)); return; }
|
||||||
|
|
||||||
|
var code = JoinCode.Encode(new JoinPayload
|
||||||
|
{
|
||||||
|
Host = _hostApex,
|
||||||
|
VersionKey = version.VersionKey,
|
||||||
|
PhotonAppId = _state.PhotonAppId,
|
||||||
|
PhotonVoiceAppId = _state.PhotonVoiceAppId,
|
||||||
|
PhotonRegion = _state.PhotonRegion,
|
||||||
|
Name = _state.ServerName,
|
||||||
|
});
|
||||||
|
JoinCodeText.Text = code;
|
||||||
|
JoinCodePanel.Visibility = Visibility.Visible;
|
||||||
|
HostStatusText.Text = $"Hosting at {publicUrl}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ShowError(ex.Message);
|
||||||
|
HostStartBtn.IsEnabled = true;
|
||||||
|
HostStopBtn.Visibility = Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnHostStop(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
await ShutdownServerAsync();
|
||||||
|
HostStartBtn.IsEnabled = true;
|
||||||
|
HostStopBtn.Visibility = Visibility.Collapsed;
|
||||||
|
JoinCodePanel.Visibility = Visibility.Collapsed;
|
||||||
|
HostStatusText.Text = "Stopped.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShutdownServerAsync()
|
||||||
|
{
|
||||||
|
try { await _server.StopAsync(); } catch { }
|
||||||
|
if (_tunnel is not null) { try { await _tunnel.DisposeAsync(); } catch { } _tunnel = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCopyJoinCode(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try { Clipboard.SetText(JoinCodeText.Text); }
|
||||||
|
catch (Exception ex) { ShowError("Copy failed: " + ex.Message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Join flow ───────────────────────────────────────────────────────
|
||||||
|
private void OnJoinCodeInputChanged(object sender, TextChangedEventArgs e)
|
||||||
|
=> RefreshJoinReadiness();
|
||||||
|
|
||||||
|
private void RefreshJoinReadiness()
|
||||||
|
{
|
||||||
|
if (JoinApplyBtn is null) return;
|
||||||
|
var hasCode = JoinCodeInput?.Text?.Trim().Length > 10;
|
||||||
|
var hasPath = !string.IsNullOrEmpty(_state.RecRoomPath);
|
||||||
|
JoinApplyBtn.IsEnabled = hasCode && hasPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDecodeJoinCode(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var code = JoinCodeInput.Text.Trim();
|
||||||
|
var payload = JoinCode.Decode(code);
|
||||||
|
if (payload is null)
|
||||||
|
{
|
||||||
|
JoinPreviewBorder.Visibility = Visibility.Visible;
|
||||||
|
JoinPreviewText.Text = "Invalid join code.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JoinPreviewBorder.Visibility = Visibility.Visible;
|
||||||
|
JoinPreviewText.Text =
|
||||||
|
$"Connecting to {(string.IsNullOrEmpty(payload.Name) ? "(unnamed server)" : payload.Name)}\n" +
|
||||||
|
$"Host: {payload.Host}\n" +
|
||||||
|
$"Version: {payload.VersionKey}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnJoinApply(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_state.RecRoomPath))
|
||||||
|
{ ShowError("Pick your Rec Room install first."); return; }
|
||||||
|
var payload = JoinCode.Decode(JoinCodeInput.Text.Trim());
|
||||||
|
if (payload is null)
|
||||||
|
{ ShowError("Invalid join code."); return; }
|
||||||
|
if (_manifest is null) await RefreshManifestAsync();
|
||||||
|
var version = _manifest?.Branches.FirstOrDefault(b => b.VersionKey == payload.VersionKey);
|
||||||
|
if (version is null)
|
||||||
|
{ ShowError($"Host is on a version this launcher doesn't know about: {payload.VersionKey}"); return; }
|
||||||
|
|
||||||
|
JoinApplyBtn.IsEnabled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var progress = new Progress<DownloadProgress>(p =>
|
||||||
|
JoinStatusText.Text = FormatProgress("Downloading patcher", p));
|
||||||
|
|
||||||
|
JoinStatusText.Text = "Downloading client patcher…";
|
||||||
|
var patcherDir = await _releases.EnsurePatcherAsync(version, progress);
|
||||||
|
|
||||||
|
JoinStatusText.Text = "Patching your Rec Room client…";
|
||||||
|
var patch = await _patcher.ApplyAsync(
|
||||||
|
patcherDir, _state.RecRoomPath!,
|
||||||
|
payload.PhotonAppId, payload.PhotonVoiceAppId, payload.Host);
|
||||||
|
if (!patch.Ok) { ShowError("Patcher failed: " + Truncate(patch.Log, 500)); return; }
|
||||||
|
|
||||||
|
JoinStatusText.Text = "Patched. Launch Rec Room from Steam to connect.";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { ShowError(ex.Message); }
|
||||||
|
finally { RefreshJoinReadiness(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
private static string FormatProgress(string label, DownloadProgress p)
|
||||||
|
{
|
||||||
|
if (p.TotalBytes <= 0) return $"{label}…";
|
||||||
|
var mb = p.BytesRead / (1024.0 * 1024.0);
|
||||||
|
var totalMb = p.TotalBytes / (1024.0 * 1024.0);
|
||||||
|
return $"{label}: {mb:F1} / {totalMb:F1} MB ({(int)(p.Fraction * 100)}%)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string s, int max)
|
||||||
|
=> s.Length <= max ? s : s[..max] + "...";
|
||||||
|
|
||||||
|
private void ShowError(string message)
|
||||||
|
=> System.Windows.MessageBox.Show(this, message, "DorkNet",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+32
-39
@@ -1,8 +1,8 @@
|
|||||||
# DorkNet Launcher
|
# DorkNet Launcher
|
||||||
|
|
||||||
Single-download desktop launcher for self-hosting / joining DorkNet
|
Single-download desktop launcher for self-hosting / joining DorkNet
|
||||||
servers. PhotinoNET-wrapped WebView2 with the orchestration logic in
|
servers. **Native WPF** — no embedded browser, no WebView2 dependency.
|
||||||
C#.
|
.NET 9 single-file self-contained build.
|
||||||
|
|
||||||
This README is for **developers** of the launcher. End-users see
|
This README is for **developers** of the launcher. End-users see
|
||||||
[the main README](../README.md) and download a prebuilt `.exe` from
|
[the main README](../README.md) and download a prebuilt `.exe` from
|
||||||
@@ -18,34 +18,31 @@ This README is for **developers** of the launcher. End-users see
|
|||||||
patcher from GitHub Releases, patches the user's Rec Room install
|
patcher from GitHub Releases, patches the user's Rec Room install
|
||||||
to point at the host.
|
to point at the host.
|
||||||
|
|
||||||
## How it's wired
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
launcher/
|
launcher/
|
||||||
├── DorkNet.Launcher.csproj .NET 9 single-file self-contained exe (Photino.NET dep)
|
├── DorkNet.Launcher.csproj .NET 9 WPF, single-file self-contained
|
||||||
├── Program.cs PhotinoNET window + message-bridge wiring
|
├── App.xaml + App.xaml.cs WPF Application bootstrap, dark-theme palette
|
||||||
├── app.manifest Windows DPI-awareness manifest
|
├── MainWindow.xaml All views (first-run / host / join / settings)
|
||||||
├── Backend/ Orchestration (C#)
|
├── MainWindow.xaml.cs Code-behind: event handlers → Backend/* calls
|
||||||
│ ├── AppPaths.cs %APPDATA%\DorkNet layout
|
├── app.manifest Windows DPI-awareness manifest
|
||||||
│ ├── AppState.cs Persisted user choices (mode, paths, Photon AppId)
|
└── Backend/ Orchestration — UI-framework-agnostic
|
||||||
│ ├── VersionsManifest.cs Fetches versions.json from main
|
├── AppPaths.cs %APPDATA%\DorkNet layout
|
||||||
│ ├── ReleaseDownloader.cs GitHub Releases API + artifact unpack
|
├── AppState.cs Persisted user choices (mode, paths, Photon AppId)
|
||||||
│ ├── ServerProcess.cs Spawns the downloaded server binary
|
├── VersionsManifest.cs Fetches versions.json from main
|
||||||
│ ├── ClientPatcher.cs Invokes install-plugin.ps1 from the unpacked patcher zip
|
├── ReleaseDownloader.cs GitHub Releases API + artifact unpack
|
||||||
│ ├── Tunnel.cs Wraps `cloudflared tunnel --url …`
|
├── ServerProcess.cs Spawns the downloaded server binary
|
||||||
│ ├── RecRoomPicker.cs Windows folder dialog
|
├── ClientPatcher.cs Invokes install-plugin.ps1 from the unpacked patcher
|
||||||
│ ├── JoinCode.cs base64url-encoded {host, version, photonAppId, …}
|
├── Tunnel.cs Wraps `cloudflared tunnel --url …`
|
||||||
│ └── MessageBridge.cs JSON envelope router between JS ↔ C#
|
├── RecRoomPicker.cs Microsoft.Win32.OpenFolderDialog
|
||||||
└── ui/ Single-file frontend (no build step)
|
└── JoinCode.cs base64url-encoded {host, version, photonAppId, …}
|
||||||
├── index.html
|
|
||||||
├── style.css
|
|
||||||
└── app.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Message protocol: both directions use
|
`MainWindow.xaml` holds all four views (first-run wizard, host, join,
|
||||||
`{ "type": "command-or-event-name", "payload": { ... } }`. New
|
settings) as sibling panels; code-behind toggles `Visibility` to
|
||||||
commands go in `MessageBridge.HandleAsync`'s switch; new outbound
|
switch between them. No MVVM framework — direct event handlers in
|
||||||
events use `bridge.SendEvent(name, payload)`.
|
the code-behind read/write `Backend/*` services.
|
||||||
|
|
||||||
## Build + run
|
## Build + run
|
||||||
|
|
||||||
@@ -54,18 +51,17 @@ cd launcher
|
|||||||
dotnet run
|
dotnet run
|
||||||
```
|
```
|
||||||
|
|
||||||
Watch the launcher window open. C# stderr lands in the console;
|
Window opens immediately on the first-run wizard.
|
||||||
the WebView2 devtools open with F12 (PhotinoNET passes them through).
|
|
||||||
|
|
||||||
## Publish a self-contained .exe
|
## Publish a self-contained .exe
|
||||||
|
|
||||||
```pwsh
|
```pwsh
|
||||||
dotnet publish -c Release
|
dotnet publish -c Release
|
||||||
# Output: bin\Release\net9.0-windows\win-x64\publish\dorknet.exe (~80 MB)
|
# Output: bin\Release\net9.0-windows\win-x64\publish\dorknet.exe (~70 MB)
|
||||||
```
|
```
|
||||||
|
|
||||||
The published exe is what ships in GitHub Releases on `main` (use the
|
The published exe is what ships in GitHub Releases on `main` (use the
|
||||||
`Easy launcher release` workflow when it exists).
|
launcher release workflow once it exists).
|
||||||
|
|
||||||
## Dependencies on per-version branches
|
## Dependencies on per-version branches
|
||||||
|
|
||||||
@@ -73,15 +69,12 @@ The launcher fetches release artifacts from GitHub Releases tagged on
|
|||||||
the per-version branches. See [RELEASES.md](RELEASES.md) for the
|
the per-version branches. See [RELEASES.md](RELEASES.md) for the
|
||||||
exact naming convention each branch must follow.
|
exact naming convention each branch must follow.
|
||||||
|
|
||||||
## Adding a new command
|
## Adding a new feature
|
||||||
|
|
||||||
1. Add a new `case` in `MessageBridge.HandleAsync`.
|
1. New backend logic → add to (or create) a `Backend/*.cs` class.
|
||||||
2. Implement the work in a `Backend/*` class.
|
2. New UI controls → add XAML in `MainWindow.xaml`.
|
||||||
3. In `ui/app.js`, call `bridge.send({ type: 'your-command', payload: {...} })`
|
3. New event handlers → method in `MainWindow.xaml.cs` calling the
|
||||||
from wherever the user triggers it.
|
backend service.
|
||||||
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;
|
Keep code-behind handlers small: they marshal between WPF dispatcher
|
||||||
long work goes in `Backend/*`.
|
thread + the backend services. Long work goes in `Backend/*`.
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
// 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' });
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
/* 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