From 446a23da2b16a4faa045f951e9d5630f2ce5a381 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Wed, 27 May 2026 00:44:28 +0300 Subject: [PATCH] launcher: rewrite as native WPF, drop PhotinoNET/WebView2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- launcher/App.xaml | 126 ++++++++++ launcher/App.xaml.cs | 13 + launcher/Backend/AppPaths.cs | 1 + launcher/Backend/AppState.cs | 1 + launcher/Backend/ClientPatcher.cs | 1 + launcher/Backend/JoinCode.cs | 1 + launcher/Backend/MessageBridge.cs | 218 ---------------- launcher/Backend/RecRoomPicker.cs | 19 +- launcher/Backend/ReleaseDownloader.cs | 2 + launcher/Backend/ServerProcess.cs | 1 + launcher/Backend/Tunnel.cs | 1 + launcher/Backend/VersionsManifest.cs | 2 + launcher/DorkNet.Launcher.csproj | 20 +- launcher/MainWindow.xaml | 285 +++++++++++++++++++++ launcher/MainWindow.xaml.cs | 349 ++++++++++++++++++++++++++ launcher/Program.cs | 70 ------ launcher/README.md | 71 +++--- launcher/ui/app.js | 285 --------------------- launcher/ui/index.html | 149 ----------- launcher/ui/style.css | 151 ----------- 20 files changed, 830 insertions(+), 936 deletions(-) create mode 100644 launcher/App.xaml create mode 100644 launcher/App.xaml.cs delete mode 100644 launcher/Backend/MessageBridge.cs create mode 100644 launcher/MainWindow.xaml create mode 100644 launcher/MainWindow.xaml.cs delete mode 100644 launcher/Program.cs delete mode 100644 launcher/ui/app.js delete mode 100644 launcher/ui/index.html delete mode 100644 launcher/ui/style.css diff --git a/launcher/App.xaml b/launcher/App.xaml new file mode 100644 index 0000000..1127dfd --- /dev/null +++ b/launcher/App.xaml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/App.xaml.cs b/launcher/App.xaml.cs new file mode 100644 index 0000000..306f045 --- /dev/null +++ b/launcher/App.xaml.cs @@ -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); + } +} diff --git a/launcher/Backend/AppPaths.cs b/launcher/Backend/AppPaths.cs index b89eaa0..b081788 100644 --- a/launcher/Backend/AppPaths.cs +++ b/launcher/Backend/AppPaths.cs @@ -1,3 +1,4 @@ +using System.IO; namespace DorkNet.Launcher.Backend; /// Single source of truth for where the Easy app stores diff --git a/launcher/Backend/AppState.cs b/launcher/Backend/AppState.cs index 5961fbf..1f3be86 100644 --- a/launcher/Backend/AppState.cs +++ b/launcher/Backend/AppState.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Text.Json; using System.Text.Json.Serialization; diff --git a/launcher/Backend/ClientPatcher.cs b/launcher/Backend/ClientPatcher.cs index 4bf125a..6832c0c 100644 --- a/launcher/Backend/ClientPatcher.cs +++ b/launcher/Backend/ClientPatcher.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Diagnostics; namespace DorkNet.Launcher.Backend; diff --git a/launcher/Backend/JoinCode.cs b/launcher/Backend/JoinCode.cs index e10d355..deb0df8 100644 --- a/launcher/Backend/JoinCode.cs +++ b/launcher/Backend/JoinCode.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; diff --git a/launcher/Backend/MessageBridge.cs b/launcher/Backend/MessageBridge.cs deleted file mode 100644 index 638d626..0000000 --- a/launcher/Backend/MessageBridge.cs +++ /dev/null @@ -1,218 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Photino.NET; - -namespace DorkNet.Launcher.Backend; - -/// The JS ↔ C# message router. Both sides use a small JSON -/// envelope: -/// { "type": "command-or-event-name", "payload": { ... }, "requestId": "optional-string" } -/// On the JS side, window.external.sendMessage(JSON.stringify(envelope)) -/// pushes to C#; window.external.receiveMessage(cb) registers -/// the inbound handler. On C# side, this class consumes the inbound -/// envelopes via and routes by type. -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(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(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(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(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; } - } -} diff --git a/launcher/Backend/RecRoomPicker.cs b/launcher/Backend/RecRoomPicker.cs index 3667c7f..126e9dc 100644 --- a/launcher/Backend/RecRoomPicker.cs +++ b/launcher/Backend/RecRoomPicker.cs @@ -1,24 +1,27 @@ -using System.Windows.Forms; +using System.IO; +using Microsoft.Win32; namespace DorkNet.Launcher.Backend; /// Windows folder picker for the user's /// Recroom_Release_Data directory. Per project policy: never -/// auto-detect via Steam library scanning, always ask the user. +/// auto-detect via Steam library scanning, always ask the user. +/// +/// Uses (added in .NET 8) so we +/// don't need to drag WinForms into a WPF project for one dialog. 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; } /// Quick sanity check that the picked path looks like a diff --git a/launcher/Backend/ReleaseDownloader.cs b/launcher/Backend/ReleaseDownloader.cs index 78eb322..a84d6fc 100644 --- a/launcher/Backend/ReleaseDownloader.cs +++ b/launcher/Backend/ReleaseDownloader.cs @@ -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; diff --git a/launcher/Backend/ServerProcess.cs b/launcher/Backend/ServerProcess.cs index 65fabba..7046740 100644 --- a/launcher/Backend/ServerProcess.cs +++ b/launcher/Backend/ServerProcess.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Diagnostics; using System.Text; diff --git a/launcher/Backend/Tunnel.cs b/launcher/Backend/Tunnel.cs index 4a0d8b5..60ed522 100644 --- a/launcher/Backend/Tunnel.cs +++ b/launcher/Backend/Tunnel.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Diagnostics; using System.Text.RegularExpressions; diff --git a/launcher/Backend/VersionsManifest.cs b/launcher/Backend/VersionsManifest.cs index 68f87bb..27aa9ee 100644 --- a/launcher/Backend/VersionsManifest.cs +++ b/launcher/Backend/VersionsManifest.cs @@ -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; diff --git a/launcher/DorkNet.Launcher.csproj b/launcher/DorkNet.Launcher.csproj index 9abcd4f..54563f7 100644 --- a/launcher/DorkNet.Launcher.csproj +++ b/launcher/DorkNet.Launcher.csproj @@ -5,7 +5,7 @@ net9.0-windows enable enable - true + true DorkNet.Launcher dorknet app.manifest @@ -16,20 +16,8 @@ true - - - - - - - - - PreserveNewest - - + diff --git a/launcher/MainWindow.xaml b/launcher/MainWindow.xaml new file mode 100644 index 0000000..1b479d9 --- /dev/null +++ b/launcher/MainWindow.xaml @@ -0,0 +1,285 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - -

You can switch modes later under Settings.

- - - - - - - - - - - - -
- - - - - diff --git a/launcher/ui/style.css b/launcher/ui/style.css deleted file mode 100644 index 7ed8253..0000000 --- a/launcher/ui/style.css +++ /dev/null @@ -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; } -}