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/
|
**/dist/
|
||||||
**/.vite/
|
**/.vite/
|
||||||
**/build/
|
**/build/
|
||||||
|
local-releases/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vs/
|
.vs/
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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><parent>\Recroom_Release.exe</c>.
|
/// folder; the executable lives at <c><parent>\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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)"),
|
||||||
|
|||||||
@@ -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
|
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>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user