Localtunnel hosting + admin panel + AppData db

- Default hosting mode is now Localtunnel — public *.loca.lt URL,
  no account, auto-fetched on first run. Replaces the prototyped
  Tailscale mode.
- Tailscale code purged from launcher + CLI; docs swapped to
  Localtunnel throughout (easy-setup, joining-a-server, server-cli,
  troubleshooting, faq, READMEs).
- Launcher sidebar gains an Open admin panel button — opens
  https://admin.<apex> in the browser once the server is live.
- SQLite db moves to %APPDATA%\DorkNet\dorknet.db (server reads
  Database__SqlitePath env var, launcher passes it). Existing dbs
  under <serverDir>\data\ are migrated on first run.
- CLI Program.cs: --mode default flips from tailscale to localtunnel;
  csproj links in LocaltunnelTunnel + LocaltunnelInstaller.
- New Backend files: LauncherNameServer, LocaltunnelInstaller,
  LocaltunnelTunnel, SingleOriginServiceMap.
- .gitignore: exclude installer/out/.
This commit is contained in:
Alexa
2026-05-28 01:39:38 +03:00
parent 7852b410ee
commit f5ad4b59ef
30 changed files with 942 additions and 184 deletions
+2
View File
@@ -6,6 +6,7 @@
**/.vite/
**/build/
local-releases/
**/installer/out/
# IDE
.vs/
@@ -66,3 +67,4 @@ Thumbs.db
# Test outputs
TestResults/
*.coverage
+3 -3
View File
@@ -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.
@@ -49,6 +49,15 @@
<Compile Include="..\..\launcher\Backend\TunneltoTunnel.cs">
<Link>Backend\TunneltoTunnel.cs</Link>
</Compile>
<Compile Include="..\..\launcher\Backend\TunneltoInstaller.cs">
<Link>Backend\TunneltoInstaller.cs</Link>
</Compile>
<Compile Include="..\..\launcher\Backend\LocaltunnelTunnel.cs">
<Link>Backend\LocaltunnelTunnel.cs</Link>
</Compile>
<Compile Include="..\..\launcher\Backend\LocaltunnelInstaller.cs">
<Link>Backend\LocaltunnelInstaller.cs</Link>
</Compile>
<Compile Include="..\..\launcher\Backend\VersionsManifest.cs">
<Link>Backend\VersionsManifest.cs</Link>
</Compile>
+39 -15
View File
@@ -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<DownloadProgress>(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 <guid> Photon Voice AppId. Empty = no voice.
--region <code> Photon cloud region (us | eu | asia | jp |
sa | kr | in | au). Default: us.
--mode <kind> tunnelto (default) — friends anywhere
wildcard — Tunnelto wildcard
base (needs --apex)
lan — same WiFi only,
binds on 0.0.0.0:80
--mode <kind> 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 <hostname> Wildcard apex (e.g. dorknet.tunnelto.me).
Required for --mode wildcard.
--name "<text>" 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
+27 -21
View File
@@ -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.<base>`, `auth.<base>`, 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://<random-name>.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\<version>\`. ~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://<random>.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-<random>.tunnelto.me` | Default — works for everyone, no setup |
| Tunnelto wildcard base | yes | `<your-apex>` + `api.<apex>`, `auth.<apex>`, … | Power users with a wildcard Tunnelto plan |
| Friends anywhere · Localtunnel | public HTTPS, anonymous | `https://<random>.loca.lt` | Default — friends connect from anywhere with just the join code |
| Same WiFi only · sslip.io | no | `<lan-ip>.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.
+9 -4
View File
@@ -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.
+16 -5
View File
@@ -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
+3 -3
View File
@@ -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
+45 -32
View File
@@ -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://<random>.loca.lt` URL. The CLI
calls `npx localtunnel --port <local>` 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 "<text>" Server display name in the join code.
Default: "DorkNet Server".
--mode <kind> tunnelto (default) — public, friends anywhere
wildcard — your own Tunnelto apex
lan — same WiFi only, no tunnel
--mode <kind> localtunnel (default) — public *.loca.lt URL,
friends anywhere
wildcard — legacy Tunnelto wildcard
apex
lan — same WiFi only, no tunnel
--apex <hostname> Wildcard apex. Required for --mode wildcard.
Example: dorknet.example.tunnelto.me
--version <key> 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`
+21 -9
View File
@@ -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://<host>/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
`<random>.loca.lt` URL on every host restart — the joiner needs the
most recent code.)
2. Open `https://<host>/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
+5 -1
View File
@@ -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"
}
+1
View File
@@ -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.
+20 -8
View File
@@ -39,13 +39,14 @@ public sealed class AppState
[JsonPropertyName("photonRegion")]
public string PhotonRegion { get; set; } = "us";
/// <summary>How the host exposes the server: <c>Internet</c> uses
/// Tunnelto (works for friends anywhere with the join code);
/// <c>LocalNetwork</c> binds on the LAN and uses an sslip.io
/// hostname in the join code (joiners must be on the same
/// network).</summary>
/// <summary>How the host exposes the server: <c>SingleOriginTunnel</c>
/// (default) opens one public Localtunnel HTTPS URL plus a
/// launcher-hosted local name server; <c>LocalNetwork</c> binds on
/// the LAN and uses an sslip.io hostname in the join code (joiners
/// must be on the same network); <c>RemoteWildcard</c> is the
/// legacy provider-owned-apex mode kept for back-compat.</summary>
[JsonPropertyName("hostingMode")]
public HostingMode HostingMode { get; set; } = HostingMode.Internet;
public HostingMode HostingMode { get; set; } = HostingMode.SingleOriginTunnel;
/// <summary>Provider-owned wildcard tunnel base for remote hosting
/// without Cloudflare, e.g. <c>dorknet.tunnelto.me</c>. 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
{
/// <summary>Default — Tunnelto tunnel, public over the internet,
/// works for joiners anywhere.</summary>
/// <summary>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.</summary>
SingleOriginTunnel,
/// <summary>Legacy "Friends anywhere" mode kept for migration
/// purposes only. New installs are normalised to
/// <see cref="SingleOriginTunnel"/> at <see cref="Load"/> time
/// (see the migration block there).</summary>
Internet,
/// <summary>LAN only — bind on all local interfaces, skip the
/// tunnel, and publish an sslip.io hostname for the machine's LAN
+6 -3
View File
@@ -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)}");
}
+22 -1
View File
@@ -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(
+11 -5
View File
@@ -6,8 +6,9 @@ using System.Text.Json.Serialization;
namespace DorkNet.Launcher.Backend;
/// <summary>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 <c>*.loca.lt</c> URL for the
/// default mode, or an <c>sslip.io</c> 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
{
/// <summary>Public hostname the joining patcher rewrites
/// <c>*.rec.net</c> URIs to. Cloudflare Tunnel typically gives
/// <c>random-words.trycloudflare.com</c>.</summary>
/// <summary>Hostname the joining patcher rewrites <c>*.rec.net</c>
/// URIs to. Default Localtunnel mode hands out
/// <c>&lt;random&gt;.loca.lt</c>; LAN mode uses an <c>sslip.io</c>
/// name that resolves to the host's private IP.</summary>
[JsonPropertyName("host")] public string Host { get; set; } = "";
/// <summary>Version key from versions.json. Tells the joiner which
@@ -65,4 +67,8 @@ public sealed class JoinPayload
/// <summary>Server name shown in the joiner's "you're about to
/// connect to ..." preview.</summary>
[JsonPropertyName("n")] public string Name { get; set; } = "";
/// <summary>True when all RecNet services are exposed under one
/// public origin using /__dn/{service}/ path prefixes.</summary>
[JsonPropertyName("so")] public bool SingleOrigin { get; set; }
}
+81
View File
@@ -0,0 +1,81 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace DorkNet.Launcher.Backend;
/// <summary>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.</summary>
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;
}
}
+3 -3
View File
@@ -5,15 +5,15 @@ using System.Net.Sockets;
namespace DorkNet.Launcher.Backend;
/// <summary>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.
///
/// <para>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.</para></summary>
/// machine but joiners need either a real LAN address or one of the
/// tunnel modes (Localtunnel / Tunnelto).</para></summary>
public static class LocalNetwork
{
private const string SslipDomain = "sslip.io";
+46 -4
View File
@@ -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;
}
}
/// <summary>Returns the local server-dir for <paramref name="versionKey"/>
/// if the releases-root override is set and the subfolder exists.
/// Convention: <c>{root}/{version_key}/server/</c>.</summary>
@@ -86,8 +128,8 @@ public static class LocalOverrides
var parts = new List<string>();
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);
}
+120
View File
@@ -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<string?> TryEnsureAsync(
IProgress<DownloadProgress>? 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<GitHubRelease>(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<DownloadProgress>? 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<GitHubAsset>? Assets { get; set; }
}
private sealed class GitHubAsset
{
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("browser_download_url")] public string? BrowserDownloadUrl { get; set; }
}
}
+164
View File
@@ -0,0 +1,164 @@
using System.Diagnostics;
using System.IO;
using System.Text.RegularExpressions;
namespace DorkNet.Launcher.Backend;
/// <summary>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.</summary>
public sealed class LocaltunnelTunnel : IAsyncDisposable
{
private Process? _process;
private TaskCompletionSource<string>? _urlReady;
private readonly object _outputLock = new();
private readonly Queue<string> _recentOutput = new();
public string? PublicUrl { get; private set; }
public async Task<string> 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<string>(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 }
}
+43 -5
View File
@@ -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");
}
/// <summary>If an older launcher build left the SQLite db under
/// <c>&lt;serverDir&gt;\data\dorknet.db</c> (where the server defaulted
/// before <see cref="Database__SqlitePath"/> took over), copy it to
/// the new <see cref="AppPaths.RoamingRoot"/> 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.</summary>
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,
@@ -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<string, string> 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('/');
}
}
+6 -2
View File
@@ -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"),
+20 -3
View File
@@ -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<GitHubRelease>(ReleasesApi, ct)
?? throw new InvalidOperationException("Couldn't reach github.com/agrinman/tunnelto/releases.");
GitHubRelease release;
try
{
release = await Http.GetFromJsonAsync<GitHubRelease>(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
/// <summary>Mark a file as executable on Unix systems. Uses
/// <see cref="File.SetUnixFileMode"/> (available since .NET 7).
/// No-op on Windows since the NTFS execute bit doesn't apply.</summary>
[UnsupportedOSPlatform("windows")]
private static void TryChmodExec(string path)
{
try
+55 -6
View File
@@ -14,6 +14,8 @@ public sealed class TunneltoTunnel : IAsyncDisposable
{
private Process? _process;
private TaskCompletionSource<string>? _urlReady;
private readonly object _outputLock = new();
private readonly Queue<string> _recentOutput = new();
public string? PublicUrl { get; private set; }
public async Task<string> 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;
+5 -3
View File
@@ -331,7 +331,7 @@
<TextBlock Text="ZERO COST" FontSize="10" FontWeight="Bold"
Foreground="{StaticResource Ink}"
Margin="0,0,0,6" />
<TextBlock Text="No subscription, no card. Tunnelto handles the public address."
<TextBlock Text="No port forwards. localtunnel gives one temporary HTTPS link."
FontSize="12" Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" LineHeight="18" />
</StackPanel>
@@ -662,11 +662,12 @@
<TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" />
<StackPanel Margin="0,0,0,16">
<RadioButton x:Name="SetupHostInternet" GroupName="SetupHostingMode"
Content="Friends anywhere · Tunnelto"
Content="Friends anywhere · localtunnel"
IsChecked="True"
Checked="OnSetupHostingModeChanged" />
<RadioButton x:Name="SetupHostRemote" GroupName="SetupHostingMode"
Content="Friends anywhere · Tunnelto wildcard base"
Visibility="Collapsed"
Checked="OnSetupHostingModeChanged" />
<RadioButton x:Name="SetupHostLocal" GroupName="SetupHostingMode"
Content="Same WiFi only · sslip.io"
@@ -880,10 +881,11 @@
<TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" />
<StackPanel>
<RadioButton x:Name="HostModeInternet" GroupName="HostingMode"
Content="Friends anywhere · Tunnelto"
Content="Friends anywhere · localtunnel"
IsChecked="True" Checked="OnHostingModeChanged" />
<RadioButton x:Name="HostModeRemote" GroupName="HostingMode"
Content="Tunnelto wildcard base"
Visibility="Collapsed"
Checked="OnHostingModeChanged" />
<RadioButton x:Name="HostModeLocal" GroupName="HostingMode"
Content="Same WiFi only · sslip.io"
+84 -43
View File
@@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Net.Http;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Navigation;
@@ -18,6 +19,7 @@ public partial class MainWindow : Window
private readonly ClientPatcher _patcher = new();
private readonly ServerProcess _server = new();
private readonly Updater _updater = new();
private readonly LauncherNameServer _nameServer = new();
private IAsyncDisposable? _tunnel;
private VersionsManifest? _manifest;
private string? _hostApex;
@@ -205,13 +207,13 @@ public partial class MainWindow : Window
// main HostView and the setup wizard's matching step.
if (HostModeLocal is not null && HostModeInternet is not null && HostModeRemote is not null)
{
HostModeInternet.IsChecked = _state.HostingMode == HostingMode.Internet;
HostModeInternet.IsChecked = _state.HostingMode == HostingMode.SingleOriginTunnel;
HostModeLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
HostModeRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
}
if (SetupHostLocal is not null && SetupHostInternet is not null && SetupHostRemote is not null)
{
SetupHostInternet.IsChecked = _state.HostingMode == HostingMode.Internet;
SetupHostInternet.IsChecked = _state.HostingMode == HostingMode.SingleOriginTunnel;
SetupHostLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
SetupHostRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
}
@@ -414,7 +416,7 @@ public partial class MainWindow : Window
// on it via Next or Re-run setup.
if (SetupHostInternet is not null && SetupHostRemote is not null && SetupHostLocal is not null)
{
SetupHostInternet.IsChecked = _state.HostingMode == HostingMode.Internet;
SetupHostInternet.IsChecked = _state.HostingMode == HostingMode.SingleOriginTunnel;
SetupHostRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
SetupHostLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
}
@@ -465,21 +467,21 @@ public partial class MainWindow : Window
? HostingMode.RemoteWildcard
: SetupHostLocal.IsChecked == true
? HostingMode.LocalNetwork
: HostingMode.Internet;
: HostingMode.SingleOriginTunnel;
_state.Save();
// Mirror to main HostView radios.
if (HostModeLocal is not null && HostModeInternet is not null && HostModeRemote is not null)
{
HostModeInternet.IsChecked = _state.HostingMode == HostingMode.Internet;
HostModeInternet.IsChecked = _state.HostingMode == HostingMode.SingleOriginTunnel;
HostModeLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
HostModeRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
}
UpdateRemoteWildcardVisibility();
}
/// <summary>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.</summary>
/// <summary>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.</summary>
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<string> StartTunneltoAsync(StartupStep step)
{
step.Detail = "Checking Tunnelto";
var installer = new TunneltoInstaller();
var progress = new Progress<DownloadProgress>(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<string> 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
/// <summary>Open the server's admin web panel in the user's default
/// browser. The admin SPA is published at <c>admin.&lt;apex&gt;</c>
/// 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.</summary>
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<DownloadProgress>(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<DownloadProgress>(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
+2 -2
View File
@@ -96,8 +96,8 @@
TextWrapping="Wrap" LineHeight="22" Margin="0,0,0,14"
Text="On the Photon dashboard:" />
<StackPanel Margin="6,0,0,12">
<TextBlock Style="{StaticResource StepBullet}" Text="1. Click the orange &quot;Create a new app&quot; button (top right)." />
<TextBlock Style="{StaticResource StepBullet}" Text="2. Under &quot;Photon Type&quot; choose Photon Realtime." />
<TextBlock Style="{StaticResource StepBullet}" Text="1. Click &quot;Create a new app&quot; on the dashboard." />
<TextBlock Style="{StaticResource StepBullet}" Text="2. Under &quot;Photon Type&quot; choose Photon SDK." />
<TextBlock Style="{StaticResource StepBullet}" Text="3. Name it anything (e.g. &quot;DorkNet Realtime&quot;)." />
<TextBlock Style="{StaticResource StepBullet}" Text="4. Click Create." />
<TextBlock Style="{StaticResource StepBullet}" Text="5. Back on the dashboard, click the new app's tile — the AppId is the long string near the top, looking like 12ab34cd-56ef-..." />
+5 -3
View File
@@ -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, …}
```