mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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:
@@ -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);
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user