diff --git a/.gitignore b/.gitignore
index 948309e..638cd54 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
**/dist/
**/.vite/
**/build/
+local-releases/
# IDE
.vs/
diff --git a/launcher/Backend/AppState.cs b/launcher/Backend/AppState.cs
index 4b09d64..2d6b848 100644
--- a/launcher/Backend/AppState.cs
+++ b/launcher/Backend/AppState.cs
@@ -40,13 +40,20 @@ public sealed class AppState
public string PhotonRegion { get; set; } = "us";
/// How the host exposes the server: Internet uses
- /// a Cloudflare quick-tunnel (works for friends anywhere with the
- /// join code); LocalNetwork binds the LAN IP and skips the
- /// tunnel entirely (no internet dependency, joiners must be on
- /// the same network).
+ /// Tunnelto (works for friends anywhere with the join code);
+ /// LocalNetwork binds on the LAN and uses an sslip.io
+ /// hostname in the join code (joiners must be on the same
+ /// network).
[JsonPropertyName("hostingMode")]
public HostingMode HostingMode { get; set; } = HostingMode.Internet;
+ /// Provider-owned wildcard tunnel base for remote hosting
+ /// without Cloudflare, e.g. dorknet.tunnelto.me. The server
+ /// publishes service URLs under api.{base},
+ /// auth.{base}, etc.
+ [JsonPropertyName("remoteWildcardApex")]
+ public string RemoteWildcardApex { get; set; } = string.Empty;
+
/// Host's server name shown in the join code. Empty until
/// the user fills it in on first-run host setup.
[JsonPropertyName("serverName")]
@@ -65,6 +72,12 @@ public sealed class AppState
[JsonPropertyName("setupComplete")]
public bool SetupComplete { get; set; }
+ /// When true, Launch Rec Room passes +forcemode:screen
+ /// so the game opens in 2D desktop mode instead of looking for a
+ /// VR headset. Toggled from a checkbox next to the Launch button.
+ [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
{
- /// Default — Cloudflare quick-tunnel, public over the
- /// internet, works for joiners anywhere.
+ /// Default — Tunnelto tunnel, public over the internet,
+ /// works for joiners anywhere.
Internet,
- /// LAN only — bind the machine's local IP, skip the
- /// tunnel, no internet dependency. Joiners must be on the same
- /// network.
+ /// 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.
LocalNetwork,
+ /// Remote tunnel supplied by another provider. The user
+ /// provides a wildcard-capable provider hostname, and the launcher
+ /// uses service subdomains under that base.
+ RemoteWildcard,
}
diff --git a/launcher/Backend/ClientPatcher.cs b/launcher/Backend/ClientPatcher.cs
index 8ef0e4b..6d043c8 100644
--- a/launcher/Backend/ClientPatcher.cs
+++ b/launcher/Backend/ClientPatcher.cs
@@ -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 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())
+ foreach (var old in BuiltInOldPluginPaths
+ .Concat(manifest.OldPluginPaths ?? Array.Empty())
+ .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;
+ }
+
/// Extracts a zip on top of an existing directory, replacing
/// any files that collide.
/// throws on existing files; we walk entries ourselves. Also guards
diff --git a/launcher/Backend/ErrorTranslator.cs b/launcher/Backend/ErrorTranslator.cs
index d691823..29ddf74 100644
--- a/launcher/Backend/ErrorTranslator.cs
+++ b/launcher/Backend/ErrorTranslator.cs
@@ -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");
}
diff --git a/launcher/Backend/LocalNetwork.cs b/launcher/Backend/LocalNetwork.cs
index f3be0f6..e439d7a 100644
--- a/launcher/Backend/LocalNetwork.cs
+++ b/launcher/Backend/LocalNetwork.cs
@@ -6,8 +6,8 @@ namespace DorkNet.Launcher.Backend;
/// Finds the machine's preferred LAN IPv4 address for
/// "local network" hosting mode. Skip Cloudflare entirely, hand
-/// out the LAN IP in the join code, and players on the same network
-/// connect direct.
+/// out an sslip.io hostname derived from the LAN IP in the join code,
+/// and players on the same network connect direct.
///
/// Selection priority: an "Up" non-loopback network interface
/// with a private (RFC1918) IPv4 address. Falls back to 127.0.0.1
@@ -16,6 +16,8 @@ namespace DorkNet.Launcher.Backend;
/// Cloudflare mode.
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";
}
+ /// Turns a LAN IPv4 address into an sslip.io apex host.
+ /// For example, 192.168.1.25 becomes
+ /// 192-168-1-25.sslip.io. Keeping the IP in one DNS label
+ /// makes derived hosts like api.192-168-1-25.sslip.io and
+ /// cdn.192-168-1-25.sslip.io resolve cleanly too.
+ 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);
diff --git a/launcher/Backend/LocalOverrides.cs b/launcher/Backend/LocalOverrides.cs
index 6775714..78a4369 100644
--- a/launcher/Backend/LocalOverrides.cs
+++ b/launcher/Backend/LocalOverrides.cs
@@ -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;
}
/// Returns the local server-dir for
@@ -82,6 +86,9 @@ public static class LocalOverrides
var parts = new List();
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);
}
diff --git a/launcher/Backend/RecRoomLauncher.cs b/launcher/Backend/RecRoomLauncher.cs
index 4f12d86..0c375bf 100644
--- a/launcher/Backend/RecRoomLauncher.cs
+++ b/launcher/Backend/RecRoomLauncher.cs
@@ -10,8 +10,10 @@ public static class RecRoomLauncher
{
/// The picked install path is the Recroom_Release_Data
/// folder; the executable lives at <parent>\Recroom_Release.exe.
- /// Returns true if Process.Start succeeded.
- public static bool TryLaunch(string recRoomDataPath, out string error)
+ /// Returns true if Process.Start succeeded.
+ /// passes Rec Room's +forcemode:screen arg so the game opens
+ /// in 2D desktop mode instead of trying to bind a headset.
+ 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;
}
diff --git a/launcher/Backend/ServerProcess.cs b/launcher/Backend/ServerProcess.cs
index 7046740..d4fab97 100644
--- a/launcher/Backend/ServerProcess.cs
+++ b/launcher/Backend/ServerProcess.cs
@@ -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.
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();
+ }
+
/// JWT signing key — random per-machine, persisted under
/// LocalAppData so re-launches keep the same key (so existing
/// tokens stay valid).
diff --git a/launcher/Backend/StartupStep.cs b/launcher/Backend/StartupStep.cs
index b97ca17..813f9eb 100644
--- a/launcher/Backend/StartupStep.cs
+++ b/launcher/Backend/StartupStep.cs
@@ -72,10 +72,15 @@ public enum StepState
/// obvious what stages a user will see.
public static class StartupFlow
{
- public static ObservableCollection NewHostFlow(bool isLocalNetwork) => new()
+ public static ObservableCollection 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)"),
diff --git a/launcher/Backend/TunneltoTunnel.cs b/launcher/Backend/TunneltoTunnel.cs
new file mode 100644
index 0000000..ca6004e
--- /dev/null
+++ b/launcher/Backend/TunneltoTunnel.cs
@@ -0,0 +1,123 @@
+using System.Diagnostics;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text.RegularExpressions;
+
+namespace DorkNet.Launcher.Backend;
+
+/// Wraps Tunnelto for remote hosting with provider-owned
+/// subdomains. The user supplies a wildcard-capable base such as
+/// dorknet.tunnelto.me; the server then publishes
+/// api.dorknet.tunnelto.me, auth.dorknet.tunnelto.me,
+/// and friends.
+public sealed class TunneltoTunnel : IAsyncDisposable
+{
+ private Process? _process;
+ private TaskCompletionSource? _urlReady;
+ public string? PublicUrl { get; private set; }
+
+ public async Task 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(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();
+ }
+}
diff --git a/launcher/DorkNet.Launcher.csproj b/launcher/DorkNet.Launcher.csproj
index 5b09e99..a4d7d92 100644
--- a/launcher/DorkNet.Launcher.csproj
+++ b/launcher/DorkNet.Launcher.csproj
@@ -25,6 +25,7 @@
module bytes, MainWindow draws it onto a WPF Image. No System.Drawing
dependency that way, so it stays portable. -->
+
@@ -352,16 +361,25 @@
-
+
+
+
+
@@ -417,6 +435,11 @@
+