mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user