launcher: Photon AppId setup walkthrough

The two empty "REALTIME APPID / VOICE APPID" textboxes assumed the
user already knew what Photon was, where to sign up, and which app
type to create. New 5-step wizard fills that gap:

1. What's Photon? — one-paragraph explanation tying back to DorkNet
2. Make an account — Open Dashboard button launches browser
3. Create a Realtime app — numbered click-by-click instructions
4. Create a Voice app — same, with a Skip button (voice is optional)
5. Paste your AppIds — text fields with live regex validation,
   region picker, Save commits to AppState

Hyphens optional on input; we normalise to canonical 8-4-4-4-12 form
before writing back. The existing Photon section keeps its three
fields for advanced users, with a "Setup walkthrough" button at the
top opening the wizard prefilled with current values.
This commit is contained in:
Alexa
2026-05-27 11:14:23 +03:00
parent 3aa0b55512
commit 7c974e39ab
5 changed files with 413 additions and 3 deletions
+9
View File
@@ -240,6 +240,15 @@
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
</Style>
<!-- Numbered-step bullet used in walkthrough text. -->
<Style x:Key="StepBullet" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="LineHeight" Value="22" />
<Setter Property="Margin" Value="0,0,0,6" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<!-- Pill-style "status chip" used in section headers. -->
<Style x:Key="StatusChip" TargetType="Border">
<Setter Property="Background" Value="{StaticResource BgElev2}" />
+9 -3
View File
@@ -174,14 +174,20 @@
<!-- Photon panel -->
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Photon AppIds" />
<Grid Margin="0,0,0,12">
<TextBlock Style="{StaticResource SectionTitle}"
Text="Photon AppIds" Margin="0" />
<Button HorizontalAlignment="Right" Style="{StaticResource Btn}"
Content="Setup walkthrough" Click="OnOpenPhotonWizard"
Padding="14,6" FontSize="12" />
</Grid>
<TextBlock Foreground="{StaticResource InkSoft}" FontSize="12"
Margin="0,-8,0,12" TextWrapping="Wrap">
Margin="0,0,0,12" TextWrapping="Wrap">
Photon Cloud powers in-room realtime sync. Free to register at
<Hyperlink NavigateUri="https://dashboard.photonengine.com"
Foreground="{StaticResource Brand}"
RequestNavigate="OnNavigate">dashboard.photonengine.com</Hyperlink>;
DorkNet never bundles one.
DorkNet never bundles one. First time? Click "Setup walkthrough" above.
</TextBlock>
<TextBlock Style="{StaticResource FieldLabel}" Text="REALTIME APPID" />
+16
View File
@@ -231,6 +231,22 @@ public partial class MainWindow : Window
_state.Save();
}
private void OnOpenPhotonWizard(object sender, RoutedEventArgs e)
{
var wiz = new PhotonWizard(_state.PhotonAppId, _state.PhotonVoiceAppId, _state.PhotonRegion)
{
Owner = this,
};
if (wiz.ShowDialog() == true && wiz.Result is not null)
{
_state.PhotonAppId = wiz.Result.RealtimeAppId;
_state.PhotonVoiceAppId = wiz.Result.VoiceAppId;
_state.PhotonRegion = wiz.Result.Region;
_state.Save();
ReflectStateInUi();
}
}
private void OnHostingModeChanged(object sender, RoutedEventArgs e)
{
// RadioButton.Checked fires twice on selection-change (once for
+190
View File
@@ -0,0 +1,190 @@
<Window x:Class="DorkNet.Launcher.PhotonWizard"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Photon setup"
Width="640" Height="560"
MinWidth="540" MinHeight="460"
WindowStartupLocation="CenterOwner"
Background="{StaticResource Bg}"
TextElement.Foreground="{StaticResource Ink}"
FontFamily="Segoe UI Variable, Segoe UI"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="ClearType"
ResizeMode="CanResize"
ShowInTaskbar="False">
<Grid>
<Grid.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#2A1E48" Offset="0" />
<GradientStop Color="#16102A" Offset="0.6" />
<GradientStop Color="#16102A" Offset="1" />
</LinearGradientBrush>
</Grid.Background>
<Grid Margin="28,22">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Header -->
<Grid Grid.Row="0" Margin="0,0,0,16">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Photon setup" FontSize="20" FontWeight="Bold"
Foreground="{StaticResource WordmarkBrush}" />
<Border Style="{StaticResource StatusChip}" Margin="14,0,0,0"
VerticalAlignment="Center">
<TextBlock x:Name="StepIndicator" FontSize="10" FontWeight="Bold"
Foreground="{StaticResource Brand}"
Text="STEP 1 OF 5" />
</Border>
</StackPanel>
<Button HorizontalAlignment="Right" Style="{StaticResource GhostBtn}"
Content="✕" Click="OnClose" FontSize="14" Padding="10,4" />
</Grid>
<!-- Step card — only one Visible at a time -->
<Border Grid.Row="1" Style="{StaticResource Panel}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid>
<!-- Step 1: What is Photon? -->
<StackPanel x:Name="Step1" Visibility="Visible">
<TextBlock Text="What's Photon?" FontSize="22" FontWeight="Bold"
Margin="0,0,0,16" />
<TextBlock Foreground="{StaticResource Ink}" FontSize="14"
TextWrapping="Wrap" LineHeight="22" Margin="0,0,0,12"
Text="Rec Room rooms run on Photon — a real-time service that keeps players in sync. DorkNet doesn't include one because Photon's free tier is plenty for friend groups and each host should use their own account." />
<TextBlock Foreground="{StaticResource Ink}" FontSize="14"
TextWrapping="Wrap" LineHeight="22" Margin="0,0,0,12"
Text="This wizard walks you through making a free Photon account, creating one (or two) apps, and pasting the keys back here. Takes about two minutes." />
<Border CornerRadius="10" Padding="14,12"
Background="{StaticResource BgDeeper}"
BorderBrush="{StaticResource Border}" BorderThickness="1"
Margin="0,8,0,0">
<TextBlock Foreground="{StaticResource InkSoft}" FontSize="12"
TextWrapping="Wrap"
Text="What you'll end up with: a 32-character &quot;AppId&quot; for realtime, and optionally a second one for voice chat. You'll paste both into the form at the end." />
</Border>
</StackPanel>
<!-- Step 2: Make an account -->
<StackPanel x:Name="Step2" Visibility="Collapsed">
<TextBlock Text="Make a Photon account" FontSize="22" FontWeight="Bold"
Margin="0,0,0,16" />
<TextBlock Foreground="{StaticResource Ink}" FontSize="14"
TextWrapping="Wrap" LineHeight="22" Margin="0,0,0,16"
Text="Click the button below to open the Photon dashboard. If you already have an account, sign in; otherwise hit &quot;Sign up&quot; and use any email + password." />
<Button Style="{StaticResource PrimaryBtn}"
Content="Open dashboard.photonengine.com"
Click="OnOpenPhotonDashboard"
HorizontalAlignment="Left" />
<TextBlock Foreground="{StaticResource InkSoft}" FontSize="13"
TextWrapping="Wrap" LineHeight="20" Margin="0,20,0,0"
Text="Photon won't ask for a credit card. The free plan covers 20 simultaneous players in a room, which is more than enough for most servers." />
<TextBlock Foreground="{StaticResource InkFaint}" FontSize="12"
TextWrapping="Wrap" LineHeight="18" Margin="0,12,0,0"
Text="Already signed in? Just click Next." />
</StackPanel>
<!-- Step 3: Create Realtime app -->
<StackPanel x:Name="Step3" Visibility="Collapsed">
<TextBlock Text="Create a Realtime app" FontSize="22" FontWeight="Bold"
Margin="0,0,0,16" />
<TextBlock Foreground="{StaticResource Ink}" FontSize="14"
TextWrapping="Wrap" LineHeight="22" Margin="0,0,0,14"
Text="On the Photon dashboard:" />
<StackPanel Margin="6,0,0,12">
<TextBlock Style="{StaticResource StepBullet}" Text="1. Click the orange &quot;Create a new app&quot; button (top right)." />
<TextBlock Style="{StaticResource StepBullet}" Text="2. Under &quot;Photon Type&quot; choose Photon Realtime." />
<TextBlock Style="{StaticResource StepBullet}" Text="3. Name it anything (e.g. &quot;DorkNet Realtime&quot;)." />
<TextBlock Style="{StaticResource StepBullet}" Text="4. Click Create." />
<TextBlock Style="{StaticResource StepBullet}" Text="5. Back on the dashboard, click the new app's tile — the AppId is the long string near the top, looking like 12ab34cd-56ef-..." />
<TextBlock Style="{StaticResource StepBullet}" Text="6. Copy that AppId. You'll paste it on the last step." />
</StackPanel>
<Border CornerRadius="10" Padding="14,12"
Background="{StaticResource CardBgHost}"
BorderBrush="{StaticResource Brand}" BorderThickness="1">
<TextBlock Foreground="{StaticResource Ink}" FontSize="13"
TextWrapping="Wrap"
Text="💡 You only need to do this once. The same AppId works every time you host." />
</Border>
</StackPanel>
<!-- Step 4: Voice app -->
<StackPanel x:Name="Step4" Visibility="Collapsed">
<TextBlock Text="Create a Voice app (optional)" FontSize="22" FontWeight="Bold"
Margin="0,0,0,16" />
<TextBlock Foreground="{StaticResource Ink}" FontSize="14"
TextWrapping="Wrap" LineHeight="22" Margin="0,0,0,14"
Text="Voice chat needs a separate Photon app. Skip this step if you don't care about voice." />
<StackPanel Margin="6,0,0,12">
<TextBlock Style="{StaticResource StepBullet}" Text="1. Back on the Photon dashboard, click &quot;Create a new app&quot; again." />
<TextBlock Style="{StaticResource StepBullet}" Text="2. This time pick Photon Voice." />
<TextBlock Style="{StaticResource StepBullet}" Text="3. Name it (e.g. &quot;DorkNet Voice&quot;) and Create." />
<TextBlock Style="{StaticResource StepBullet}" Text="4. Copy the Voice app's AppId — also a long 32-character string." />
</StackPanel>
<TextBlock Foreground="{StaticResource InkSoft}" FontSize="13"
TextWrapping="Wrap" LineHeight="20" Margin="0,8,0,0"
Text="Without voice, players can still chat via Discord or any other voice call. The room sync (movement, ink, gizmos) all runs through the Realtime app." />
</StackPanel>
<!-- Step 5: Paste -->
<StackPanel x:Name="Step5" Visibility="Collapsed">
<TextBlock Text="Paste your AppIds" FontSize="22" FontWeight="Bold"
Margin="0,0,0,16" />
<TextBlock Style="{StaticResource FieldLabel}" Text="REALTIME APPID" />
<TextBox x:Name="WizRealtime" Margin="0,0,0,4"
FontFamily="Cascadia Mono, Consolas" FontSize="12"
TextChanged="OnAppIdChanged" />
<TextBlock x:Name="WizRealtimeStatus" FontSize="11"
Foreground="{StaticResource InkFaint}"
Margin="0,0,0,12"
Text="32-character ID with hyphens, like 12ab34cd-56ef-78ab-90cd-12ef34ab56cd" />
<TextBlock Style="{StaticResource FieldLabel}" Text="VOICE APPID (LEAVE BLANK TO SKIP)" />
<TextBox x:Name="WizVoice" Margin="0,0,0,4"
FontFamily="Cascadia Mono, Consolas" FontSize="12"
TextChanged="OnAppIdChanged" />
<TextBlock x:Name="WizVoiceStatus" FontSize="11"
Foreground="{StaticResource InkFaint}"
Margin="0,0,0,12"
Text="Optional — leave empty if you skipped step 4." />
<TextBlock Style="{StaticResource FieldLabel}" Text="CLOUD REGION" />
<ComboBox x:Name="WizRegion">
<ComboBoxItem Content="US" Tag="us" />
<ComboBoxItem Content="EU" Tag="eu" />
<ComboBoxItem Content="Asia" Tag="asia" />
<ComboBoxItem Content="Japan" Tag="jp" />
<ComboBoxItem Content="South America" Tag="sa" />
<ComboBoxItem Content="Korea" Tag="kr" />
<ComboBoxItem Content="India" Tag="in" />
<ComboBoxItem Content="Australia" Tag="au" />
</ComboBox>
<TextBlock Foreground="{StaticResource InkFaint}" FontSize="11"
Margin="0,4,0,0" TextWrapping="Wrap"
Text="Pick the closest region to where most of your players live." />
</StackPanel>
</Grid>
</ScrollViewer>
</Border>
<!-- Footer with nav buttons -->
<Grid Grid.Row="2" Margin="0,16,0,0">
<Button x:Name="PrevBtn" HorizontalAlignment="Left"
Style="{StaticResource GhostBtn}"
Content="← Back" Click="OnPrev" Visibility="Collapsed" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="SkipBtn" Style="{StaticResource GhostBtn}"
Content="Skip" Click="OnNext" Visibility="Collapsed"
Margin="0,0,10,0" />
<Button x:Name="NextBtn" Style="{StaticResource PrimaryBtn}"
Content="Next →" Click="OnNext" />
</StackPanel>
</Grid>
</Grid>
</Grid>
</Window>
+189
View File
@@ -0,0 +1,189 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using DorkNet.Launcher.Backend;
namespace DorkNet.Launcher;
/// <summary>Five-step walkthrough for first-time Photon setup. Opens
/// dashboard.photonengine.com on step 2, walks the user through app
/// creation in steps 3–4, then collects + validates AppIds on step 5.
///
/// <para>Result lives in <see cref="Result"/> when <see cref="DialogResult"/>
/// is true. Caller (MainWindow) writes it back into AppState.</para></summary>
public partial class PhotonWizard : Window
{
public PhotonResult? Result { get; private set; }
// Photon AppIds are GUIDs — accept with or without hyphens; user
// gets confused if we're strict about formatting. We render the
// canonical form back into AppState.
private static readonly Regex AppIdRegex =
new("^[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}$",
RegexOptions.Compiled);
private readonly StackPanel[] _steps;
private int _stepIndex;
public PhotonWizard(string? existingRealtime, string? existingVoice, string? existingRegion)
{
InitializeComponent();
_steps = new[] { Step1, Step2, Step3, Step4, Step5 };
// Pre-fill so a user re-opening the wizard sees their existing
// values — they might just want to fix the region or swap apps.
WizRealtime.Text = existingRealtime ?? "";
WizVoice.Text = existingVoice ?? "";
SelectRegion(existingRegion ?? "us");
UpdateAppIdStatuses();
ShowStep(0);
}
private void ShowStep(int index)
{
_stepIndex = Math.Clamp(index, 0, _steps.Length - 1);
for (var i = 0; i < _steps.Length; i++)
_steps[i].Visibility = i == _stepIndex ? Visibility.Visible : Visibility.Collapsed;
StepIndicator.Text = $"STEP {_stepIndex + 1} OF {_steps.Length}";
PrevBtn.Visibility = _stepIndex == 0 ? Visibility.Collapsed : Visibility.Visible;
// Step 4 (Voice) is optional — surface a Skip button so the user
// doesn't feel stuck if they don't want voice chat. Final step
// swaps Next for a Save action.
SkipBtn.Visibility = _stepIndex == 3 ? Visibility.Visible : Visibility.Collapsed;
NextBtn.Content = _stepIndex == _steps.Length - 1 ? "Save" : "Next →";
}
private void OnPrev(object sender, RoutedEventArgs e) => ShowStep(_stepIndex - 1);
private void OnNext(object sender, RoutedEventArgs e)
{
if (_stepIndex < _steps.Length - 1)
{
ShowStep(_stepIndex + 1);
return;
}
TrySave();
}
private void OnClose(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
private void OnOpenPhotonDashboard(object sender, RoutedEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo("https://dashboard.photonengine.com")
{ UseShellExecute = true });
}
catch (Exception ex)
{
MessageBox.Show(this, "Could not open the dashboard: " + ex.Message,
"Photon setup", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private void OnAppIdChanged(object sender, TextChangedEventArgs e) => UpdateAppIdStatuses();
private void UpdateAppIdStatuses()
{
var realtime = WizRealtime.Text.Trim();
if (string.IsNullOrEmpty(realtime))
{
WizRealtimeStatus.Text = "32-character ID with hyphens, like 12ab34cd-56ef-78ab-90cd-12ef34ab56cd";
WizRealtimeStatus.Foreground = (System.Windows.Media.Brush)FindResource("InkFaint");
}
else if (AppIdRegex.IsMatch(realtime))
{
WizRealtimeStatus.Text = "✓ Looks like a valid AppId.";
WizRealtimeStatus.Foreground = (System.Windows.Media.Brush)FindResource("Ok");
}
else
{
WizRealtimeStatus.Text = "⚠ That doesn't look like a Photon AppId. Should be 32 hex characters (with or without hyphens).";
WizRealtimeStatus.Foreground = (System.Windows.Media.Brush)FindResource("Danger");
}
var voice = WizVoice.Text.Trim();
if (string.IsNullOrEmpty(voice))
{
WizVoiceStatus.Text = "Optional — leave empty if you skipped step 4.";
WizVoiceStatus.Foreground = (System.Windows.Media.Brush)FindResource("InkFaint");
}
else if (AppIdRegex.IsMatch(voice))
{
WizVoiceStatus.Text = "✓ Looks like a valid AppId.";
WizVoiceStatus.Foreground = (System.Windows.Media.Brush)FindResource("Ok");
}
else
{
WizVoiceStatus.Text = "⚠ That doesn't look like a Photon AppId.";
WizVoiceStatus.Foreground = (System.Windows.Media.Brush)FindResource("Danger");
}
}
private void TrySave()
{
var realtime = WizRealtime.Text.Trim();
var voice = WizVoice.Text.Trim();
if (string.IsNullOrEmpty(realtime))
{
MessageBox.Show(this,
"A Realtime AppId is required — without it nobody can connect to your server. Voice is optional.",
"Photon setup", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
if (!AppIdRegex.IsMatch(realtime))
{
MessageBox.Show(this,
"Your Realtime AppId doesn't look right. It should be 32 hex characters (with or without hyphens). Double-check that you copied the entire string from the Photon dashboard.",
"Photon setup", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
if (!string.IsNullOrEmpty(voice) && !AppIdRegex.IsMatch(voice))
{
MessageBox.Show(this,
"Your Voice AppId doesn't look right. Either fix it or clear the field to skip voice.",
"Photon setup", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var region = WizRegion.SelectedItem is ComboBoxItem item && item.Tag is not null
? item.Tag.ToString() ?? "us"
: "us";
Result = new PhotonResult(Normalise(realtime), Normalise(voice), region);
DialogResult = true;
Close();
}
private void SelectRegion(string tag)
{
foreach (var item in WizRegion.Items.OfType<ComboBoxItem>())
{
if (string.Equals(item.Tag?.ToString(), tag, StringComparison.OrdinalIgnoreCase))
{
item.IsSelected = true;
return;
}
}
}
/// <summary>Render the AppId in canonical 8-4-4-4-12 form regardless
/// of how the user pasted it — the server's Photon SDK accepts both
/// but normalising keeps the rest of the launcher tidy.</summary>
private static string Normalise(string appId)
{
if (string.IsNullOrEmpty(appId)) return "";
var stripped = appId.Replace("-", "").Trim();
if (stripped.Length != 32) return appId;
return $"{stripped[..8]}-{stripped[8..12]}-{stripped[12..16]}-{stripped[16..20]}-{stripped[20..]}";
}
}
public sealed record PhotonResult(string RealtimeAppId, string VoiceAppId, string Region);