diff --git a/.gitignore b/.gitignore index 638cd54..6ca60f7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ **/.vite/ **/build/ local-releases/ +**/installer/out/ # IDE .vs/ @@ -66,3 +67,4 @@ Thumbs.db # Test outputs TestResults/ *.coverage + diff --git a/README.md b/README.md index 623c644..96dbb6d 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ One download, two flavours: an **installer** (Start menu shortcut + rolling auto-updates) or a **portable `dorknet.exe`** (drop anywhere). Pick a Rec Room build on first launch, host or join. -- **Host a server** — runs a local server + Tunnelto tunnel for the - client version you pick, patches your Rec Room install, and gives - you a shareable join code. +- **Host a server** — runs a local server, opens a free Localtunnel + HTTPS URL for it (no account), patches your Rec Room install for the + client version you pick, and gives you a shareable join code. - **Join a server** — paste the code, point at your Rec Room install, hit play. The launcher fetches the matching patcher. diff --git a/cli/DorkNet.ServerCli/DorkNet.ServerCli.csproj b/cli/DorkNet.ServerCli/DorkNet.ServerCli.csproj index 4d61cde..0a62991 100644 --- a/cli/DorkNet.ServerCli/DorkNet.ServerCli.csproj +++ b/cli/DorkNet.ServerCli/DorkNet.ServerCli.csproj @@ -49,6 +49,15 @@ Backend\TunneltoTunnel.cs + + Backend\TunneltoInstaller.cs + + + Backend\LocaltunnelTunnel.cs + + + Backend\LocaltunnelInstaller.cs + Backend\VersionsManifest.cs diff --git a/cli/DorkNet.ServerCli/Program.cs b/cli/DorkNet.ServerCli/Program.cs index 32a481e..d267d1b 100644 --- a/cli/DorkNet.ServerCli/Program.cs +++ b/cli/DorkNet.ServerCli/Program.cs @@ -33,6 +33,8 @@ public static class Program Console.WriteLine($" mode: {parsed.Mode}"); Console.WriteLine($" server: {parsed.ServerName}"); Console.WriteLine($" photon: {Redact(parsed.PhotonAppId)} ({parsed.Region})"); + if (parsed.Mode is HostingMode.Internet or HostingMode.SingleOriginTunnel) + Console.WriteLine(" network: Localtunnel"); if (parsed.Mode == HostingMode.RemoteWildcard) Console.WriteLine($" apex: {state.RemoteWildcardApex}"); Console.WriteLine(); @@ -57,7 +59,7 @@ public static class Program var releases = new ReleaseDownloader(); var server = new ServerProcess(); - TunneltoTunnel? tunnel = null; + IAsyncDisposable? tunnel = null; var cts = new CancellationTokenSource(); Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; @@ -95,17 +97,38 @@ public static class Program apex = lan.Host; Console.WriteLine($"[lan] bound on {lan.Ip} (apex={apex})"); } - else + else if (parsed.Mode == HostingMode.RemoteWildcard) { + Console.WriteLine("[tunnel] checking tunnelto…"); + await new TunneltoInstaller().EnsureInstalledAsync( + new Progress(p => + { + if (p.TotalBytes > 0) + Console.Write($"\r {p.Fraction * 100,5:F1}% ({p.BytesRead / (1024.0 * 1024):F1} / {p.TotalBytes / (1024.0 * 1024):F1} MB) "); + }), + cts.Token); + Console.WriteLine(); Console.WriteLine("[tunnel] starting tunnelto…"); - tunnel = new TunneltoTunnel(); - var publicUrl = await tunnel.StartAsync( + var tt = new TunneltoTunnel(); + tunnel = tt; + var publicUrl = await tt.StartAsync( state.RemoteWildcardApex, ServerProcess.DefaultLocalPort, cts.Token); apex = TunneltoTunnel.NormalizeHost(state.RemoteWildcardApex); Console.WriteLine($"[tunnel] live at {publicUrl} (apex={apex})"); } + else + { + // Default Localtunnel path. Anonymous public *.loca.lt URL, + // no account, regenerated each run. + Console.WriteLine("[tunnel] starting localtunnel…"); + var lt = new LocaltunnelTunnel(); + tunnel = lt; + var publicUrl = await lt.StartAsync(ServerProcess.DefaultLocalPort, cts.Token); + apex = new Uri(publicUrl).Host; + Console.WriteLine($"[tunnel] live at {publicUrl} (apex={apex})"); + } // Step 3 — server process Console.WriteLine("[server] starting…"); @@ -200,7 +223,7 @@ public static class Program } string photon = "", voice = "", region = "us"; - string mode = "tunnelto", apex = "", name = "DorkNet Server"; + string mode = "localtunnel", apex = "", name = "DorkNet Server"; string versionKey = "march_2020_03_10"; string? serverDir = null; @@ -230,10 +253,10 @@ public static class Program HostingMode parsedMode = mode.ToLowerInvariant() switch { - "tunnelto" or "tunnel" or "internet" => HostingMode.Internet, - "wildcard" or "remote" => HostingMode.RemoteWildcard, - "lan" or "local" or "wifi" => HostingMode.LocalNetwork, - _ => throw new ArgumentException($"unknown --mode '{mode}' (try tunnelto, wildcard, lan)"), + "localtunnel" or "lt" or "internet" or "tunnel" => HostingMode.SingleOriginTunnel, + "wildcard" or "remote" or "tunnelto" => HostingMode.RemoteWildcard, + "lan" or "local" or "wifi" => HostingMode.LocalNetwork, + _ => throw new ArgumentException($"unknown --mode '{mode}' (try localtunnel, wildcard, lan)"), }; return new ParsedArgs(photon, voice, region, parsedMode, apex, name, versionKey, serverDir); @@ -255,11 +278,12 @@ public static class Program --voice-id Photon Voice AppId. Empty = no voice. --region Photon cloud region (us | eu | asia | jp | sa | kr | in | au). Default: us. - --mode tunnelto (default) — friends anywhere - wildcard — Tunnelto wildcard - base (needs --apex) - lan — same WiFi only, - binds on 0.0.0.0:80 + --mode localtunnel (default) — public *.loca.lt + URL, friends anywhere + wildcard — Tunnelto wildcard + base (needs --apex) + lan — same WiFi only, + binds on 0.0.0.0:80 --apex Wildcard apex (e.g. dorknet.tunnelto.me). Required for --mode wildcard. --name "" Server name shown in the join code. @@ -270,7 +294,7 @@ public static class Program build. Useful for dev iteration. EXAMPLES - # Linux host, default Tunnelto mode + # Linux host, default Localtunnel mode dorknet-server --photon-id 00000000-1111-... --name "Sunday games" # Wildcard base, custom region diff --git a/docs/easy-setup.md b/docs/easy-setup.md index 5067f29..3bd6328 100644 --- a/docs/easy-setup.md +++ b/docs/easy-setup.md @@ -12,7 +12,8 @@ your own machine and hand a join code to your friends. The launcher is a single Windows app that: - runs the server, -- opens a public tunnel so friends anywhere can connect, +- opens a Localtunnel HTTPS URL (or, in LAN mode, publishes your local + address) so friends can connect, - patches your local copy of Rec Room to point at the server, - packages a join code your friends paste into their launcher. @@ -24,6 +25,10 @@ single Windows app that: [Getting the 2020 client](#getting-the-2020-client) below. - A free Photon Cloud account — the launcher walks you through this on first run. +- No account needed for the default "friends anywhere" hosting mode — + the launcher uses [Localtunnel](https://localtunnel.github.io/www/) + to get a free, anonymous `*.loca.lt` URL for your server. Auto-fetched + by the launcher on first run; no sign-in. - ~5 GB free disk space - About 10 minutes for first-run setup, ~30 seconds per subsequent launch @@ -102,12 +107,11 @@ when you continue. - **Server name** — what shows up in your join code. Use whatever your friends will recognise ("Adam's Sunday games", "DorkSquad Test", etc.). - **Who can join?** — three options, only one shows extra inputs: - - **Friends anywhere · Tunnelto** *(default, recommended)* — the - launcher runs a Tunnelto tunnel that gives your server a public - `*.tunnelto.me` URL. Works for anyone, anywhere. No router config. - - **Tunnelto wildcard base** — advanced. If you've got a wildcard - Tunnelto subscription, paste the apex domain. Subdomains like - `api.`, `auth.`, etc. get auto-generated. + - **Friends anywhere · Localtunnel** *(default, recommended)* — the + launcher fetches the Localtunnel client (no account, no sign-in) + and gets you a public `https://.loca.lt` URL. Friends + can join from anywhere with just the join code; nothing extra to + install on their side. - **Same WiFi only · sslip.io** — bind on your LAN only. Friends must be on the same network. No tunnel, lowest latency. @@ -161,10 +165,10 @@ session, the new values get used the next time you hit Start hosting. - **REC ROOM VERSION TO HOST FOR** — dropdown. Pre-selects the version matching your install. Picking a different version means the launcher will fetch that server build the next time you Start hosting. -- **WHO CAN JOIN?** — the same three options as step 3 of the wizard. -- **TUNNELTO BASE HOST** — only visible when you pick the wildcard - option. Auto-generated otherwise; not needed for the default or LAN - modes. +- **WHO CAN JOIN?** — same options as step 3 of the wizard. The + Localtunnel option auto-fetches the tunnel client on first use; no + account or sign-in required, and the URL is regenerated every host + session. ### Photon Cloud panel @@ -191,8 +195,9 @@ The launcher runs through six steps in order: 1. **Download server** — fetches the matching server build into `%LOCALAPPDATA%\DorkNet\servers\\`. ~50 MB the first time; reused on subsequent launches. -2. **Open tunnel** — starts Tunnelto and assigns a public hostname. - Skipped in LAN mode. +2. **Open tunnel** — fetches the Localtunnel client on first run, then + opens a tunnel and waits for the `https://.loca.lt` URL. + LAN mode skips this step and just resolves your local WiFi IP. 3. **Start server** — boots the server process. SQLite database lives in `%APPDATA%\DorkNet\dorknet.db`. 4. **Download patcher** — fetches the client-patcher payload that @@ -275,13 +280,11 @@ See [joining-a-server.md](joining-a-server.md) for the join-side flow. | Mode | Tunnel | Public URL | Best for | | --- | --- | --- | --- | -| Friends anywhere · Tunnelto | yes | `dorknet-.tunnelto.me` | Default — works for everyone, no setup | -| Tunnelto wildcard base | yes | `` + `api.`, `auth.`, … | Power users with a wildcard Tunnelto plan | +| Friends anywhere · Localtunnel | public HTTPS, anonymous | `https://.loca.lt` | Default — friends connect from anywhere with just the join code | | Same WiFi only · sslip.io | no | `.sslip.io` | LAN games, lowest latency, no internet exposure | -Default mode is the right pick for >95% of cases. The wildcard mode is -only useful if you've already got a stable apex domain set up. LAN mode -is for when everyone's in the same room. +Default mode is the right pick for >95% of cases. LAN mode is for when +everyone's in the same room and you do not want a public URL. --- @@ -308,9 +311,12 @@ See [troubleshooting.md](troubleshooting.md) for the full list. The most common problems: -- **Server starts but friend can't connect** — Cloudflare Quick Tunnels - rotate the URL on every restart. Re-share the join code if you've - restarted hosting. +- **Server starts but friend can't connect** — Localtunnel hands you a + fresh `*.loca.lt` URL every host session, so the join code from your + previous run is stale. Hit Start hosting again, copy the new join + code, re-share. (Localtunnel sometimes also shows a one-time "Click + to Continue" interstitial in the browser the first time a joiner's + machine touches the URL; friends might need to open the URL once.) - **"Photon CustomAuth 401" in the server log** — your Realtime AppId on the server doesn't match what the launcher patched into the client. Make sure both sides use the same AppIds. diff --git a/docs/faq.md b/docs/faq.md index f9fc3ed..8cc8211 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -80,11 +80,16 @@ the server config; the client mod handles the rest. ### My friend can't connect Most common causes, in order: -1. **Tunnel URL stale.** Cloudflare Quick Tunnels rotate the URL on each - restart. Re-share the join code. +1. **Stale join code.** Localtunnel hands out a fresh `*.loca.lt` URL + on every host restart. Hit Start hosting, copy the new join code + from the launcher, send the new code to your friend. 2. **Photon region mismatch.** Both clients must use the same Photon region — set in Settings. -3. **Friend didn't patch.** They need to run the Patch tab pointed at - their own Rec Room install, with your join code. +3. **Friend didn't patch.** They need to open the Join view in their + launcher, paste your join code, and click APPLY PATCH before + launching Rec Room. +4. **Localtunnel interstitial.** First time a joiner's machine hits a + given `*.loca.lt` URL, Localtunnel may show a one-time "Click to + Continue" page. Open the URL once in any browser and retry. 4. **Junior account / accessibility settings.** Server settings → relax moderation gates if friends are on accounts with parental controls. diff --git a/docs/joining-a-server.md b/docs/joining-a-server.md index d86443c..3aab928 100644 --- a/docs/joining-a-server.md +++ b/docs/joining-a-server.md @@ -16,6 +16,11 @@ locally — nothing is sent anywhere until you actually launch the game. [Getting the 2020 client](easy-setup.md#getting-the-2020-client) - The join code your friend gave you (a base64-looking blob a few hundred characters long) +- No accounts / installs needed for the default **Friends anywhere · + Localtunnel** join: the host's `*.loca.lt` URL is baked into the join + code, you just paste it and play. The first time your machine hits a + given `*.loca.lt` URL, your browser may show a one-time "Click to + Continue" interstitial — open the URL once in any browser to clear it. - About 2 minutes You **don't** need a Photon account of your own. The join code carries @@ -71,8 +76,8 @@ HOST SHARED** textbox. Hit **Preview** — the launcher decodes the code and pops a "Server preview" panel showing: - the server's display name, -- the public host (Tunnelto hostname, sslip.io address, or wildcard - apex), +- the host address (Localtunnel `*.loca.lt`, LAN `sslip.io`, or a custom + host), - the version key (must match your install — the patcher will warn if not). @@ -169,9 +174,15 @@ Almost always one of: ## "Check your internet connection" / black screen -Most often: your host's tunnel URL changed (Tunnelto sometimes rotates -hostnames between sessions). Get a fresh join code from them and -re-apply the patch. +Almost always: the host restarted hosting since they sent you the join +code, so the Localtunnel URL it points at no longer exists. Ask the +host to copy the current join code from their launcher and re-share it. +Re-apply the patch with the new code. + +If that doesn't fix it, open the host's `*.loca.lt` URL in your browser +once — Localtunnel sometimes shows a one-time "Click to Continue" +interstitial that needs to be cleared by hand before the patched client +can reach the server. ## More troubleshooting diff --git a/docs/photon-setup.md b/docs/photon-setup.md index d86e374..8c3979e 100644 --- a/docs/photon-setup.md +++ b/docs/photon-setup.md @@ -41,15 +41,15 @@ card. After login, click **CREATE A NEW APP** on the dashboard. The form asks for: -- **Photon Type** — pick **Photon Realtime**. Not Fusion, not Quantum, - not Bolt. +- **Photon Type** — pick **Photon SDK**. Not Fusion, not Quantum, not + Bolt. - **Name** — anything you'll recognise later ("DorkNet Realtime", "Sunday Server", etc.). Joiners don't see this. - **Description** — optional. Click **CREATE**. -![photon "Create a new app" form, Realtime selected](images/photon-2-create-realtime.png) +![photon "Create a new app" form, Photon SDK selected](images/photon-2-create-realtime.png) ## Step 3 — Copy the Realtime AppId diff --git a/docs/server-cli.md b/docs/server-cli.md index b2ce802..eb043d8 100644 --- a/docs/server-cli.md +++ b/docs/server-cli.md @@ -6,15 +6,17 @@ `dorknet-server` is a small command-line tool that does the host side of what the Windows launcher does: downloads the server build, opens a -[Tunnelto](https://tunnelto.dev) tunnel, runs the server, and prints -a join code. It runs on Linux, macOS, and Windows. +[Localtunnel](https://localtunnel.github.io/www/) HTTPS URL (or binds +on the LAN), runs the server, and prints a join code. It runs on Linux, +macOS, and Windows. ## What you'll need - A machine running Linux (x64 or arm64), macOS (Intel or Apple Silicon), or Windows -- [Tunnelto](https://github.com/agrinman/tunnelto) on PATH (for the - default mode that exposes your server publicly) +- For the default friends-anywhere mode: Node.js on `PATH` (the CLI + invokes `npx localtunnel` to fetch a free `*.loca.lt` URL — no account + needed). LAN mode doesn't need this. - A Photon Realtime AppId — see [photon-setup.md](photon-setup.md) - ~500 MB free disk (server binary + SQLite database) @@ -51,22 +53,21 @@ Expand-Archive dorknet-server.zip .\dorknet-server\dorknet-server.exe --help ``` -### Tunnelto +### Localtunnel -The CLI shells out to `tunnelto` for the public tunnel. Quickest install: +Default mode shells out to the [Localtunnel](https://localtunnel.github.io/www/) +client to get a free, anonymous `https://.loca.lt` URL. The CLI +calls `npx localtunnel --port ` if `npx` is on `PATH`; if not, +install Node.js (16 or newer) or the standalone `lt` CLI from the +Localtunnel repo and put it on `PATH`. -```sh -# macOS (Homebrew) -brew install tunnelto +The URL is regenerated every host session — copy a fresh join code +after each restart and re-share it. Joiners might see a one-time +"Click to Continue" interstitial on first contact with a given URL; +opening it once in a browser clears it. -# Linux (cargo) -cargo install tunnelto - -# Or grab a binary from github.com/agrinman/tunnelto/releases -``` - -Skip Tunnelto if you're running in `--mode lan` (LAN-only, no public -URL). +Skip Localtunnel only if you're running in `--mode lan` (same WiFi +only). ## Usage @@ -81,24 +82,25 @@ The CLI prints progress, then a join code: ``` DorkNet server CLI · v0.1.0 mode: Internet + network: Localtunnel server: DorkNet Server photon: ********90ab (us) [server] downloading… 54.2% (27.1 / 50.0 MB) [server] cached at /home/alex/.local/share/DorkNet/servers/march_2020_03_10 -[tunnel] starting tunnelto… -[tunnel] live at https://dorknet-abc123.tunnelto.me (apex=dorknet-abc123.tunnelto.me) +[tunnel] starting localtunnel… +[tunnel] live at https://lucky-pelican-42.loca.lt [server] starting… [server] listening (logs: /home/alex/.local/share/DorkNet/logs/server-…log) ══════════════════════════════════════════════════════ Server live: DorkNet Server - Address: https://dorknet-abc123.tunnelto.me + Address: https://lucky-pelican-42.loca.lt Join code (paste this into your friend's launcher): - eyJob3N0IjoiZG9ya25ldC1hYmMxMjMudHVubmVsdG8ubWUiLCJ2I... + eyJob3N0IjoibHVja3ktcGVsaWNhbi00Mi5sb2NhLmx0IiwidiI... Ctrl-C to stop. ══════════════════════════════════════════════════════ @@ -120,9 +122,11 @@ down cleanly. in, au. Default: us. All joiners use the same. --name "" Server display name in the join code. Default: "DorkNet Server". ---mode tunnelto (default) — public, friends anywhere - wildcard — your own Tunnelto apex - lan — same WiFi only, no tunnel +--mode localtunnel (default) — public *.loca.lt URL, + friends anywhere + wildcard — legacy Tunnelto wildcard + apex + lan — same WiFi only, no tunnel --apex Wildcard apex. Required for --mode wildcard. Example: dorknet.example.tunnelto.me --version Rec Room version key. Default: march_2020_03_10 @@ -145,7 +149,7 @@ dorknet-server \ # LAN party — no tunnel, joiners must be on the same WiFi dorknet-server --photon-id ... --mode lan -# Custom Tunnelto wildcard base +# Legacy custom Tunnelto wildcard base dorknet-server \ --photon-id ... \ --mode wildcard \ @@ -190,10 +194,11 @@ sudo systemctl enable --now dorknet journalctl -fu dorknet # follow the logs ``` -> Heads-up: each new tunnel session gets a fresh `*.tunnelto.me` -> hostname, so the join code rotates on restart. Either re-share -> the new code with your friends, or use `--mode wildcard` with a -> stable apex. +> Heads-up: Localtunnel hands out a fresh `*.loca.lt` URL on every +> restart, so each time systemd restarts the service the join code +> rotates. Either commit to a stable apex via `--mode wildcard`, or +> arrange to re-share the join code (e.g. a tiny post-start hook that +> emails / Discord-webhooks the new code). ## Run as a launchd agent (macOS) @@ -251,10 +256,18 @@ it's stateless across runs (all config comes from CLI args). ## Troubleshooting -### `tunnelto was not found` +### `localtunnel client wasn't found` -The CLI couldn't find a `tunnelto` binary. Install it (see above) or -drop the binary next to `dorknet-server`. +The CLI couldn't find an `lt` binary or an `npx` in `PATH`. Install +Node.js 16+ (which brings `npx`) or install the standalone Localtunnel +client (`npm install -g localtunnel` then `lt --version` to confirm). +For same-WiFi hosting without any tunnel, pass `--mode lan`. + +### Joiner hits "Click to Continue" on a Localtunnel landing page + +First-touch interstitial that Localtunnel shows once per (IP, URL) +pair. Tell the joiner to open the URL in any browser once; the gate +clears and the patched client connects normally on the next attempt. ### `couldn't fetch versions.json` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 49d547a..0ecd4d9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -16,10 +16,14 @@ this re-fetches the server's current pubkey before patching. The watch can't reach the server URL. Verify: 1. The host string in `dorknet-clientmod.json` matches the server's - public URL (tunnel URL for Easy mode). -2. The server responds to `https:///healthz` with `200 OK` from - the same machine the client runs on. -3. No corporate proxy / DNS filter is intercepting `*.trycloudflare.com`. + current join-code URL. (Localtunnel hands out a fresh + `.loca.lt` URL on every host restart — the joiner needs the + most recent code.) +2. Open `https:///healthz` in a browser from the joiner's machine + and confirm a `200 OK`. If you instead see Localtunnel's "Click to + Continue" page, click through once and retry the connect. +3. No corporate proxy / DNS filter is intercepting `loca.lt` / + `sslip.io`. ### Black screen after the loading bar fills @@ -73,12 +77,20 @@ fixes: ## Easy app -### "Cloudflared exited unexpectedly" +### "Localtunnel couldn't get a URL" -The bundled `cloudflared.exe` couldn't start. Most likely a Windows -Defender false-positive — check Defender's quarantine. If you find a -cleaner alternative tunnel provider, open an issue; we've been looking -for one. +DorkNet shells out to the Localtunnel client and waits for it to +report back with a `*.loca.lt` URL. If that fails, the most common +causes are: +- Localtunnel's service is rate-limiting / down — wait a couple + minutes and retry, or switch to LAN mode if friends are local. +- No Node.js / `npx` available for the fallback path (the launcher + bundles a .NET Localtunnel client, but if both that and `npx` + fail you'll see this). Install Node.js 16+ from + [nodejs.org](https://nodejs.org/) and click Retry. +- Outbound HTTPS to `loca.lt` is blocked (corporate firewall, DNS + filter). Switch to LAN mode or use the wildcard option with your + own apex. ### Patch button is greyed out diff --git a/installer/build-installer.ps1 b/installer/build-installer.ps1 index cb584d5..4a3db65 100644 --- a/installer/build-installer.ps1 +++ b/installer/build-installer.ps1 @@ -44,9 +44,13 @@ if (-not (Test-Path $exe)) { throw "Expected $exe not found after publish" } $exeSize = "{0:N1} MB" -f ((Get-Item $exe).Length / 1MB) Write-Host "==> Published: $exe ($exeSize)" -ForegroundColor Green -# Resolve ISCC.exe — try PATH first, then the default install dir. +# Resolve ISCC.exe — try PATH first, then common machine/user install dirs. $iscc = (Get-Command "ISCC.exe" -ErrorAction SilentlyContinue).Source if (-not $iscc) { $iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" } +if (-not (Test-Path $iscc)) { + $iscc = Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe" +} +if (-not (Test-Path $iscc)) { $iscc = "C:\Program Files\Inno Setup 6\ISCC.exe" } if (-not (Test-Path $iscc)) { throw "ISCC.exe not found. Install Inno Setup 6 from https://jrsoftware.org/isinfo.php" } diff --git a/installer/dorknet.iss b/installer/dorknet.iss index aa210e9..ebd6fb5 100644 --- a/installer/dorknet.iss +++ b/installer/dorknet.iss @@ -54,6 +54,7 @@ Name: "autorunupdates"; Description: "Check for updates on launch"; GroupDescrip [Files] Source: "{#PublishDir}\{#AppExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\versions.json"; DestDir: "{app}"; Flags: ignoreversion ; Bundle the tunnelto helper if it's been built next to the launcher. ; The launcher auto-falls-back to PATH lookup when not bundled, so this ; is purely a convenience for users who don't already have it installed. diff --git a/launcher/Backend/AppState.cs b/launcher/Backend/AppState.cs index 2d6b848..33351a0 100644 --- a/launcher/Backend/AppState.cs +++ b/launcher/Backend/AppState.cs @@ -39,13 +39,14 @@ public sealed class AppState [JsonPropertyName("photonRegion")] public string PhotonRegion { get; set; } = "us"; - /// How the host exposes the server: Internet uses - /// 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). + /// How the host exposes the server: SingleOriginTunnel + /// (default) opens one public Localtunnel HTTPS URL plus a + /// launcher-hosted local name server; LocalNetwork binds on + /// the LAN and uses an sslip.io hostname in the join code (joiners + /// must be on the same network); RemoteWildcard is the + /// legacy provider-owned-apex mode kept for back-compat. [JsonPropertyName("hostingMode")] - public HostingMode HostingMode { get; set; } = HostingMode.Internet; + public HostingMode HostingMode { get; set; } = HostingMode.SingleOriginTunnel; /// Provider-owned wildcard tunnel base for remote hosting /// without Cloudflare, e.g. dorknet.tunnelto.me. The server @@ -109,6 +110,11 @@ public sealed class AppState // the authoritative source instead of needing to re-derive // them every load. if (migrated) { try { state.Save(); } catch { } } + if (state.HostingMode is HostingMode.RemoteWildcard or HostingMode.Internet) + { + state.HostingMode = HostingMode.SingleOriginTunnel; + try { state.Save(); } catch { } + } return state; } @@ -138,8 +144,14 @@ public enum AppMode public enum HostingMode { - /// Default — Tunnelto tunnel, public over the internet, - /// works for joiners anywhere. + /// Easy Launcher remote mode — one public HTTPS tunnel URL. + /// The launcher answers the client's localhost name-server bootstrap + /// and points every RecNet service at path prefixes under that URL. + SingleOriginTunnel, + /// Legacy "Friends anywhere" mode kept for migration + /// purposes only. New installs are normalised to + /// at time + /// (see the migration block there). Internet, /// LAN only — bind on all local interfaces, skip the /// tunnel, and publish an sslip.io hostname for the machine's LAN diff --git a/launcher/Backend/ClientPatcher.cs b/launcher/Backend/ClientPatcher.cs index 6d043c8..9b9f52d 100644 --- a/launcher/Backend/ClientPatcher.cs +++ b/launcher/Backend/ClientPatcher.cs @@ -58,10 +58,11 @@ public sealed class ClientPatcher string photonVoiceAppId, string photonRegion, string apexHost, + bool singleOrigin = false, CancellationToken ct = default) { return Task.Run(() => ApplyCore(patcherDir, recRoomDataPath, - photonAppId, photonVoiceAppId, photonRegion, apexHost), ct); + photonAppId, photonVoiceAppId, photonRegion, apexHost, singleOrigin), ct); } private PatchResult ApplyCore( @@ -70,7 +71,8 @@ public sealed class ClientPatcher string photonAppId, string photonVoiceAppId, string photonRegion, - string apexHost) + string apexHost, + bool singleOrigin) { var log = new System.Text.StringBuilder(); try @@ -217,7 +219,8 @@ public sealed class ClientPatcher .Replace("{PHOTON_APPID}", photonAppId ?? "") .Replace("{PHOTON_VOICE_APPID}", string.IsNullOrEmpty(photonVoiceAppId) ? photonAppId ?? "" : photonVoiceAppId) - .Replace("{PHOTON_REGION}", string.IsNullOrEmpty(photonRegion) ? "us" : photonRegion); + .Replace("{PHOTON_REGION}", string.IsNullOrEmpty(photonRegion) ? "us" : photonRegion) + .Replace("{SINGLE_ORIGIN_BASE_URL}", singleOrigin ? $"https://{apexHost}" : ""); File.WriteAllText(configDest, rendered); log.AppendLine($"[patch] wrote config: {Path.GetFileName(configDest)}"); } diff --git a/launcher/Backend/ErrorTranslator.cs b/launcher/Backend/ErrorTranslator.cs index 29ddf74..46f0139 100644 --- a/launcher/Backend/ErrorTranslator.cs +++ b/launcher/Backend/ErrorTranslator.cs @@ -61,7 +61,17 @@ public static class ErrorTranslator private static FriendlyError? TryMatch(Exception ex) { - // Network — GitHub releases, Tunnelto tunnel API, etc. + if (ex.Message.Contains("localtunnel", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("loca.lt", StringComparison.OrdinalIgnoreCase)) + { + return new FriendlyError( + "Localtunnel couldn't get a URL.", + TrimToSentence(ex.Message) + + " Localtunnel sometimes rate-limits or blips. Retry in a minute, or switch hosting mode to LAN if your friends are local.", + "Retry"); + } + + // Network — GitHub releases, legacy tunnel APIs, etc. if (ex is HttpRequestException http) { return new FriendlyError( @@ -80,6 +90,17 @@ public static class ErrorTranslator "Retry"); } + if (ex is TimeoutException timeout) + { + var title = timeout.Message.Contains("Tunnelto", StringComparison.OrdinalIgnoreCase) + ? "Tunnelto tunnel timed out." + : "Download timed out."; + return new FriendlyError( + title, + TrimToSentence(timeout.Message) + " Try again in a minute; if it repeats, Tunnelto or GitHub may be blocked on this network.", + "Retry"); + } + if (ex is SocketException sock) { return new FriendlyError( diff --git a/launcher/Backend/JoinCode.cs b/launcher/Backend/JoinCode.cs index deb0df8..6d5d0f1 100644 --- a/launcher/Backend/JoinCode.cs +++ b/launcher/Backend/JoinCode.cs @@ -6,8 +6,9 @@ using System.Text.Json.Serialization; namespace DorkNet.Launcher.Backend; /// Compact URL-safe encoding of the bits a joining player -/// needs: the host's public address (typically a Cloudflare-Tunnel -/// hostname), the Photon AppIds + region they used, the host's +/// needs: the host address (a Localtunnel *.loca.lt URL for the +/// default mode, or an sslip.io name for LAN), the Photon +/// AppIds + region they used, the host's /// server name, and the version key so the joiner's launcher fetches /// the matching client patcher. /// @@ -44,9 +45,10 @@ public static class JoinCode public sealed class JoinPayload { - /// Public hostname the joining patcher rewrites - /// *.rec.net URIs to. Cloudflare Tunnel typically gives - /// random-words.trycloudflare.com. + /// Hostname the joining patcher rewrites *.rec.net + /// URIs to. Default Localtunnel mode hands out + /// <random>.loca.lt; LAN mode uses an sslip.io + /// name that resolves to the host's private IP. [JsonPropertyName("host")] public string Host { get; set; } = ""; /// Version key from versions.json. Tells the joiner which @@ -65,4 +67,8 @@ public sealed class JoinPayload /// Server name shown in the joiner's "you're about to /// connect to ..." preview. [JsonPropertyName("n")] public string Name { get; set; } = ""; + + /// True when all RecNet services are exposed under one + /// public origin using /__dn/{service}/ path prefixes. + [JsonPropertyName("so")] public bool SingleOrigin { get; set; } } diff --git a/launcher/Backend/LauncherNameServer.cs b/launcher/Backend/LauncherNameServer.cs new file mode 100644 index 0000000..c8acc1d --- /dev/null +++ b/launcher/Backend/LauncherNameServer.cs @@ -0,0 +1,81 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace DorkNet.Launcher.Backend; + +/// Minimal localhost HTTP name-server for the patched client. +/// The patcher points the hardcoded RecNet bootstrap URL at +/// http://127.0.0.1:80; this listener answers that request with the +/// service URL map for the active Easy Launcher session. +public sealed class LauncherNameServer : IAsyncDisposable +{ + private TcpListener? _listener; + private CancellationTokenSource? _cts; + private Task? _loop; + private string _publicBaseUrl = ""; + + public void Start(string publicBaseUrl) + { + _publicBaseUrl = SingleOriginServiceMap.NormalizeBaseUrl(publicBaseUrl); + if (_listener is not null) return; + + _cts = new CancellationTokenSource(); + _listener = new TcpListener(IPAddress.Loopback, 80); + _listener.Start(); + _loop = Task.Run(() => AcceptLoopAsync(_cts.Token)); + } + + public void Update(string publicBaseUrl) + => _publicBaseUrl = SingleOriginServiceMap.NormalizeBaseUrl(publicBaseUrl); + + private async Task AcceptLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested && _listener is not null) + { + TcpClient client; + try { client = await _listener.AcceptTcpClientAsync(ct); } + catch (OperationCanceledException) { break; } + catch { if (ct.IsCancellationRequested) break; else continue; } + _ = Task.Run(() => HandleAsync(client, ct), ct); + } + } + + private async Task HandleAsync(TcpClient client, CancellationToken ct) + { + using var clientHandle = client; + try + { + await using var stream = client.GetStream(); + var buffer = new byte[2048]; + _ = await stream.ReadAsync(buffer, ct); + + var body = SingleOriginServiceMap.ToJson(_publicBaseUrl); + var bodyBytes = Encoding.UTF8.GetBytes(body); + var header = + "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json; charset=utf-8\r\n" + + $"Content-Length: {bodyBytes.Length}\r\n" + + "Cache-Control: no-store\r\n" + + "Connection: close\r\n\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(header); + await stream.WriteAsync(headerBytes, ct); + await stream.WriteAsync(bodyBytes, ct); + } + catch { } + } + + public async ValueTask DisposeAsync() + { + try { _cts?.Cancel(); } catch { } + try { _listener?.Stop(); } catch { } + if (_loop is not null) + { + try { await _loop; } catch { } + } + _cts?.Dispose(); + _listener = null; + _cts = null; + _loop = null; + } +} diff --git a/launcher/Backend/LocalNetwork.cs b/launcher/Backend/LocalNetwork.cs index e439d7a..4bf4db6 100644 --- a/launcher/Backend/LocalNetwork.cs +++ b/launcher/Backend/LocalNetwork.cs @@ -5,15 +5,15 @@ using System.Net.Sockets; namespace DorkNet.Launcher.Backend; /// Finds the machine's preferred LAN IPv4 address for -/// "local network" hosting mode. Skip Cloudflare entirely, hand +/// "local network" hosting mode. Skip public tunnels entirely, hand /// 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 /// if nothing usable is found — works for solo testing on one -/// machine but joiners need either a real LAN address or -/// Cloudflare mode. +/// machine but joiners need either a real LAN address or one of the +/// tunnel modes (Localtunnel / Tunnelto). public static class LocalNetwork { private const string SslipDomain = "sslip.io"; diff --git a/launcher/Backend/LocalOverrides.cs b/launcher/Backend/LocalOverrides.cs index 78a4369..499b49e 100644 --- a/launcher/Backend/LocalOverrides.cs +++ b/launcher/Backend/LocalOverrides.cs @@ -50,12 +50,54 @@ public static class LocalOverrides var path = Environment.GetEnvironmentVariable(ReleasesEnvVar); if (!string.IsNullOrEmpty(path)) return Directory.Exists(path) ? path : null; - var sideBySide = Path.Combine(AppContext.BaseDirectory, "local-releases"); - if (Directory.Exists(sideBySide)) return sideBySide; + var sideBySide = FindSideBySideLocalReleases(); + if (sideBySide is not null) return sideBySide; return null; } + private static string? FindSideBySideLocalReleases() + { + var baseDir = AppContext.BaseDirectory; + var direct = Path.Combine(baseDir, "local-releases"); + if (LooksLikeLocalReleasesRoot(direct)) return direct; + + // Be forgiving when users extract the zip into the wrong level: + // DorkNet\local-releases\local-releases\... + var doubled = Path.Combine(direct, "local-releases"); + if (LooksLikeLocalReleasesRoot(doubled)) return doubled; + + // Windows Explorer's "Extract All" often creates a folder named + // after the zip, then places local-releases inside it. + try + { + foreach (var dir in Directory.GetDirectories(baseDir)) + { + var nested = Path.Combine(dir, "local-releases"); + if (LooksLikeLocalReleasesRoot(nested)) return nested; + } + } + catch { } + + return null; + } + + private static bool LooksLikeLocalReleasesRoot(string path) + { + if (!Directory.Exists(path)) return false; + try + { + return Directory.GetDirectories(path) + .Any(versionDir => + Directory.Exists(Path.Combine(versionDir, "server")) || + Directory.Exists(Path.Combine(versionDir, "patcher"))); + } + catch + { + return false; + } + } + /// Returns the local server-dir for /// if the releases-root override is set and the subfolder exists. /// Convention: {root}/{version_key}/server/. @@ -86,8 +128,8 @@ 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)) + var sideBySide = FindSideBySideLocalReleases(); + if (sideBySide is not null) parts.Add($"{ReleasesEnvVar} -> {sideBySide} [side-by-side fallback]"); return string.Join("\n", parts); } diff --git a/launcher/Backend/LocaltunnelInstaller.cs b/launcher/Backend/LocaltunnelInstaller.cs new file mode 100644 index 0000000..4399077 --- /dev/null +++ b/launcher/Backend/LocaltunnelInstaller.cs @@ -0,0 +1,120 @@ +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace DorkNet.Launcher.Backend; + +public static class LocaltunnelInstaller +{ + private const string LatestReleaseApi = + "https://api.github.com/repos/angelobreuer/localtunnel.net/releases/latest"; + + private static readonly HttpClient Http = new() + { + Timeout = TimeSpan.FromMinutes(3), + }; + + static LocaltunnelInstaller() + { + Http.DefaultRequestHeaders.UserAgent.ParseAdd("DorkNet-Launcher/1.0"); + } + + public static async Task TryEnsureAsync( + IProgress? progress = null, + CancellationToken ct = default) + { + var existing = FindExisting(); + if (existing is not null) return existing; + + try + { + Directory.CreateDirectory(AppPaths.LocalRoot); + var release = await Http.GetFromJsonAsync(LatestReleaseApi, ct); + var asset = release?.Assets? + .Where(a => !string.IsNullOrEmpty(a.BrowserDownloadUrl)) + .OrderByDescending(a => ScoreAsset(a.Name ?? "")) + .FirstOrDefault(a => ScoreAsset(a.Name ?? "") > 0); + if (asset is null) return null; + + var zipPath = Path.Combine(AppPaths.LocalRoot, $"localtunnel-{Guid.NewGuid():N}.zip"); + await DownloadAsync(asset.BrowserDownloadUrl!, zipPath, progress, ct); + var extractDir = Path.Combine(AppPaths.LocalRoot, "localtunnel.net"); + if (Directory.Exists(extractDir)) Directory.Delete(extractDir, recursive: true); + Directory.CreateDirectory(extractDir); + ZipFile.ExtractToDirectory(zipPath, extractDir); + File.Delete(zipPath); + + return Directory.GetFiles(extractDir, OperatingSystem.IsWindows() ? "localtunnel.exe" : "localtunnel", + SearchOption.AllDirectories) + .FirstOrDefault(); + } + catch + { + return null; + } + } + + private static string? FindExisting() + { + var name = OperatingSystem.IsWindows() ? "localtunnel.exe" : "localtunnel"; + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, name), + Path.Combine(AppPaths.LocalRoot, name), + Path.Combine(AppPaths.LocalRoot, "localtunnel.net", name), + }; + foreach (var candidate in candidates) + if (File.Exists(candidate)) return candidate; + + return Directory.Exists(Path.Combine(AppPaths.LocalRoot, "localtunnel.net")) + ? Directory.GetFiles(Path.Combine(AppPaths.LocalRoot, "localtunnel.net"), name, SearchOption.AllDirectories) + .FirstOrDefault() + : null; + } + + private static int ScoreAsset(string name) + { + var n = name.ToLowerInvariant(); + if (!n.EndsWith(".zip")) return 0; + if (OperatingSystem.IsWindows() && n.Contains("win") && n.Contains("x64")) return 100; + if (OperatingSystem.IsLinux() && n.Contains("linux") && n.Contains("x64")) return 100; + if (OperatingSystem.IsMacOS() && (n.Contains("osx") || n.Contains("mac")) && n.Contains("x64")) return 100; + if (OperatingSystem.IsWindows() && n.Contains("win")) return 50; + return n.Contains("localtunnel") ? 10 : 0; + } + + private static async Task DownloadAsync( + string url, + string destPath, + IProgress? progress, + CancellationToken ct) + { + using var resp = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + resp.EnsureSuccessStatusCode(); + var total = resp.Content.Headers.ContentLength ?? 0; + await using var src = await resp.Content.ReadAsStreamAsync(ct); + await using var dst = File.Create(destPath); + var buffer = new byte[128 * 1024]; + long read = 0; + int n; + while ((n = await src.ReadAsync(buffer, ct)) > 0) + { + await dst.WriteAsync(buffer.AsMemory(0, n), ct); + read += n; + progress?.Report(new DownloadProgress(read, total)); + } + } + + private sealed class GitHubRelease + { + [JsonPropertyName("assets")] public List? Assets { get; set; } + } + + private sealed class GitHubAsset + { + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("browser_download_url")] public string? BrowserDownloadUrl { get; set; } + } +} diff --git a/launcher/Backend/LocaltunnelTunnel.cs b/launcher/Backend/LocaltunnelTunnel.cs new file mode 100644 index 0000000..abad303 --- /dev/null +++ b/launcher/Backend/LocaltunnelTunnel.cs @@ -0,0 +1,164 @@ +using System.Diagnostics; +using System.IO; +using System.Text.RegularExpressions; + +namespace DorkNet.Launcher.Backend; + +/// Runs a localtunnel client. Prefer angelobreuer/localtunnel.net +/// because it is a native .NET CLI and does not require Node.js; fall +/// back to npx localtunnel when users already have Node on PATH. +public sealed class LocaltunnelTunnel : IAsyncDisposable +{ + private Process? _process; + private TaskCompletionSource? _urlReady; + private readonly object _outputLock = new(); + private readonly Queue _recentOutput = new(); + + public string? PublicUrl { get; private set; } + + public async Task StartAsync(int localPort, CancellationToken ct = default) + { + var executable = await LocaltunnelInstaller.TryEnsureAsync(ct: ct); + LocaltunnelKind kind; + if (executable is not null) + { + kind = LocaltunnelKind.DotNet; + } + else + { + executable = ResolveExecutable(out kind); + } + _urlReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var psi = new ProcessStartInfo + { + FileName = executable, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + if (kind == LocaltunnelKind.DotNet) + { + psi.ArgumentList.Add("--no-dashboard"); + psi.ArgumentList.Add("--host"); + psi.ArgumentList.Add("127.0.0.1"); + psi.ArgumentList.Add("--port"); + psi.ArgumentList.Add(localPort.ToString()); + psi.ArgumentList.Add("http"); + } + else + { + psi.ArgumentList.Add("--yes"); + psi.ArgumentList.Add("localtunnel"); + psi.ArgumentList.Add("--port"); + psi.ArgumentList.Add(localPort.ToString()); + psi.ArgumentList.Add("--local-host"); + psi.ArgumentList.Add("127.0.0.1"); + } + + _process = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start localtunnel through npx."); + _process.EnableRaisingEvents = true; + _process.Exited += (_, _) => + { + if (_urlReady is null || _urlReady.Task.IsCompleted) return; + _urlReady.TrySetException(new InvalidOperationException( + "localtunnel exited before reporting a public URL." + FormatRecentOutput())); + }; + _process.OutputDataReceived += (_, e) => ParseLine(e.Data); + _process.ErrorDataReceived += (_, e) => ParseLine(e.Data); + _process.BeginOutputReadLine(); + _process.BeginErrorReadLine(); + + var completed = await Task.WhenAny(_urlReady.Task, Task.Delay(TimeSpan.FromSeconds(60), ct)); + if (completed != _urlReady.Task) + { + ct.ThrowIfCancellationRequested(); + throw new TimeoutException("localtunnel did not report a public URL within 60 seconds." + FormatRecentOutput()); + } + + PublicUrl = await _urlReady.Task; + return PublicUrl; + } + + private static string ResolveExecutable(out LocaltunnelKind kind) + { + var ltName = OperatingSystem.IsWindows() ? "localtunnel.exe" : "localtunnel"; + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, ltName), + Path.Combine(AppPaths.LocalRoot, ltName), + }; + foreach (var candidate in candidates) + { + if (!File.Exists(candidate)) continue; + kind = LocaltunnelKind.DotNet; + return candidate; + } + var ltOnPath = Environment.GetEnvironmentVariable("PATH")? + .Split(Path.PathSeparator) + .Select(p => Path.Combine(p.Trim(), ltName)) + .FirstOrDefault(File.Exists); + if (ltOnPath is not null) + { + kind = LocaltunnelKind.DotNet; + return ltOnPath; + } + + var name = OperatingSystem.IsWindows() ? "npx.cmd" : "npx"; + var onPath = Environment.GetEnvironmentVariable("PATH")? + .Split(Path.PathSeparator) + .Select(p => Path.Combine(p.Trim(), name)) + .FirstOrDefault(File.Exists); + if (onPath is not null) + { + kind = LocaltunnelKind.Npx; + return onPath; + } + + throw new FileNotFoundException( + "localtunnel was not found. Put angelobreuer/localtunnel.net's localtunnel.exe next to dorknet.exe, or install Node.js LTS for the npx fallback."); + } + + private static readonly Regex UrlRegex = new( + @"https://[a-z0-9][a-z0-9\-]*\.(?:loca\.lt|localtunnel\.me)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private void ParseLine(string? line) + { + if (line is null) return; + lock (_outputLock) + { + _recentOutput.Enqueue(line); + while (_recentOutput.Count > 8) _recentOutput.Dequeue(); + } + if (_urlReady is null || _urlReady.Task.IsCompleted) return; + var match = UrlRegex.Match(line); + if (match.Success) _urlReady.TrySetResult(match.Value); + } + + private string FormatRecentOutput() + { + lock (_outputLock) + { + return _recentOutput.Count == 0 + ? "" + : " Last localtunnel output: " + string.Join(" | ", _recentOutput); + } + } + + public async ValueTask DisposeAsync() + { + if (_process is null) return; + try + { + if (!_process.HasExited) _process.Kill(entireProcessTree: true); + await _process.WaitForExitAsync(); + } + catch { } + _process.Dispose(); + } + + private enum LocaltunnelKind { DotNet, Npx } +} diff --git a/launcher/Backend/ServerProcess.cs b/launcher/Backend/ServerProcess.cs index c0912cf..29b9a3a 100644 --- a/launcher/Backend/ServerProcess.cs +++ b/launcher/Backend/ServerProcess.cs @@ -32,7 +32,11 @@ public sealed class ServerProcess : IAsyncDisposable throw new InvalidOperationException("A server is already running; stop it first."); var exe = FindServerExecutable(serverDir); - ApplySqliteCompatibilityPatches(serverDir); + // db lives under %APPDATA%\DorkNet so it survives reinstalls of + // the server cache dir (which sits under %LOCALAPPDATA%). + var dbPath = Path.Combine(AppPaths.RoamingRoot, "dorknet.db"); + MigrateLegacyDbIfPresent(serverDir, dbPath); + ApplySqliteCompatibilityPatches(dbPath); StdoutLogPath = Path.Combine(AppPaths.LogsRoot, $"server-{DateTime.UtcNow:yyyyMMdd-HHmmss}.log"); var psi = new ProcessStartInfo @@ -45,23 +49,30 @@ public sealed class ServerProcess : IAsyncDisposable RedirectStandardError = true, }; psi.Environment["ASPNETCORE_ENVIRONMENT"] = "Development"; - psi.Environment["ASPNETCORE_URLS"] = hostingMode == HostingMode.LocalNetwork + var singleOriginMode = hostingMode == HostingMode.SingleOriginTunnel; + var directMode = hostingMode is HostingMode.LocalNetwork or HostingMode.Internet; + psi.Environment["ASPNETCORE_URLS"] = directMode ? $"http://0.0.0.0:{LanPort}" : $"http://127.0.0.1:{DefaultLocalPort}"; psi.Environment["Database__Provider"] = "sqlite"; + psi.Environment["Database__SqlitePath"] = dbPath; psi.Environment["Jwt__Secret"] = EnsureJwtSecret(); psi.Environment["Photon__AppId"] = state.PhotonAppId; psi.Environment["Photon__VoiceAppId"] = 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__Scheme"] = directMode ? "http" : "https"; psi.Environment["Domain__Port"] = ""; + if (singleOriginMode) + psi.Environment["Domain__SingleOriginBaseUrl"] = $"https://{apex}"; 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"]}"); + if (singleOriginMode) + AppendLog($"[launcher] single origin={psi.Environment["Domain__SingleOriginBaseUrl"]}"); _process = new Process { StartInfo = psi, EnableRaisingEvents = true }; _process.OutputDataReceived += (_, e) => AppendLog(e.Data); @@ -128,9 +139,8 @@ public sealed class ServerProcess : IAsyncDisposable $"No server binary found in {serverDir}. The release artifact may be corrupt."); } - private static void ApplySqliteCompatibilityPatches(string serverDir) + private static void ApplySqliteCompatibilityPatches(string dbPath) { - var dbPath = Path.Combine(serverDir, "data", "dorknet.db"); if (!File.Exists(dbPath)) return; using var conn = new SqliteConnection($"Data Source={dbPath}"); @@ -139,6 +149,34 @@ public sealed class ServerProcess : IAsyncDisposable @"""HiddenFromBrowse"" INTEGER NOT NULL DEFAULT 0"); } + /// If an older launcher build left the SQLite db under + /// <serverDir>\data\dorknet.db (where the server defaulted + /// before took over), copy it to + /// the new location so existing + /// players, rooms, and dorms survive the move. Best-effort — never + /// throws, never overwrites a db that's already at the new path. + private static void MigrateLegacyDbIfPresent(string serverDir, string newDbPath) + { + try + { + if (File.Exists(newDbPath)) return; + var legacy = Path.Combine(serverDir, "data", "dorknet.db"); + if (!File.Exists(legacy)) return; + + var destDir = Path.GetDirectoryName(newDbPath); + if (!string.IsNullOrEmpty(destDir)) Directory.CreateDirectory(destDir); + File.Copy(legacy, newDbPath); + // SQLite WAL/SHM sidecars live next to the .db — copy them + // too so the move doesn't drop in-flight wal frames. + foreach (var suffix in new[] { "-wal", "-shm" }) + { + var src = legacy + suffix; + if (File.Exists(src)) File.Copy(src, newDbPath + suffix, overwrite: true); + } + } + catch { /* best-effort — fresh db will be created on first server boot */ } + } + private static void AddSqliteColumnIfMissing( SqliteConnection conn, string table, diff --git a/launcher/Backend/SingleOriginServiceMap.cs b/launcher/Backend/SingleOriginServiceMap.cs new file mode 100644 index 0000000..159f8ea --- /dev/null +++ b/launcher/Backend/SingleOriginServiceMap.cs @@ -0,0 +1,69 @@ +using System.Text.Json; + +namespace DorkNet.Launcher.Backend; + +public static class SingleOriginServiceMap +{ + private static readonly (string Service, string Prefix)[] Services = + [ + ("WWW", "www"), + ("API", "api"), + ("Accounts", "accounts"), + ("Auth", "auth"), + ("BugReporting", "bugreporting"), + ("Cards", "cards"), + ("CDN", "cdn"), + ("Chat", "chat"), + ("Clubs", "clubs"), + ("CMS", "cms"), + ("Commerce", "commerce"), + ("Data", "data"), + ("DataCollection", "datacollection"), + ("Discovery", "discovery"), + ("Econ", "econ"), + ("GameLogs", "gamelogs"), + ("Geo", "geo"), + ("Images", "img"), + ("Leaderboard", "leaderboard"), + ("Link", "link"), + ("Lists", "lists"), + ("Matchmaking", "match"), + ("Moderation", "moderation"), + ("NameServer", "ns"), + ("Notifications", "notify"), + ("PlatformNotifications", "platformnotifications"), + ("PlayerSettings", "playersettings"), + ("RoomComments", "roomcomments"), + ("RoomieIntegrations", "roomieintegrations"), + ("Rooms", "rooms"), + ("Storage", "storage"), + ("Strings", "strings"), + ("StringsCDN", "strings-cdn"), + ("Studio", "studio"), + ("Thorn", "thorn"), + ("Videos", "videos"), + ]; + + public static Dictionary Build(string publicBaseUrl) + { + var baseUrl = NormalizeBaseUrl(publicBaseUrl); + return Services.ToDictionary( + x => x.Service, + x => $"{baseUrl}/__dn/{x.Prefix}", + StringComparer.OrdinalIgnoreCase); + } + + public static string ToJson(string publicBaseUrl) + => JsonSerializer.Serialize(Build(publicBaseUrl)); + + public static string NormalizeBaseUrl(string value) + { + var trimmed = (value ?? "").Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + throw new ArgumentException("Public tunnel URL is empty.", nameof(value)); + if (!trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + trimmed = "https://" + trimmed; + return trimmed.TrimEnd('/'); + } +} diff --git a/launcher/Backend/StartupStep.cs b/launcher/Backend/StartupStep.cs index 813f9eb..738156b 100644 --- a/launcher/Backend/StartupStep.cs +++ b/launcher/Backend/StartupStep.cs @@ -77,9 +77,13 @@ public static class StartupFlow new StartupStep("Download server binary"), new StartupStep(mode switch { + HostingMode.SingleOriginTunnel => "Start localtunnel", HostingMode.LocalNetwork => "Set up local sslip.io address", - HostingMode.RemoteWildcard => "Set up Tunnelto tunnel", - _ => "Set up Tunnelto tunnel", + HostingMode.RemoteWildcard => "Install / start Tunnelto tunnel", + // Internet maps onto SingleOriginTunnel via AppState.Load + // migration; keep the arm in case a stale value sneaks in. + HostingMode.Internet => "Start localtunnel", + _ => "Start localtunnel", }), new StartupStep("Start the server"), new StartupStep("Download client patcher"), diff --git a/launcher/Backend/TunneltoInstaller.cs b/launcher/Backend/TunneltoInstaller.cs index 78c92f0..6e1aaee 100644 --- a/launcher/Backend/TunneltoInstaller.cs +++ b/launcher/Backend/TunneltoInstaller.cs @@ -4,6 +4,7 @@ using System.IO.Compression; using System.Net.Http; using System.Net.Http.Json; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using System.Text.Json.Serialization; namespace DorkNet.Launcher.Backend; @@ -72,8 +73,16 @@ public sealed class TunneltoInstaller "`cargo install tunnelto` (or grab a build from github.com/agrinman/tunnelto/releases)."); } - var release = await Http.GetFromJsonAsync(ReleasesApi, ct) - ?? throw new InvalidOperationException("Couldn't reach github.com/agrinman/tunnelto/releases."); + GitHubRelease release; + try + { + release = await Http.GetFromJsonAsync(ReleasesApi, ct) + ?? throw new InvalidOperationException("Couldn't reach github.com/agrinman/tunnelto/releases."); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException("Timed out while checking GitHub for the latest Tunnelto release."); + } var (assetName, kind) = AssetForCurrentPlatform(); var asset = release.Assets?.FirstOrDefault(a => @@ -91,7 +100,14 @@ public sealed class TunneltoInstaller var stagingPath = Path.Combine(AppPaths.LocalRoot, $"tunnelto-staging-{Guid.NewGuid():N}"); try { - await DownloadAsync(asset.BrowserDownloadUrl, stagingPath, progress, ct); + try + { + await DownloadAsync(asset.BrowserDownloadUrl, stagingPath, progress, ct); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException("Timed out while downloading Tunnelto from GitHub."); + } switch (kind) { case AssetKind.RawExe: @@ -184,6 +200,7 @@ public sealed class TunneltoInstaller /// Mark a file as executable on Unix systems. Uses /// (available since .NET 7). /// No-op on Windows since the NTFS execute bit doesn't apply. + [UnsupportedOSPlatform("windows")] private static void TryChmodExec(string path) { try diff --git a/launcher/Backend/TunneltoTunnel.cs b/launcher/Backend/TunneltoTunnel.cs index 872ac83..80454ee 100644 --- a/launcher/Backend/TunneltoTunnel.cs +++ b/launcher/Backend/TunneltoTunnel.cs @@ -14,6 +14,8 @@ public sealed class TunneltoTunnel : IAsyncDisposable { private Process? _process; private TaskCompletionSource? _urlReady; + private readonly object _outputLock = new(); + private readonly Queue _recentOutput = new(); public string? PublicUrl { get; private set; } public async Task StartAsync( @@ -36,20 +38,41 @@ public sealed class TunneltoTunnel : IAsyncDisposable RedirectStandardOutput = true, RedirectStandardError = true, }; - psi.ArgumentList.Add("add"); - psi.ArgumentList.Add(tunnelHost); + psi.ArgumentList.Add("--host"); + psi.ArgumentList.Add("127.0.0.1"); + psi.ArgumentList.Add("--port"); psi.ArgumentList.Add(localPort.ToString()); + if (TryGetTunneltoSubdomain(tunnelHost, out var subdomain)) + { + psi.ArgumentList.Add("--subdomain"); + psi.ArgumentList.Add(subdomain); + } _process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to spawn tunnelto.exe"); + _process.EnableRaisingEvents = true; + _process.Exited += (_, _) => + { + if (_urlReady is null || _urlReady.Task.IsCompleted) return; + _urlReady.TrySetException(new InvalidOperationException( + "Tunnelto exited before reporting a public URL." + FormatRecentOutput())); + }; _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); + var readyTask = _urlReady.Task; + var timeoutTask = Task.Delay(TimeSpan.FromSeconds(45), ct); + var completed = await Task.WhenAny(readyTask, timeoutTask); + if (completed == timeoutTask) + { + ct.ThrowIfCancellationRequested(); + throw new TimeoutException( + "Tunnelto didn't report a public URL within 45 seconds." + FormatRecentOutput()); + } + + PublicUrl = await readyTask; return PublicUrl; } @@ -74,6 +97,16 @@ public sealed class TunneltoTunnel : IAsyncDisposable return $"dorknet-{token}.tunnelto.me"; } + private static bool TryGetTunneltoSubdomain(string host, out string subdomain) + { + const string suffix = ".tunnelto.me"; + subdomain = ""; + if (!host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) return false; + + subdomain = host[..^suffix.Length]; + return !string.IsNullOrWhiteSpace(subdomain); + } + private static string ResolveExecutable() { var isWindows = OperatingSystem.IsWindows(); @@ -112,11 +145,27 @@ public sealed class TunneltoTunnel : IAsyncDisposable private void ParseLine(string? line) { - if (line is null || _urlReady is null || _urlReady.Task.IsCompleted) return; + if (line is null) return; + lock (_outputLock) + { + _recentOutput.Enqueue(line); + while (_recentOutput.Count > 8) _recentOutput.Dequeue(); + } + + if (_urlReady is null || _urlReady.Task.IsCompleted) return; var match = UrlRegex.Match(line); if (match.Success) _urlReady.TrySetResult(match.Value); } + private string FormatRecentOutput() + { + lock (_outputLock) + { + if (_recentOutput.Count == 0) return ""; + return " Last Tunnelto output: " + string.Join(" | ", _recentOutput); + } + } + public async ValueTask DisposeAsync() { if (_process is null) return; diff --git a/launcher/MainWindow.xaml b/launcher/MainWindow.xaml index bb5c603..2b9988c 100644 --- a/launcher/MainWindow.xaml +++ b/launcher/MainWindow.xaml @@ -331,7 +331,7 @@ - @@ -662,11 +662,12 @@ The Tunnelto base host field only matters when the user - /// picks the wildcard-base option. Plain Tunnelto auto-generates it; - /// LAN doesn't use it at all. Hide it everywhere else. + /// The custom tunnel base field is legacy-only. The guided + /// path uses localtunnel or LAN direct hosting, so hide it unless an + /// older saved state still selects the custom provider mode. private void UpdateRemoteWildcardVisibility() { var show = _state.HostingMode == HostingMode.RemoteWildcard @@ -603,7 +605,7 @@ public partial class MainWindow : Window ? HostingMode.RemoteWildcard : HostModeLocal.IsChecked == true ? HostingMode.LocalNetwork - : HostingMode.Internet; + : HostingMode.SingleOriginTunnel; _state.Save(); UpdateRemoteWildcardVisibility(); } @@ -626,7 +628,7 @@ public partial class MainWindow : Window { ShowError("Pick your Rec Room install first."); return; } if (string.IsNullOrEmpty(_state.PhotonAppId)) { ShowError("Photon AppId required (free at dashboard.photonengine.com)."); return; } - if (_state.HostingMode != HostingMode.LocalNetwork) + if (_state.HostingMode == HostingMode.RemoteWildcard) { var typed = TunneltoTunnel.NormalizeHost(RemoteWildcardInput.Text); _state.RemoteWildcardApex = string.IsNullOrWhiteSpace(typed) @@ -674,30 +676,19 @@ public partial class MainWindow : Window _hostApex = lan.Host; activeStep.Detail = $"LAN address {lan.Ip} via {_hostApex}"; } - else if (_state.HostingMode == HostingMode.RemoteWildcard) + else if (_state.HostingMode == HostingMode.SingleOriginTunnel + || _state.HostingMode == HostingMode.Internet) { - 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}"; + var publicUrl = await StartLocaltunnelAsync(activeStep); + _hostApex = new Uri(publicUrl).Host; + _nameServer.Start(publicUrl); + activeStep.Detail = $"localtunnel ready: {publicUrl}; launcher name-server on 127.0.0.1:80"; } else { - 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; + var publicHost = await StartTunneltoAsync(activeStep); _hostApex = TunneltoTunnel.NormalizeHost(_state.RemoteWildcardApex); activeStep.Detail = $"Tunnel ready: {publicHost}; services under {_hostApex}"; - activeStep.Progress = null; } CompleteStep(activeStep); @@ -729,7 +720,8 @@ public partial class MainWindow : Window activeStep = StartStep(_hostSteps, 5); var patch = await _patcher.ApplyAsync( patcherDir, _state.RecRoomPath!, - _state.PhotonAppId, _state.PhotonVoiceAppId, _state.PhotonRegion, _hostApex); + _state.PhotonAppId, _state.PhotonVoiceAppId, _state.PhotonRegion, _hostApex, + singleOrigin: _state.HostingMode == HostingMode.SingleOriginTunnel); if (!patch.Ok) { FailStepWithFriendly(activeStep, ErrorTranslator.TranslateMessage(patch.Log)); @@ -747,13 +739,20 @@ public partial class MainWindow : Window PhotonVoiceAppId = _state.PhotonVoiceAppId, PhotonRegion = _state.PhotonRegion, Name = _state.ServerName, + SingleOrigin = _state.HostingMode == HostingMode.SingleOriginTunnel, }); JoinCodeText.Text = code; JoinCodeQrImage.Source = QrCodeRenderer.Render(code); JoinCodePanel.Visibility = Visibility.Visible; - HostStatusText.Text = _state.HostingMode == HostingMode.LocalNetwork - ? $"Hosting on your local network at {_hostApex}. Share the join code with friends on the same WiFi." - : $"Hosting at https://{_hostApex}. Share the join code below or launch Rec Room to test."; + HostStatusText.Text = _state.HostingMode switch + { + HostingMode.LocalNetwork => + $"Hosting on your local network at {_hostApex}. Share the join code with friends on the same WiFi.", + HostingMode.SingleOriginTunnel or HostingMode.Internet => + $"Hosting through Localtunnel at https://{_hostApex}. Keep this launcher open while playing.", + _ => + $"Hosting at https://{_hostApex}. Share the join code below or launch Rec Room to test.", + }; HostLaunchBtn.Visibility = Visibility.Visible; HostAdminBtn.Visibility = Visibility.Visible; SetSidebarStatus("Live", "Ok"); @@ -775,6 +774,32 @@ public partial class MainWindow : Window HostRetryPanel.Visibility = Visibility.Visible; } + private async Task StartTunneltoAsync(StartupStep step) + { + step.Detail = "Checking Tunnelto"; + var installer = new TunneltoInstaller(); + var progress = new Progress(p => UpdateStepProgress(step, p)); + await installer.EnsureInstalledAsync(progress); + step.Progress = null; + + var tunnel = new TunneltoTunnel(); + _tunnel = tunnel; + step.Detail = $"Connecting Tunnelto for {_state.RemoteWildcardApex}"; + var publicUrl = await tunnel.StartAsync( + _state.RemoteWildcardApex, + ServerProcess.DefaultLocalPort); + return new Uri(publicUrl).Host; + } + + private async Task StartLocaltunnelAsync(StartupStep step) + { + step.Detail = "Starting localtunnel through npx"; + step.Progress = null; + var tunnel = new LocaltunnelTunnel(); + _tunnel = tunnel; + return await tunnel.StartAsync(ServerProcess.DefaultLocalPort); + } + private void OnHostRetry(object sender, RoutedEventArgs e) { HostRetryPanel.Visibility = Visibility.Collapsed; @@ -836,8 +861,8 @@ public partial class MainWindow : Window /// Open the server's admin web panel in the user's default /// browser. The admin SPA is published at admin.<apex> - /// over the same scheme the server uses (https for tunnel modes, - /// http for LAN). First account created on a fresh server is + /// over the same scheme the server uses (http for direct modes, + /// https for legacy tunnel modes). First account created on a fresh server is /// auto-promoted; subsequent admins are added from inside the panel. private void OnOpenAdminPanel(object sender, RoutedEventArgs e) { @@ -846,8 +871,12 @@ public partial class MainWindow : Window ShowError("Server isn't running. Start hosting first, then the admin panel becomes reachable."); return; } - var scheme = _state.HostingMode == HostingMode.LocalNetwork ? "http" : "https"; - var url = $"{scheme}://admin.{_hostApex}"; + var url = _state.HostingMode == HostingMode.SingleOriginTunnel + ? $"https://{_hostApex}/__dn/admin/" + : null; + var scheme = _state.HostingMode is HostingMode.LocalNetwork or HostingMode.Internet + ? "http" : "https"; + url ??= $"{scheme}://admin.{_hostApex}"; try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); @@ -862,6 +891,7 @@ public partial class MainWindow : Window { try { await _server.StopAsync(); } catch { } if (_tunnel is not null) { try { await _tunnel.DisposeAsync(); } catch { } _tunnel = null; } + try { await _nameServer.DisposeAsync(); } catch { } } private void OnCopyJoinCode(object sender, RoutedEventArgs e) @@ -877,7 +907,8 @@ public partial class MainWindow : Window : _state.ServerName; var msg = $"Join {name} on DorkNet! Grab the launcher at " + - "https://github.com/DorkSquadRR/DorkNet and paste this code:\n\n" + + "https://github.com/DorkSquadRR/DorkNet\n\n" + + "Paste this code:\n\n" + JoinCodeText.Text; try { Clipboard.SetText(msg); } catch (Exception ex) { ShowError("Copy failed: " + ex.Message); } @@ -894,7 +925,7 @@ public partial class MainWindow : Window var body = Uri.EscapeDataString( $"Hey! Want to play on {name}?\n\n" + "1. Download the DorkNet launcher: https://github.com/DorkSquadRR/DorkNet\n" + - "2. Open it and pick \"Join a friend\"\n" + + "2. Open DorkNet and pick \"Join a friend\"\n" + "3. Paste this code:\n\n" + JoinCodeText.Text); try { Process.Start(new ProcessStartInfo($"mailto:?subject={subject}&body={body}") @@ -925,10 +956,14 @@ public partial class MainWindow : Window return; } JoinPreviewBorder.Visibility = Visibility.Visible; + var singleOriginHint = payload.SingleOrigin + ? "\nNetwork: Localtunnel — keep this launcher open while Rec Room is running." + : ""; JoinPreviewText.Text = $"Connecting to {(string.IsNullOrEmpty(payload.Name) ? "(unnamed server)" : payload.Name)}\n" + $"Host: {payload.Host}\n" + - $"Version: {payload.VersionKey}"; + $"Version: {payload.VersionKey}" + + singleOriginHint; } private async void OnJoinApply(object sender, RoutedEventArgs e) @@ -953,12 +988,16 @@ public partial class MainWindow : Window StartupStep? activeStep = null; try { - activeStep = StartStep(_joinSteps, 0); + var stepIndex = 0; + if (payload.SingleOrigin) + _nameServer.Start($"https://{payload.Host}"); + + activeStep = StartStep(_joinSteps, stepIndex++); var progress = new Progress(p => UpdateStepProgress(activeStep, p)); var patcherDir = await _releases.EnsurePatcherAsync(version, progress); CompleteStep(activeStep); - activeStep = StartStep(_joinSteps, 1); + activeStep = StartStep(_joinSteps, stepIndex++); var stripProgress = new Progress(p => UpdateStepProgress(activeStep, p)); var stripResult = await SteamDrmStripper.StripIfNeededAsync( _state.RecRoomPath!, stripProgress); @@ -970,10 +1009,11 @@ public partial class MainWindow : Window }; CompleteStep(activeStep); - activeStep = StartStep(_joinSteps, 2); + activeStep = StartStep(_joinSteps, stepIndex++); var patch = await _patcher.ApplyAsync( patcherDir, _state.RecRoomPath!, - payload.PhotonAppId, payload.PhotonVoiceAppId, payload.PhotonRegion, payload.Host); + payload.PhotonAppId, payload.PhotonVoiceAppId, payload.PhotonRegion, payload.Host, + singleOrigin: payload.SingleOrigin); if (!patch.Ok) { FailStepWithFriendly(activeStep, ErrorTranslator.TranslateMessage(patch.Log)); @@ -1003,6 +1043,7 @@ public partial class MainWindow : Window OnJoinApply(sender, e); } + private void OnOpenTroubleshooting(object sender, RoutedEventArgs e) { try diff --git a/launcher/PhotonWizard.xaml b/launcher/PhotonWizard.xaml index 5574dce..d5cb987 100644 --- a/launcher/PhotonWizard.xaml +++ b/launcher/PhotonWizard.xaml @@ -96,8 +96,8 @@ TextWrapping="Wrap" LineHeight="22" Margin="0,0,0,14" Text="On the Photon dashboard:" /> - - + + diff --git a/launcher/README.md b/launcher/README.md index 38ffef2..d4e4b48 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -12,8 +12,9 @@ This README is for **developers** of the launcher. End-users see - **Host mode:** fetches the matching server binary from GitHub Releases for the user's picked Rec Room version, runs it as a - subprocess, opens a Cloudflare quick-tunnel, patches the user's - Rec Room install, hands the user a shareable join code. + subprocess and exposes it through a Localtunnel HTTPS URL (or + binds on the LAN if the user picked that mode), patches the + user's Rec Room install, hands the user a shareable join code. - **Join mode:** parses the join code, fetches the matching client patcher from GitHub Releases, patches the user's Rec Room install to point at the host. @@ -34,7 +35,8 @@ launcher/ ├── ReleaseDownloader.cs GitHub Releases API + artifact unpack ├── ServerProcess.cs Spawns the downloaded server binary ├── ClientPatcher.cs Invokes install-plugin.ps1 from the unpacked patcher - ├── Tunnel.cs Wraps `cloudflared tunnel --url …` + ├── LocaltunnelInstaller.cs Downloads the Localtunnel .NET client + ├── LocaltunnelTunnel.cs Spawns the tunnel + parses the *.loca.lt URL ├── RecRoomPicker.cs Microsoft.Win32.OpenFolderDialog └── JoinCode.cs base64url-encoded {host, version, photonAppId, …} ```