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/ **/.vite/
**/build/ **/build/
local-releases/ local-releases/
**/installer/out/
# IDE # IDE
.vs/ .vs/
@@ -66,3 +67,4 @@ Thumbs.db
# Test outputs # Test outputs
TestResults/ TestResults/
*.coverage *.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). rolling auto-updates) or a **portable `dorknet.exe`** (drop anywhere).
Pick a Rec Room build on first launch, host or join. Pick a Rec Room build on first launch, host or join.
- **Host a server** — runs a local server + Tunnelto tunnel for the - **Host a server** — runs a local server, opens a free Localtunnel
client version you pick, patches your Rec Room install, and gives HTTPS URL for it (no account), patches your Rec Room install for the
you a shareable join code. client version you pick, and gives you a shareable join code.
- **Join a server** — paste the code, point at your Rec Room install, - **Join a server** — paste the code, point at your Rec Room install,
hit play. The launcher fetches the matching patcher. hit play. The launcher fetches the matching patcher.
@@ -49,6 +49,15 @@
<Compile Include="..\..\launcher\Backend\TunneltoTunnel.cs"> <Compile Include="..\..\launcher\Backend\TunneltoTunnel.cs">
<Link>Backend\TunneltoTunnel.cs</Link> <Link>Backend\TunneltoTunnel.cs</Link>
</Compile> </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"> <Compile Include="..\..\launcher\Backend\VersionsManifest.cs">
<Link>Backend\VersionsManifest.cs</Link> <Link>Backend\VersionsManifest.cs</Link>
</Compile> </Compile>
+34 -10
View File
@@ -33,6 +33,8 @@ public static class Program
Console.WriteLine($" mode: {parsed.Mode}"); Console.WriteLine($" mode: {parsed.Mode}");
Console.WriteLine($" server: {parsed.ServerName}"); Console.WriteLine($" server: {parsed.ServerName}");
Console.WriteLine($" photon: {Redact(parsed.PhotonAppId)} ({parsed.Region})"); 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) if (parsed.Mode == HostingMode.RemoteWildcard)
Console.WriteLine($" apex: {state.RemoteWildcardApex}"); Console.WriteLine($" apex: {state.RemoteWildcardApex}");
Console.WriteLine(); Console.WriteLine();
@@ -57,7 +59,7 @@ public static class Program
var releases = new ReleaseDownloader(); var releases = new ReleaseDownloader();
var server = new ServerProcess(); var server = new ServerProcess();
TunneltoTunnel? tunnel = null; IAsyncDisposable? tunnel = null;
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
@@ -95,17 +97,38 @@ public static class Program
apex = lan.Host; apex = lan.Host;
Console.WriteLine($"[lan] bound on {lan.Ip} (apex={apex})"); 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…"); Console.WriteLine("[tunnel] starting tunnelto…");
tunnel = new TunneltoTunnel(); var tt = new TunneltoTunnel();
var publicUrl = await tunnel.StartAsync( tunnel = tt;
var publicUrl = await tt.StartAsync(
state.RemoteWildcardApex, state.RemoteWildcardApex,
ServerProcess.DefaultLocalPort, ServerProcess.DefaultLocalPort,
cts.Token); cts.Token);
apex = TunneltoTunnel.NormalizeHost(state.RemoteWildcardApex); apex = TunneltoTunnel.NormalizeHost(state.RemoteWildcardApex);
Console.WriteLine($"[tunnel] live at {publicUrl} (apex={apex})"); 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 // Step 3 — server process
Console.WriteLine("[server] starting…"); Console.WriteLine("[server] starting…");
@@ -200,7 +223,7 @@ public static class Program
} }
string photon = "", voice = "", region = "us"; 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 versionKey = "march_2020_03_10";
string? serverDir = null; string? serverDir = null;
@@ -230,10 +253,10 @@ public static class Program
HostingMode parsedMode = mode.ToLowerInvariant() switch HostingMode parsedMode = mode.ToLowerInvariant() switch
{ {
"tunnelto" or "tunnel" or "internet" => HostingMode.Internet, "localtunnel" or "lt" or "internet" or "tunnel" => HostingMode.SingleOriginTunnel,
"wildcard" or "remote" => HostingMode.RemoteWildcard, "wildcard" or "remote" or "tunnelto" => HostingMode.RemoteWildcard,
"lan" or "local" or "wifi" => HostingMode.LocalNetwork, "lan" or "local" or "wifi" => HostingMode.LocalNetwork,
_ => throw new ArgumentException($"unknown --mode '{mode}' (try tunnelto, wildcard, lan)"), _ => throw new ArgumentException($"unknown --mode '{mode}' (try localtunnel, wildcard, lan)"),
}; };
return new ParsedArgs(photon, voice, region, parsedMode, apex, name, versionKey, serverDir); return new ParsedArgs(photon, voice, region, parsedMode, apex, name, versionKey, serverDir);
@@ -255,7 +278,8 @@ public static class Program
--voice-id <guid> Photon Voice AppId. Empty = no voice. --voice-id <guid> Photon Voice AppId. Empty = no voice.
--region <code> Photon cloud region (us | eu | asia | jp | --region <code> Photon cloud region (us | eu | asia | jp |
sa | kr | in | au). Default: us. sa | kr | in | au). Default: us.
--mode <kind> tunnelto (default) — friends anywhere --mode <kind> localtunnel (default) — public *.loca.lt
URL, friends anywhere
wildcard — Tunnelto wildcard wildcard — Tunnelto wildcard
base (needs --apex) base (needs --apex)
lan — same WiFi only, lan — same WiFi only,
@@ -270,7 +294,7 @@ public static class Program
build. Useful for dev iteration. build. Useful for dev iteration.
EXAMPLES EXAMPLES
# Linux host, default Tunnelto mode # Linux host, default Localtunnel mode
dorknet-server --photon-id 00000000-1111-... --name "Sunday games" dorknet-server --photon-id 00000000-1111-... --name "Sunday games"
# Wildcard base, custom region # 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: single Windows app that:
- runs the server, - 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, - patches your local copy of Rec Room to point at the server,
- packages a join code your friends paste into their launcher. - 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. [Getting the 2020 client](#getting-the-2020-client) below.
- A free Photon Cloud account — the launcher walks you through this on - A free Photon Cloud account — the launcher walks you through this on
first run. 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 - ~5 GB free disk space
- About 10 minutes for first-run setup, ~30 seconds per subsequent launch - 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 - **Server name** — what shows up in your join code. Use whatever your
friends will recognise ("Adam's Sunday games", "DorkSquad Test", etc.). friends will recognise ("Adam's Sunday games", "DorkSquad Test", etc.).
- **Who can join?** — three options, only one shows extra inputs: - **Who can join?** — three options, only one shows extra inputs:
- **Friends anywhere · Tunnelto** *(default, recommended)* — the - **Friends anywhere · Localtunnel** *(default, recommended)* — the
launcher runs a Tunnelto tunnel that gives your server a public launcher fetches the Localtunnel client (no account, no sign-in)
`*.tunnelto.me` URL. Works for anyone, anywhere. No router config. and gets you a public `https://<random-name>.loca.lt` URL. Friends
- **Tunnelto wildcard base** — advanced. If you've got a wildcard can join from anywhere with just the join code; nothing extra to
Tunnelto subscription, paste the apex domain. Subdomains like install on their side.
`api.<base>`, `auth.<base>`, etc. get auto-generated.
- **Same WiFi only · sslip.io** — bind on your LAN only. Friends must - **Same WiFi only · sslip.io** — bind on your LAN only. Friends must
be on the same network. No tunnel, lowest latency. 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 - **REC ROOM VERSION TO HOST FOR** — dropdown. Pre-selects the version
matching your install. Picking a different version means the launcher matching your install. Picking a different version means the launcher
will fetch that server build the next time you Start hosting. will fetch that server build the next time you Start hosting.
- **WHO CAN JOIN?** — the same three options as step 3 of the wizard. - **WHO CAN JOIN?** — same options as step 3 of the wizard. The
- **TUNNELTO BASE HOST** — only visible when you pick the wildcard Localtunnel option auto-fetches the tunnel client on first use; no
option. Auto-generated otherwise; not needed for the default or LAN account or sign-in required, and the URL is regenerated every host
modes. session.
### Photon Cloud panel ### Photon Cloud panel
@@ -191,8 +195,9 @@ The launcher runs through six steps in order:
1. **Download server** — fetches the matching server build into 1. **Download server** — fetches the matching server build into
`%LOCALAPPDATA%\DorkNet\servers\<version>\`. ~50 MB the first time; `%LOCALAPPDATA%\DorkNet\servers\<version>\`. ~50 MB the first time;
reused on subsequent launches. reused on subsequent launches.
2. **Open tunnel** — starts Tunnelto and assigns a public hostname. 2. **Open tunnel** — fetches the Localtunnel client on first run, then
Skipped in LAN mode. 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 3. **Start server** — boots the server process. SQLite database lives
in `%APPDATA%\DorkNet\dorknet.db`. in `%APPDATA%\DorkNet\dorknet.db`.
4. **Download patcher** — fetches the client-patcher payload that 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 | | Mode | Tunnel | Public URL | Best for |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Friends anywhere · Tunnelto | yes | `dorknet-<random>.tunnelto.me` | Default — works for everyone, no setup | | Friends anywhere · Localtunnel | public HTTPS, anonymous | `https://<random>.loca.lt` | Default — friends connect from anywhere with just the join code |
| Tunnelto wildcard base | yes | `<your-apex>` + `api.<apex>`, `auth.<apex>`, … | Power users with a wildcard Tunnelto plan |
| Same WiFi only · sslip.io | no | `<lan-ip>.sslip.io` | LAN games, lowest latency, no internet exposure | | 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 Default mode is the right pick for >95% of cases. LAN mode is for when
only useful if you've already got a stable apex domain set up. LAN mode everyone's in the same room and you do not want a public URL.
is for when everyone's in the same room.
--- ---
@@ -308,9 +311,12 @@ See [troubleshooting.md](troubleshooting.md) for the full list.
The most common problems: The most common problems:
- **Server starts but friend can't connect** — Cloudflare Quick Tunnels - **Server starts but friend can't connect** — Localtunnel hands you a
rotate the URL on every restart. Re-share the join code if you've fresh `*.loca.lt` URL every host session, so the join code from your
restarted hosting. 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 - **"Photon CustomAuth 401" in the server log** — your Realtime AppId
on the server doesn't match what the launcher patched into the on the server doesn't match what the launcher patched into the
client. Make sure both sides use the same AppIds. 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 ### My friend can't connect
Most common causes, in order: Most common causes, in order:
1. **Tunnel URL stale.** Cloudflare Quick Tunnels rotate the URL on each 1. **Stale join code.** Localtunnel hands out a fresh `*.loca.lt` URL
restart. Re-share the join code. 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 2. **Photon region mismatch.** Both clients must use the same Photon
region — set in Settings. region — set in Settings.
3. **Friend didn't patch.** They need to run the Patch tab pointed at 3. **Friend didn't patch.** They need to open the Join view in their
their own Rec Room install, with your join code. 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 4. **Junior account / accessibility settings.** Server settings → relax
moderation gates if friends are on accounts with parental controls. 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) [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 - The join code your friend gave you (a base64-looking blob a few hundred
characters long) 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 - About 2 minutes
You **don't** need a Photon account of your own. The join code carries 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: and pops a "Server preview" panel showing:
- the server's display name, - the server's display name,
- the public host (Tunnelto hostname, sslip.io address, or wildcard - the host address (Localtunnel `*.loca.lt`, LAN `sslip.io`, or a custom
apex), host),
- the version key (must match your install — the patcher will warn if - the version key (must match your install — the patcher will warn if
not). not).
@@ -169,9 +174,15 @@ Almost always one of:
## "Check your internet connection" / black screen ## "Check your internet connection" / black screen
Most often: your host's tunnel URL changed (Tunnelto sometimes rotates Almost always: the host restarted hosting since they sent you the join
hostnames between sessions). Get a fresh join code from them and code, so the Localtunnel URL it points at no longer exists. Ask the
re-apply the patch. 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 ## More troubleshooting
+3 -3
View File
@@ -41,15 +41,15 @@ card.
After login, click **CREATE A NEW APP** on the dashboard. The form asks After login, click **CREATE A NEW APP** on the dashboard. The form asks
for: for:
- **Photon Type** — pick **Photon Realtime**. Not Fusion, not Quantum, - **Photon Type** — pick **Photon SDK**. Not Fusion, not Quantum, not
not Bolt. Bolt.
- **Name** — anything you'll recognise later ("DorkNet Realtime", - **Name** — anything you'll recognise later ("DorkNet Realtime",
"Sunday Server", etc.). Joiners don't see this. "Sunday Server", etc.). Joiners don't see this.
- **Description** — optional. - **Description** — optional.
Click **CREATE**. 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 ## Step 3 — Copy the Realtime AppId
+44 -31
View File
@@ -6,15 +6,17 @@
`dorknet-server` is a small command-line tool that does the host side `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 of what the Windows launcher does: downloads the server build, opens a
[Tunnelto](https://tunnelto.dev) tunnel, runs the server, and prints [Localtunnel](https://localtunnel.github.io/www/) HTTPS URL (or binds
a join code. It runs on Linux, macOS, and Windows. on the LAN), runs the server, and prints a join code. It runs on Linux,
macOS, and Windows.
## What you'll need ## What you'll need
- A machine running Linux (x64 or arm64), macOS (Intel or Apple - A machine running Linux (x64 or arm64), macOS (Intel or Apple
Silicon), or Windows Silicon), or Windows
- [Tunnelto](https://github.com/agrinman/tunnelto) on PATH (for the - For the default friends-anywhere mode: Node.js on `PATH` (the CLI
default mode that exposes your server publicly) 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) - A Photon Realtime AppId — see [photon-setup.md](photon-setup.md)
- ~500 MB free disk (server binary + SQLite database) - ~500 MB free disk (server binary + SQLite database)
@@ -51,22 +53,21 @@ Expand-Archive dorknet-server.zip
.\dorknet-server\dorknet-server.exe --help .\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 The URL is regenerated every host session — copy a fresh join code
# macOS (Homebrew) after each restart and re-share it. Joiners might see a one-time
brew install tunnelto "Click to Continue" interstitial on first contact with a given URL;
opening it once in a browser clears it.
# Linux (cargo) Skip Localtunnel only if you're running in `--mode lan` (same WiFi
cargo install tunnelto only).
# Or grab a binary from github.com/agrinman/tunnelto/releases
```
Skip Tunnelto if you're running in `--mode lan` (LAN-only, no public
URL).
## Usage ## Usage
@@ -81,24 +82,25 @@ The CLI prints progress, then a join code:
``` ```
DorkNet server CLI · v0.1.0 DorkNet server CLI · v0.1.0
mode: Internet mode: Internet
network: Localtunnel
server: DorkNet Server server: DorkNet Server
photon: ********90ab (us) photon: ********90ab (us)
[server] downloading… [server] downloading…
54.2% (27.1 / 50.0 MB) 54.2% (27.1 / 50.0 MB)
[server] cached at /home/alex/.local/share/DorkNet/servers/march_2020_03_10 [server] cached at /home/alex/.local/share/DorkNet/servers/march_2020_03_10
[tunnel] starting tunnelto… [tunnel] starting localtunnel…
[tunnel] live at https://dorknet-abc123.tunnelto.me (apex=dorknet-abc123.tunnelto.me) [tunnel] live at https://lucky-pelican-42.loca.lt
[server] starting… [server] starting…
[server] listening (logs: /home/alex/.local/share/DorkNet/logs/server-…log) [server] listening (logs: /home/alex/.local/share/DorkNet/logs/server-…log)
══════════════════════════════════════════════════════ ══════════════════════════════════════════════════════
Server live: DorkNet Server 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): Join code (paste this into your friend's launcher):
eyJob3N0IjoiZG9ya25ldC1hYmMxMjMudHVubmVsdG8ubWUiLCJ2I... eyJob3N0IjoibHVja3ktcGVsaWNhbi00Mi5sb2NhLmx0IiwidiI...
Ctrl-C to stop. Ctrl-C to stop.
══════════════════════════════════════════════════════ ══════════════════════════════════════════════════════
@@ -120,8 +122,10 @@ down cleanly.
in, au. Default: us. All joiners use the same. in, au. Default: us. All joiners use the same.
--name "<text>" Server display name in the join code. --name "<text>" Server display name in the join code.
Default: "DorkNet Server". Default: "DorkNet Server".
--mode <kind> tunnelto (default) — public, friends anywhere --mode <kind> localtunnel (default) — public *.loca.lt URL,
wildcard — your own Tunnelto apex friends anywhere
wildcard — legacy Tunnelto wildcard
apex
lan — same WiFi only, no tunnel lan — same WiFi only, no tunnel
--apex <hostname> Wildcard apex. Required for --mode wildcard. --apex <hostname> Wildcard apex. Required for --mode wildcard.
Example: dorknet.example.tunnelto.me Example: dorknet.example.tunnelto.me
@@ -145,7 +149,7 @@ dorknet-server \
# LAN party — no tunnel, joiners must be on the same WiFi # LAN party — no tunnel, joiners must be on the same WiFi
dorknet-server --photon-id ... --mode lan dorknet-server --photon-id ... --mode lan
# Custom Tunnelto wildcard base # Legacy custom Tunnelto wildcard base
dorknet-server \ dorknet-server \
--photon-id ... \ --photon-id ... \
--mode wildcard \ --mode wildcard \
@@ -190,10 +194,11 @@ sudo systemctl enable --now dorknet
journalctl -fu dorknet # follow the logs journalctl -fu dorknet # follow the logs
``` ```
> Heads-up: each new tunnel session gets a fresh `*.tunnelto.me` > Heads-up: Localtunnel hands out a fresh `*.loca.lt` URL on every
> hostname, so the join code rotates on restart. Either re-share > restart, so each time systemd restarts the service the join code
> the new code with your friends, or use `--mode wildcard` with a > rotates. Either commit to a stable apex via `--mode wildcard`, or
> stable apex. > 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) ## Run as a launchd agent (macOS)
@@ -251,10 +256,18 @@ it's stateless across runs (all config comes from CLI args).
## Troubleshooting ## Troubleshooting
### `tunnelto was not found` ### `localtunnel client wasn't found`
The CLI couldn't find a `tunnelto` binary. Install it (see above) or The CLI couldn't find an `lt` binary or an `npx` in `PATH`. Install
drop the binary next to `dorknet-server`. 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` ### `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: The watch can't reach the server URL. Verify:
1. The host string in `dorknet-clientmod.json` matches the server's 1. The host string in `dorknet-clientmod.json` matches the server's
public URL (tunnel URL for Easy mode). current join-code URL. (Localtunnel hands out a fresh
2. The server responds to `https://<host>/healthz` with `200 OK` from `<random>.loca.lt` URL on every host restart — the joiner needs the
the same machine the client runs on. most recent code.)
3. No corporate proxy / DNS filter is intercepting `*.trycloudflare.com`. 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 ### Black screen after the loading bar fills
@@ -73,12 +77,20 @@ fixes:
## Easy app ## Easy app
### "Cloudflared exited unexpectedly" ### "Localtunnel couldn't get a URL"
The bundled `cloudflared.exe` couldn't start. Most likely a Windows DorkNet shells out to the Localtunnel client and waits for it to
Defender false-positive — check Defender's quarantine. If you find a report back with a `*.loca.lt` URL. If that fails, the most common
cleaner alternative tunnel provider, open an issue; we've been looking causes are:
for one. - 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 ### 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) $exeSize = "{0:N1} MB" -f ((Get-Item $exe).Length / 1MB)
Write-Host "==> Published: $exe ($exeSize)" -ForegroundColor Green 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 $iscc = (Get-Command "ISCC.exe" -ErrorAction SilentlyContinue).Source
if (-not $iscc) { $iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" } 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)) { if (-not (Test-Path $iscc)) {
throw "ISCC.exe not found. Install Inno Setup 6 from https://jrsoftware.org/isinfo.php" 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] [Files]
Source: "{#PublishDir}\{#AppExeName}"; DestDir: "{app}"; Flags: ignoreversion 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. ; 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 ; 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. ; 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")] [JsonPropertyName("photonRegion")]
public string PhotonRegion { get; set; } = "us"; public string PhotonRegion { get; set; } = "us";
/// <summary>How the host exposes the server: <c>Internet</c> uses /// <summary>How the host exposes the server: <c>SingleOriginTunnel</c>
/// Tunnelto (works for friends anywhere with the join code); /// (default) opens one public Localtunnel HTTPS URL plus a
/// <c>LocalNetwork</c> binds on the LAN and uses an sslip.io /// launcher-hosted local name server; <c>LocalNetwork</c> binds on
/// hostname in the join code (joiners must be on the same /// the LAN and uses an sslip.io hostname in the join code (joiners
/// network).</summary> /// must be on the same network); <c>RemoteWildcard</c> is the
/// legacy provider-owned-apex mode kept for back-compat.</summary>
[JsonPropertyName("hostingMode")] [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 /// <summary>Provider-owned wildcard tunnel base for remote hosting
/// without Cloudflare, e.g. <c>dorknet.tunnelto.me</c>. The server /// 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 // the authoritative source instead of needing to re-derive
// them every load. // them every load.
if (migrated) { try { state.Save(); } catch { } } 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; return state;
} }
@@ -138,8 +144,14 @@ public enum AppMode
public enum HostingMode public enum HostingMode
{ {
/// <summary>Default — Tunnelto tunnel, public over the internet, /// <summary>Easy Launcher remote mode — one public HTTPS tunnel URL.
/// works for joiners anywhere.</summary> /// 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, Internet,
/// <summary>LAN only — bind on all local interfaces, skip the /// <summary>LAN only — bind on all local interfaces, skip the
/// tunnel, and publish an sslip.io hostname for the machine's LAN /// 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 photonVoiceAppId,
string photonRegion, string photonRegion,
string apexHost, string apexHost,
bool singleOrigin = false,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return Task.Run(() => ApplyCore(patcherDir, recRoomDataPath, return Task.Run(() => ApplyCore(patcherDir, recRoomDataPath,
photonAppId, photonVoiceAppId, photonRegion, apexHost), ct); photonAppId, photonVoiceAppId, photonRegion, apexHost, singleOrigin), ct);
} }
private PatchResult ApplyCore( private PatchResult ApplyCore(
@@ -70,7 +71,8 @@ public sealed class ClientPatcher
string photonAppId, string photonAppId,
string photonVoiceAppId, string photonVoiceAppId,
string photonRegion, string photonRegion,
string apexHost) string apexHost,
bool singleOrigin)
{ {
var log = new System.Text.StringBuilder(); var log = new System.Text.StringBuilder();
try try
@@ -217,7 +219,8 @@ public sealed class ClientPatcher
.Replace("{PHOTON_APPID}", photonAppId ?? "") .Replace("{PHOTON_APPID}", photonAppId ?? "")
.Replace("{PHOTON_VOICE_APPID}", .Replace("{PHOTON_VOICE_APPID}",
string.IsNullOrEmpty(photonVoiceAppId) ? photonAppId ?? "" : photonVoiceAppId) 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); File.WriteAllText(configDest, rendered);
log.AppendLine($"[patch] wrote config: {Path.GetFileName(configDest)}"); 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) 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) if (ex is HttpRequestException http)
{ {
return new FriendlyError( return new FriendlyError(
@@ -80,6 +90,17 @@ public static class ErrorTranslator
"Retry"); "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) if (ex is SocketException sock)
{ {
return new FriendlyError( return new FriendlyError(
+11 -5
View File
@@ -6,8 +6,9 @@ using System.Text.Json.Serialization;
namespace DorkNet.Launcher.Backend; namespace DorkNet.Launcher.Backend;
/// <summary>Compact URL-safe encoding of the bits a joining player /// <summary>Compact URL-safe encoding of the bits a joining player
/// needs: the host's public address (typically a Cloudflare-Tunnel /// needs: the host address (a Localtunnel <c>*.loca.lt</c> URL for the
/// hostname), the Photon AppIds + region they used, the host's /// 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 /// server name, and the version key so the joiner's launcher fetches
/// the matching client patcher. /// the matching client patcher.
/// ///
@@ -44,9 +45,10 @@ public static class JoinCode
public sealed class JoinPayload public sealed class JoinPayload
{ {
/// <summary>Public hostname the joining patcher rewrites /// <summary>Hostname the joining patcher rewrites <c>*.rec.net</c>
/// <c>*.rec.net</c> URIs to. Cloudflare Tunnel typically gives /// URIs to. Default Localtunnel mode hands out
/// <c>random-words.trycloudflare.com</c>.</summary> /// <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; } = ""; [JsonPropertyName("host")] public string Host { get; set; } = "";
/// <summary>Version key from versions.json. Tells the joiner which /// <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 /// <summary>Server name shown in the joiner's "you're about to
/// connect to ..." preview.</summary> /// connect to ..." preview.</summary>
[JsonPropertyName("n")] public string Name { get; set; } = ""; [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; namespace DorkNet.Launcher.Backend;
/// <summary>Finds the machine's preferred LAN IPv4 address for /// <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, /// out an sslip.io hostname derived from the LAN IP in the join code,
/// and players on the same network connect direct. /// and players on the same network connect direct.
/// ///
/// <para>Selection priority: an "Up" non-loopback network interface /// <para>Selection priority: an "Up" non-loopback network interface
/// with a private (RFC1918) IPv4 address. Falls back to 127.0.0.1 /// with a private (RFC1918) IPv4 address. Falls back to 127.0.0.1
/// if nothing usable is found — works for solo testing on one /// if nothing usable is found — works for solo testing on one
/// machine but joiners need either a real LAN address or /// machine but joiners need either a real LAN address or one of the
/// Cloudflare mode.</para></summary> /// tunnel modes (Localtunnel / Tunnelto).</para></summary>
public static class LocalNetwork public static class LocalNetwork
{ {
private const string SslipDomain = "sslip.io"; private const string SslipDomain = "sslip.io";
+46 -4
View File
@@ -50,12 +50,54 @@ public static class LocalOverrides
var path = Environment.GetEnvironmentVariable(ReleasesEnvVar); var path = Environment.GetEnvironmentVariable(ReleasesEnvVar);
if (!string.IsNullOrEmpty(path)) return Directory.Exists(path) ? path : null; if (!string.IsNullOrEmpty(path)) return Directory.Exists(path) ? path : null;
var sideBySide = Path.Combine(AppContext.BaseDirectory, "local-releases"); var sideBySide = FindSideBySideLocalReleases();
if (Directory.Exists(sideBySide)) return sideBySide; if (sideBySide is not null) return sideBySide;
return null; 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"/> /// <summary>Returns the local server-dir for <paramref name="versionKey"/>
/// if the releases-root override is set and the subfolder exists. /// if the releases-root override is set and the subfolder exists.
/// Convention: <c>{root}/{version_key}/server/</c>.</summary> /// Convention: <c>{root}/{version_key}/server/</c>.</summary>
@@ -86,8 +128,8 @@ public static class LocalOverrides
var parts = new List<string>(); var parts = new List<string>();
Describe(ManifestEnvVar, isDir: false, parts); Describe(ManifestEnvVar, isDir: false, parts);
Describe(ReleasesEnvVar, isDir: true, parts); Describe(ReleasesEnvVar, isDir: true, parts);
var sideBySide = Path.Combine(AppContext.BaseDirectory, "local-releases"); var sideBySide = FindSideBySideLocalReleases();
if (Directory.Exists(sideBySide)) if (sideBySide is not null)
parts.Add($"{ReleasesEnvVar} -> {sideBySide} [side-by-side fallback]"); parts.Add($"{ReleasesEnvVar} -> {sideBySide} [side-by-side fallback]");
return string.Join("\n", parts); 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."); throw new InvalidOperationException("A server is already running; stop it first.");
var exe = FindServerExecutable(serverDir); 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"); StdoutLogPath = Path.Combine(AppPaths.LogsRoot, $"server-{DateTime.UtcNow:yyyyMMdd-HHmmss}.log");
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
@@ -45,23 +49,30 @@ public sealed class ServerProcess : IAsyncDisposable
RedirectStandardError = true, RedirectStandardError = true,
}; };
psi.Environment["ASPNETCORE_ENVIRONMENT"] = "Development"; 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://0.0.0.0:{LanPort}"
: $"http://127.0.0.1:{DefaultLocalPort}"; : $"http://127.0.0.1:{DefaultLocalPort}";
psi.Environment["Database__Provider"] = "sqlite"; psi.Environment["Database__Provider"] = "sqlite";
psi.Environment["Database__SqlitePath"] = dbPath;
psi.Environment["Jwt__Secret"] = EnsureJwtSecret(); psi.Environment["Jwt__Secret"] = EnsureJwtSecret();
psi.Environment["Photon__AppId"] = state.PhotonAppId; psi.Environment["Photon__AppId"] = state.PhotonAppId;
psi.Environment["Photon__VoiceAppId"] = psi.Environment["Photon__VoiceAppId"] =
string.IsNullOrEmpty(state.PhotonVoiceAppId) ? state.PhotonAppId : state.PhotonVoiceAppId; string.IsNullOrEmpty(state.PhotonVoiceAppId) ? state.PhotonAppId : state.PhotonVoiceAppId;
psi.Environment["Photon__CloudRegion"] = state.PhotonRegion; psi.Environment["Photon__CloudRegion"] = state.PhotonRegion;
psi.Environment["Domain__Apex"] = apex; 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"] = ""; psi.Environment["Domain__Port"] = "";
if (singleOriginMode)
psi.Environment["Domain__SingleOriginBaseUrl"] = $"https://{apex}";
AppendLog($"[launcher] serverDir={serverDir}"); AppendLog($"[launcher] serverDir={serverDir}");
AppendLog($"[launcher] exe={exe}"); AppendLog($"[launcher] exe={exe}");
AppendLog($"[launcher] urls={psi.Environment["ASPNETCORE_URLS"]}"); AppendLog($"[launcher] urls={psi.Environment["ASPNETCORE_URLS"]}");
AppendLog($"[launcher] domain apex={apex}, scheme={psi.Environment["Domain__Scheme"]}, port={psi.Environment["Domain__Port"]}"); 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 = new Process { StartInfo = psi, EnableRaisingEvents = true };
_process.OutputDataReceived += (_, e) => AppendLog(e.Data); _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."); $"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; if (!File.Exists(dbPath)) return;
using var conn = new SqliteConnection($"Data Source={dbPath}"); using var conn = new SqliteConnection($"Data Source={dbPath}");
@@ -139,6 +149,34 @@ public sealed class ServerProcess : IAsyncDisposable
@"""HiddenFromBrowse"" INTEGER NOT NULL DEFAULT 0"); @"""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( private static void AddSqliteColumnIfMissing(
SqliteConnection conn, SqliteConnection conn,
string table, 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("Download server binary"),
new StartupStep(mode switch new StartupStep(mode switch
{ {
HostingMode.SingleOriginTunnel => "Start localtunnel",
HostingMode.LocalNetwork => "Set up local sslip.io address", HostingMode.LocalNetwork => "Set up local sslip.io address",
HostingMode.RemoteWildcard => "Set up Tunnelto tunnel", HostingMode.RemoteWildcard => "Install / start Tunnelto tunnel",
_ => "Set up 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("Start the server"),
new StartupStep("Download client patcher"), new StartupStep("Download client patcher"),
+18 -1
View File
@@ -4,6 +4,7 @@ using System.IO.Compression;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace DorkNet.Launcher.Backend; 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)."); "`cargo install tunnelto` (or grab a build from github.com/agrinman/tunnelto/releases).");
} }
var release = await Http.GetFromJsonAsync<GitHubRelease>(ReleasesApi, ct) GitHubRelease release;
try
{
release = await Http.GetFromJsonAsync<GitHubRelease>(ReleasesApi, ct)
?? throw new InvalidOperationException("Couldn't reach github.com/agrinman/tunnelto/releases."); ?? 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 (assetName, kind) = AssetForCurrentPlatform();
var asset = release.Assets?.FirstOrDefault(a => var asset = release.Assets?.FirstOrDefault(a =>
@@ -89,9 +98,16 @@ public sealed class TunneltoInstaller
// Stream to a temp file first so a half-finished download // Stream to a temp file first so a half-finished download
// doesn't leave a broken binary in place. // doesn't leave a broken binary in place.
var stagingPath = Path.Combine(AppPaths.LocalRoot, $"tunnelto-staging-{Guid.NewGuid():N}"); var stagingPath = Path.Combine(AppPaths.LocalRoot, $"tunnelto-staging-{Guid.NewGuid():N}");
try
{
try try
{ {
await DownloadAsync(asset.BrowserDownloadUrl, stagingPath, progress, ct); await DownloadAsync(asset.BrowserDownloadUrl, stagingPath, progress, ct);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
throw new TimeoutException("Timed out while downloading Tunnelto from GitHub.");
}
switch (kind) switch (kind)
{ {
case AssetKind.RawExe: case AssetKind.RawExe:
@@ -184,6 +200,7 @@ public sealed class TunneltoInstaller
/// <summary>Mark a file as executable on Unix systems. Uses /// <summary>Mark a file as executable on Unix systems. Uses
/// <see cref="File.SetUnixFileMode"/> (available since .NET 7). /// <see cref="File.SetUnixFileMode"/> (available since .NET 7).
/// No-op on Windows since the NTFS execute bit doesn't apply.</summary> /// No-op on Windows since the NTFS execute bit doesn't apply.</summary>
[UnsupportedOSPlatform("windows")]
private static void TryChmodExec(string path) private static void TryChmodExec(string path)
{ {
try try
+55 -6
View File
@@ -14,6 +14,8 @@ public sealed class TunneltoTunnel : IAsyncDisposable
{ {
private Process? _process; private Process? _process;
private TaskCompletionSource<string>? _urlReady; private TaskCompletionSource<string>? _urlReady;
private readonly object _outputLock = new();
private readonly Queue<string> _recentOutput = new();
public string? PublicUrl { get; private set; } public string? PublicUrl { get; private set; }
public async Task<string> StartAsync( public async Task<string> StartAsync(
@@ -36,20 +38,41 @@ public sealed class TunneltoTunnel : IAsyncDisposable
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
}; };
psi.ArgumentList.Add("add"); psi.ArgumentList.Add("--host");
psi.ArgumentList.Add(tunnelHost); psi.ArgumentList.Add("127.0.0.1");
psi.ArgumentList.Add("--port");
psi.ArgumentList.Add(localPort.ToString()); psi.ArgumentList.Add(localPort.ToString());
if (TryGetTunneltoSubdomain(tunnelHost, out var subdomain))
{
psi.ArgumentList.Add("--subdomain");
psi.ArgumentList.Add(subdomain);
}
_process = Process.Start(psi) ?? _process = Process.Start(psi) ??
throw new InvalidOperationException("Failed to spawn tunnelto.exe"); 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.OutputDataReceived += (_, e) => ParseLine(e.Data);
_process.ErrorDataReceived += (_, e) => ParseLine(e.Data); _process.ErrorDataReceived += (_, e) => ParseLine(e.Data);
_process.BeginOutputReadLine(); _process.BeginOutputReadLine();
_process.BeginErrorReadLine(); _process.BeginErrorReadLine();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); var readyTask = _urlReady.Task;
timeoutCts.CancelAfter(TimeSpan.FromSeconds(45)); var timeoutTask = Task.Delay(TimeSpan.FromSeconds(45), ct);
PublicUrl = await _urlReady.Task.WaitAsync(timeoutCts.Token); 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; return PublicUrl;
} }
@@ -74,6 +97,16 @@ public sealed class TunneltoTunnel : IAsyncDisposable
return $"dorknet-{token}.tunnelto.me"; 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() private static string ResolveExecutable()
{ {
var isWindows = OperatingSystem.IsWindows(); var isWindows = OperatingSystem.IsWindows();
@@ -112,11 +145,27 @@ public sealed class TunneltoTunnel : IAsyncDisposable
private void ParseLine(string? line) 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); var match = UrlRegex.Match(line);
if (match.Success) _urlReady.TrySetResult(match.Value); 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() public async ValueTask DisposeAsync()
{ {
if (_process is null) return; if (_process is null) return;
+5 -3
View File
@@ -331,7 +331,7 @@
<TextBlock Text="ZERO COST" FontSize="10" FontWeight="Bold" <TextBlock Text="ZERO COST" FontSize="10" FontWeight="Bold"
Foreground="{StaticResource Ink}" Foreground="{StaticResource Ink}"
Margin="0,0,0,6" /> 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}" FontSize="12" Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" LineHeight="18" /> TextWrapping="Wrap" LineHeight="18" />
</StackPanel> </StackPanel>
@@ -662,11 +662,12 @@
<TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" /> <TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" />
<StackPanel Margin="0,0,0,16"> <StackPanel Margin="0,0,0,16">
<RadioButton x:Name="SetupHostInternet" GroupName="SetupHostingMode" <RadioButton x:Name="SetupHostInternet" GroupName="SetupHostingMode"
Content="Friends anywhere · Tunnelto" Content="Friends anywhere · localtunnel"
IsChecked="True" IsChecked="True"
Checked="OnSetupHostingModeChanged" /> Checked="OnSetupHostingModeChanged" />
<RadioButton x:Name="SetupHostRemote" GroupName="SetupHostingMode" <RadioButton x:Name="SetupHostRemote" GroupName="SetupHostingMode"
Content="Friends anywhere · Tunnelto wildcard base" Content="Friends anywhere · Tunnelto wildcard base"
Visibility="Collapsed"
Checked="OnSetupHostingModeChanged" /> Checked="OnSetupHostingModeChanged" />
<RadioButton x:Name="SetupHostLocal" GroupName="SetupHostingMode" <RadioButton x:Name="SetupHostLocal" GroupName="SetupHostingMode"
Content="Same WiFi only · sslip.io" Content="Same WiFi only · sslip.io"
@@ -880,10 +881,11 @@
<TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" /> <TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" />
<StackPanel> <StackPanel>
<RadioButton x:Name="HostModeInternet" GroupName="HostingMode" <RadioButton x:Name="HostModeInternet" GroupName="HostingMode"
Content="Friends anywhere · Tunnelto" Content="Friends anywhere · localtunnel"
IsChecked="True" Checked="OnHostingModeChanged" /> IsChecked="True" Checked="OnHostingModeChanged" />
<RadioButton x:Name="HostModeRemote" GroupName="HostingMode" <RadioButton x:Name="HostModeRemote" GroupName="HostingMode"
Content="Tunnelto wildcard base" Content="Tunnelto wildcard base"
Visibility="Collapsed"
Checked="OnHostingModeChanged" /> Checked="OnHostingModeChanged" />
<RadioButton x:Name="HostModeLocal" GroupName="HostingMode" <RadioButton x:Name="HostModeLocal" GroupName="HostingMode"
Content="Same WiFi only · sslip.io" Content="Same WiFi only · sslip.io"
+84 -43
View File
@@ -1,5 +1,6 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics; using System.Diagnostics;
using System.Net.Http;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Navigation; using System.Windows.Navigation;
@@ -18,6 +19,7 @@ public partial class MainWindow : Window
private readonly ClientPatcher _patcher = new(); private readonly ClientPatcher _patcher = new();
private readonly ServerProcess _server = new(); private readonly ServerProcess _server = new();
private readonly Updater _updater = new(); private readonly Updater _updater = new();
private readonly LauncherNameServer _nameServer = new();
private IAsyncDisposable? _tunnel; private IAsyncDisposable? _tunnel;
private VersionsManifest? _manifest; private VersionsManifest? _manifest;
private string? _hostApex; private string? _hostApex;
@@ -205,13 +207,13 @@ public partial class MainWindow : Window
// main HostView and the setup wizard's matching step. // main HostView and the setup wizard's matching step.
if (HostModeLocal is not null && HostModeInternet is not null && HostModeRemote is not null) 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; HostModeLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
HostModeRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard; HostModeRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
} }
if (SetupHostLocal is not null && SetupHostInternet is not null && SetupHostRemote is not null) 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; SetupHostLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
SetupHostRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard; SetupHostRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
} }
@@ -414,7 +416,7 @@ public partial class MainWindow : Window
// on it via Next or Re-run setup. // on it via Next or Re-run setup.
if (SetupHostInternet is not null && SetupHostRemote is not null && SetupHostLocal is not null) 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; SetupHostRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
SetupHostLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork; SetupHostLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
} }
@@ -465,21 +467,21 @@ public partial class MainWindow : Window
? HostingMode.RemoteWildcard ? HostingMode.RemoteWildcard
: SetupHostLocal.IsChecked == true : SetupHostLocal.IsChecked == true
? HostingMode.LocalNetwork ? HostingMode.LocalNetwork
: HostingMode.Internet; : HostingMode.SingleOriginTunnel;
_state.Save(); _state.Save();
// Mirror to main HostView radios. // Mirror to main HostView radios.
if (HostModeLocal is not null && HostModeInternet is not null && HostModeRemote is not null) 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; HostModeLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
HostModeRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard; HostModeRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
} }
UpdateRemoteWildcardVisibility(); UpdateRemoteWildcardVisibility();
} }
/// <summary>The Tunnelto base host field only matters when the user /// <summary>The custom tunnel base field is legacy-only. The guided
/// picks the wildcard-base option. Plain Tunnelto auto-generates it; /// path uses localtunnel or LAN direct hosting, so hide it unless an
/// LAN doesn't use it at all. Hide it everywhere else.</summary> /// older saved state still selects the custom provider mode.</summary>
private void UpdateRemoteWildcardVisibility() private void UpdateRemoteWildcardVisibility()
{ {
var show = _state.HostingMode == HostingMode.RemoteWildcard var show = _state.HostingMode == HostingMode.RemoteWildcard
@@ -603,7 +605,7 @@ public partial class MainWindow : Window
? HostingMode.RemoteWildcard ? HostingMode.RemoteWildcard
: HostModeLocal.IsChecked == true : HostModeLocal.IsChecked == true
? HostingMode.LocalNetwork ? HostingMode.LocalNetwork
: HostingMode.Internet; : HostingMode.SingleOriginTunnel;
_state.Save(); _state.Save();
UpdateRemoteWildcardVisibility(); UpdateRemoteWildcardVisibility();
} }
@@ -626,7 +628,7 @@ public partial class MainWindow : Window
{ ShowError("Pick your Rec Room install first."); return; } { ShowError("Pick your Rec Room install first."); return; }
if (string.IsNullOrEmpty(_state.PhotonAppId)) if (string.IsNullOrEmpty(_state.PhotonAppId))
{ ShowError("Photon AppId required (free at dashboard.photonengine.com)."); return; } { 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); var typed = TunneltoTunnel.NormalizeHost(RemoteWildcardInput.Text);
_state.RemoteWildcardApex = string.IsNullOrWhiteSpace(typed) _state.RemoteWildcardApex = string.IsNullOrWhiteSpace(typed)
@@ -674,30 +676,19 @@ public partial class MainWindow : Window
_hostApex = lan.Host; _hostApex = lan.Host;
activeStep.Detail = $"LAN address {lan.Ip} via {_hostApex}"; 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(); var publicUrl = await StartLocaltunnelAsync(activeStep);
_tunnel = tunnel; _hostApex = new Uri(publicUrl).Host;
activeStep.Detail = $"Connecting Tunnelto for {_state.RemoteWildcardApex}"; _nameServer.Start(publicUrl);
var publicUrl = await tunnel.StartAsync( activeStep.Detail = $"localtunnel ready: {publicUrl}; launcher name-server on 127.0.0.1:80";
_state.RemoteWildcardApex,
ServerProcess.DefaultLocalPort);
var publicHost = new Uri(publicUrl).Host;
_hostApex = TunneltoTunnel.NormalizeHost(_state.RemoteWildcardApex);
activeStep.Detail = $"Tunnel ready: {publicHost}; services under {_hostApex}";
} }
else else
{ {
var tunnel = new TunneltoTunnel(); var publicHost = await StartTunneltoAsync(activeStep);
_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); _hostApex = TunneltoTunnel.NormalizeHost(_state.RemoteWildcardApex);
activeStep.Detail = $"Tunnel ready: {publicHost}; services under {_hostApex}"; activeStep.Detail = $"Tunnel ready: {publicHost}; services under {_hostApex}";
activeStep.Progress = null;
} }
CompleteStep(activeStep); CompleteStep(activeStep);
@@ -729,7 +720,8 @@ public partial class MainWindow : Window
activeStep = StartStep(_hostSteps, 5); activeStep = StartStep(_hostSteps, 5);
var patch = await _patcher.ApplyAsync( var patch = await _patcher.ApplyAsync(
patcherDir, _state.RecRoomPath!, 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) if (!patch.Ok)
{ {
FailStepWithFriendly(activeStep, ErrorTranslator.TranslateMessage(patch.Log)); FailStepWithFriendly(activeStep, ErrorTranslator.TranslateMessage(patch.Log));
@@ -747,13 +739,20 @@ public partial class MainWindow : Window
PhotonVoiceAppId = _state.PhotonVoiceAppId, PhotonVoiceAppId = _state.PhotonVoiceAppId,
PhotonRegion = _state.PhotonRegion, PhotonRegion = _state.PhotonRegion,
Name = _state.ServerName, Name = _state.ServerName,
SingleOrigin = _state.HostingMode == HostingMode.SingleOriginTunnel,
}); });
JoinCodeText.Text = code; JoinCodeText.Text = code;
JoinCodeQrImage.Source = QrCodeRenderer.Render(code); JoinCodeQrImage.Source = QrCodeRenderer.Render(code);
JoinCodePanel.Visibility = Visibility.Visible; JoinCodePanel.Visibility = Visibility.Visible;
HostStatusText.Text = _state.HostingMode == HostingMode.LocalNetwork HostStatusText.Text = _state.HostingMode switch
? $"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."; 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; HostLaunchBtn.Visibility = Visibility.Visible;
HostAdminBtn.Visibility = Visibility.Visible; HostAdminBtn.Visibility = Visibility.Visible;
SetSidebarStatus("Live", "Ok"); SetSidebarStatus("Live", "Ok");
@@ -775,6 +774,32 @@ public partial class MainWindow : Window
HostRetryPanel.Visibility = Visibility.Visible; 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) private void OnHostRetry(object sender, RoutedEventArgs e)
{ {
HostRetryPanel.Visibility = Visibility.Collapsed; 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 /// <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> /// browser. The admin SPA is published at <c>admin.&lt;apex&gt;</c>
/// over the same scheme the server uses (https for tunnel modes, /// over the same scheme the server uses (http for direct modes,
/// http for LAN). First account created on a fresh server is /// https for legacy tunnel modes). First account created on a fresh server is
/// auto-promoted; subsequent admins are added from inside the panel.</summary> /// auto-promoted; subsequent admins are added from inside the panel.</summary>
private void OnOpenAdminPanel(object sender, RoutedEventArgs e) 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."); ShowError("Server isn't running. Start hosting first, then the admin panel becomes reachable.");
return; return;
} }
var scheme = _state.HostingMode == HostingMode.LocalNetwork ? "http" : "https"; var url = _state.HostingMode == HostingMode.SingleOriginTunnel
var url = $"{scheme}://admin.{_hostApex}"; ? $"https://{_hostApex}/__dn/admin/"
: null;
var scheme = _state.HostingMode is HostingMode.LocalNetwork or HostingMode.Internet
? "http" : "https";
url ??= $"{scheme}://admin.{_hostApex}";
try try
{ {
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
@@ -862,6 +891,7 @@ public partial class MainWindow : Window
{ {
try { await _server.StopAsync(); } catch { } try { await _server.StopAsync(); } catch { }
if (_tunnel is not null) { try { await _tunnel.DisposeAsync(); } catch { } _tunnel = null; } if (_tunnel is not null) { try { await _tunnel.DisposeAsync(); } catch { } _tunnel = null; }
try { await _nameServer.DisposeAsync(); } catch { }
} }
private void OnCopyJoinCode(object sender, RoutedEventArgs e) private void OnCopyJoinCode(object sender, RoutedEventArgs e)
@@ -877,7 +907,8 @@ public partial class MainWindow : Window
: _state.ServerName; : _state.ServerName;
var msg = var msg =
$"Join {name} on DorkNet! Grab the launcher at " + $"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; JoinCodeText.Text;
try { Clipboard.SetText(msg); } try { Clipboard.SetText(msg); }
catch (Exception ex) { ShowError("Copy failed: " + ex.Message); } catch (Exception ex) { ShowError("Copy failed: " + ex.Message); }
@@ -894,7 +925,7 @@ public partial class MainWindow : Window
var body = Uri.EscapeDataString( var body = Uri.EscapeDataString(
$"Hey! Want to play on {name}?\n\n" + $"Hey! Want to play on {name}?\n\n" +
"1. Download the DorkNet launcher: https://github.com/DorkSquadRR/DorkNet\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" + "3. Paste this code:\n\n" +
JoinCodeText.Text); JoinCodeText.Text);
try { Process.Start(new ProcessStartInfo($"mailto:?subject={subject}&body={body}") try { Process.Start(new ProcessStartInfo($"mailto:?subject={subject}&body={body}")
@@ -925,10 +956,14 @@ public partial class MainWindow : Window
return; return;
} }
JoinPreviewBorder.Visibility = Visibility.Visible; JoinPreviewBorder.Visibility = Visibility.Visible;
var singleOriginHint = payload.SingleOrigin
? "\nNetwork: Localtunnel — keep this launcher open while Rec Room is running."
: "";
JoinPreviewText.Text = JoinPreviewText.Text =
$"Connecting to {(string.IsNullOrEmpty(payload.Name) ? "(unnamed server)" : payload.Name)}\n" + $"Connecting to {(string.IsNullOrEmpty(payload.Name) ? "(unnamed server)" : payload.Name)}\n" +
$"Host: {payload.Host}\n" + $"Host: {payload.Host}\n" +
$"Version: {payload.VersionKey}"; $"Version: {payload.VersionKey}" +
singleOriginHint;
} }
private async void OnJoinApply(object sender, RoutedEventArgs e) private async void OnJoinApply(object sender, RoutedEventArgs e)
@@ -953,12 +988,16 @@ public partial class MainWindow : Window
StartupStep? activeStep = null; StartupStep? activeStep = null;
try 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 progress = new Progress<DownloadProgress>(p => UpdateStepProgress(activeStep, p));
var patcherDir = await _releases.EnsurePatcherAsync(version, progress); var patcherDir = await _releases.EnsurePatcherAsync(version, progress);
CompleteStep(activeStep); CompleteStep(activeStep);
activeStep = StartStep(_joinSteps, 1); activeStep = StartStep(_joinSteps, stepIndex++);
var stripProgress = new Progress<DownloadProgress>(p => UpdateStepProgress(activeStep, p)); var stripProgress = new Progress<DownloadProgress>(p => UpdateStepProgress(activeStep, p));
var stripResult = await SteamDrmStripper.StripIfNeededAsync( var stripResult = await SteamDrmStripper.StripIfNeededAsync(
_state.RecRoomPath!, stripProgress); _state.RecRoomPath!, stripProgress);
@@ -970,10 +1009,11 @@ public partial class MainWindow : Window
}; };
CompleteStep(activeStep); CompleteStep(activeStep);
activeStep = StartStep(_joinSteps, 2); activeStep = StartStep(_joinSteps, stepIndex++);
var patch = await _patcher.ApplyAsync( var patch = await _patcher.ApplyAsync(
patcherDir, _state.RecRoomPath!, 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) if (!patch.Ok)
{ {
FailStepWithFriendly(activeStep, ErrorTranslator.TranslateMessage(patch.Log)); FailStepWithFriendly(activeStep, ErrorTranslator.TranslateMessage(patch.Log));
@@ -1003,6 +1043,7 @@ public partial class MainWindow : Window
OnJoinApply(sender, e); OnJoinApply(sender, e);
} }
private void OnOpenTroubleshooting(object sender, RoutedEventArgs e) private void OnOpenTroubleshooting(object sender, RoutedEventArgs e)
{ {
try try
+2 -2
View File
@@ -96,8 +96,8 @@
TextWrapping="Wrap" LineHeight="22" Margin="0,0,0,14" TextWrapping="Wrap" LineHeight="22" Margin="0,0,0,14"
Text="On the Photon dashboard:" /> Text="On the Photon dashboard:" />
<StackPanel Margin="6,0,0,12"> <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="1. Click &quot;Create a new app&quot; on the dashboard." />
<TextBlock Style="{StaticResource StepBullet}" Text="2. Under &quot;Photon Type&quot; choose Photon Realtime." /> <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="3. Name it anything (e.g. &quot;DorkNet Realtime&quot;)." />
<TextBlock Style="{StaticResource StepBullet}" Text="4. Click Create." /> <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-..." /> <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 - **Host mode:** fetches the matching server binary from GitHub
Releases for the user's picked Rec Room version, runs it as a Releases for the user's picked Rec Room version, runs it as a
subprocess, opens a Cloudflare quick-tunnel, patches the user's subprocess and exposes it through a Localtunnel HTTPS URL (or
Rec Room install, hands the user a shareable join code. 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 - **Join mode:** parses the join code, fetches the matching client
patcher from GitHub Releases, patches the user's Rec Room install patcher from GitHub Releases, patches the user's Rec Room install
to point at the host. to point at the host.
@@ -34,7 +35,8 @@ launcher/
├── ReleaseDownloader.cs GitHub Releases API + artifact unpack ├── ReleaseDownloader.cs GitHub Releases API + artifact unpack
├── ServerProcess.cs Spawns the downloaded server binary ├── ServerProcess.cs Spawns the downloaded server binary
├── ClientPatcher.cs Invokes install-plugin.ps1 from the unpacked patcher ├── 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 ├── RecRoomPicker.cs Microsoft.Win32.OpenFolderDialog
└── JoinCode.cs base64url-encoded {host, version, photonAppId, …} └── JoinCode.cs base64url-encoded {host, version, photonAppId, …}
``` ```