From 3039bebc6c391a577673e1040b3d63d52375199c Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Wed, 27 May 2026 11:09:20 +0300 Subject: [PATCH] launcher: step-by-step progress UI replaces single-line status The single HostStatusText/JoinStatusText line gave no sense of where the user was in the flow. Replaced with a step list: - StartupStep model (INotifyPropertyChanged) tracks Title, State (Pending/Active/Done/Failed), Detail, optional Progress 0..1 - ItemsControl + DataTemplate in App.xaml renders each step as a status bullet (empty ring -> filled brand -> green check -> red X), with conditional detail line + restyled mini progress bar - StartupFlow.NewHostFlow / NewJoinFlow build the canonical 5-step host and 2-step join lists; host flow swaps the tunnel step label for LocalNetwork mode - OnHostStart / OnJoinApply advance steps via StartStep/CompleteStep/ FailStep helpers and route progress callbacks to UpdateStepProgress so each download phase has its own bar - Errors mark the active step Failed with the message inline, leaving the rest of the list visible so the user sees where it broke --- launcher/App.xaml | 140 ++++++++++++++++++++++++++++++++ launcher/Backend/StartupStep.cs | 89 ++++++++++++++++++++ launcher/MainWindow.xaml | 24 ++++-- launcher/MainWindow.xaml.cs | 136 +++++++++++++++++++++++-------- 4 files changed, 348 insertions(+), 41 deletions(-) create mode 100644 launcher/Backend/StartupStep.cs diff --git a/launcher/App.xaml b/launcher/App.xaml index 002d4c0..4df9a14 100644 --- a/launcher/App.xaml +++ b/launcher/App.xaml @@ -1,6 +1,7 @@ @@ -245,5 +246,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/launcher/Backend/StartupStep.cs b/launcher/Backend/StartupStep.cs new file mode 100644 index 0000000..1d81631 --- /dev/null +++ b/launcher/Backend/StartupStep.cs @@ -0,0 +1,89 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace DorkNet.Launcher.Backend; + +/// A single visible step in the host/join startup flow. The +/// XAML ItemsControl binds to a collection of these and the +/// DataTemplate switches presentation off . +/// +/// This is a tiny INotifyPropertyChanged DTO on purpose — no MVVM +/// framework, no commands, no view-model layer. Code-behind owns the +/// collection and mutates fields as the flow progresses. +public sealed class StartupStep : INotifyPropertyChanged +{ + public StartupStep(string title) { Title = title; } + + public string Title { get; } + + private StepState _state = StepState.Pending; + public StepState State + { + get => _state; + set { if (_state == value) return; _state = value; OnChanged(); OnChanged(nameof(IsActive)); } + } + + private string _detail = ""; + public string Detail + { + get => _detail; + set { if (_detail == value) return; _detail = value; OnChanged(); OnChanged(nameof(HasDetail)); } + } + + /// 0..1 progress fraction, or null for "no progress bar" + /// (e.g. an indeterminate phase like "starting server"). The + /// DataTemplate hides the bar when null. + private double? _progress; + public double? Progress + { + get => _progress; + set { if (_progress == value) return; _progress = value; OnChanged(); OnChanged(nameof(HasProgress)); OnChanged(nameof(ProgressPercent)); } + } + + public bool HasDetail => !string.IsNullOrEmpty(_detail); + public bool HasProgress => _progress.HasValue; + public bool IsActive => _state == StepState.Active; + + /// 0..100 for the ProgressBar Value binding. + public double ProgressPercent => (_progress ?? 0) * 100; + + public event PropertyChangedEventHandler? PropertyChanged; + private void OnChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name!)); +} + +public enum StepState +{ + /// Hasn't started yet. Dim icon, no detail. + Pending, + /// In progress right now. Brand-coloured icon, detail + + /// optional progress bar visible. + Active, + /// Finished successfully. Green checkmark. + Done, + /// Failed — error in . Red + /// X, the rest of the flow won't proceed. + Failed, +} + +/// Helpers for the two known flows. Centralising the step +/// names here keeps the XAML free of hardcoded strings and makes it +/// obvious what stages a user will see. +public static class StartupFlow +{ + public static ObservableCollection NewHostFlow(bool isLocalNetwork) => new() + { + new StartupStep("Download server binary"), + new StartupStep(isLocalNetwork ? "Find your local network address" : "Set up Cloudflare tunnel"), + new StartupStep("Start the server"), + new StartupStep("Download client patcher"), + new StartupStep("Patch your Rec Room install"), + }; + + public static ObservableCollection NewJoinFlow() => new() + { + new StartupStep("Download client patcher"), + new StartupStep("Patch your Rec Room install"), + }; +} diff --git a/launcher/MainWindow.xaml b/launcher/MainWindow.xaml index 1c5c1bf..e958a6a 100644 --- a/launcher/MainWindow.xaml +++ b/launcher/MainWindow.xaml @@ -219,13 +219,19 @@ Margin="10,0,0,0" Visibility="Collapsed" /> - - + + - + + + TextWrapping="Wrap" FontSize="13" + Margin="0,4,0,0" /> @@ -338,12 +344,16 @@ Margin="10,0,0,0" Visibility="Collapsed" /> - + - + + + TextWrapping="Wrap" FontSize="13" + Margin="0,4,0,0" /> diff --git a/launcher/MainWindow.xaml.cs b/launcher/MainWindow.xaml.cs index 9b8e323..fce1c62 100644 --- a/launcher/MainWindow.xaml.cs +++ b/launcher/MainWindow.xaml.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.Diagnostics; using System.Windows; using System.Windows.Controls; @@ -19,6 +20,8 @@ public partial class MainWindow : Window private Tunnel? _tunnel; private VersionsManifest? _manifest; private string? _hostApex; + private ObservableCollection? _hostSteps; + private ObservableCollection? _joinSteps; public MainWindow() { @@ -260,46 +263,63 @@ public partial class MainWindow : Window HostStopBtn.Visibility = Visibility.Visible; JoinCodePanel.Visibility = Visibility.Collapsed; + // Build a fresh step list so progress is visible the moment the + // user clicks. Steps advance via StartStep/CompleteStep helpers + // below so flow stays readable. + _hostSteps = StartupFlow.NewHostFlow(_state.HostingMode == HostingMode.LocalNetwork); + HostStepsList.ItemsSource = _hostSteps; + HostStepsPanel.Visibility = Visibility.Visible; + HostStatusText.Text = ""; + + StartupStep? activeStep = null; try { - var progress = new Progress(p => - HostStatusText.Text = FormatProgress("Downloading server", p)); + activeStep = StartStep(_hostSteps, 0); + var serverProgress = new Progress(p => UpdateStepProgress(activeStep, p)); + var serverDir = await _releases.EnsureServerAsync(version, serverProgress); + CompleteStep(activeStep); - HostStatusText.Text = "Downloading server binary…"; - var serverDir = await _releases.EnsureServerAsync(version, progress); - - // Hosting mode branch — Internet (Cloudflare tunnel, joiners - // anywhere) vs LocalNetwork (LAN IP, joiners on same network, - // zero internet dependency). + activeStep = StartStep(_hostSteps, 1); if (_state.HostingMode == HostingMode.LocalNetwork) { _hostApex = LocalNetwork.GetLanIp(); - HostStatusText.Text = $"Local-network mode: hosting on {_hostApex}"; + activeStep.Detail = $"Hosting on {_hostApex}"; } else { - HostStatusText.Text = "Setting up Cloudflare tunnel (first run downloads ~17 MB)…"; _tunnel = new Tunnel(); + activeStep.Detail = "Connecting to Cloudflare (first run downloads cloudflared, ~17 MB)"; var publicUrl = await _tunnel.StartAsync( localPort: 5005, - downloadProgress: new Progress(p => - HostStatusText.Text = FormatProgress("Downloading Cloudflare tunnel", p))); + downloadProgress: new Progress(p => UpdateStepProgress(activeStep!, p))); _hostApex = new Uri(publicUrl).Host; + activeStep.Detail = $"Tunnel ready: {_hostApex}"; + activeStep.Progress = null; } + CompleteStep(activeStep); - HostStatusText.Text = "Starting the server…"; + activeStep = StartStep(_hostSteps, 2); await _server.StartAsync(serverDir, _state, _hostApex); _state.SelectedVersion = version.VersionKey; _state.Save(); + CompleteStep(activeStep); - HostStatusText.Text = "Downloading client patcher…"; - var patcherDir = await _releases.EnsurePatcherAsync(version, progress); + activeStep = StartStep(_hostSteps, 3); + var patcherProgress = new Progress(p => UpdateStepProgress(activeStep, p)); + var patcherDir = await _releases.EnsurePatcherAsync(version, patcherProgress); + CompleteStep(activeStep); - HostStatusText.Text = "Patching your Rec Room client…"; + activeStep = StartStep(_hostSteps, 4); var patch = await _patcher.ApplyAsync( patcherDir, _state.RecRoomPath!, _state.PhotonAppId, _state.PhotonVoiceAppId, _state.PhotonRegion, _hostApex); - if (!patch.Ok) { ShowError("Patcher failed: " + Truncate(patch.Log, 500)); return; } + if (!patch.Ok) + { + FailStep(activeStep, "Patcher failed: " + Truncate(patch.Log, 300)); + return; + } + CompleteStep(activeStep); + activeStep = null; var code = JoinCode.Encode(new JoinPayload { @@ -319,12 +339,50 @@ public partial class MainWindow : Window } catch (Exception ex) { - ShowError(ex.Message); + if (activeStep is not null) FailStep(activeStep, ex.Message); + else HostStatusText.Text = ex.Message; HostStartBtn.IsEnabled = true; HostStopBtn.Visibility = Visibility.Collapsed; } } + // ── Step helpers ──────────────────────────────────────────────────── + private static StartupStep StartStep(ObservableCollection steps, int index) + { + var s = steps[index]; + s.State = StepState.Active; + return s; + } + + private static void CompleteStep(StartupStep step) + { + step.State = StepState.Done; + step.Progress = null; + } + + private static void FailStep(StartupStep step, string detail) + { + step.State = StepState.Failed; + step.Progress = null; + step.Detail = detail; + } + + private static void UpdateStepProgress(StartupStep step, DownloadProgress p) + { + if (p.TotalBytes > 0) + { + var mb = p.BytesRead / (1024.0 * 1024.0); + var totalMb = p.TotalBytes / (1024.0 * 1024.0); + step.Detail = $"{mb:F1} / {totalMb:F1} MB ({(int)(p.Fraction * 100)}%)"; + step.Progress = p.Fraction; + } + else + { + var mb = p.BytesRead / (1024.0 * 1024.0); + step.Detail = $"{mb:F1} MB downloaded"; + } + } + private async void OnHostStop(object sender, RoutedEventArgs e) { await ShutdownServerAsync(); @@ -332,7 +390,10 @@ public partial class MainWindow : Window HostStopBtn.Visibility = Visibility.Collapsed; HostLaunchBtn.Visibility = Visibility.Collapsed; JoinCodePanel.Visibility = Visibility.Collapsed; - HostStatusText.Text = "Stopped."; + HostStepsPanel.Visibility = Visibility.Collapsed; + HostStepsList.ItemsSource = null; + _hostSteps = null; + HostStatusText.Text = ""; } private async Task ShutdownServerAsync() @@ -389,25 +450,40 @@ public partial class MainWindow : Window { ShowError($"Host is on a version this launcher doesn't know about: {payload.VersionKey}"); return; } JoinApplyBtn.IsEnabled = false; + _joinSteps = StartupFlow.NewJoinFlow(); + JoinStepsList.ItemsSource = _joinSteps; + JoinStepsPanel.Visibility = Visibility.Visible; + JoinStatusText.Text = ""; + + StartupStep? activeStep = null; try { - var progress = new Progress(p => - JoinStatusText.Text = FormatProgress("Downloading patcher", p)); - - JoinStatusText.Text = "Downloading client patcher…"; + activeStep = StartStep(_joinSteps, 0); + var progress = new Progress(p => UpdateStepProgress(activeStep, p)); var patcherDir = await _releases.EnsurePatcherAsync(version, progress); + CompleteStep(activeStep); - JoinStatusText.Text = "Patching your Rec Room client…"; + activeStep = StartStep(_joinSteps, 1); var patch = await _patcher.ApplyAsync( patcherDir, _state.RecRoomPath!, payload.PhotonAppId, payload.PhotonVoiceAppId, payload.PhotonRegion, payload.Host); - if (!patch.Ok) { ShowError("Patcher failed: " + Truncate(patch.Log, 500)); return; } + if (!patch.Ok) + { + FailStep(activeStep, "Patcher failed: " + Truncate(patch.Log, 300)); + return; + } + CompleteStep(activeStep); + activeStep = null; JoinStatusText.Text = $"Patched and ready. Connecting to {payload.Host}. " + "Click Launch Rec Room when you're ready to play."; JoinLaunchBtn.Visibility = Visibility.Visible; } - catch (Exception ex) { ShowError(ex.Message); } + catch (Exception ex) + { + if (activeStep is not null) FailStep(activeStep, ex.Message); + else JoinStatusText.Text = ex.Message; + } finally { RefreshJoinReadiness(); } } @@ -420,14 +496,6 @@ public partial class MainWindow : Window } // ── Helpers ───────────────────────────────────────────────────────── - private static string FormatProgress(string label, DownloadProgress p) - { - if (p.TotalBytes <= 0) return $"{label}…"; - var mb = p.BytesRead / (1024.0 * 1024.0); - var totalMb = p.TotalBytes / (1024.0 * 1024.0); - return $"{label}: {mb:F1} / {totalMb:F1} MB ({(int)(p.Fraction * 100)}%)"; - } - private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max] + "...";