Add Tunnelto hosting support

This commit is contained in:
Alexa
2026-05-27 18:26:39 +03:00
parent f9a40ba788
commit b6a0990afe
13 changed files with 498 additions and 83 deletions
+1
View File
@@ -5,6 +5,7 @@
**/dist/ **/dist/
**/.vite/ **/.vite/
**/build/ **/build/
local-releases/
# IDE # IDE
.vs/ .vs/
+26 -9
View File
@@ -40,13 +40,20 @@ public sealed class AppState
public string PhotonRegion { get; set; } = "us"; public string PhotonRegion { get; set; } = "us";
/// <summary>How the host exposes the server: <c>Internet</c> uses /// <summary>How the host exposes the server: <c>Internet</c> uses
/// a Cloudflare quick-tunnel (works for friends anywhere with the /// Tunnelto (works for friends anywhere with the join code);
/// join code); <c>LocalNetwork</c> binds the LAN IP and skips the /// <c>LocalNetwork</c> binds on the LAN and uses an sslip.io
/// tunnel entirely (no internet dependency, joiners must be on /// hostname in the join code (joiners must be on the same
/// the same network).</summary> /// network).</summary>
[JsonPropertyName("hostingMode")] [JsonPropertyName("hostingMode")]
public HostingMode HostingMode { get; set; } = HostingMode.Internet; public HostingMode HostingMode { get; set; } = HostingMode.Internet;
/// <summary>Provider-owned wildcard tunnel base for remote hosting
/// without Cloudflare, e.g. <c>dorknet.tunnelto.me</c>. The server
/// publishes service URLs under <c>api.{base}</c>,
/// <c>auth.{base}</c>, etc.</summary>
[JsonPropertyName("remoteWildcardApex")]
public string RemoteWildcardApex { get; set; } = string.Empty;
/// <summary>Host's server name shown in the join code. Empty until /// <summary>Host's server name shown in the join code. Empty until
/// the user fills it in on first-run host setup.</summary> /// the user fills it in on first-run host setup.</summary>
[JsonPropertyName("serverName")] [JsonPropertyName("serverName")]
@@ -65,6 +72,12 @@ public sealed class AppState
[JsonPropertyName("setupComplete")] [JsonPropertyName("setupComplete")]
public bool SetupComplete { get; set; } public bool SetupComplete { get; set; }
/// <summary>When true, Launch Rec Room passes <c>+forcemode:screen</c>
/// so the game opens in 2D desktop mode instead of looking for a
/// VR headset. Toggled from a checkbox next to the Launch button.</summary>
[JsonPropertyName("launchInScreenMode")]
public bool LaunchInScreenMode { get; set; }
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
WriteIndented = true, WriteIndented = true,
@@ -125,11 +138,15 @@ public enum AppMode
public enum HostingMode public enum HostingMode
{ {
/// <summary>Default — Cloudflare quick-tunnel, public over the /// <summary>Default — Tunnelto tunnel, public over the internet,
/// internet, works for joiners anywhere.</summary> /// works for joiners anywhere.</summary>
Internet, Internet,
/// <summary>LAN only — bind the machine's local IP, skip the /// <summary>LAN only — bind on all local interfaces, skip the
/// tunnel, no internet dependency. Joiners must be on the same /// tunnel, and publish an sslip.io hostname for the machine's LAN
/// network.</summary> /// IP. Joiners must be on the same network.</summary>
LocalNetwork, LocalNetwork,
/// <summary>Remote tunnel supplied by another provider. The user
/// provides a wildcard-capable provider hostname, and the launcher
/// uses service subdomains under that base.</summary>
RemoteWildcard,
} }
+91 -2
View File
@@ -45,6 +45,11 @@ public sealed class ClientPatcher
// Mod.cs hardcodes when reading its config. // Mod.cs hardcodes when reading its config.
private const string DefaultPluginDest = "Mods"; private const string DefaultPluginDest = "Mods";
private const string DefaultConfigDest = "MelonLoader/UserData/dorknet-clientmod.json"; private const string DefaultConfigDest = "MelonLoader/UserData/dorknet-clientmod.json";
private static readonly string[] BuiltInOldPluginPaths =
[
"BepInEx/plugins/DorkNet.ClientPatch.dll",
"MelonLoader/Mods/DorkNet.ClientMod.dll",
];
public Task<PatchResult> ApplyAsync( public Task<PatchResult> ApplyAsync(
string patcherDir, string patcherDir,
@@ -107,7 +112,9 @@ public sealed class ClientPatcher
} }
// 2. Remove stale plugin paths (e.g. a previous BepInEx-era DLL). // 2. Remove stale plugin paths (e.g. a previous BepInEx-era DLL).
foreach (var old in manifest.OldPluginPaths ?? Array.Empty<string>()) foreach (var old in BuiltInOldPluginPaths
.Concat(manifest.OldPluginPaths ?? Array.Empty<string>())
.Distinct(StringComparer.OrdinalIgnoreCase))
{ {
var stale = Path.Combine(recRoomRoot, old.Replace('/', Path.DirectorySeparatorChar)); var stale = Path.Combine(recRoomRoot, old.Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(stale)) if (File.Exists(stale))
@@ -123,8 +130,9 @@ public sealed class ClientPatcher
var dllSrc = Path.Combine(patcherDir, manifest.PluginDll); var dllSrc = Path.Combine(patcherDir, manifest.PluginDll);
if (!File.Exists(dllSrc)) if (!File.Exists(dllSrc))
return PatchResult.Failure($"plugin DLL listed in manifest but missing on disk: {dllSrc}"); return PatchResult.Failure($"plugin DLL listed in manifest but missing on disk: {dllSrc}");
var pluginDestRel = NormalizePluginDest(manifest.PluginDest, log);
var pluginDestDir = Path.Combine(recRoomRoot, var pluginDestDir = Path.Combine(recRoomRoot,
(manifest.PluginDest ?? DefaultPluginDest).Replace('/', Path.DirectorySeparatorChar)); pluginDestRel.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(pluginDestDir); Directory.CreateDirectory(pluginDestDir);
var dllDest = Path.Combine(pluginDestDir, Path.GetFileName(manifest.PluginDll)); var dllDest = Path.Combine(pluginDestDir, Path.GetFileName(manifest.PluginDll));
File.Copy(dllSrc, dllDest, overwrite: true); File.Copy(dllSrc, dllDest, overwrite: true);
@@ -214,6 +222,8 @@ public sealed class ClientPatcher
log.AppendLine($"[patch] wrote config: {Path.GetFileName(configDest)}"); log.AppendLine($"[patch] wrote config: {Path.GetFileName(configDest)}");
} }
PatchNamespaceBootstrap(recRoomRoot, log);
log.AppendLine("[patch] done."); log.AppendLine("[patch] done.");
return PatchResult.Success(log.ToString()); return PatchResult.Success(log.ToString());
} }
@@ -225,6 +235,85 @@ public sealed class ClientPatcher
} }
} }
private static string NormalizePluginDest(string? pluginDest, System.Text.StringBuilder log)
{
var dest = string.IsNullOrWhiteSpace(pluginDest) ? DefaultPluginDest : pluginDest;
if (string.Equals(dest.Replace('\\', '/'), "MelonLoader/Mods", StringComparison.OrdinalIgnoreCase))
{
log.AppendLine("[patch] manifest uses legacy MelonLoader/Mods destination; installing to top-level Mods instead");
return DefaultPluginDest;
}
return dest;
}
private static void PatchNamespaceBootstrap(string recRoomRoot, System.Text.StringBuilder log)
{
var metadata = Path.Combine(recRoomRoot,
"Recroom_Release_Data", "il2cpp_data", "Metadata", "global-metadata.dat");
if (!File.Exists(metadata))
{
log.AppendLine("[patch] namespace bootstrap skipped: global-metadata.dat not found");
return;
}
var original = "https://ns.rec.net/?v=2"u8.ToArray();
var patched = "http://127.0.0.1:0080/#"u8.ToArray();
var legacyPatched = "http://127.0.0.1:5005/#"u8.ToArray();
if (original.Length != patched.Length)
throw new InvalidOperationException("namespace bootstrap patch length mismatch");
if (legacyPatched.Length != patched.Length)
throw new InvalidOperationException("legacy namespace bootstrap patch length mismatch");
var bytes = File.ReadAllBytes(metadata);
var patchOffset = IndexOf(bytes, patched);
if (patchOffset >= 0)
{
log.AppendLine("[patch] namespace bootstrap already points at local server");
return;
}
var offset = IndexOf(bytes, original);
var wasLegacyPatch = false;
if (offset < 0)
{
offset = IndexOf(bytes, legacyPatched);
wasLegacyPatch = offset >= 0;
if (offset < 0)
{
log.AppendLine("[patch] namespace bootstrap skipped: original ns.rec.net literal not found");
return;
}
}
var backup = metadata + ".dorknet-original";
if (!File.Exists(backup) && !wasLegacyPatch)
File.Copy(metadata, backup);
Buffer.BlockCopy(patched, 0, bytes, offset, patched.Length);
File.WriteAllBytes(metadata, bytes);
log.AppendLine(wasLegacyPatch
? "[patch] namespace bootstrap: http://127.0.0.1:5005/# -> http://127.0.0.1:0080/#"
: "[patch] namespace bootstrap: https://ns.rec.net/?v=2 -> http://127.0.0.1:0080/#");
}
private static int IndexOf(byte[] haystack, byte[] needle)
{
if (needle.Length == 0 || haystack.Length < needle.Length) return -1;
for (var i = 0; i <= haystack.Length - needle.Length; i++)
{
var matched = true;
for (var j = 0; j < needle.Length; j++)
{
if (haystack[i + j] == needle[j]) continue;
matched = false;
break;
}
if (matched) return i;
}
return -1;
}
/// <summary>Extracts a zip on top of an existing directory, replacing /// <summary>Extracts a zip on top of an existing directory, replacing
/// any files that collide. <see cref="ZipFile.ExtractToDirectory"/> /// any files that collide. <see cref="ZipFile.ExtractToDirectory"/>
/// throws on existing files; we walk entries ourselves. Also guards /// throws on existing files; we walk entries ourselves. Also guards
+20 -21
View File
@@ -61,7 +61,7 @@ public static class ErrorTranslator
private static FriendlyError? TryMatch(Exception ex) private static FriendlyError? TryMatch(Exception ex)
{ {
// Network — GitHub releases, Cloudflare quick-tunnel API, etc. // Network — GitHub releases, Tunnelto tunnel API, etc.
if (ex is HttpRequestException http) if (ex is HttpRequestException http)
{ {
return new FriendlyError( return new FriendlyError(
@@ -88,6 +88,23 @@ public static class ErrorTranslator
"Retry"); "Retry");
} }
// Tunnelto specifics
if (ex.Message.Contains("tunnelto", StringComparison.OrdinalIgnoreCase))
{
if (ex is FileNotFoundException)
{
return new FriendlyError(
"Tunnelto isn't installed.",
TrimToSentence(ex.Message),
"Retry");
}
return new FriendlyError(
"Tunnelto tunnel didn't start.",
TrimToSentence(ex.Message) +
" Check that Tunnelto is signed in and that the base host is available, then retry.",
"Retry");
}
// File-system — locked DLL, missing install path, etc. // File-system — locked DLL, missing install path, etc.
if (ex is UnauthorizedAccessException) if (ex is UnauthorizedAccessException)
{ {
@@ -113,32 +130,14 @@ public static class ErrorTranslator
"Pick folder again"); "Pick folder again");
} }
// cloudflared specifics
if (ex is InvalidOperationException inv &&
inv.Message.Contains("cloudflared", StringComparison.OrdinalIgnoreCase))
{
if (inv.Message.Contains("unexpectedly small", StringComparison.OrdinalIgnoreCase))
{
return new FriendlyError(
"cloudflared download was interrupted.",
"The launcher needs to download a small (~17 MB) Cloudflare helper on first run. Try again — if it keeps failing, switch to \"Same WiFi only\" mode under WHO CAN JOIN.",
"Retry");
}
return new FriendlyError(
"Cloudflare tunnel didn't start.",
TrimToSentence(inv.Message) +
" Try again, or switch to \"Same WiFi only\" mode if you only need LAN players.",
"Retry");
}
// Server port already in use // Server port already in use
if (ex.Message.Contains("address already in use", StringComparison.OrdinalIgnoreCase) || if (ex.Message.Contains("address already in use", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("Only one usage of each socket address", ex.Message.Contains("Only one usage of each socket address",
StringComparison.OrdinalIgnoreCase)) StringComparison.OrdinalIgnoreCase))
{ {
return new FriendlyError( return new FriendlyError(
"Port 5005 is already in use.", "Server port is already in use.",
"Another DorkNet server (or some other app) is already listening on port 5005. Close it and retry, or reboot.", "Another DorkNet server (or some other app) is already listening on the required port. Close it and retry, or reboot.",
"Retry"); "Retry");
} }
+28 -2
View File
@@ -6,8 +6,8 @@ namespace DorkNet.Launcher.Backend;
/// <summary>Finds the machine's preferred LAN IPv4 address for /// <summary>Finds the machine's preferred LAN IPv4 address for
/// "local network" hosting mode. Skip Cloudflare entirely, hand /// "local network" hosting mode. Skip Cloudflare entirely, hand
/// out the LAN IP in the join code, and players on the same network /// out an sslip.io hostname derived from the LAN IP in the join code,
/// connect direct. /// and players on the same network connect direct.
/// ///
/// <para>Selection priority: an "Up" non-loopback network interface /// <para>Selection priority: an "Up" non-loopback network interface
/// with a private (RFC1918) IPv4 address. Falls back to 127.0.0.1 /// with a private (RFC1918) IPv4 address. Falls back to 127.0.0.1
@@ -16,6 +16,8 @@ namespace DorkNet.Launcher.Backend;
/// Cloudflare mode.</para></summary> /// Cloudflare mode.</para></summary>
public static class LocalNetwork public static class LocalNetwork
{ {
private const string SslipDomain = "sslip.io";
public static string GetLanIp() public static string GetLanIp()
{ {
try try
@@ -48,6 +50,28 @@ public static class LocalNetwork
return "127.0.0.1"; return "127.0.0.1";
} }
/// <summary>Turns a LAN IPv4 address into an sslip.io apex host.
/// For example, <c>192.168.1.25</c> becomes
/// <c>192-168-1-25.sslip.io</c>. Keeping the IP in one DNS label
/// makes derived hosts like <c>api.192-168-1-25.sslip.io</c> and
/// <c>cdn.192-168-1-25.sslip.io</c> resolve cleanly too.</summary>
public static string ToSslipHost(string lanIp)
{
if (IPAddress.TryParse(lanIp, out var parsed) &&
parsed.AddressFamily == AddressFamily.InterNetwork)
{
return $"{parsed.ToString().Replace('.', '-')}.{SslipDomain}";
}
return lanIp;
}
public static LocalNetworkAddress GetLanAddress()
{
var ip = GetLanIp();
return new LocalNetworkAddress(ip, ToSslipHost(ip));
}
private static bool IsPrivate(IPAddress ip) private static bool IsPrivate(IPAddress ip)
{ {
var b = ip.GetAddressBytes(); var b = ip.GetAddressBytes();
@@ -61,3 +85,5 @@ public static class LocalNetwork
}; };
} }
} }
public sealed record LocalNetworkAddress(string Ip, string Host);
+9 -2
View File
@@ -48,8 +48,12 @@ public static class LocalOverrides
public static string? GetLocalReleasesRoot() public static string? GetLocalReleasesRoot()
{ {
var path = Environment.GetEnvironmentVariable(ReleasesEnvVar); var path = Environment.GetEnvironmentVariable(ReleasesEnvVar);
if (string.IsNullOrEmpty(path)) return null; if (!string.IsNullOrEmpty(path)) return Directory.Exists(path) ? path : null;
return Directory.Exists(path) ? path : null;
var sideBySide = Path.Combine(AppContext.BaseDirectory, "local-releases");
if (Directory.Exists(sideBySide)) return sideBySide;
return null;
} }
/// <summary>Returns the local server-dir for <paramref name="versionKey"/> /// <summary>Returns the local server-dir for <paramref name="versionKey"/>
@@ -82,6 +86,9 @@ public static class LocalOverrides
var parts = new List<string>(); var parts = new List<string>();
Describe(ManifestEnvVar, isDir: false, parts); Describe(ManifestEnvVar, isDir: false, parts);
Describe(ReleasesEnvVar, isDir: true, parts); Describe(ReleasesEnvVar, isDir: true, parts);
var sideBySide = Path.Combine(AppContext.BaseDirectory, "local-releases");
if (Directory.Exists(sideBySide))
parts.Add($"{ReleasesEnvVar} -> {sideBySide} [side-by-side fallback]");
return string.Join("\n", parts); return string.Join("\n", parts);
} }
+5 -2
View File
@@ -10,8 +10,10 @@ public static class RecRoomLauncher
{ {
/// <summary>The picked install path is the <c>Recroom_Release_Data</c> /// <summary>The picked install path is the <c>Recroom_Release_Data</c>
/// folder; the executable lives at <c>&lt;parent&gt;\Recroom_Release.exe</c>. /// folder; the executable lives at <c>&lt;parent&gt;\Recroom_Release.exe</c>.
/// Returns true if Process.Start succeeded.</summary> /// Returns true if Process.Start succeeded. <paramref name="screenMode"/>
public static bool TryLaunch(string recRoomDataPath, out string error) /// passes Rec Room's <c>+forcemode:screen</c> arg so the game opens
/// in 2D desktop mode instead of trying to bind a headset.</summary>
public static bool TryLaunch(string recRoomDataPath, bool screenMode, out string error)
{ {
error = ""; error = "";
if (string.IsNullOrEmpty(recRoomDataPath)) if (string.IsNullOrEmpty(recRoomDataPath))
@@ -39,6 +41,7 @@ public static class RecRoomLauncher
FileName = exe, FileName = exe,
WorkingDirectory = parent, WorkingDirectory = parent,
UseShellExecute = true, UseShellExecute = true,
Arguments = screenMode ? "+forcemode:screen" : "",
}); });
return true; return true;
} }
+50 -1
View File
@@ -1,6 +1,7 @@
using System.IO; using System.IO;
using System.Diagnostics; using System.Diagnostics;
using System.Text; using System.Text;
using Microsoft.Data.Sqlite;
namespace DorkNet.Launcher.Backend; namespace DorkNet.Launcher.Backend;
@@ -9,6 +10,9 @@ namespace DorkNet.Launcher.Backend;
/// previous one is killed before starting a new one.</summary> /// previous one is killed before starting a new one.</summary>
public sealed class ServerProcess : IAsyncDisposable public sealed class ServerProcess : IAsyncDisposable
{ {
public const int DefaultLocalPort = 5005;
public const int LanPort = 80;
private Process? _process; private Process? _process;
private readonly StringBuilder _stderrBuffer = new(); private readonly StringBuilder _stderrBuffer = new();
public string? StdoutLogPath { get; private set; } public string? StdoutLogPath { get; private set; }
@@ -21,12 +25,14 @@ public sealed class ServerProcess : IAsyncDisposable
string serverDir, string serverDir,
AppState state, AppState state,
string apex, string apex,
HostingMode hostingMode,
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (IsRunning) if (IsRunning)
throw new InvalidOperationException("A server is already running; stop it first."); throw new InvalidOperationException("A server is already running; stop it first.");
var exe = FindServerExecutable(serverDir); var exe = FindServerExecutable(serverDir);
ApplySqliteCompatibilityPatches(serverDir);
StdoutLogPath = Path.Combine(AppPaths.LogsRoot, $"server-{DateTime.UtcNow:yyyyMMdd-HHmmss}.log"); StdoutLogPath = Path.Combine(AppPaths.LogsRoot, $"server-{DateTime.UtcNow:yyyyMMdd-HHmmss}.log");
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
@@ -39,7 +45,9 @@ public sealed class ServerProcess : IAsyncDisposable
RedirectStandardError = true, RedirectStandardError = true,
}; };
psi.Environment["ASPNETCORE_ENVIRONMENT"] = "Development"; psi.Environment["ASPNETCORE_ENVIRONMENT"] = "Development";
psi.Environment["ASPNETCORE_URLS"] = "http://127.0.0.1:5005"; psi.Environment["ASPNETCORE_URLS"] = hostingMode == HostingMode.LocalNetwork
? $"http://0.0.0.0:{LanPort}"
: $"http://127.0.0.1:{DefaultLocalPort}";
psi.Environment["Database__Provider"] = "sqlite"; psi.Environment["Database__Provider"] = "sqlite";
psi.Environment["Jwt__Secret"] = EnsureJwtSecret(); psi.Environment["Jwt__Secret"] = EnsureJwtSecret();
psi.Environment["Photon__AppId"] = state.PhotonAppId; psi.Environment["Photon__AppId"] = state.PhotonAppId;
@@ -47,6 +55,13 @@ public sealed class ServerProcess : IAsyncDisposable
string.IsNullOrEmpty(state.PhotonVoiceAppId) ? state.PhotonAppId : state.PhotonVoiceAppId; string.IsNullOrEmpty(state.PhotonVoiceAppId) ? state.PhotonAppId : state.PhotonVoiceAppId;
psi.Environment["Photon__CloudRegion"] = state.PhotonRegion; psi.Environment["Photon__CloudRegion"] = state.PhotonRegion;
psi.Environment["Domain__Apex"] = apex; psi.Environment["Domain__Apex"] = apex;
psi.Environment["Domain__Scheme"] = hostingMode == HostingMode.LocalNetwork ? "http" : "https";
psi.Environment["Domain__Port"] = "";
AppendLog($"[launcher] serverDir={serverDir}");
AppendLog($"[launcher] exe={exe}");
AppendLog($"[launcher] urls={psi.Environment["ASPNETCORE_URLS"]}");
AppendLog($"[launcher] domain apex={apex}, scheme={psi.Environment["Domain__Scheme"]}, port={psi.Environment["Domain__Port"]}");
_process = new Process { StartInfo = psi, EnableRaisingEvents = true }; _process = new Process { StartInfo = psi, EnableRaisingEvents = true };
_process.OutputDataReceived += (_, e) => AppendLog(e.Data); _process.OutputDataReceived += (_, e) => AppendLog(e.Data);
@@ -100,6 +115,40 @@ public sealed class ServerProcess : IAsyncDisposable
return any; return any;
} }
private static void ApplySqliteCompatibilityPatches(string serverDir)
{
var dbPath = Path.Combine(serverDir, "data", "dorknet.db");
if (!File.Exists(dbPath)) return;
using var conn = new SqliteConnection($"Data Source={dbPath}");
conn.Open();
AddSqliteColumnIfMissing(conn, "Rooms", "HiddenFromBrowse",
@"""HiddenFromBrowse"" INTEGER NOT NULL DEFAULT 0");
}
private static void AddSqliteColumnIfMissing(
SqliteConnection conn,
string table,
string column,
string definition)
{
var tableSql = table.Replace("\"", "\"\"");
using (var check = conn.CreateCommand())
{
check.CommandText = $"PRAGMA table_info(\"{tableSql}\");";
using var reader = check.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase))
return;
}
}
using var alter = conn.CreateCommand();
alter.CommandText = $"ALTER TABLE \"{tableSql}\" ADD COLUMN {definition};";
alter.ExecuteNonQuery();
}
/// <summary>JWT signing key — random per-machine, persisted under /// <summary>JWT signing key — random per-machine, persisted under
/// LocalAppData so re-launches keep the same key (so existing /// LocalAppData so re-launches keep the same key (so existing
/// tokens stay valid).</summary> /// tokens stay valid).</summary>
+7 -2
View File
@@ -72,10 +72,15 @@ public enum StepState
/// obvious what stages a user will see.</summary> /// obvious what stages a user will see.</summary>
public static class StartupFlow public static class StartupFlow
{ {
public static ObservableCollection<StartupStep> NewHostFlow(bool isLocalNetwork) => new() public static ObservableCollection<StartupStep> NewHostFlow(HostingMode mode) => new()
{ {
new StartupStep("Download server binary"), new StartupStep("Download server binary"),
new StartupStep(isLocalNetwork ? "Find your local network address" : "Set up Cloudflare tunnel"), new StartupStep(mode switch
{
HostingMode.LocalNetwork => "Set up local sslip.io address",
HostingMode.RemoteWildcard => "Set up Tunnelto tunnel",
_ => "Set up Tunnelto tunnel",
}),
new StartupStep("Start the server"), new StartupStep("Start the server"),
new StartupStep("Download client patcher"), new StartupStep("Download client patcher"),
new StartupStep("Unlock Rec Room from Steam (if needed)"), new StartupStep("Unlock Rec Room from Steam (if needed)"),
+123
View File
@@ -0,0 +1,123 @@
using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
namespace DorkNet.Launcher.Backend;
/// <summary>Wraps Tunnelto for remote hosting with provider-owned
/// subdomains. The user supplies a wildcard-capable base such as
/// <c>dorknet.tunnelto.me</c>; the server then publishes
/// <c>api.dorknet.tunnelto.me</c>, <c>auth.dorknet.tunnelto.me</c>,
/// and friends.</summary>
public sealed class TunneltoTunnel : IAsyncDisposable
{
private Process? _process;
private TaskCompletionSource<string>? _urlReady;
public string? PublicUrl { get; private set; }
public async Task<string> StartAsync(
string requestedHost,
int localPort,
CancellationToken ct = default)
{
var exe = ResolveExecutable();
var tunnelHost = NormalizeHost(requestedHost);
if (string.IsNullOrWhiteSpace(tunnelHost))
throw new InvalidOperationException("Enter a Tunnelto base host first, e.g. dorknet.tunnelto.me.");
_urlReady = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var psi = new ProcessStartInfo
{
FileName = exe,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
psi.ArgumentList.Add("add");
psi.ArgumentList.Add(tunnelHost);
psi.ArgumentList.Add(localPort.ToString());
_process = Process.Start(psi) ??
throw new InvalidOperationException("Failed to spawn tunnelto.exe");
_process.OutputDataReceived += (_, e) => ParseLine(e.Data);
_process.ErrorDataReceived += (_, e) => ParseLine(e.Data);
_process.BeginOutputReadLine();
_process.BeginErrorReadLine();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(45));
PublicUrl = await _urlReady.Task.WaitAsync(timeoutCts.Token);
return PublicUrl;
}
public static string NormalizeHost(string value)
{
var host = value.Trim();
if (host.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
host.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
host = new Uri(host).Host;
}
host = host.Trim().TrimEnd('/').ToLowerInvariant();
if (host.StartsWith("*.", StringComparison.Ordinal)) host = host[2..];
return host;
}
public static string GenerateBaseHost()
{
var bytes = RandomNumberGenerator.GetBytes(8);
var token = Convert.ToHexString(bytes).ToLowerInvariant();
return $"dorknet-{token}.tunnelto.me";
}
private static string ResolveExecutable()
{
var sideBySide = Path.Combine(AppContext.BaseDirectory, "tunnelto.exe");
if (File.Exists(sideBySide)) return sideBySide;
var local = Path.Combine(AppPaths.LocalRoot, "tunnelto.exe");
if (File.Exists(local)) return local;
var scoopShim = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"scoop", "shims", "tunnelto.exe");
if (File.Exists(scoopShim)) return scoopShim;
var onPath = Environment.GetEnvironmentVariable("PATH")?
.Split(';')
.Select(p => Path.Combine(p.Trim(), "tunnelto.exe"))
.FirstOrDefault(File.Exists);
if (onPath is not null) return onPath;
throw new FileNotFoundException(
"tunnelto.exe was not found. Install Tunnelto first: " +
"scoop bucket add tunnelto https://github.com/asabi/scoop-tunnelto && scoop install tunnelto");
}
private static readonly Regex UrlRegex = new(
@"https://(?!dashboard\.)(?:[a-z0-9][a-z0-9\-]*\.)+tunnelto\.me",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private void ParseLine(string? line)
{
if (line is null || _urlReady is null || _urlReady.Task.IsCompleted) return;
var match = UrlRegex.Match(line);
if (match.Success) _urlReady.TrySetResult(match.Value);
}
public async ValueTask DisposeAsync()
{
if (_process is null) return;
try
{
if (!_process.HasExited) _process.Kill(entireProcessTree: true);
await _process.WaitForExitAsync();
}
catch { }
_process.Dispose();
}
}
+5
View File
@@ -25,6 +25,7 @@
module bytes, MainWindow draws it onto a WPF Image. No System.Drawing module bytes, MainWindow draws it onto a WPF Image. No System.Drawing
dependency that way, so it stays portable. --> dependency that way, so it stays portable. -->
<PackageReference Include="QRCoder" Version="1.6.0" /> <PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.4" />
</ItemGroup> </ItemGroup>
<!-- Dev convenience: copy the repo-root versions.json next to dorknet.exe <!-- Dev convenience: copy the repo-root versions.json next to dorknet.exe
@@ -38,6 +39,10 @@
<Link>versions.json</Link> <Link>versions.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
<None Include="..\local-releases\**\*" Condition="Exists('..\local-releases')">
<Link>local-releases\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup> </ItemGroup>
</Project> </Project>
+35 -7
View File
@@ -227,18 +227,27 @@
<TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" /> <TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" />
<StackPanel Margin="0,0,0,16"> <StackPanel Margin="0,0,0,16">
<RadioButton x:Name="SetupHostInternet" GroupName="SetupHostingMode" <RadioButton x:Name="SetupHostInternet" GroupName="SetupHostingMode"
Content="Friends anywhere (internet via Cloudflare)" Content="Friends anywhere (Tunnelto)"
IsChecked="True" Margin="0,0,0,6" IsChecked="True" Margin="0,0,0,6"
Checked="OnSetupHostingModeChanged" Checked="OnSetupHostingModeChanged"
Foreground="{StaticResource Ink}" /> Foreground="{StaticResource Ink}" />
<RadioButton x:Name="SetupHostRemote" GroupName="SetupHostingMode"
Content="Friends anywhere (Tunnelto wildcard base)"
Margin="0,0,0,6"
Checked="OnSetupHostingModeChanged"
Foreground="{StaticResource Ink}" />
<RadioButton x:Name="SetupHostLocal" GroupName="SetupHostingMode" <RadioButton x:Name="SetupHostLocal" GroupName="SetupHostingMode"
Content="Same WiFi only (no internet needed)" Content="Same WiFi only (sslip.io)"
Checked="OnSetupHostingModeChanged" Checked="OnSetupHostingModeChanged"
Foreground="{StaticResource Ink}" /> Foreground="{StaticResource Ink}" />
</StackPanel> </StackPanel>
<TextBlock Style="{StaticResource FieldLabel}" Text="TUNNELTO BASE HOST" />
<TextBox x:Name="SetupRemoteWildcardInput" Margin="0,0,0,12"
LostFocus="OnRemoteWildcardChanged"
ToolTip="Auto-generated. Service URLs become api.base, auth.base, etc." />
<TextBlock Foreground="{StaticResource InkFaint}" FontSize="11" <TextBlock Foreground="{StaticResource InkFaint}" FontSize="11"
TextWrapping="Wrap" TextWrapping="Wrap"
Text="Cloudflare mode works for friends anywhere. Same-WiFi mode is fastest but only works for people on your local network." /> Text="Tunnelto mode works for friends anywhere. For subdomain support, use a base host whose provider routes wildcard subdomains to the same tunnel." />
</StackPanel> </StackPanel>
<!-- Setup step 4 (HOST only): Photon AppIds --> <!-- Setup step 4 (HOST only): Photon AppIds -->
@@ -352,16 +361,25 @@
<ComboBox x:Name="VersionSelect" Margin="0,0,0,16" /> <ComboBox x:Name="VersionSelect" Margin="0,0,0,16" />
<TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" /> <TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" />
<StackPanel Orientation="Horizontal"> <StackPanel>
<RadioButton x:Name="HostModeInternet" GroupName="HostingMode" <RadioButton x:Name="HostModeInternet" GroupName="HostingMode"
Content="Friends anywhere (internet via Cloudflare)" Content="Friends anywhere (Tunnelto)"
Margin="0,0,0,6"
IsChecked="True" Checked="OnHostingModeChanged" IsChecked="True" Checked="OnHostingModeChanged"
Foreground="{StaticResource Ink}" /> Foreground="{StaticResource Ink}" />
<RadioButton x:Name="HostModeRemote" GroupName="HostingMode"
Content="Tunnelto wildcard base"
Margin="0,0,0,6" Checked="OnHostingModeChanged"
Foreground="{StaticResource Ink}" />
<RadioButton x:Name="HostModeLocal" GroupName="HostingMode" <RadioButton x:Name="HostModeLocal" GroupName="HostingMode"
Content="Same WiFi only (no internet needed)" Content="Same WiFi only (sslip.io)"
Margin="22,0,0,0" Checked="OnHostingModeChanged" Checked="OnHostingModeChanged"
Foreground="{StaticResource Ink}" /> Foreground="{StaticResource Ink}" />
</StackPanel> </StackPanel>
<TextBlock Style="{StaticResource FieldLabel}" Text="TUNNELTO BASE HOST" Margin="0,14,0,6" />
<TextBox x:Name="RemoteWildcardInput" Margin="0,0,0,0"
LostFocus="OnRemoteWildcardChanged"
ToolTip="Auto-generated. Service URLs become api.base, auth.base, etc." />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -417,6 +435,11 @@
<Button x:Name="HostLaunchBtn" Style="{StaticResource TealBtn}" <Button x:Name="HostLaunchBtn" Style="{StaticResource TealBtn}"
Content="Launch Rec Room" Click="OnLaunchRecRoom" Content="Launch Rec Room" Click="OnLaunchRecRoom"
Margin="10,0,0,0" Visibility="Collapsed" /> Margin="10,0,0,0" Visibility="Collapsed" />
<CheckBox x:Name="HostScreenModeChk" Content="Screen mode"
VerticalAlignment="Center" Margin="14,0,0,0"
Foreground="{StaticResource Ink}"
ToolTip="Launch in 2D desktop mode (passes +forcemode:screen) instead of VR."
Checked="OnScreenModeChanged" Unchecked="OnScreenModeChanged" />
</StackPanel> </StackPanel>
<!-- Progress steps — populated when the user hits <!-- Progress steps — populated when the user hits
@@ -586,6 +609,11 @@
<Button x:Name="JoinLaunchBtn" Style="{StaticResource TealBtn}" <Button x:Name="JoinLaunchBtn" Style="{StaticResource TealBtn}"
Content="Launch Rec Room" Click="OnLaunchRecRoom" Content="Launch Rec Room" Click="OnLaunchRecRoom"
Margin="10,0,0,0" Visibility="Collapsed" /> Margin="10,0,0,0" Visibility="Collapsed" />
<CheckBox x:Name="JoinScreenModeChk" Content="Screen mode"
VerticalAlignment="Center" Margin="14,0,0,0"
Foreground="{StaticResource Ink}"
ToolTip="Launch in 2D desktop mode (passes +forcemode:screen) instead of VR."
Checked="OnScreenModeChanged" Unchecked="OnScreenModeChanged" />
</StackPanel> </StackPanel>
<Border x:Name="JoinStepsPanel" Style="{StaticResource Panel}" <Border x:Name="JoinStepsPanel" Style="{StaticResource Panel}"
+98 -35
View File
@@ -17,7 +17,7 @@ public partial class MainWindow : Window
private readonly ReleaseDownloader _releases = new(); private readonly ReleaseDownloader _releases = new();
private readonly ClientPatcher _patcher = new(); private readonly ClientPatcher _patcher = new();
private readonly ServerProcess _server = new(); private readonly ServerProcess _server = new();
private Tunnel? _tunnel; private IAsyncDisposable? _tunnel;
private VersionsManifest? _manifest; private VersionsManifest? _manifest;
private string? _hostApex; private string? _hostApex;
private ObservableCollection<StartupStep>? _hostSteps; private ObservableCollection<StartupStep>? _hostSteps;
@@ -128,22 +128,27 @@ public partial class MainWindow : Window
SelectComboBoxByTag(PhotonRegionSelect, _state.PhotonRegion); SelectComboBoxByTag(PhotonRegionSelect, _state.PhotonRegion);
ServerNameInput.Text = _state.ServerName; ServerNameInput.Text = _state.ServerName;
SetupServerName.Text = _state.ServerName; SetupServerName.Text = _state.ServerName;
EnsureRemoteWildcardApex();
RemoteWildcardInput.Text = _state.RemoteWildcardApex;
SetupRemoteWildcardInput.Text = _state.RemoteWildcardApex;
if (HostScreenModeChk is not null)
HostScreenModeChk.IsChecked = _state.LaunchInScreenMode;
if (JoinScreenModeChk is not null)
JoinScreenModeChk.IsChecked = _state.LaunchInScreenMode;
// Hosting mode radios — reflect persisted choice on both the // Hosting mode radios — reflect persisted choice on both the
// main HostView and the setup wizard's matching step. // main HostView and the setup wizard's matching step.
if (HostModeLocal is not null && HostModeInternet is not null) if (HostModeLocal is not null && HostModeInternet is not null && HostModeRemote is not null)
{ {
if (_state.HostingMode == HostingMode.LocalNetwork) HostModeInternet.IsChecked = _state.HostingMode == HostingMode.Internet;
HostModeLocal.IsChecked = true; HostModeLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
else HostModeRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
HostModeInternet.IsChecked = true;
} }
if (SetupHostLocal is not null && SetupHostInternet is not null) if (SetupHostLocal is not null && SetupHostInternet is not null && SetupHostRemote is not null)
{ {
if (_state.HostingMode == HostingMode.LocalNetwork) SetupHostInternet.IsChecked = _state.HostingMode == HostingMode.Internet;
SetupHostLocal.IsChecked = true; SetupHostLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
else SetupHostRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
SetupHostInternet.IsChecked = true;
} }
// Setup step 2 banner — surface the auto-detected install so the // Setup step 2 banner — surface the auto-detected install so the
@@ -358,16 +363,19 @@ public partial class MainWindow : Window
private void OnSetupHostingModeChanged(object sender, RoutedEventArgs e) private void OnSetupHostingModeChanged(object sender, RoutedEventArgs e)
{ {
if (SetupHostLocal is null || SetupHostInternet is null) return; if (SetupHostLocal is null || SetupHostInternet is null || SetupHostRemote is null) return;
_state.HostingMode = SetupHostLocal.IsChecked == true _state.HostingMode = SetupHostRemote.IsChecked == true
? HostingMode.LocalNetwork ? HostingMode.RemoteWildcard
: HostingMode.Internet; : SetupHostLocal.IsChecked == true
? HostingMode.LocalNetwork
: HostingMode.Internet;
_state.Save(); _state.Save();
// Mirror to main HostView radios. // Mirror to main HostView radios.
if (HostModeLocal is not null && HostModeInternet is not null) if (HostModeLocal is not null && HostModeInternet is not null && HostModeRemote is not null)
{ {
if (_state.HostingMode == HostingMode.LocalNetwork) HostModeLocal.IsChecked = true; HostModeInternet.IsChecked = _state.HostingMode == HostingMode.Internet;
else HostModeInternet.IsChecked = true; HostModeLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
HostModeRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
} }
} }
@@ -441,6 +449,14 @@ public partial class MainWindow : Window
_state.Save(); _state.Save();
} }
private void OnRemoteWildcardChanged(object sender, RoutedEventArgs e)
{
_state.RemoteWildcardApex = TunneltoTunnel.NormalizeHost((sender as TextBox)?.Text ?? "");
RemoteWildcardInput.Text = _state.RemoteWildcardApex;
SetupRemoteWildcardInput.Text = _state.RemoteWildcardApex;
_state.Save();
}
private void OnPhotonChanged(object sender, RoutedEventArgs e) private void OnPhotonChanged(object sender, RoutedEventArgs e)
{ {
_state.PhotonAppId = PhotonAppIdInput.Text.Trim(); _state.PhotonAppId = PhotonAppIdInput.Text.Trim();
@@ -469,12 +485,14 @@ public partial class MainWindow : Window
private void OnHostingModeChanged(object sender, RoutedEventArgs e) private void OnHostingModeChanged(object sender, RoutedEventArgs e)
{ {
// RadioButton.Checked fires twice on selection-change (once for // RadioButton.Checked fires twice on selection-change (once for
// the un-checked, once for the new-checked). HostModeLocal/Internet // the un-checked, once for the new-checked). HostMode* controls
// could be null during InitializeComponent so guard against that. // could be null during InitializeComponent so guard against that.
if (HostModeLocal is null || HostModeInternet is null) return; if (HostModeLocal is null || HostModeInternet is null || HostModeRemote is null) return;
_state.HostingMode = HostModeLocal.IsChecked == true _state.HostingMode = HostModeRemote.IsChecked == true
? HostingMode.LocalNetwork ? HostingMode.RemoteWildcard
: HostingMode.Internet; : HostModeLocal.IsChecked == true
? HostingMode.LocalNetwork
: HostingMode.Internet;
_state.Save(); _state.Save();
} }
@@ -485,6 +503,16 @@ public partial class MainWindow : Window
{ ShowError("Pick your Rec Room install first."); return; } { ShowError("Pick your Rec Room install first."); return; }
if (string.IsNullOrEmpty(_state.PhotonAppId)) if (string.IsNullOrEmpty(_state.PhotonAppId))
{ ShowError("Photon AppId required (free at dashboard.photonengine.com)."); return; } { ShowError("Photon AppId required (free at dashboard.photonengine.com)."); return; }
if (_state.HostingMode != HostingMode.LocalNetwork)
{
var typed = TunneltoTunnel.NormalizeHost(RemoteWildcardInput.Text);
_state.RemoteWildcardApex = string.IsNullOrWhiteSpace(typed)
? TunneltoTunnel.GenerateBaseHost()
: typed;
RemoteWildcardInput.Text = _state.RemoteWildcardApex;
SetupRemoteWildcardInput.Text = _state.RemoteWildcardApex;
_state.Save();
}
if (_manifest is null) await RefreshManifestAsync(); if (_manifest is null) await RefreshManifestAsync();
if (VersionSelect.SelectedItem is not ComboBoxItem item || item.Tag is null) if (VersionSelect.SelectedItem is not ComboBoxItem item || item.Tag is null)
@@ -501,7 +529,7 @@ public partial class MainWindow : Window
// Build a fresh step list so progress is visible the moment the // Build a fresh step list so progress is visible the moment the
// user clicks. Steps advance via StartStep/CompleteStep helpers // user clicks. Steps advance via StartStep/CompleteStep helpers
// below so flow stays readable. // below so flow stays readable.
_hostSteps = StartupFlow.NewHostFlow(_state.HostingMode == HostingMode.LocalNetwork); _hostSteps = StartupFlow.NewHostFlow(_state.HostingMode);
HostStepsList.ItemsSource = _hostSteps; HostStepsList.ItemsSource = _hostSteps;
HostStepsPanel.Visibility = Visibility.Visible; HostStepsPanel.Visibility = Visibility.Visible;
HostStatusText.Text = ""; HostStatusText.Text = "";
@@ -518,24 +546,41 @@ public partial class MainWindow : Window
activeStep = StartStep(_hostSteps, 1); activeStep = StartStep(_hostSteps, 1);
if (_state.HostingMode == HostingMode.LocalNetwork) if (_state.HostingMode == HostingMode.LocalNetwork)
{ {
_hostApex = LocalNetwork.GetLanIp(); var lan = LocalNetwork.GetLanAddress();
activeStep.Detail = $"Hosting on {_hostApex}"; _hostApex = lan.Host;
activeStep.Detail = $"LAN address {lan.Ip} via {_hostApex}";
}
else if (_state.HostingMode == HostingMode.RemoteWildcard)
{
var tunnel = new TunneltoTunnel();
_tunnel = tunnel;
activeStep.Detail = $"Connecting Tunnelto for {_state.RemoteWildcardApex}";
var publicUrl = await tunnel.StartAsync(
_state.RemoteWildcardApex,
ServerProcess.DefaultLocalPort);
var publicHost = new Uri(publicUrl).Host;
_hostApex = TunneltoTunnel.NormalizeHost(_state.RemoteWildcardApex);
activeStep.Detail = $"Tunnel ready: {publicHost}; services under {_hostApex}";
} }
else else
{ {
_tunnel = new Tunnel(); var tunnel = new TunneltoTunnel();
activeStep.Detail = "Connecting to Cloudflare (first run downloads cloudflared, ~17 MB)"; _tunnel = tunnel;
var publicUrl = await _tunnel.StartAsync( activeStep.Detail = $"Connecting Tunnelto for {_state.RemoteWildcardApex}";
localPort: 5005, var publicUrl = await tunnel.StartAsync(
downloadProgress: new Progress<DownloadProgress>(p => UpdateStepProgress(activeStep!, p))); _state.RemoteWildcardApex,
_hostApex = new Uri(publicUrl).Host; ServerProcess.DefaultLocalPort);
activeStep.Detail = $"Tunnel ready: {_hostApex}"; var publicHost = new Uri(publicUrl).Host;
_hostApex = TunneltoTunnel.NormalizeHost(_state.RemoteWildcardApex);
activeStep.Detail = $"Tunnel ready: {publicHost}; services under {_hostApex}";
activeStep.Progress = null; activeStep.Progress = null;
} }
CompleteStep(activeStep); CompleteStep(activeStep);
activeStep = StartStep(_hostSteps, 2); activeStep = StartStep(_hostSteps, 2);
await _server.StartAsync(serverDir, _state, _hostApex); await _server.StartAsync(
serverDir, _state, _hostApex,
hostingMode: _state.HostingMode);
_state.SelectedVersion = version.VersionKey; _state.SelectedVersion = version.VersionKey;
_state.Save(); _state.Save();
CompleteStep(activeStep); CompleteStep(activeStep);
@@ -816,11 +861,29 @@ public partial class MainWindow : Window
catch (Exception ex) { ShowError("Couldn't open help: " + ex.Message); } catch (Exception ex) { ShowError("Couldn't open help: " + ex.Message); }
} }
private void OnScreenModeChanged(object sender, RoutedEventArgs e)
{
var enabled = (sender as CheckBox)?.IsChecked == true;
_state.LaunchInScreenMode = enabled;
if (HostScreenModeChk is not null && !ReferenceEquals(sender, HostScreenModeChk))
HostScreenModeChk.IsChecked = enabled;
if (JoinScreenModeChk is not null && !ReferenceEquals(sender, JoinScreenModeChk))
JoinScreenModeChk.IsChecked = enabled;
_state.Save();
}
private void EnsureRemoteWildcardApex()
{
if (!string.IsNullOrWhiteSpace(_state.RemoteWildcardApex)) return;
_state.RemoteWildcardApex = TunneltoTunnel.GenerateBaseHost();
_state.Save();
}
private void OnLaunchRecRoom(object sender, RoutedEventArgs e) private void OnLaunchRecRoom(object sender, RoutedEventArgs e)
{ {
if (string.IsNullOrEmpty(_state.RecRoomPath)) if (string.IsNullOrEmpty(_state.RecRoomPath))
{ ShowError("Pick your Rec Room install first."); return; } { ShowError("Pick your Rec Room install first."); return; }
if (!RecRoomLauncher.TryLaunch(_state.RecRoomPath, out var error)) if (!RecRoomLauncher.TryLaunch(_state.RecRoomPath, _state.LaunchInScreenMode, out var error))
ShowError(error); ShowError(error);
} }