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
+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;