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;
/// <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.Serialization;
+1
View File
@@ -1,3 +1,4 @@
using System.IO;
using System.Diagnostics;
namespace DorkNet.Launcher.Backend;
+1
View File
@@ -1,3 +1,4 @@
using System.IO;
using System.Text;
using System.Text.Json;
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;
/// <summary>Windows folder picker for the user's
/// <c>Recroom_Release_Data</c> directory. Per project policy: never
/// auto-detect via Steam library scanning, always ask the user.</summary>
/// 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 string? Pick(string? initialPath = null)
{
using var dlg = new FolderBrowserDialog
var dlg = new OpenFolderDialog
{
Description = "Select your Rec Room install's *_Data folder " +
"(e.g. Recroom_Release_Data inside the install).",
UseDescriptionForTitle = true,
ShowNewFolderButton = false,
Title = "Select your Rec Room install's *_Data folder " +
"(e.g. Recroom_Release_Data inside the install).",
InitialDirectory = initialPath ?? Environment.GetFolderPath(
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
+2
View File
@@ -1,4 +1,6 @@
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
+1
View File
@@ -1,3 +1,4 @@
using System.IO;
using System.Diagnostics;
using System.Text;
+1
View File
@@ -1,3 +1,4 @@
using System.IO;
using System.Diagnostics;
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.Text.Json;
using System.Text.Json.Serialization;
+4 -16
View File
@@ -5,7 +5,7 @@
<TargetFramework>net9.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<UseWPF>true</UseWPF>
<RootNamespace>DorkNet.Launcher</RootNamespace>
<AssemblyName>dorknet</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
@@ -16,20 +16,8 @@
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
</PropertyGroup>
<ItemGroup>
<!-- PhotinoNET wraps WebView2 with a tiny .NET host. The WebView2 runtime
ships in Windows 11 + modern Windows 10 (Edge auto-installs it). -->
<PackageReference Include="Photino.NET" Version="4.0.16" />
</ItemGroup>
<ItemGroup>
<!-- UI assets get copied next to the binary at publish time so the
PhotinoNET window can load them from disk. Keeps the binary
smaller than embedding base64; the user can also patch the UI
in-place without rebuilding the .exe. -->
<None Update="ui\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<!-- WPF is built into the .NET 9 Windows SDK; no NuGet package needed.
The Backend/* classes (release downloader, server process, etc.) are
UI-framework-agnostic and stay unchanged from the PhotinoNET draft. -->
</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
Single-download desktop launcher for self-hosting / joining DorkNet
servers. PhotinoNET-wrapped WebView2 with the orchestration logic in
C#.
servers. **Native WPF** — no embedded browser, no WebView2 dependency.
.NET 9 single-file self-contained build.
This README is for **developers** of the launcher. End-users see
[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
to point at the host.
## How it's wired
## Layout
```
launcher/
├── DorkNet.Launcher.csproj .NET 9 single-file self-contained exe (Photino.NET dep)
├── Program.cs PhotinoNET window + message-bridge wiring
├── app.manifest Windows DPI-awareness manifest
├── Backend/ Orchestration (C#)
│ ├── AppPaths.cs %APPDATA%\DorkNet layout
│ ├── AppState.cs Persisted user choices (mode, paths, Photon AppId)
│ ├── VersionsManifest.cs Fetches versions.json from main
│ ├── ReleaseDownloader.cs GitHub Releases API + artifact unpack
│ ├── ServerProcess.cs Spawns the downloaded server binary
│ ├── ClientPatcher.cs Invokes install-plugin.ps1 from the unpacked patcher zip
│ ├── Tunnel.cs Wraps `cloudflared tunnel --url …`
│ ├── RecRoomPicker.cs Windows folder dialog
│ ├── JoinCode.cs base64url-encoded {host, version, photonAppId, …}
│ └── MessageBridge.cs JSON envelope router between JS ↔ C#
└── ui/ Single-file frontend (no build step)
├── index.html
├── style.css
└── app.js
├── DorkNet.Launcher.csproj .NET 9 WPF, single-file self-contained
├── App.xaml + App.xaml.cs WPF Application bootstrap, dark-theme palette
├── MainWindow.xaml All views (first-run / host / join / settings)
├── MainWindow.xaml.cs Code-behind: event handlers → Backend/* calls
├── app.manifest Windows DPI-awareness manifest
└── Backend/ Orchestration — UI-framework-agnostic
├── AppPaths.cs %APPDATA%\DorkNet layout
├── AppState.cs Persisted user choices (mode, paths, Photon AppId)
├── VersionsManifest.cs Fetches versions.json from main
├── ReleaseDownloader.cs GitHub Releases API + artifact unpack
├── ServerProcess.cs Spawns the downloaded server binary
├── ClientPatcher.cs Invokes install-plugin.ps1 from the unpacked patcher
├── Tunnel.cs Wraps `cloudflared tunnel --url …`
├── RecRoomPicker.cs Microsoft.Win32.OpenFolderDialog
└── JoinCode.cs base64url-encoded {host, version, photonAppId, …}
```
Message protocol: both directions use
`{ "type": "command-or-event-name", "payload": { ... } }`. New
commands go in `MessageBridge.HandleAsync`'s switch; new outbound
events use `bridge.SendEvent(name, payload)`.
`MainWindow.xaml` holds all four views (first-run wizard, host, join,
settings) as sibling panels; code-behind toggles `Visibility` to
switch between them. No MVVM framework — direct event handlers in
the code-behind read/write `Backend/*` services.
## Build + run
@@ -54,18 +51,17 @@ cd launcher
dotnet run
```
Watch the launcher window open. C# stderr lands in the console;
the WebView2 devtools open with F12 (PhotinoNET passes them through).
Window opens immediately on the first-run wizard.
## Publish a self-contained .exe
```pwsh
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
`Easy launcher release` workflow when it exists).
launcher release workflow once it exists).
## 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
exact naming convention each branch must follow.
## Adding a new command
## Adding a new feature
1. Add a new `case` in `MessageBridge.HandleAsync`.
2. Implement the work in a `Backend/*` class.
3. In `ui/app.js`, call `bridge.send({ type: 'your-command', payload: {...} })`
from wherever the user triggers it.
4. If the command needs to push events back, call
`bridge.SendEvent('your-event-name', { ... })` from the handler
and add a case in `app.js`'s `handleEvent` switch.
1. New backend logic → add to (or create) a `Backend/*.cs` class.
2. New UI controls → add XAML in `MainWindow.xaml`.
3. New event handlers → method in `MainWindow.xaml.cs` calling the
backend service.
Keep the bridge handlers tight — they're routing + state mutation;
long work goes in `Backend/*`.
Keep code-behind handlers small: they marshal between WPF dispatcher
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; }
}