diff --git a/launcher/Backend/AppState.cs b/launcher/Backend/AppState.cs
index 1f3be86..f82eec7 100644
--- a/launcher/Backend/AppState.cs
+++ b/launcher/Backend/AppState.cs
@@ -14,9 +14,10 @@ public sealed class AppState
[JsonPropertyName("mode")]
public AppMode Mode { get; set; } = AppMode.Unset;
- /// Path to the user's Recroom_Release_Data folder. Picked
- /// via the folder dialog; never auto-detected per project policy
- /// (no Steam library scanning).
+ /// Path to the user's Recroom_Release_Data folder. First
+ /// run auto-fills via (scans
+ /// common manual-install paths only, never Steam library); user
+ /// can override via the folder dialog.
[JsonPropertyName("recRoomPath")]
public string? RecRoomPath { get; set; }
@@ -38,6 +39,14 @@ public sealed class AppState
[JsonPropertyName("photonRegion")]
public string PhotonRegion { get; set; } = "us";
+ /// How the host exposes the server: Internet uses
+ /// a Cloudflare quick-tunnel (works for friends anywhere with the
+ /// join code); LocalNetwork binds the LAN IP and skips the
+ /// tunnel entirely (no internet dependency, joiners must be on
+ /// the same network).
+ [JsonPropertyName("hostingMode")]
+ public HostingMode HostingMode { get; set; } = HostingMode.Internet;
+
/// Host's server name shown in the join code. Empty until
/// the user fills it in on first-run host setup.
[JsonPropertyName("serverName")]
@@ -81,3 +90,14 @@ public enum AppMode
Host,
Join,
}
+
+public enum HostingMode
+{
+ /// Default — Cloudflare quick-tunnel, public over the
+ /// internet, works for joiners anywhere.
+ Internet,
+ /// LAN only — bind the machine's local IP, skip the
+ /// tunnel, no internet dependency. Joiners must be on the same
+ /// network.
+ LocalNetwork,
+}
diff --git a/launcher/Backend/CloudflaredInstaller.cs b/launcher/Backend/CloudflaredInstaller.cs
new file mode 100644
index 0000000..3a95544
--- /dev/null
+++ b/launcher/Backend/CloudflaredInstaller.cs
@@ -0,0 +1,112 @@
+using System.IO;
+using System.Net.Http;
+
+namespace DorkNet.Launcher.Backend;
+
+/// Resolves a usable cloudflared.exe: prefers a
+/// side-by-side binary, falls back to one on PATH, otherwise downloads
+/// the latest official release from Cloudflare's GitHub mirror into
+/// and caches it there forever.
+///
+/// The cached binary lives at %LOCALAPPDATA%\DorkNet\cloudflared.exe.
+/// 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.
+///
+/// Download source: https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe
+/// — GitHub redirects this stable URL to the most-recent release's
+/// asset, so we never have to query the Releases API.
+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),
+ };
+
+ /// Returns the path to a usable cloudflared.exe. Downloads
+ /// + caches on first call if needed. Throws if download fails AND
+ /// nothing is found locally.
+ public static async Task EnsureAsync(
+ IProgress? 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;
+ }
+
+ /// Synchronous lookup of an already-installed cloudflared.exe
+ /// without touching the network. Returns null if not found anywhere.
+ 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? 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;
+ }
+ }
+}
diff --git a/launcher/Backend/LocalNetwork.cs b/launcher/Backend/LocalNetwork.cs
new file mode 100644
index 0000000..f3be0f6
--- /dev/null
+++ b/launcher/Backend/LocalNetwork.cs
@@ -0,0 +1,63 @@
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+
+namespace DorkNet.Launcher.Backend;
+
+/// 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.
+///
+/// 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.
+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,
+ };
+ }
+}
diff --git a/launcher/Backend/RecRoomPicker.cs b/launcher/Backend/RecRoomPicker.cs
index 126e9dc..37adb3d 100644
--- a/launcher/Backend/RecRoomPicker.cs
+++ b/launcher/Backend/RecRoomPicker.cs
@@ -24,6 +24,53 @@ public static class RecRoomPicker
return dlg.ShowDialog() == true ? dlg.FolderName : null;
}
+ /// Scan common manual-install paths and return the newest
+ /// valid *_Data 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.
+ 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();
+ }
+
/// 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
diff --git a/launcher/Backend/RecRoomVersionDetector.cs b/launcher/Backend/RecRoomVersionDetector.cs
new file mode 100644
index 0000000..f20eb1d
--- /dev/null
+++ b/launcher/Backend/RecRoomVersionDetector.cs
@@ -0,0 +1,174 @@
+using System.IO;
+
+namespace DorkNet.Launcher.Backend;
+
+/// 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 to pick the right server
+/// + patcher.
+///
+/// Three signals, tried in order of reliability:
+///
+/// - StreamingAssets\version.txt — when present in
+/// the install, it contains the canonical build string
+/// (e.g. 20201218). Fastest + most reliable.
+/// - Executable PE timestamp — 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.
+/// - File size of Recroom_Release.exe — 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).
+///
+public static class RecRoomVersionDetector
+{
+ /// Probe 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.
+ 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;
+ }
+
+ /// Pick the from
+ /// that matches the detected build, including the alt_builds
+ /// list (March 2020.03.10 also covers 2020.03.06, etc).
+ 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;
+ }
+
+ /// Normalize the various formats version.txt ships
+ /// in across builds. Rec Room has shipped 2020.12.18,
+ /// 20201218, and timestamped variants — flatten to the
+ /// dotted form the manifest uses (2020.12.18).
+ 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;
+ }
+
+ /// Known release dates for the builds the launcher
+ /// supports. Maps DATE → canonical build string.
+ 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;
+ }
+
+ /// Coarse file-size fingerprint. Each known build's
+ /// Recroom_Release.exe falls in a narrow size window — they
+ /// don't collide across versions. Update this table when we
+ /// onboard a new branch.
+ 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;
+ }
+
+ /// Read the PE COFF header timestamp field. Returns null
+ /// on any IO / parse failure.
+ 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);
diff --git a/launcher/Backend/Tunnel.cs b/launcher/Backend/Tunnel.cs
index 60ed522..b6d5faa 100644
--- a/launcher/Backend/Tunnel.cs
+++ b/launcher/Backend/Tunnel.cs
@@ -8,22 +8,23 @@ namespace DorkNet.Launcher.Backend;
/// host doesn't need to open ports / configure DNS. Each launch gets
/// a fresh random *.trycloudflare.com URL.
///
-/// Requires cloudflared.exe on PATH or alongside the
-/// launcher binary. If absent, returns a clear error and the host
-/// can fall back to LAN-only play.
+/// cloudflared.exe is resolved via
+/// , 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.
public sealed class Tunnel : IAsyncDisposable
{
private Process? _process;
private TaskCompletionSource? _urlReady;
public string? PublicUrl { get; private set; }
- public async Task StartAsync(int localPort, CancellationToken ct = default)
+ public async Task StartAsync(
+ int localPort,
+ IProgress? 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(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;
diff --git a/launcher/MainWindow.xaml b/launcher/MainWindow.xaml
index ba93d40..1c5c1bf 100644
--- a/launcher/MainWindow.xaml
+++ b/launcher/MainWindow.xaml
@@ -155,7 +155,19 @@
LostFocus="OnServerNameChanged" />
-
+
+
+
+
+
+
+
diff --git a/launcher/MainWindow.xaml.cs b/launcher/MainWindow.xaml.cs
index 2d72a47..9b8e323 100644
--- a/launcher/MainWindow.xaml.cs
+++ b/launcher/MainWindow.xaml.cs
@@ -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();
+ }
+
+ /// 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.
+ 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())
+ {
+ 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(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)