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
This commit is contained in:
Alexa
2026-05-27 11:17:39 +03:00
parent 7c974e39ab
commit 5b98886937
3 changed files with 226 additions and 11 deletions
+163
View File
@@ -0,0 +1,163 @@
using System.IO;
using System.Net.Http;
using System.Net.Sockets;
namespace DorkNet.Launcher.Backend;
/// <summary>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.
///
/// <para>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.</para></summary>
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;
}
/// <summary>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.</summary>
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] + "...";
}
}
/// <summary>Translated error: a short headline, a longer explanation,
/// and the label for the action button (usually "Retry").</summary>
public sealed record FriendlyError(string Title, string Explanation, string ActionLabel);
+18
View File
@@ -238,6 +238,15 @@
Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" FontSize="13"
Margin="0,4,0,0" />
<StackPanel x:Name="HostRetryPanel" Visibility="Collapsed"
Orientation="Horizontal" Margin="0,12,0,0">
<Button x:Name="HostRetryBtn" Style="{StaticResource PrimaryBtn}"
Content="Retry" Click="OnHostRetry" />
<Button Style="{StaticResource GhostBtn}"
Content="Get help"
Click="OnOpenTroubleshooting"
Margin="10,0,0,0" />
</StackPanel>
</StackPanel>
</Border>
@@ -395,6 +404,15 @@
Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" FontSize="13"
Margin="0,4,0,0" />
<StackPanel x:Name="JoinRetryPanel" Visibility="Collapsed"
Orientation="Horizontal" Margin="0,12,0,0">
<Button x:Name="JoinRetryBtn" Style="{StaticResource PrimaryBtn}"
Content="Retry" Click="OnJoinRetry" />
<Button Style="{StaticResource GhostBtn}"
Content="Get help"
Click="OnOpenTroubleshooting"
Margin="10,0,0,0" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
+45 -11
View File
@@ -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<StartupStep> 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);