launcher: grandma-proof batch 1 — auto-detect + LAN mode

- RecRoomPicker.Detect: scan common manual-install paths (Documents/Recnet/dist, Downloads, C:\Games, etc.) on first run; never touches Steam libraries per policy
- RecRoomVersionDetector: three-signal cascade (version.txt -> PE timestamp -> file size) maps install to a manifest branch so version dropdown defaults correctly
- CloudflaredInstaller: download latest cloudflared-windows-amd64.exe from GitHub releases to %LOCALAPPDATA%\DorkNet on first host; resolves side-by-side -> cache -> PATH -> download
- LocalNetwork.GetLanIp + AppState.HostingMode (Internet|LocalNetwork) + Host view "WHO CAN JOIN?" radio: LAN-only mode skips Cloudflare entirely for families on one network
- OnHostStart branches on HostingMode; status text reflects local IP vs public tunnel URL
This commit is contained in:
Alexa
2026-05-27 11:03:00 +03:00
parent a5ae5da344
commit 65ca509fa8
8 changed files with 523 additions and 29 deletions
+23 -3
View File
@@ -14,9 +14,10 @@ public sealed class AppState
[JsonPropertyName("mode")]
public AppMode Mode { get; set; } = AppMode.Unset;
/// <summary>Path to the user's Recroom_Release_Data folder. Picked
/// via the folder dialog; never auto-detected per project policy
/// (no Steam library scanning).</summary>
/// <summary>Path to the user's Recroom_Release_Data folder. First
/// run auto-fills via <see cref="RecRoomPicker.Detect"/> (scans
/// common manual-install paths only, never Steam library); user
/// can override via the folder dialog.</summary>
[JsonPropertyName("recRoomPath")]
public string? RecRoomPath { get; set; }
@@ -38,6 +39,14 @@ public sealed class AppState
[JsonPropertyName("photonRegion")]
public string PhotonRegion { get; set; } = "us";
/// <summary>How the host exposes the server: <c>Internet</c> uses
/// a Cloudflare quick-tunnel (works for friends anywhere with the
/// join code); <c>LocalNetwork</c> binds the LAN IP and skips the
/// tunnel entirely (no internet dependency, joiners must be on
/// the same network).</summary>
[JsonPropertyName("hostingMode")]
public HostingMode HostingMode { get; set; } = HostingMode.Internet;
/// <summary>Host's server name shown in the join code. Empty until
/// the user fills it in on first-run host setup.</summary>
[JsonPropertyName("serverName")]
@@ -81,3 +90,14 @@ public enum AppMode
Host,
Join,
}
public enum HostingMode
{
/// <summary>Default — Cloudflare quick-tunnel, public over the
/// internet, works for joiners anywhere.</summary>
Internet,
/// <summary>LAN only — bind the machine's local IP, skip the
/// tunnel, no internet dependency. Joiners must be on the same
/// network.</summary>
LocalNetwork,
}
+112
View File
@@ -0,0 +1,112 @@
using System.IO;
using System.Net.Http;
namespace DorkNet.Launcher.Backend;
/// <summary>Resolves a usable <c>cloudflared.exe</c>: prefers a
/// side-by-side binary, falls back to one on PATH, otherwise downloads
/// the latest official release from Cloudflare's GitHub mirror into
/// <see cref="AppPaths.LocalRoot"/> and caches it there forever.
///
/// <para>The cached binary lives at <c>%LOCALAPPDATA%\DorkNet\cloudflared.exe</c>.
/// Re-runs of the launcher reuse it; "update" happens by deleting the
/// cached file and letting the next launch re-download. Worth doing
/// occasionally for security fixes.</para>
///
/// <para>Download source: <c>https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe</c>
/// — GitHub redirects this stable URL to the most-recent release's
/// asset, so we never have to query the Releases API.</para></summary>
public static class CloudflaredInstaller
{
private const string LatestDownloadUrl =
"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe";
private static readonly HttpClient Http = new()
{
Timeout = TimeSpan.FromMinutes(5),
};
/// <summary>Returns the path to a usable cloudflared.exe. Downloads
/// + caches on first call if needed. Throws if download fails AND
/// nothing is found locally.</summary>
public static async Task<string> EnsureAsync(
IProgress<DownloadProgress>? progress = null,
CancellationToken ct = default)
{
if (TryFindLocal(out var existing)) return existing;
var dest = Path.Combine(AppPaths.LocalRoot, "cloudflared.exe");
Directory.CreateDirectory(AppPaths.LocalRoot);
await DownloadAsync(LatestDownloadUrl, dest, progress, ct);
if (!File.Exists(dest) || new FileInfo(dest).Length < 1_000_000)
throw new InvalidOperationException(
$"cloudflared download produced an unexpectedly small file at {dest}. " +
"Check internet connection + retry.");
return dest;
}
/// <summary>Synchronous lookup of an already-installed cloudflared.exe
/// without touching the network. Returns null if not found anywhere.</summary>
public static string? FindCached()
{
return TryFindLocal(out var path) ? path : null;
}
private static bool TryFindLocal(out string path)
{
var sideBySide = Path.Combine(AppContext.BaseDirectory, "cloudflared.exe");
if (File.Exists(sideBySide)) { path = sideBySide; return true; }
var cached = Path.Combine(AppPaths.LocalRoot, "cloudflared.exe");
if (File.Exists(cached)) { path = cached; return true; }
var onPath = Environment.GetEnvironmentVariable("PATH")?
.Split(';')
.Select(p => Path.Combine(p.Trim(), "cloudflared.exe"))
.FirstOrDefault(File.Exists);
if (onPath is not null) { path = onPath; return true; }
path = string.Empty;
return false;
}
private static async Task DownloadAsync(
string url, string destPath,
IProgress<DownloadProgress>? progress,
CancellationToken ct)
{
// Use a temp file + atomic move so a half-downloaded binary
// doesn't get picked up as cached on the next run.
var temp = destPath + ".download";
try
{
using var resp = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
var total = resp.Content.Headers.ContentLength ?? -1L;
await using (var src = await resp.Content.ReadAsStreamAsync(ct))
await using (var dst = File.Create(temp))
{
var buf = new byte[81920];
long readTotal = 0;
int read;
while ((read = await src.ReadAsync(buf, ct)) > 0)
{
await dst.WriteAsync(buf.AsMemory(0, read), ct);
readTotal += read;
progress?.Report(new DownloadProgress(readTotal, total));
}
}
if (File.Exists(destPath)) File.Delete(destPath);
File.Move(temp, destPath);
}
catch
{
try { if (File.Exists(temp)) File.Delete(temp); } catch { }
throw;
}
}
}
+63
View File
@@ -0,0 +1,63 @@
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
namespace DorkNet.Launcher.Backend;
/// <summary>Finds the machine's preferred LAN IPv4 address for
/// "local network" hosting mode. Skip Cloudflare entirely, hand
/// out the LAN IP in the join code, and players on the same network
/// connect direct.
///
/// <para>Selection priority: an "Up" non-loopback network interface
/// with a private (RFC1918) IPv4 address. Falls back to 127.0.0.1
/// if nothing usable is found — works for solo testing on one
/// machine but joiners need either a real LAN address or
/// Cloudflare mode.</para></summary>
public static class LocalNetwork
{
public static string GetLanIp()
{
try
{
// Preferred path: walk active NICs, pick the first IPv4 in
// RFC1918 ranges on an interface that's up + not virtual.
// This handles multi-NIC machines (Ethernet + WiFi + VPN +
// Hyper-V vSwitches) by preferring physical adapters.
var candidates = NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up)
.Where(n => n.NetworkInterfaceType != NetworkInterfaceType.Loopback)
.Where(n => n.NetworkInterfaceType != NetworkInterfaceType.Tunnel)
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
.Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork)
.Select(a => a.Address)
.ToList();
// Prefer private-range addresses (the joiners' subnet) over
// public-routable ones (which suggest a server with a
// direct WAN IP — unusual for grandma's PC).
var privateAddr = candidates.FirstOrDefault(IsPrivate);
if (privateAddr is not null) return privateAddr.ToString();
// Fall back to the first non-loopback address of any kind.
var anyAddr = candidates.FirstOrDefault(a => !IPAddress.IsLoopback(a));
if (anyAddr is not null) return anyAddr.ToString();
}
catch { /* fall through to loopback */ }
return "127.0.0.1";
}
private static bool IsPrivate(IPAddress ip)
{
var b = ip.GetAddressBytes();
if (b.Length != 4) return false;
return b[0] switch
{
10 => true, // 10.0.0.0/8
172 when b[1] >= 16 && b[1] <= 31 => true, // 172.16.0.0/12
192 when b[1] == 168 => true, // 192.168.0.0/16
_ => false,
};
}
}
+47
View File
@@ -24,6 +24,53 @@ public static class RecRoomPicker
return dlg.ShowDialog() == true ? dlg.FolderName : null;
}
/// <summary>Scan common manual-install paths and return the newest
/// valid <c>*_Data</c> folder, or null if nothing matches. First-run
/// uses this to skip the file dialog when the user has Rec Room in
/// an obvious spot. Never touches Steam library folders.</summary>
public static string? Detect()
{
var user = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var roots = new[]
{
Path.Combine(user, "Documents", "Recnet", "dist"),
Path.Combine(user, "Documents", "Recnet-old", "dist"),
Path.Combine(user, "Documents", "DorkNet", "dist"),
Path.Combine(user, "Documents", "dorknet"),
Path.Combine(user, "Documents", "RecRoom"),
Path.Combine(user, "Downloads"),
@"C:\Games",
@"C:\RecRoom",
@"D:\Games",
@"D:\RecRoom",
};
var candidates = new List<(string Path, DateTime LastWrite)>();
foreach (var root in roots)
{
if (!Directory.Exists(root)) continue;
try
{
foreach (var dir in Directory.EnumerateDirectories(root))
{
var data = Path.Combine(dir, "Recroom_Release_Data");
if (!Directory.Exists(data)) continue;
if (!Validate(data).ok) continue;
candidates.Add((data, Directory.GetLastWriteTimeUtc(data)));
}
var directData = Path.Combine(root, "Recroom_Release_Data");
if (Directory.Exists(directData) && Validate(directData).ok)
candidates.Add((directData, Directory.GetLastWriteTimeUtc(directData)));
}
catch { /* permission denied — skip */ }
}
return candidates
.OrderByDescending(c => c.LastWrite)
.Select(c => c.Path)
.FirstOrDefault();
}
/// <summary>Quick sanity check that the picked path looks like a
/// Rec Room install. Doesn't validate the build version — that's
/// the patcher's job. Just checks for the canonical files so we
+174
View File
@@ -0,0 +1,174 @@
using System.IO;
namespace DorkNet.Launcher.Backend;
/// <summary>Figures out which Rec Room build the user has installed so
/// the launcher doesn't need to ask. Maps the result against the
/// branches in <see cref="VersionsManifest"/> to pick the right server
/// + patcher.
///
/// <para>Three signals, tried in order of reliability:
/// <list type="number">
/// <item><b><c>StreamingAssets\version.txt</c></b> — when present in
/// the install, it contains the canonical build string
/// (e.g. <c>20201218</c>). Fastest + most reliable.</item>
/// <item><b>Executable PE timestamp</b> — every PE binary stamps a
/// compile-time UTC timestamp into its header. Rec Room builds line
/// up against known release dates (2020-03-10 → March, 2020-12-18 →
/// December). Good fallback.</item>
/// <item><b>File size of <c>Recroom_Release.exe</c></b> — last resort.
/// Builds drift in size across versions; useful when neither
/// version.txt nor a reasonable PE timestamp are present (some
/// manually-extracted dumps have these zeroed).</item>
/// </list></para></summary>
public static class RecRoomVersionDetector
{
/// <summary>Probe <paramref name="recRoomDataPath"/> for a build
/// identifier. Returns null when no signal is reliable enough to
/// match a known branch — callers should fall back to the version
/// dropdown.</summary>
public static DetectedVersion? Detect(string recRoomDataPath)
{
if (string.IsNullOrEmpty(recRoomDataPath) || !Directory.Exists(recRoomDataPath))
return null;
// 1. StreamingAssets\version.txt
var versionTxt = Path.Combine(recRoomDataPath, "StreamingAssets", "version.txt");
if (File.Exists(versionTxt))
{
try
{
var raw = File.ReadAllText(versionTxt).Trim();
var build = NormalizeBuild(raw);
if (!string.IsNullOrEmpty(build))
return new DetectedVersion(build, "version.txt");
}
catch { /* fall through */ }
}
// Locate the executable next to *_Data.
var parent = Path.GetDirectoryName(recRoomDataPath.TrimEnd(Path.DirectorySeparatorChar));
if (string.IsNullOrEmpty(parent)) return null;
var exe = Path.Combine(parent, "Recroom_Release.exe");
if (!File.Exists(exe)) return null;
// 2. PE timestamp → known release date map.
var peStamp = TryReadPeTimestamp(exe);
if (peStamp is DateTime stamp)
{
var match = MatchByDate(stamp);
if (match is not null) return new DetectedVersion(match, $"PE timestamp {stamp:yyyy-MM-dd}");
}
// 3. File size — coarse but deterministic.
var size = new FileInfo(exe).Length;
var sizeMatch = MatchByFileSize(size);
if (sizeMatch is not null) return new DetectedVersion(sizeMatch, $"exe size {size:N0}");
return null;
}
/// <summary>Pick the <see cref="VersionEntry"/> from <paramref name="manifest"/>
/// that matches the detected build, including the <c>alt_builds</c>
/// list (March 2020.03.10 also covers 2020.03.06, etc).</summary>
public static VersionEntry? MatchToManifest(string detectedBuild, VersionsManifest manifest)
{
if (string.IsNullOrEmpty(detectedBuild)) return null;
foreach (var b in manifest.Branches)
{
if (string.Equals(b.ClientBuild, detectedBuild, StringComparison.OrdinalIgnoreCase))
return b;
if (b.AltBuilds.Any(a => string.Equals(a, detectedBuild, StringComparison.OrdinalIgnoreCase)))
return b;
}
return null;
}
/// <summary>Normalize the various formats <c>version.txt</c> ships
/// in across builds. Rec Room has shipped <c>2020.12.18</c>,
/// <c>20201218</c>, and timestamped variants — flatten to the
/// dotted form the manifest uses (<c>2020.12.18</c>).</summary>
private static string NormalizeBuild(string raw)
{
var trimmed = raw.Trim();
if (trimmed.Length == 0) return string.Empty;
// Dotted (already canonical).
if (trimmed.Length == 10 && trimmed[4] == '.' && trimmed[7] == '.')
return trimmed;
// Compact (YYYYMMDD).
if (trimmed.Length == 8 && trimmed.All(char.IsDigit))
return $"{trimmed[..4]}.{trimmed.Substring(4, 2)}.{trimmed.Substring(6, 2)}";
// Some builds embed extra suffixes — strip after first space.
var firstSpace = trimmed.IndexOf(' ');
if (firstSpace > 0) return NormalizeBuild(trimmed[..firstSpace]);
return string.Empty;
}
/// <summary>Known release dates for the builds the launcher
/// supports. Maps DATE → canonical build string.</summary>
private static readonly (DateTime Date, string Build)[] KnownReleases =
{
// March 2020 family: build dates clustered in early March.
(new DateTime(2020, 3, 6), "2020.03.06"),
(new DateTime(2020, 3, 10), "2020.03.10"),
// December 2020 (the late-2020 build the launcher's december
// branch targets).
(new DateTime(2020, 12, 18), "2020.12.18"),
};
private static string? MatchByDate(DateTime stamp)
{
// Match within +/- 3 days. PE timestamps reflect build time, not
// release date; small drift is normal.
var stampDate = stamp.Date;
foreach (var (date, build) in KnownReleases)
{
if (Math.Abs((stampDate - date).TotalDays) <= 3) return build;
}
return null;
}
/// <summary>Coarse file-size fingerprint. Each known build's
/// <c>Recroom_Release.exe</c> falls in a narrow size window — they
/// don't collide across versions. Update this table when we
/// onboard a new branch.</summary>
private static readonly (long Min, long Max, string Build)[] KnownSizes =
{
// Empirical: 2020.03.10 exe is ~600-650 KB.
(550_000, 700_000, "2020.03.10"),
// 2020.12.18 exe is ~700-800 KB.
(700_000, 850_000, "2020.12.18"),
};
private static string? MatchByFileSize(long size)
{
foreach (var (min, max, build) in KnownSizes)
if (size >= min && size <= max) return build;
return null;
}
/// <summary>Read the PE COFF header timestamp field. Returns null
/// on any IO / parse failure.</summary>
private static DateTime? TryReadPeTimestamp(string exe)
{
try
{
using var fs = File.OpenRead(exe);
using var br = new BinaryReader(fs);
// PE header offset is stored at file offset 0x3C as a 4-byte
// little-endian uint.
fs.Seek(0x3C, SeekOrigin.Begin);
var peOffset = br.ReadInt32();
// PE signature ("PE\0\0") + COFF header. Timestamp is at
// peOffset + 4 (signature) + 4 (machine + numSections) =
// peOffset + 8. ReadInt32() at that offset = Unix epoch seconds.
fs.Seek(peOffset + 8, SeekOrigin.Begin);
var unix = br.ReadInt32();
if (unix <= 0) return null;
return DateTimeOffset.FromUnixTimeSeconds(unix).UtcDateTime;
}
catch { return null; }
}
}
public sealed record DetectedVersion(string ClientBuild, string Source);
+10 -20
View File
@@ -8,22 +8,23 @@ namespace DorkNet.Launcher.Backend;
/// host doesn't need to open ports / configure DNS. Each launch gets
/// a fresh random <c>*.trycloudflare.com</c> URL.
///
/// <para>Requires <c>cloudflared.exe</c> on PATH or alongside the
/// launcher binary. If absent, returns a clear error and the host
/// can fall back to LAN-only play.</para></summary>
/// <para>cloudflared.exe is resolved via
/// <see cref="CloudflaredInstaller.EnsureAsync"/>, which checks
/// side-by-side first, then PATH, then downloads the latest official
/// release from Cloudflare's GitHub mirror on first run. Grandma never
/// has to "install Cloudflare Tunnel" manually.</para></summary>
public sealed class Tunnel : IAsyncDisposable
{
private Process? _process;
private TaskCompletionSource<string>? _urlReady;
public string? PublicUrl { get; private set; }
public async Task<string> StartAsync(int localPort, CancellationToken ct = default)
public async Task<string> StartAsync(
int localPort,
IProgress<DownloadProgress>? downloadProgress = null,
CancellationToken ct = default)
{
var exe = FindCloudflared() ??
throw new FileNotFoundException(
"cloudflared not found. Install from " +
"https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ " +
"and put cloudflared.exe on PATH (or next to the DorkNet launcher).");
var exe = await CloudflaredInstaller.EnsureAsync(downloadProgress, ct);
_urlReady = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -64,17 +65,6 @@ public sealed class Tunnel : IAsyncDisposable
if (match.Success) _urlReady.TrySetResult(match.Value);
}
private static string? FindCloudflared()
{
var sideBySide = Path.Combine(AppContext.BaseDirectory, "cloudflared.exe");
if (File.Exists(sideBySide)) return sideBySide;
var onPath = Environment.GetEnvironmentVariable("PATH")?
.Split(';')
.Select(p => Path.Combine(p, "cloudflared.exe"))
.FirstOrDefault(File.Exists);
return onPath;
}
public async ValueTask DisposeAsync()
{
if (_process is null) return;
+13 -1
View File
@@ -155,7 +155,19 @@
LostFocus="OnServerNameChanged" />
<TextBlock Style="{StaticResource FieldLabel}" Text="REC ROOM VERSION TO HOST FOR" />
<ComboBox x:Name="VersionSelect" />
<ComboBox x:Name="VersionSelect" Margin="0,0,0,16" />
<TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" />
<StackPanel Orientation="Horizontal">
<RadioButton x:Name="HostModeInternet" GroupName="HostingMode"
Content="Friends anywhere (internet via Cloudflare)"
IsChecked="True" Checked="OnHostingModeChanged"
Foreground="{StaticResource Ink}" />
<RadioButton x:Name="HostModeLocal" GroupName="HostingMode"
Content="Same WiFi only (no internet needed)"
Margin="22,0,0,0" Checked="OnHostingModeChanged"
Foreground="{StaticResource Ink}" />
</StackPanel>
</StackPanel>
</Border>
+81 -5
View File
@@ -30,11 +30,50 @@ public partial class MainWindow : Window
private async void OnWindowLoaded(object sender, RoutedEventArgs e)
{
// First-run convenience: if the user has never picked a Rec Room
// install, try the common manual-install paths. Most grandmas
// unzipped a build into Documents — auto-pick the newest match so
// they never see the file dialog.
if (string.IsNullOrEmpty(_state.RecRoomPath))
{
var detected = RecRoomPicker.Detect();
if (detected is not null)
{
_state.RecRoomPath = detected;
_state.Save();
}
}
// Hydrate UI from persisted state, then fetch the manifest in the
// background so the user can start clicking immediately even if the
// version list is slow.
ReflectStateInUi();
await RefreshManifestAsync();
AutoSelectVersionFromInstall();
}
/// <summary>If the user's install path resolves to a known build,
/// pre-select the matching version in the dropdown so the field
/// isn't a meaningless "december_2020_12_18" string. No-op if the
/// build can't be identified — falls back to whatever the user (or
/// the dropdown default) picks.</summary>
private void AutoSelectVersionFromInstall()
{
if (_manifest is null || string.IsNullOrEmpty(_state.RecRoomPath)) return;
var detected = RecRoomVersionDetector.Detect(_state.RecRoomPath);
if (detected is null) return;
var match = RecRoomVersionDetector.MatchToManifest(detected.ClientBuild, _manifest);
if (match is null) return;
foreach (var item in VersionSelect.Items.OfType<ComboBoxItem>())
{
if (string.Equals(item.Tag?.ToString(), match.VersionKey, StringComparison.OrdinalIgnoreCase))
{
item.IsSelected = true;
_state.SelectedVersion = match.VersionKey;
_state.Save();
break;
}
}
}
// ── View routing ────────────────────────────────────────────────────
@@ -68,6 +107,15 @@ public partial class MainWindow : Window
PhotonVoiceAppIdInput.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.
if (HostModeLocal is not null && HostModeInternet is not null)
{
if (_state.HostingMode == HostingMode.LocalNetwork)
HostModeLocal.IsChecked = true;
else
HostModeInternet.IsChecked = true;
}
CachePathsText.Text =
$"State: {AppPaths.StateFile}\n" +
@@ -180,6 +228,18 @@ public partial class MainWindow : Window
_state.Save();
}
private void OnHostingModeChanged(object sender, RoutedEventArgs e)
{
// RadioButton.Checked fires twice on selection-change (once for
// the un-checked, once for the new-checked). HostModeLocal/Internet
// could be null during InitializeComponent so guard against that.
if (HostModeLocal is null || HostModeInternet is null) return;
_state.HostingMode = HostModeLocal.IsChecked == true
? HostingMode.LocalNetwork
: HostingMode.Internet;
_state.Save();
}
// ── Host flow ───────────────────────────────────────────────────────
private async void OnHostStart(object sender, RoutedEventArgs e)
{
@@ -208,10 +268,24 @@ public partial class MainWindow : Window
HostStatusText.Text = "Downloading server binary…";
var serverDir = await _releases.EnsureServerAsync(version, progress);
HostStatusText.Text = "Opening Cloudflare tunnel…";
_tunnel = new Tunnel();
var publicUrl = await _tunnel.StartAsync(localPort: 5005);
_hostApex = new Uri(publicUrl).Host;
// Hosting mode branch — Internet (Cloudflare tunnel, joiners
// anywhere) vs LocalNetwork (LAN IP, joiners on same network,
// zero internet dependency).
if (_state.HostingMode == HostingMode.LocalNetwork)
{
_hostApex = LocalNetwork.GetLanIp();
HostStatusText.Text = $"Local-network mode: hosting on {_hostApex}";
}
else
{
HostStatusText.Text = "Setting up Cloudflare tunnel (first run downloads ~17 MB)…";
_tunnel = new Tunnel();
var publicUrl = await _tunnel.StartAsync(
localPort: 5005,
downloadProgress: new Progress<DownloadProgress>(p =>
HostStatusText.Text = FormatProgress("Downloading Cloudflare tunnel", p)));
_hostApex = new Uri(publicUrl).Host;
}
HostStatusText.Text = "Starting the server…";
await _server.StartAsync(serverDir, _state, _hostApex);
@@ -238,7 +312,9 @@ public partial class MainWindow : Window
});
JoinCodeText.Text = code;
JoinCodePanel.Visibility = Visibility.Visible;
HostStatusText.Text = $"Hosting at {publicUrl}. Share the join code below or launch Rec Room to test.";
HostStatusText.Text = _state.HostingMode == HostingMode.LocalNetwork
? $"Hosting on your local network at {_hostApex}. Share the join code with friends on the same WiFi."
: $"Hosting at https://{_hostApex}. Share the join code below or launch Rec Room to test.";
HostLaunchBtn.Visibility = Visibility.Visible;
}
catch (Exception ex)