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**.
-
+
## 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, …}
```