mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Add Tunnelto hosting support
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
**/dist/
|
||||
**/.vite/
|
||||
**/build/
|
||||
local-releases/
|
||||
|
||||
# IDE
|
||||
.vs/
|
||||
|
||||
@@ -40,13 +40,20 @@ public sealed class AppState
|
||||
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>
|
||||
/// Tunnelto (works for friends anywhere with the join code);
|
||||
/// <c>LocalNetwork</c> binds on the LAN and uses an sslip.io
|
||||
/// hostname in the join code (joiners must be on the same
|
||||
/// network).</summary>
|
||||
[JsonPropertyName("hostingMode")]
|
||||
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
|
||||
/// the user fills it in on first-run host setup.</summary>
|
||||
[JsonPropertyName("serverName")]
|
||||
@@ -65,6 +72,12 @@ public sealed class AppState
|
||||
[JsonPropertyName("setupComplete")]
|
||||
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()
|
||||
{
|
||||
WriteIndented = true,
|
||||
@@ -125,11 +138,15 @@ public enum AppMode
|
||||
|
||||
public enum HostingMode
|
||||
{
|
||||
/// <summary>Default — Cloudflare quick-tunnel, public over the
|
||||
/// internet, works for joiners anywhere.</summary>
|
||||
/// <summary>Default — Tunnelto 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>
|
||||
/// <summary>LAN only — bind on all local interfaces, skip the
|
||||
/// tunnel, and publish an sslip.io hostname for the machine's LAN
|
||||
/// IP. Joiners must be on the same network.</summary>
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ public sealed class ClientPatcher
|
||||
// Mod.cs hardcodes when reading its config.
|
||||
private const string DefaultPluginDest = "Mods";
|
||||
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(
|
||||
string patcherDir,
|
||||
@@ -107,7 +112,9 @@ public sealed class ClientPatcher
|
||||
}
|
||||
|
||||
// 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));
|
||||
if (File.Exists(stale))
|
||||
@@ -123,8 +130,9 @@ public sealed class ClientPatcher
|
||||
var dllSrc = Path.Combine(patcherDir, manifest.PluginDll);
|
||||
if (!File.Exists(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,
|
||||
(manifest.PluginDest ?? DefaultPluginDest).Replace('/', Path.DirectorySeparatorChar));
|
||||
pluginDestRel.Replace('/', Path.DirectorySeparatorChar));
|
||||
Directory.CreateDirectory(pluginDestDir);
|
||||
var dllDest = Path.Combine(pluginDestDir, Path.GetFileName(manifest.PluginDll));
|
||||
File.Copy(dllSrc, dllDest, overwrite: true);
|
||||
@@ -214,6 +222,8 @@ public sealed class ClientPatcher
|
||||
log.AppendLine($"[patch] wrote config: {Path.GetFileName(configDest)}");
|
||||
}
|
||||
|
||||
PatchNamespaceBootstrap(recRoomRoot, log);
|
||||
|
||||
log.AppendLine("[patch] done.");
|
||||
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
|
||||
/// any files that collide. <see cref="ZipFile.ExtractToDirectory"/>
|
||||
/// throws on existing files; we walk entries ourselves. Also guards
|
||||
|
||||
@@ -61,7 +61,7 @@ public static class ErrorTranslator
|
||||
|
||||
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)
|
||||
{
|
||||
return new FriendlyError(
|
||||
@@ -88,6 +88,23 @@ public static class ErrorTranslator
|
||||
"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.
|
||||
if (ex is UnauthorizedAccessException)
|
||||
{
|
||||
@@ -113,32 +130,14 @@ public static class ErrorTranslator
|
||||
"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
|
||||
if (ex.Message.Contains("address already in use", StringComparison.OrdinalIgnoreCase) ||
|
||||
ex.Message.Contains("Only one usage of each socket address",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new FriendlyError(
|
||||
"Port 5005 is already in use.",
|
||||
"Another DorkNet server (or some other app) is already listening on port 5005. Close it and retry, or reboot.",
|
||||
"Server port is already in use.",
|
||||
"Another DorkNet server (or some other app) is already listening on the required port. Close it and retry, or reboot.",
|
||||
"Retry");
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ 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.
|
||||
/// out an sslip.io hostname derived from 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
|
||||
@@ -16,6 +16,8 @@ namespace DorkNet.Launcher.Backend;
|
||||
/// Cloudflare mode.</para></summary>
|
||||
public static class LocalNetwork
|
||||
{
|
||||
private const string SslipDomain = "sslip.io";
|
||||
|
||||
public static string GetLanIp()
|
||||
{
|
||||
try
|
||||
@@ -48,6 +50,28 @@ public static class LocalNetwork
|
||||
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)
|
||||
{
|
||||
var b = ip.GetAddressBytes();
|
||||
@@ -61,3 +85,5 @@ public static class LocalNetwork
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record LocalNetworkAddress(string Ip, string Host);
|
||||
|
||||
@@ -48,8 +48,12 @@ public static class LocalOverrides
|
||||
public static string? GetLocalReleasesRoot()
|
||||
{
|
||||
var path = Environment.GetEnvironmentVariable(ReleasesEnvVar);
|
||||
if (string.IsNullOrEmpty(path)) return null;
|
||||
return Directory.Exists(path) ? path : null;
|
||||
if (!string.IsNullOrEmpty(path)) 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"/>
|
||||
@@ -82,6 +86,9 @@ public static class LocalOverrides
|
||||
var parts = new List<string>();
|
||||
Describe(ManifestEnvVar, isDir: false, 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ public static class RecRoomLauncher
|
||||
{
|
||||
/// <summary>The picked install path is the <c>Recroom_Release_Data</c>
|
||||
/// folder; the executable lives at <c><parent>\Recroom_Release.exe</c>.
|
||||
/// Returns true if Process.Start succeeded.</summary>
|
||||
public static bool TryLaunch(string recRoomDataPath, out string error)
|
||||
/// Returns true if Process.Start succeeded. <paramref name="screenMode"/>
|
||||
/// 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 = "";
|
||||
if (string.IsNullOrEmpty(recRoomDataPath))
|
||||
@@ -39,6 +41,7 @@ public static class RecRoomLauncher
|
||||
FileName = exe,
|
||||
WorkingDirectory = parent,
|
||||
UseShellExecute = true,
|
||||
Arguments = screenMode ? "+forcemode:screen" : "",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IO;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace DorkNet.Launcher.Backend;
|
||||
|
||||
@@ -9,6 +10,9 @@ namespace DorkNet.Launcher.Backend;
|
||||
/// previous one is killed before starting a new one.</summary>
|
||||
public sealed class ServerProcess : IAsyncDisposable
|
||||
{
|
||||
public const int DefaultLocalPort = 5005;
|
||||
public const int LanPort = 80;
|
||||
|
||||
private Process? _process;
|
||||
private readonly StringBuilder _stderrBuffer = new();
|
||||
public string? StdoutLogPath { get; private set; }
|
||||
@@ -21,12 +25,14 @@ public sealed class ServerProcess : IAsyncDisposable
|
||||
string serverDir,
|
||||
AppState state,
|
||||
string apex,
|
||||
HostingMode hostingMode,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (IsRunning)
|
||||
throw new InvalidOperationException("A server is already running; stop it first.");
|
||||
|
||||
var exe = FindServerExecutable(serverDir);
|
||||
ApplySqliteCompatibilityPatches(serverDir);
|
||||
StdoutLogPath = Path.Combine(AppPaths.LogsRoot, $"server-{DateTime.UtcNow:yyyyMMdd-HHmmss}.log");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
@@ -39,7 +45,9 @@ public sealed class ServerProcess : IAsyncDisposable
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
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["Jwt__Secret"] = EnsureJwtSecret();
|
||||
psi.Environment["Photon__AppId"] = state.PhotonAppId;
|
||||
@@ -47,6 +55,13 @@ public sealed class ServerProcess : IAsyncDisposable
|
||||
string.IsNullOrEmpty(state.PhotonVoiceAppId) ? state.PhotonAppId : state.PhotonVoiceAppId;
|
||||
psi.Environment["Photon__CloudRegion"] = state.PhotonRegion;
|
||||
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.OutputDataReceived += (_, e) => AppendLog(e.Data);
|
||||
@@ -100,6 +115,40 @@ public sealed class ServerProcess : IAsyncDisposable
|
||||
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
|
||||
/// LocalAppData so re-launches keep the same key (so existing
|
||||
/// tokens stay valid).</summary>
|
||||
|
||||
@@ -72,10 +72,15 @@ public enum StepState
|
||||
/// obvious what stages a user will see.</summary>
|
||||
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(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("Download client patcher"),
|
||||
new StartupStep("Unlock Rec Room from Steam (if needed)"),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
module bytes, MainWindow draws it onto a WPF Image. No System.Drawing
|
||||
dependency that way, so it stays portable. -->
|
||||
<PackageReference Include="QRCoder" Version="1.6.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Dev convenience: copy the repo-root versions.json next to dorknet.exe
|
||||
@@ -38,6 +39,10 @@
|
||||
<Link>versions.json</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="..\local-releases\**\*" Condition="Exists('..\local-releases')">
|
||||
<Link>local-releases\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -227,18 +227,27 @@
|
||||
<TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" />
|
||||
<StackPanel Margin="0,0,0,16">
|
||||
<RadioButton x:Name="SetupHostInternet" GroupName="SetupHostingMode"
|
||||
Content="Friends anywhere (internet via Cloudflare)"
|
||||
Content="Friends anywhere (Tunnelto)"
|
||||
IsChecked="True" Margin="0,0,0,6"
|
||||
Checked="OnSetupHostingModeChanged"
|
||||
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"
|
||||
Content="Same WiFi only (no internet needed)"
|
||||
Content="Same WiFi only (sslip.io)"
|
||||
Checked="OnSetupHostingModeChanged"
|
||||
Foreground="{StaticResource Ink}" />
|
||||
</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"
|
||||
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>
|
||||
|
||||
<!-- Setup step 4 (HOST only): Photon AppIds -->
|
||||
@@ -352,16 +361,25 @@
|
||||
<ComboBox x:Name="VersionSelect" Margin="0,0,0,16" />
|
||||
|
||||
<TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" />
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<StackPanel>
|
||||
<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"
|
||||
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"
|
||||
Content="Same WiFi only (no internet needed)"
|
||||
Margin="22,0,0,0" Checked="OnHostingModeChanged"
|
||||
Content="Same WiFi only (sslip.io)"
|
||||
Checked="OnHostingModeChanged"
|
||||
Foreground="{StaticResource Ink}" />
|
||||
</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>
|
||||
</Border>
|
||||
|
||||
@@ -417,6 +435,11 @@
|
||||
<Button x:Name="HostLaunchBtn" Style="{StaticResource TealBtn}"
|
||||
Content="Launch Rec Room" Click="OnLaunchRecRoom"
|
||||
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>
|
||||
|
||||
<!-- Progress steps — populated when the user hits
|
||||
@@ -586,6 +609,11 @@
|
||||
<Button x:Name="JoinLaunchBtn" Style="{StaticResource TealBtn}"
|
||||
Content="Launch Rec Room" Click="OnLaunchRecRoom"
|
||||
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>
|
||||
|
||||
<Border x:Name="JoinStepsPanel" Style="{StaticResource Panel}"
|
||||
|
||||
+98
-35
@@ -17,7 +17,7 @@ public partial class MainWindow : Window
|
||||
private readonly ReleaseDownloader _releases = new();
|
||||
private readonly ClientPatcher _patcher = new();
|
||||
private readonly ServerProcess _server = new();
|
||||
private Tunnel? _tunnel;
|
||||
private IAsyncDisposable? _tunnel;
|
||||
private VersionsManifest? _manifest;
|
||||
private string? _hostApex;
|
||||
private ObservableCollection<StartupStep>? _hostSteps;
|
||||
@@ -128,22 +128,27 @@ public partial class MainWindow : Window
|
||||
SelectComboBoxByTag(PhotonRegionSelect, _state.PhotonRegion);
|
||||
ServerNameInput.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
|
||||
// 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)
|
||||
HostModeLocal.IsChecked = true;
|
||||
else
|
||||
HostModeInternet.IsChecked = true;
|
||||
HostModeInternet.IsChecked = _state.HostingMode == HostingMode.Internet;
|
||||
HostModeLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
|
||||
HostModeRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
|
||||
}
|
||||
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)
|
||||
SetupHostLocal.IsChecked = true;
|
||||
else
|
||||
SetupHostInternet.IsChecked = true;
|
||||
SetupHostInternet.IsChecked = _state.HostingMode == HostingMode.Internet;
|
||||
SetupHostLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
|
||||
SetupHostRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
if (SetupHostLocal is null || SetupHostInternet is null) return;
|
||||
_state.HostingMode = SetupHostLocal.IsChecked == true
|
||||
? HostingMode.LocalNetwork
|
||||
: HostingMode.Internet;
|
||||
if (SetupHostLocal is null || SetupHostInternet is null || SetupHostRemote is null) return;
|
||||
_state.HostingMode = SetupHostRemote.IsChecked == true
|
||||
? HostingMode.RemoteWildcard
|
||||
: SetupHostLocal.IsChecked == true
|
||||
? HostingMode.LocalNetwork
|
||||
: HostingMode.Internet;
|
||||
_state.Save();
|
||||
// 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;
|
||||
else HostModeInternet.IsChecked = true;
|
||||
HostModeInternet.IsChecked = _state.HostingMode == HostingMode.Internet;
|
||||
HostModeLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
|
||||
HostModeRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +449,14 @@ public partial class MainWindow : Window
|
||||
_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)
|
||||
{
|
||||
_state.PhotonAppId = PhotonAppIdInput.Text.Trim();
|
||||
@@ -469,12 +485,14 @@ public partial class MainWindow : Window
|
||||
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
|
||||
// the un-checked, once for the new-checked). HostMode* controls
|
||||
// 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;
|
||||
if (HostModeLocal is null || HostModeInternet is null || HostModeRemote is null) return;
|
||||
_state.HostingMode = HostModeRemote.IsChecked == true
|
||||
? HostingMode.RemoteWildcard
|
||||
: HostModeLocal.IsChecked == true
|
||||
? HostingMode.LocalNetwork
|
||||
: HostingMode.Internet;
|
||||
_state.Save();
|
||||
}
|
||||
|
||||
@@ -485,6 +503,16 @@ public partial class MainWindow : Window
|
||||
{ ShowError("Pick your Rec Room install first."); return; }
|
||||
if (string.IsNullOrEmpty(_state.PhotonAppId))
|
||||
{ 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 (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
|
||||
// user clicks. Steps advance via StartStep/CompleteStep helpers
|
||||
// below so flow stays readable.
|
||||
_hostSteps = StartupFlow.NewHostFlow(_state.HostingMode == HostingMode.LocalNetwork);
|
||||
_hostSteps = StartupFlow.NewHostFlow(_state.HostingMode);
|
||||
HostStepsList.ItemsSource = _hostSteps;
|
||||
HostStepsPanel.Visibility = Visibility.Visible;
|
||||
HostStatusText.Text = "";
|
||||
@@ -518,24 +546,41 @@ public partial class MainWindow : Window
|
||||
activeStep = StartStep(_hostSteps, 1);
|
||||
if (_state.HostingMode == HostingMode.LocalNetwork)
|
||||
{
|
||||
_hostApex = LocalNetwork.GetLanIp();
|
||||
activeStep.Detail = $"Hosting on {_hostApex}";
|
||||
var lan = LocalNetwork.GetLanAddress();
|
||||
_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
|
||||
{
|
||||
_tunnel = new Tunnel();
|
||||
activeStep.Detail = "Connecting to Cloudflare (first run downloads cloudflared, ~17 MB)";
|
||||
var publicUrl = await _tunnel.StartAsync(
|
||||
localPort: 5005,
|
||||
downloadProgress: new Progress<DownloadProgress>(p => UpdateStepProgress(activeStep!, p)));
|
||||
_hostApex = new Uri(publicUrl).Host;
|
||||
activeStep.Detail = $"Tunnel ready: {_hostApex}";
|
||||
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}";
|
||||
activeStep.Progress = null;
|
||||
}
|
||||
CompleteStep(activeStep);
|
||||
|
||||
activeStep = StartStep(_hostSteps, 2);
|
||||
await _server.StartAsync(serverDir, _state, _hostApex);
|
||||
await _server.StartAsync(
|
||||
serverDir, _state, _hostApex,
|
||||
hostingMode: _state.HostingMode);
|
||||
_state.SelectedVersion = version.VersionKey;
|
||||
_state.Save();
|
||||
CompleteStep(activeStep);
|
||||
@@ -816,11 +861,29 @@ public partial class MainWindow : Window
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_state.RecRoomPath))
|
||||
{ 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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user