From 5b9888693728a93df407a84c7ff74308944b1727 Mon Sep 17 00:00:00 2001
From: Alexa <53471801+Alexa-RR@users.noreply.github.com>
Date: Wed, 27 May 2026 11:17:39 +0300
Subject: [PATCH] launcher: friendly errors + Retry / Get help buttons
Raw exception messages ("Access is denied", HttpRequestException
stack traces) gave the user nothing to do. The flow now translates
the common failures into plain language and offers a one-click
retry that re-runs the host/join flow from the top:
- ErrorTranslator handles HttpRequestException (network down),
TaskCanceledException (timeout), SocketException (firewall),
UnauthorizedAccessException + IOException "in use" (Rec Room
open), missing folder / files (re-pick install), cloudflared
download truncation (suggests LAN mode), port 5005 already bound
- Patcher log output goes through TranslateMessage which catches
the same "access denied" / missing files cases
- HostStepsPanel / JoinStepsPanel gain a Retry + "Get help" row
below the steps; visible only on failure
- "Get help" opens README.md#troubleshooting on GitHub
- Stop / new run hides the retry row so a fresh start is clean
---
launcher/Backend/ErrorTranslator.cs | 163 ++++++++++++++++++++++++++++
launcher/MainWindow.xaml | 18 +++
launcher/MainWindow.xaml.cs | 56 ++++++++--
3 files changed, 226 insertions(+), 11 deletions(-)
create mode 100644 launcher/Backend/ErrorTranslator.cs
diff --git a/launcher/Backend/ErrorTranslator.cs b/launcher/Backend/ErrorTranslator.cs
new file mode 100644
index 0000000..d691823
--- /dev/null
+++ b/launcher/Backend/ErrorTranslator.cs
@@ -0,0 +1,163 @@
+using System.IO;
+using System.Net.Http;
+using System.Net.Sockets;
+
+namespace DorkNet.Launcher.Backend;
+
+/// Maps the most common failure modes from the host/join flow
+/// into plain-language messages with a hint about what to try. The
+/// goal: never leave the user staring at a raw .NET stack trace.
+///
+/// Order of checks matters — more specific patterns are checked
+/// first. Unknown exceptions fall through with the raw message so we
+/// don't silently lose information on novel failures.
+public static class ErrorTranslator
+{
+ public static FriendlyError Translate(Exception ex)
+ {
+ // Walk the inner-exception chain — async stacks often wrap the
+ // real cause inside AggregateException or TaskCanceledException.
+ for (var cur = ex; cur is not null; cur = cur.InnerException!)
+ {
+ var hit = TryMatch(cur);
+ if (hit is not null) return hit;
+ if (cur.InnerException is null) break;
+ }
+
+ // Fallback: surface the first sentence of the message and a
+ // generic "try again" hint. Better than nothing, worse than a
+ // proper translation — add a case above when you see it twice.
+ return new FriendlyError(
+ "Something went wrong.",
+ TrimToSentence(ex.Message),
+ "Retry");
+ }
+
+ public static FriendlyError TranslateMessage(string raw)
+ {
+ if (string.IsNullOrEmpty(raw))
+ return new FriendlyError("Something went wrong.", "(no detail)", "Retry");
+
+ // Patcher returns a raw log on failure (not an Exception), so we
+ // pattern-match on the message string directly here.
+ if (raw.Contains("Access is denied", StringComparison.OrdinalIgnoreCase) ||
+ raw.Contains("being used by another process", StringComparison.OrdinalIgnoreCase))
+ {
+ return new FriendlyError(
+ "Rec Room is currently open.",
+ "Close Rec Room (and any DorkNet launcher copies) so we can update the files, then retry.",
+ "Retry");
+ }
+ if (raw.Contains("Could not find", StringComparison.OrdinalIgnoreCase) &&
+ raw.Contains("Recroom_Release", StringComparison.OrdinalIgnoreCase))
+ {
+ return new FriendlyError(
+ "That doesn't look like a Rec Room install.",
+ "The folder you picked is missing Rec Room's files. Pick the *_Data folder inside your Rec Room install (the one with StreamingAssets in it).",
+ "Pick folder again");
+ }
+ return new FriendlyError("Patch failed.", TrimToSentence(raw), "Retry");
+ }
+
+ private static FriendlyError? TryMatch(Exception ex)
+ {
+ // Network — GitHub releases, Cloudflare quick-tunnel API, etc.
+ if (ex is HttpRequestException http)
+ {
+ return new FriendlyError(
+ "Couldn't reach the download server.",
+ "Check your internet connection and try again. If GitHub is having an outage, wait a minute and retry. " +
+ $"({TrimToSentence(http.Message)})",
+ "Retry");
+ }
+
+ if (ex is TaskCanceledException tce && tce.CancellationToken == default)
+ {
+ // Most TaskCanceledException with default token = HttpClient timeout
+ return new FriendlyError(
+ "Download timed out.",
+ "Your connection is slow or unstable. Try again — the launcher resumes from scratch but can use cached files.",
+ "Retry");
+ }
+
+ if (ex is SocketException sock)
+ {
+ return new FriendlyError(
+ "Network error.",
+ $"Couldn't open a connection ({sock.SocketErrorCode}). Check your firewall isn't blocking DorkNet, then retry.",
+ "Retry");
+ }
+
+ // File-system — locked DLL, missing install path, etc.
+ if (ex is UnauthorizedAccessException)
+ {
+ return new FriendlyError(
+ "Rec Room is currently open.",
+ "Close Rec Room (and any DorkNet launcher copies) so we can update the files, then retry.",
+ "Retry");
+ }
+ if (ex is IOException io && io.Message.Contains("being used by another process",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return new FriendlyError(
+ "Rec Room is locked by another program.",
+ "Close Rec Room so we can patch it, then retry.",
+ "Retry");
+ }
+ if (ex is DirectoryNotFoundException || ex is FileNotFoundException)
+ {
+ return new FriendlyError(
+ "Missing files.",
+ TrimToSentence(ex.Message) +
+ " If you re-installed or moved Rec Room, point the launcher at the new location.",
+ "Pick folder again");
+ }
+
+ // cloudflared specifics
+ if (ex is InvalidOperationException inv &&
+ inv.Message.Contains("cloudflared", StringComparison.OrdinalIgnoreCase))
+ {
+ if (inv.Message.Contains("unexpectedly small", StringComparison.OrdinalIgnoreCase))
+ {
+ return new FriendlyError(
+ "cloudflared download was interrupted.",
+ "The launcher needs to download a small (~17 MB) Cloudflare helper on first run. Try again — if it keeps failing, switch to \"Same WiFi only\" mode under WHO CAN JOIN.",
+ "Retry");
+ }
+ return new FriendlyError(
+ "Cloudflare tunnel didn't start.",
+ TrimToSentence(inv.Message) +
+ " Try again, or switch to \"Same WiFi only\" mode if you only need LAN players.",
+ "Retry");
+ }
+
+ // Server port already in use
+ if (ex.Message.Contains("address already in use", StringComparison.OrdinalIgnoreCase) ||
+ ex.Message.Contains("Only one usage of each socket address",
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return new FriendlyError(
+ "Port 5005 is already in use.",
+ "Another DorkNet server (or some other app) is already listening on port 5005. Close it and retry, or reboot.",
+ "Retry");
+ }
+
+ return null;
+ }
+
+ /// Keep messages tight — anything past the first sentence
+ /// in a .NET exception is usually noise. Strips trailing newlines
+ /// too so it lays out cleanly in the step row.
+ private static string TrimToSentence(string s)
+ {
+ if (string.IsNullOrEmpty(s)) return s;
+ var trimmed = s.Trim().Replace("\r", " ").Replace("\n", " ");
+ var dot = trimmed.IndexOf('.');
+ if (dot > 20 && dot < trimmed.Length - 1) return trimmed[..(dot + 1)];
+ return trimmed.Length <= 200 ? trimmed : trimmed[..200] + "...";
+ }
+}
+
+/// Translated error: a short headline, a longer explanation,
+/// and the label for the action button (usually "Retry").
+public sealed record FriendlyError(string Title, string Explanation, string ActionLabel);
diff --git a/launcher/MainWindow.xaml b/launcher/MainWindow.xaml
index c7fd683..a6a9bad 100644
--- a/launcher/MainWindow.xaml
+++ b/launcher/MainWindow.xaml
@@ -238,6 +238,15 @@
Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" FontSize="13"
Margin="0,4,0,0" />
+
+
+
+
@@ -395,6 +404,15 @@
Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" FontSize="13"
Margin="0,4,0,0" />
+
+
+
+
diff --git a/launcher/MainWindow.xaml.cs b/launcher/MainWindow.xaml.cs
index 3167768..39a07e5 100644
--- a/launcher/MainWindow.xaml.cs
+++ b/launcher/MainWindow.xaml.cs
@@ -288,6 +288,7 @@ public partial class MainWindow : Window
HostStatusText.Text = "";
StartupStep? activeStep = null;
+ HostRetryPanel.Visibility = Visibility.Collapsed;
try
{
activeStep = StartStep(_hostSteps, 0);
@@ -331,7 +332,8 @@ public partial class MainWindow : Window
_state.PhotonAppId, _state.PhotonVoiceAppId, _state.PhotonRegion, _hostApex);
if (!patch.Ok)
{
- FailStep(activeStep, "Patcher failed: " + Truncate(patch.Log, 300));
+ FailStepWithFriendly(activeStep, ErrorTranslator.TranslateMessage(patch.Log));
+ ShowHostRetry();
return;
}
CompleteStep(activeStep);
@@ -356,13 +358,26 @@ public partial class MainWindow : Window
}
catch (Exception ex)
{
- if (activeStep is not null) FailStep(activeStep, ex.Message);
- else HostStatusText.Text = ex.Message;
+ var friendly = ErrorTranslator.Translate(ex);
+ if (activeStep is not null) FailStepWithFriendly(activeStep, friendly);
+ else HostStatusText.Text = $"{friendly.Title} {friendly.Explanation}";
+ ShowHostRetry();
HostStartBtn.IsEnabled = true;
HostStopBtn.Visibility = Visibility.Collapsed;
}
}
+ private void ShowHostRetry()
+ {
+ HostRetryPanel.Visibility = Visibility.Visible;
+ }
+
+ private void OnHostRetry(object sender, RoutedEventArgs e)
+ {
+ HostRetryPanel.Visibility = Visibility.Collapsed;
+ OnHostStart(sender, e);
+ }
+
// ── Step helpers ────────────────────────────────────────────────────
private static StartupStep StartStep(ObservableCollection steps, int index)
{
@@ -377,11 +392,11 @@ public partial class MainWindow : Window
step.Progress = null;
}
- private static void FailStep(StartupStep step, string detail)
+ private static void FailStepWithFriendly(StartupStep step, FriendlyError err)
{
step.State = StepState.Failed;
step.Progress = null;
- step.Detail = detail;
+ step.Detail = $"{err.Title} {err.Explanation}";
}
private static void UpdateStepProgress(StartupStep step, DownloadProgress p)
@@ -411,6 +426,7 @@ public partial class MainWindow : Window
HostStepsList.ItemsSource = null;
_hostSteps = null;
HostStatusText.Text = "";
+ HostRetryPanel.Visibility = Visibility.Collapsed;
}
private async Task ShutdownServerAsync()
@@ -503,6 +519,7 @@ public partial class MainWindow : Window
JoinStepsList.ItemsSource = _joinSteps;
JoinStepsPanel.Visibility = Visibility.Visible;
JoinStatusText.Text = "";
+ JoinRetryPanel.Visibility = Visibility.Collapsed;
StartupStep? activeStep = null;
try
@@ -518,7 +535,8 @@ public partial class MainWindow : Window
payload.PhotonAppId, payload.PhotonVoiceAppId, payload.PhotonRegion, payload.Host);
if (!patch.Ok)
{
- FailStep(activeStep, "Patcher failed: " + Truncate(patch.Log, 300));
+ FailStepWithFriendly(activeStep, ErrorTranslator.TranslateMessage(patch.Log));
+ JoinRetryPanel.Visibility = Visibility.Visible;
return;
}
CompleteStep(activeStep);
@@ -530,12 +548,31 @@ public partial class MainWindow : Window
}
catch (Exception ex)
{
- if (activeStep is not null) FailStep(activeStep, ex.Message);
- else JoinStatusText.Text = ex.Message;
+ var friendly = ErrorTranslator.Translate(ex);
+ if (activeStep is not null) FailStepWithFriendly(activeStep, friendly);
+ else JoinStatusText.Text = $"{friendly.Title} {friendly.Explanation}";
+ JoinRetryPanel.Visibility = Visibility.Visible;
}
finally { RefreshJoinReadiness(); }
}
+ private void OnJoinRetry(object sender, RoutedEventArgs e)
+ {
+ JoinRetryPanel.Visibility = Visibility.Collapsed;
+ OnJoinApply(sender, e);
+ }
+
+ private void OnOpenTroubleshooting(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo(
+ "https://github.com/DorkSquadRR/DorkNet/blob/main/README.md#troubleshooting")
+ { UseShellExecute = true });
+ }
+ catch (Exception ex) { ShowError("Couldn't open help: " + ex.Message); }
+ }
+
private void OnLaunchRecRoom(object sender, RoutedEventArgs e)
{
if (string.IsNullOrEmpty(_state.RecRoomPath))
@@ -545,9 +582,6 @@ public partial class MainWindow : Window
}
// ── Helpers ─────────────────────────────────────────────────────────
- 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);