From 5452ff06eec03dc2c28d907a2da40029b478c5ff Mon Sep 17 00:00:00 2001
From: Alexa <53471801+Alexa-RR@users.noreply.github.com>
Date: Wed, 27 May 2026 11:25:28 +0300
Subject: [PATCH] launcher: welcome screen + guided setup wizard
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
First launch went straight to a Host/Join card picker with no
framing. Replaced with a two-phase onboarding:
WelcomeView — branded intro with the wordmark, three feature
bullets (2 min setup, free, works with regular Rec Room), and one
big "Get started" button. Dismissed via AppState.WelcomeSeen.
SetupView — multi-step wizard with a STEP X OF N indicator:
1. Host vs Join card picker (self-advances on click)
2. Rec Room install (shows auto-detected path with a green "we
found it" banner; Pick... overrides)
3. (Host only) Server name + WHO CAN JOIN radios
4. (Host only) Photon AppIds — "Open setup walkthrough" launches
the existing PhotonWizard, or paste fields below for advanced
users; live status line confirms when AppIds are set
5. Done card with Finish button
Join mode hops 2 -> done since it doesn't need server config or
Photon. AppState.SetupComplete gates entry; future launches skip
the wizard and route directly to HostView / JoinView. Existing
state files get auto-migrated on load (Mode set + RecRoomPath
populated = WelcomeSeen + SetupComplete backfilled to true, then
flushed). Settings -> "Re-run setup" clears the flag for users
who want to walk through again.
---
launcher/Backend/AppState.cs | 34 +++-
launcher/MainWindow.xaml | 339 +++++++++++++++++++++++++++--------
launcher/MainWindow.xaml.cs | 224 +++++++++++++++++++++--
3 files changed, 511 insertions(+), 86 deletions(-)
diff --git a/launcher/Backend/AppState.cs b/launcher/Backend/AppState.cs
index f82eec7..4b09d64 100644
--- a/launcher/Backend/AppState.cs
+++ b/launcher/Backend/AppState.cs
@@ -52,6 +52,19 @@ public sealed class AppState
[JsonPropertyName("serverName")]
public string ServerName { get; set; } = string.Empty;
+ /// True once the user has dismissed the branded welcome
+ /// screen. Subsequent launches skip straight to the setup wizard
+ /// (or the main host/join view if setup is also done).
+ [JsonPropertyName("welcomeSeen")]
+ public bool WelcomeSeen { get; set; }
+
+ /// True once the user has finished the multi-step setup
+ /// wizard. Future launches skip the wizard and open the
+ /// host/join view directly. Settings -> "Re-run setup" can clear
+ /// this if the user wants to walk through again.
+ [JsonPropertyName("setupComplete")]
+ public bool SetupComplete { get; set; }
+
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
@@ -65,7 +78,26 @@ public sealed class AppState
try
{
var json = File.ReadAllText(AppPaths.StateFile);
- return JsonSerializer.Deserialize(json, JsonOptions) ?? new AppState();
+ var state = JsonSerializer.Deserialize(json, JsonOptions) ?? new AppState();
+
+ // Backfill the welcome/setup flags for users upgrading from a
+ // pre-wizard build. If they already had a working host setup
+ // they've effectively passed those screens — don't drag them
+ // back through the wizard on first launch of the new build.
+ var migrated = false;
+ if (!state.WelcomeSeen && state.Mode != AppMode.Unset)
+ { state.WelcomeSeen = true; migrated = true; }
+ if (!state.SetupComplete && state.Mode != AppMode.Unset &&
+ !string.IsNullOrEmpty(state.RecRoomPath) &&
+ (state.Mode == AppMode.Join || !string.IsNullOrEmpty(state.PhotonAppId)))
+ { state.SetupComplete = true; migrated = true; }
+ // Flush the migration so subsequent launches read the
+ // backfilled values directly from disk — keeps the file as
+ // the authoritative source instead of needing to re-derive
+ // them every load.
+ if (migrated) { try { state.Save(); } catch { } }
+
+ return state;
}
catch
{
diff --git a/launcher/MainWindow.xaml b/launcher/MainWindow.xaml
index a6a9bad..9c2500d 100644
--- a/launcher/MainWindow.xaml
+++ b/launcher/MainWindow.xaml
@@ -27,85 +27,279 @@
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
+
+
+ FontSize="11" Margin="0,28,0,0" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -450,6 +644,9 @@
+
diff --git a/launcher/MainWindow.xaml.cs b/launcher/MainWindow.xaml.cs
index 39a07e5..0aafcca 100644
--- a/launcher/MainWindow.xaml.cs
+++ b/launcher/MainWindow.xaml.cs
@@ -82,10 +82,12 @@ public partial class MainWindow : Window
// ── View routing ────────────────────────────────────────────────────
private void ShowView(string name)
{
- FirstRunView.Visibility = name == "first-run" ? Visibility.Visible : Visibility.Collapsed;
+ WelcomeView.Visibility = name == "welcome" ? Visibility.Visible : Visibility.Collapsed;
+ SetupView.Visibility = name == "setup" ? Visibility.Visible : Visibility.Collapsed;
HostView.Visibility = name == "host" ? Visibility.Visible : Visibility.Collapsed;
JoinView.Visibility = name == "join" ? Visibility.Visible : Visibility.Collapsed;
SettingsView.Visibility = name == "settings" ? Visibility.Visible : Visibility.Collapsed;
+ if (name == "setup") RenderSetupStep();
}
private string CurrentViewName()
@@ -93,25 +95,42 @@ public partial class MainWindow : Window
if (SettingsView.Visibility == Visibility.Visible) return "settings";
if (HostView.Visibility == Visibility.Visible) return "host";
if (JoinView.Visibility == Visibility.Visible) return "join";
- return "first-run";
+ if (SetupView.Visibility == Visibility.Visible) return "setup";
+ return "welcome";
}
private string? _previousView;
+ /// Pick the right initial view based on persisted state.
+ /// True first launch: welcome -> setup -> host/join. Subsequent
+ /// launches skip the wizard since SetupComplete is true.
+ private string PickRoutingTarget()
+ {
+ if (!_state.WelcomeSeen) return "welcome";
+ if (_state.Mode == AppMode.Unset || !_state.SetupComplete) return "setup";
+ return _state.Mode == AppMode.Join ? "join" : "host";
+ }
+
private void ReflectStateInUi()
{
- // Mirror persisted values into the host/join input fields so the
- // user doesn't re-type them every launch.
+ // Mirror persisted values into the host/join + setup input fields
+ // so the user doesn't re-type them every launch (or every step
+ // back/forward in the wizard).
var path = _state.RecRoomPath ?? "(not picked)";
HostRecRoomPath.Text = path;
JoinRecRoomPath.Text = path;
+ SetupRecRoomPath.Text = path;
PhotonAppIdInput.Text = _state.PhotonAppId;
PhotonVoiceAppIdInput.Text = _state.PhotonVoiceAppId;
+ SetupPhotonAppId.Text = _state.PhotonAppId;
+ SetupPhotonVoiceAppId.Text = _state.PhotonVoiceAppId;
SelectComboBoxByTag(PhotonRegionSelect, _state.PhotonRegion);
ServerNameInput.Text = _state.ServerName;
- // Hosting mode radio — reflect persisted choice; defaults to Internet
- // since that's the install-and-play case most users want.
+ SetupServerName.Text = _state.ServerName;
+
+ // Hosting mode radios — reflect persisted choice on both the
+ // main HostView and the setup wizard's matching step.
if (HostModeLocal is not null && HostModeInternet is not null)
{
if (_state.HostingMode == HostingMode.LocalNetwork)
@@ -119,6 +138,27 @@ public partial class MainWindow : Window
else
HostModeInternet.IsChecked = true;
}
+ if (SetupHostLocal is not null && SetupHostInternet is not null)
+ {
+ if (_state.HostingMode == HostingMode.LocalNetwork)
+ SetupHostLocal.IsChecked = true;
+ else
+ SetupHostInternet.IsChecked = true;
+ }
+
+ // Setup step 2 banner — surface the auto-detected install so the
+ // user knows the launcher already did the work.
+ if (!string.IsNullOrEmpty(_state.RecRoomPath))
+ {
+ SetupDetectedBanner.Visibility = Visibility.Visible;
+ SetupDetectedPath.Text = _state.RecRoomPath;
+ }
+ else
+ {
+ SetupDetectedBanner.Visibility = Visibility.Collapsed;
+ }
+
+ UpdateSetupPhotonStatus();
CachePathsText.Text =
$"State: {AppPaths.StateFile}\n" +
@@ -127,13 +167,7 @@ public partial class MainWindow : Window
$"Logs: {AppPaths.LogsRoot}";
RefreshJoinReadiness();
-
- ShowView(_state.Mode switch
- {
- AppMode.Host => "host",
- AppMode.Join => "join",
- _ => "first-run",
- });
+ ShowView(PickRoutingTarget());
}
private async Task RefreshManifestAsync()
@@ -172,7 +206,169 @@ public partial class MainWindow : Window
}
}
- // ── First-run wizard / mode picks ───────────────────────────────────
+ // ── Welcome + setup wizard ──────────────────────────────────────────
+ private int _setupStep = 1; // 1=mode, 2=install, 3=server, 4=photon, 5=done
+
+ private void OnWelcomeContinue(object sender, RoutedEventArgs e)
+ {
+ _state.WelcomeSeen = true;
+ _state.Save();
+ _setupStep = 1;
+ ShowView("setup");
+ }
+
+ private void OnSetupPickHost(object sender, RoutedEventArgs e)
+ {
+ _state.Mode = AppMode.Host;
+ _state.Save();
+ _setupStep = 2;
+ RenderSetupStep();
+ }
+
+ private void OnSetupPickJoin(object sender, RoutedEventArgs e)
+ {
+ _state.Mode = AppMode.Join;
+ _state.Save();
+ // Join mode only needs the install path — Photon/server config
+ // come from the host's join code, not the joiner.
+ _setupStep = 2;
+ RenderSetupStep();
+ }
+
+ private void OnSetupBack(object sender, RoutedEventArgs e)
+ {
+ if (_setupStep <= 1) return;
+ _setupStep--;
+ // For Join mode, skip the host-only steps (3 + 4) when walking back
+ // — there's nothing to show there.
+ if (_state.Mode == AppMode.Join && _setupStep is 3 or 4)
+ _setupStep = 2;
+ RenderSetupStep();
+ }
+
+ private void OnSetupNext(object sender, RoutedEventArgs e)
+ {
+ // Validate before advancing — surface a friendly nudge if the
+ // step's prereq isn't met.
+ switch (_setupStep)
+ {
+ case 2 when string.IsNullOrEmpty(_state.RecRoomPath):
+ ShowError("Pick your Rec Room install folder first.");
+ return;
+ case 3 when string.IsNullOrWhiteSpace(_state.ServerName):
+ ShowError("Give your server a name first.");
+ return;
+ case 4 when string.IsNullOrWhiteSpace(_state.PhotonAppId):
+ ShowError("A Realtime Photon AppId is required. Use the walkthrough button if you don't have one yet.");
+ return;
+ }
+
+ _setupStep++;
+ // Join mode skips host-only steps (3 = server name, 4 = Photon).
+ if (_state.Mode == AppMode.Join && _setupStep is 3 or 4)
+ _setupStep = 5;
+
+ if (_setupStep > 5)
+ {
+ _state.SetupComplete = true;
+ _state.Save();
+ ShowView(_state.Mode == AppMode.Join ? "join" : "host");
+ return;
+ }
+ RenderSetupStep();
+ }
+
+ private void RenderSetupStep()
+ {
+ SetupStep1.Visibility = _setupStep == 1 ? Visibility.Visible : Visibility.Collapsed;
+ SetupStep2.Visibility = _setupStep == 2 ? Visibility.Visible : Visibility.Collapsed;
+ SetupStep3.Visibility = _setupStep == 3 ? Visibility.Visible : Visibility.Collapsed;
+ SetupStep4.Visibility = _setupStep == 4 ? Visibility.Visible : Visibility.Collapsed;
+ SetupStepDone.Visibility = _setupStep == 5 ? Visibility.Visible : Visibility.Collapsed;
+
+ // Step indicator chip and totals — Join skips host-only steps so
+ // the "of N" denominator changes between modes. Join's step 5
+ // (done) is the third visible step, since 3 + 4 are skipped.
+ var total = _state.Mode == AppMode.Join ? 3 : 5;
+ var displayed = (_state.Mode == AppMode.Join && _setupStep == 5) ? 3 : _setupStep;
+ SetupStepIndicator.Text = $"STEP {displayed} OF {total}";
+
+ // Step 1 (mode pick) has no Back / Next — the cards self-advance.
+ SetupBackBtn.Visibility = _setupStep > 1 ? Visibility.Visible : Visibility.Collapsed;
+ SetupNextBtn.Visibility = _setupStep == 1 ? Visibility.Collapsed : Visibility.Visible;
+ SetupNextBtn.Content = _setupStep == 5 ? "Finish →" : "Next →";
+
+ // Refresh the install banner + Photon status each render — the
+ // user may have hit "Pick…" mid-wizard and we want the new path
+ // reflected immediately.
+ var path = _state.RecRoomPath ?? "(not picked)";
+ SetupRecRoomPath.Text = path;
+ SetupDetectedBanner.Visibility = string.IsNullOrEmpty(_state.RecRoomPath)
+ ? Visibility.Collapsed : Visibility.Visible;
+ if (!string.IsNullOrEmpty(_state.RecRoomPath)) SetupDetectedPath.Text = _state.RecRoomPath;
+ UpdateSetupPhotonStatus();
+ }
+
+ private void UpdateSetupPhotonStatus()
+ {
+ if (SetupPhotonStatus is null) return;
+ if (string.IsNullOrWhiteSpace(_state.PhotonAppId))
+ {
+ SetupPhotonStatus.Text = "Realtime AppId required before you can host.";
+ SetupPhotonStatus.Foreground = (System.Windows.Media.Brush)FindResource("InkFaint");
+ }
+ else
+ {
+ SetupPhotonStatus.Text = string.IsNullOrWhiteSpace(_state.PhotonVoiceAppId)
+ ? "✓ Realtime AppId set. (No voice — players can use Discord.)"
+ : "✓ Realtime + Voice AppIds set.";
+ SetupPhotonStatus.Foreground = (System.Windows.Media.Brush)FindResource("Ok");
+ }
+ }
+
+ private void OnSetupServerNameChanged(object sender, RoutedEventArgs e)
+ {
+ _state.ServerName = SetupServerName.Text.Trim();
+ ServerNameInput.Text = _state.ServerName;
+ _state.Save();
+ }
+
+ private void OnSetupHostingModeChanged(object sender, RoutedEventArgs e)
+ {
+ if (SetupHostLocal is null || SetupHostInternet is null) return;
+ _state.HostingMode = SetupHostLocal.IsChecked == true
+ ? HostingMode.LocalNetwork
+ : HostingMode.Internet;
+ _state.Save();
+ // Mirror to main HostView radios.
+ if (HostModeLocal is not null && HostModeInternet is not null)
+ {
+ if (_state.HostingMode == HostingMode.LocalNetwork) HostModeLocal.IsChecked = true;
+ else HostModeInternet.IsChecked = true;
+ }
+ }
+
+ private void OnSetupPhotonChanged(object sender, RoutedEventArgs e)
+ {
+ _state.PhotonAppId = SetupPhotonAppId.Text.Trim();
+ _state.PhotonVoiceAppId = SetupPhotonVoiceAppId.Text.Trim();
+ _state.Save();
+ // Mirror into the canonical Photon section so re-opening HostView
+ // doesn't show stale values.
+ PhotonAppIdInput.Text = _state.PhotonAppId;
+ PhotonVoiceAppIdInput.Text = _state.PhotonVoiceAppId;
+ UpdateSetupPhotonStatus();
+ }
+
+ private void OnRerunSetup(object sender, RoutedEventArgs e)
+ {
+ _state.SetupComplete = false;
+ _state.Save();
+ _setupStep = 1;
+ ShowView("setup");
+ }
+
+ // ── Legacy mode-pick handlers used by Settings -> Switch mode ──────
private void OnPickHost(object sender, RoutedEventArgs e)
{
_state.Mode = AppMode.Host;