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)