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:
Alexa
2026-05-27 00:44:28 +03:00
parent df9dac053d
commit 446a23da2b
20 changed files with 830 additions and 936 deletions
+126
View File
@@ -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>
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,3 +1,4 @@
using System.IO;
using System.Diagnostics; using System.Diagnostics;
namespace DorkNet.Launcher.Backend; namespace DorkNet.Launcher.Backend;
+1
View File
@@ -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;
-218
View File
@@ -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; }
}
}
+11 -8
View File
@@ -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
+2
View File
@@ -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
View File
@@ -1,3 +1,4 @@
using System.IO;
using System.Diagnostics; using System.Diagnostics;
using System.Text; using System.Text;
+1
View File
@@ -1,3 +1,4 @@
using System.IO;
using System.Diagnostics; using System.Diagnostics;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
+2
View File
@@ -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;
+4 -16
View File
@@ -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>
+285
View File
@@ -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>
+349
View File
@@ -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);
}
-70
View File
@@ -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
View File
@@ -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/*`.
-285
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ── Boot ─────────────────────────────────────────────────────────────
// Render an empty 'first-run' until the C# init reply arrives.
showView('first-run');
bridge.send({ type: 'init' });
-149
View File
@@ -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>
-151
View File
@@ -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; }
}