From b6a0990afecbdca2ab93f9521a9ac324aa7d65f0 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Wed, 27 May 2026 18:26:39 +0300 Subject: [PATCH] Add Tunnelto hosting support --- .gitignore | 1 + launcher/Backend/AppState.cs | 35 ++++++-- launcher/Backend/ClientPatcher.cs | 93 ++++++++++++++++++- launcher/Backend/ErrorTranslator.cs | 41 +++++---- launcher/Backend/LocalNetwork.cs | 30 ++++++- launcher/Backend/LocalOverrides.cs | 11 ++- launcher/Backend/RecRoomLauncher.cs | 7 +- launcher/Backend/ServerProcess.cs | 51 ++++++++++- launcher/Backend/StartupStep.cs | 9 +- launcher/Backend/TunneltoTunnel.cs | 123 +++++++++++++++++++++++++ launcher/DorkNet.Launcher.csproj | 5 ++ launcher/MainWindow.xaml | 42 +++++++-- launcher/MainWindow.xaml.cs | 133 ++++++++++++++++++++-------- 13 files changed, 498 insertions(+), 83 deletions(-) create mode 100644 launcher/Backend/TunneltoTunnel.cs 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 @@