mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,11 +278,12 @@ 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
|
||||||
wildcard — Tunnelto wildcard
|
URL, friends anywhere
|
||||||
base (needs --apex)
|
wildcard — Tunnelto wildcard
|
||||||
lan — same WiFi only,
|
base (needs --apex)
|
||||||
binds on 0.0.0.0:80
|
lan — same WiFi only,
|
||||||
|
binds on 0.0.0.0:80
|
||||||
--apex <hostname> Wildcard apex (e.g. dorknet.tunnelto.me).
|
--apex <hostname> Wildcard apex (e.g. dorknet.tunnelto.me).
|
||||||
Required for --mode wildcard.
|
Required for --mode wildcard.
|
||||||
--name "<text>" Server name shown in the join code.
|
--name "<text>" Server name shown in the join code.
|
||||||
@@ -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
@@ -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
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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**.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Step 3 — Copy the Realtime AppId
|
## Step 3 — Copy the Realtime AppId
|
||||||
|
|
||||||
|
|||||||
+45
-32
@@ -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,9 +122,11 @@ 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
|
||||||
lan — same WiFi only, no tunnel
|
wildcard — legacy Tunnelto wildcard
|
||||||
|
apex
|
||||||
|
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
|
||||||
--version <key> Rec Room version key. Default: march_2020_03_10
|
--version <key> Rec Room version key. Default: march_2020_03_10
|
||||||
@@ -145,7 +149,7 @@ dorknet-server \
|
|||||||
# LAN party — no tunnel, joiners must be on the same WiFi
|
# 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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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><random>.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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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><serverDir>\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('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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;
|
||||||
?? throw new InvalidOperationException("Couldn't reach github.com/agrinman/tunnelto/releases.");
|
try
|
||||||
|
{
|
||||||
|
release = await Http.GetFromJsonAsync<GitHubRelease>(ReleasesApi, ct)
|
||||||
|
?? throw new InvalidOperationException("Couldn't reach github.com/agrinman/tunnelto/releases.");
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new TimeoutException("Timed out while checking GitHub for the latest Tunnelto release.");
|
||||||
|
}
|
||||||
|
|
||||||
var (assetName, kind) = AssetForCurrentPlatform();
|
var (assetName, kind) = AssetForCurrentPlatform();
|
||||||
var asset = release.Assets?.FirstOrDefault(a =>
|
var asset = release.Assets?.FirstOrDefault(a =>
|
||||||
@@ -91,7 +100,14 @@ public sealed class TunneltoInstaller
|
|||||||
var stagingPath = Path.Combine(AppPaths.LocalRoot, $"tunnelto-staging-{Guid.NewGuid():N}");
|
var stagingPath = Path.Combine(AppPaths.LocalRoot, $"tunnelto-staging-{Guid.NewGuid():N}");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await DownloadAsync(asset.BrowserDownloadUrl, stagingPath, progress, ct);
|
try
|
||||||
|
{
|
||||||
|
await DownloadAsync(asset.BrowserDownloadUrl, stagingPath, progress, ct);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new TimeoutException("Timed out while downloading Tunnelto from GitHub.");
|
||||||
|
}
|
||||||
switch (kind)
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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.<apex></c>
|
/// browser. The admin SPA is published at <c>admin.<apex></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
|
||||||
|
|||||||
@@ -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 "Create a new app" button (top right)." />
|
<TextBlock Style="{StaticResource StepBullet}" Text="1. Click "Create a new app" on the dashboard." />
|
||||||
<TextBlock Style="{StaticResource StepBullet}" Text="2. Under "Photon Type" choose Photon Realtime." />
|
<TextBlock Style="{StaticResource StepBullet}" Text="2. Under "Photon Type" choose Photon SDK." />
|
||||||
<TextBlock Style="{StaticResource StepBullet}" Text="3. Name it anything (e.g. "DorkNet Realtime")." />
|
<TextBlock Style="{StaticResource StepBullet}" Text="3. Name it anything (e.g. "DorkNet Realtime")." />
|
||||||
<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
@@ -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, …}
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user