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
This commit is contained in:
Alexa
2026-05-27 11:09:20 +03:00
parent 65ca509fa8
commit 3039bebc6c
4 changed files with 348 additions and 41 deletions
+140
View File
@@ -1,6 +1,7 @@
<Application x:Class="DorkNet.Launcher.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:backend="clr-namespace:DorkNet.Launcher.Backend"
StartupUri="MainWindow.xaml">
<Application.Resources>
@@ -245,5 +246,144 @@
<Setter Property="CornerRadius" Value="999" />
<Setter Property="Padding" Value="10,4" />
</Style>
<!-- ── Mini progress bar — restyled WPF ProgressBar to match the
launcher palette. Used inside the step-row template. ─────── -->
<Style x:Key="StepProgressBar" TargetType="ProgressBar">
<Setter Property="Minimum" Value="0" />
<Setter Property="Maximum" Value="100" />
<Setter Property="Height" Value="6" />
<Setter Property="Foreground" Value="{StaticResource Brand}" />
<Setter Property="Background" Value="{StaticResource BgDeeper}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ProgressBar">
<Border Background="{TemplateBinding Background}"
CornerRadius="3" ClipToBounds="True">
<Grid HorizontalAlignment="Stretch">
<Rectangle x:Name="PART_Track" />
<Border x:Name="PART_Indicator"
Background="{TemplateBinding Foreground}"
HorizontalAlignment="Left"
CornerRadius="3" />
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ── Bool→Visibility converter for HasDetail/HasProgress flags ─ -->
<BooleanToVisibilityConverter x:Key="BoolToVis" />
<!-- ── StartupStep row template ─────────────────────────────────
A single row in the host/join step list. State drives the
status icon's colour + glyph; detail/progress are shown only
when populated (so pending steps render as a clean single
line). -->
<DataTemplate x:Key="StepRowTemplate" DataType="{x:Type backend:StartupStep}">
<Grid Margin="0,0,0,14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Status bullet — circle that recolours and re-glyphs
based on the bound State. Pending = empty ring,
Active = filled brand, Done = green checkmark,
Failed = red X. -->
<Border Grid.Column="0" Width="22" Height="22" CornerRadius="11"
VerticalAlignment="Top" Margin="0,1,0,0"
BorderThickness="2">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource InkFaint}" />
<Style.Triggers>
<DataTrigger Binding="{Binding State}" Value="Active">
<Setter Property="Background" Value="{StaticResource Brand}" />
<Setter Property="BorderBrush" Value="{StaticResource Brand}" />
</DataTrigger>
<DataTrigger Binding="{Binding State}" Value="Done">
<Setter Property="Background" Value="{StaticResource Ok}" />
<Setter Property="BorderBrush" Value="{StaticResource Ok}" />
</DataTrigger>
<DataTrigger Binding="{Binding State}" Value="Failed">
<Setter Property="Background" Value="{StaticResource Danger}" />
<Setter Property="BorderBrush" Value="{StaticResource Danger}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock HorizontalAlignment="Center" VerticalAlignment="Center"
FontSize="13" FontWeight="Bold" Foreground="White">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="" />
<Style.Triggers>
<DataTrigger Binding="{Binding State}" Value="Active">
<Setter Property="Text" Value="●" />
<Setter Property="FontSize" Value="9" />
</DataTrigger>
<DataTrigger Binding="{Binding State}" Value="Done">
<Setter Property="Text" Value="✓" />
<Setter Property="Foreground" Value="#0E0A1F" />
</DataTrigger>
<DataTrigger Binding="{Binding State}" Value="Failed">
<Setter Property="Text" Value="✕" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
<!-- Title + (optional) detail + (optional) progress bar -->
<StackPanel Grid.Column="1" Margin="12,0,0,0">
<TextBlock Text="{Binding Title}" FontSize="14">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource InkSoft}" />
<Setter Property="FontWeight" Value="Normal" />
<Style.Triggers>
<DataTrigger Binding="{Binding State}" Value="Active">
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="FontWeight" Value="SemiBold" />
</DataTrigger>
<DataTrigger Binding="{Binding State}" Value="Done">
<Setter Property="Foreground" Value="{StaticResource Ink}" />
</DataTrigger>
<DataTrigger Binding="{Binding State}" Value="Failed">
<Setter Property="Foreground" Value="{StaticResource Danger}" />
<Setter Property="FontWeight" Value="SemiBold" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding Detail}" TextWrapping="Wrap"
FontSize="12" Margin="0,4,0,0"
Visibility="{Binding HasDetail, Converter={StaticResource BoolToVis}}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource InkSoft}" />
<Style.Triggers>
<DataTrigger Binding="{Binding State}" Value="Failed">
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<ProgressBar Style="{StaticResource StepProgressBar}"
Value="{Binding ProgressPercent, Mode=OneWay}"
Margin="0,8,0,0"
Visibility="{Binding HasProgress, Converter={StaticResource BoolToVis}}" />
</StackPanel>
</Grid>
</DataTemplate>
</Application.Resources>
</Application>
+89
View File
@@ -0,0 +1,89 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace DorkNet.Launcher.Backend;
/// <summary>A single visible step in the host/join startup flow. The
/// XAML <c>ItemsControl</c> binds to a collection of these and the
/// DataTemplate switches presentation off <see cref="State"/>.
///
/// <para>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.</para></summary>
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)); }
}
/// <summary>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.</summary>
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;
/// <summary>0..100 for the ProgressBar Value binding.</summary>
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
{
/// <summary>Hasn't started yet. Dim icon, no detail.</summary>
Pending,
/// <summary>In progress right now. Brand-coloured icon, detail +
/// optional progress bar visible.</summary>
Active,
/// <summary>Finished successfully. Green checkmark.</summary>
Done,
/// <summary>Failed — error in <see cref="StartupStep.Detail"/>. Red
/// X, the rest of the flow won't proceed.</summary>
Failed,
}
/// <summary>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.</summary>
public static class StartupFlow
{
public static ObservableCollection<StartupStep> 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<StartupStep> NewJoinFlow() => new()
{
new StartupStep("Download client patcher"),
new StartupStep("Patch your Rec Room install"),
};
}
+17 -7
View File
@@ -219,13 +219,19 @@
Margin="10,0,0,0" Visibility="Collapsed" />
</StackPanel>
<!-- Status -->
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<!-- Progress steps — populated when the user hits
"Start hosting". Hidden until then so the
idle Host view stays clean. -->
<Border x:Name="HostStepsPanel" Style="{StaticResource Panel}"
Margin="0,0,0,16" Visibility="Collapsed">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Status" />
<TextBlock Style="{StaticResource SectionTitle}" Text="Progress" />
<ItemsControl x:Name="HostStepsList"
ItemTemplate="{StaticResource StepRowTemplate}" />
<TextBlock x:Name="HostStatusText"
Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" Text="Idle." FontSize="14" />
TextWrapping="Wrap" FontSize="13"
Margin="0,4,0,0" />
</StackPanel>
</Border>
@@ -338,12 +344,16 @@
Margin="10,0,0,0" Visibility="Collapsed" />
</StackPanel>
<Border Style="{StaticResource Panel}">
<Border x:Name="JoinStepsPanel" Style="{StaticResource Panel}"
Visibility="Collapsed">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Status" />
<TextBlock Style="{StaticResource SectionTitle}" Text="Progress" />
<ItemsControl x:Name="JoinStepsList"
ItemTemplate="{StaticResource StepRowTemplate}" />
<TextBlock x:Name="JoinStatusText"
Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" Text="Idle." FontSize="14" />
TextWrapping="Wrap" FontSize="13"
Margin="0,4,0,0" />
</StackPanel>
</Border>
</StackPanel>
+102 -34
View File
@@ -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<StartupStep>? _hostSteps;
private ObservableCollection<StartupStep>? _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<DownloadProgress>(p =>
HostStatusText.Text = FormatProgress("Downloading server", p));
activeStep = StartStep(_hostSteps, 0);
var serverProgress = new Progress<DownloadProgress>(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<DownloadProgress>(p =>
HostStatusText.Text = FormatProgress("Downloading Cloudflare tunnel", p)));
downloadProgress: new Progress<DownloadProgress>(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<DownloadProgress>(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<StartupStep> 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<DownloadProgress>(p =>
JoinStatusText.Text = FormatProgress("Downloading patcher", p));
JoinStatusText.Text = "Downloading client patcher…";
activeStep = StartStep(_joinSteps, 0);
var progress = new Progress<DownloadProgress>(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] + "...";