Add installer updater and server CLI

This commit is contained in:
Alexa
2026-05-27 20:54:53 +03:00
parent 4f44505759
commit 7852b410ee
25 changed files with 4153 additions and 703 deletions
+82
View File
@@ -0,0 +1,82 @@
# Push / PR CI on `main` — fast feedback that the launcher + CLI
# both still build, and that the CLI publishes for the cross-platform
# RIDs the release workflow will package. Does not produce release
# artifacts — only proves things compile.
#
# Runs on:
# - every push to main
# - every pull request targeting main
# - manual workflow_dispatch (so you can re-run after a flake without
# pushing an empty commit)
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
env:
DOTNET_VERSION: "9.0.x"
DOTNET_NOLOGO: "true"
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
jobs:
# ── Launcher needs Windows for the WPF target framework ────────────
build-launcher:
runs-on: windows-2022
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore
run: dotnet restore launcher/DorkNet.Launcher.csproj
- name: Build
run: dotnet build launcher/DorkNet.Launcher.csproj -c Release --no-restore
# A self-contained publish is the closest thing to "what we ship"
# without invoking Inno Setup, which would require an extra
# toolchain install for every CI run.
- name: Publish (smoke)
run: dotnet publish launcher/DorkNet.Launcher.csproj -c Release -r win-x64 --self-contained true --no-restore
# ── CLI builds on Ubuntu — proves the shared Backend/*.cs files
# truly don't have Windows-only dependencies. The release pipeline
# later publishes the matrix; here we just confirm restore + build
# on the canonical Linux runner.
build-cli:
strategy:
fail-fast: false
matrix:
rid: [win-x64, linux-x64, linux-arm64, osx-x64, osx-arm64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore
run: dotnet restore cli/DorkNet.ServerCli/DorkNet.ServerCli.csproj
- name: Publish ${{ matrix.rid }}
run: |
dotnet publish cli/DorkNet.ServerCli/DorkNet.ServerCli.csproj \
-c Release \
-r ${{ matrix.rid }} \
--self-contained true \
--no-restore
- name: Smoke-test --help (host RID only)
if: matrix.rid == 'linux-x64'
run: |
out=cli/DorkNet.ServerCli/bin/Release/net9.0/${{ matrix.rid }}/publish/dorknet-server
chmod +x "$out"
"$out" --help
+221
View File
@@ -0,0 +1,221 @@
# Tagged release pipeline for the host-side tooling on `main` —
# the Windows GUI launcher (installer + portable) and the
# cross-platform dorknet-server CLI. Triggers on a SemVer tag like
# v0.2.0 (or v0.2.0-rc1 for pre-releases).
#
# Produces, on each release:
# * dorknet-setup-<version>.exe Inno Setup installer
# * dorknet.exe Portable single-file Windows build
# * dorknet-server-win-x64.zip Cross-platform server CLI, per RID
# * dorknet-server-linux-x64.tar.gz
# * dorknet-server-linux-arm64.tar.gz
# * dorknet-server-osx-x64.tar.gz
# * dorknet-server-osx-arm64.tar.gz
# * SHA256SUMS Checksums of all of the above
#
# All of these get attached to the GitHub release the tag creates.
name: release-launcher
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-*"
workflow_dispatch:
inputs:
version:
description: "Version to release (e.g. 0.2.0). Leave blank to use the workflow's git tag."
required: false
permissions:
contents: write # required to create / update GitHub releases
env:
DOTNET_VERSION: "9.0.x"
DOTNET_NOLOGO: "true"
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
jobs:
# ── Resolve the version once and pass it to all downstream jobs ─────
resolve-version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
steps:
- id: v
shell: bash
run: |
if [[ -n "${{ inputs.version }}" ]]; then
v="${{ inputs.version }}"
else
v="${GITHUB_REF_NAME#v}"
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "Releasing version: $v"
# ── Windows: GUI launcher (installer + portable) ─────────────────────
build-launcher-windows:
needs: resolve-version
runs-on: windows-2022
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Install Inno Setup
run: choco install innosetup --no-progress -y
- name: Build installer + portable exe
shell: pwsh
run: |
./installer/build-installer.ps1 -Version "${{ needs.resolve-version.outputs.version }}"
- name: Stage Windows artifacts
shell: pwsh
run: |
$stage = "artifacts"
New-Item -ItemType Directory -Force -Path $stage | Out-Null
# Installer
Copy-Item "installer/out/dorknet-setup-${{ needs.resolve-version.outputs.version }}.exe" $stage
# Portable single-file build (same publish output the installer wraps)
Copy-Item "launcher/bin/Release/net9.0-windows/win-x64/publish/dorknet.exe" $stage
- uses: actions/upload-artifact@v4
with:
name: launcher-windows
path: artifacts/*
# ── Cross-platform server CLI, one RID per matrix entry ─────────────
build-server-cli:
needs: resolve-version
strategy:
fail-fast: false
matrix:
include:
- rid: win-x64
os: windows-2022
ext: zip
- rid: linux-x64
os: ubuntu-latest
ext: tar.gz
- rid: linux-arm64
os: ubuntu-latest
ext: tar.gz
- rid: osx-x64
os: macos-latest
ext: tar.gz
- rid: osx-arm64
os: macos-latest
ext: tar.gz
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Publish dorknet-server for ${{ matrix.rid }}
shell: bash
run: |
v="${{ needs.resolve-version.outputs.version }}"
dotnet publish cli/DorkNet.ServerCli \
-c Release \
-r "${{ matrix.rid }}" \
--self-contained true \
/p:Version="$v" \
/p:AssemblyVersion="${v}.0" \
/p:FileVersion="${v}.0" \
/p:InformationalVersion="$v"
- name: Package
shell: bash
run: |
src="cli/DorkNet.ServerCli/bin/Release/net9.0/${{ matrix.rid }}/publish"
out="artifacts"
mkdir -p "$out"
case "${{ matrix.ext }}" in
zip)
# Use PowerShell on Windows so the archive is consumable
# by Windows Explorer + curl without weird zip flags.
pwsh -Command "Compress-Archive -Path '$src/*' -DestinationPath '$out/dorknet-server-${{ matrix.rid }}.zip' -Force"
;;
tar.gz)
# chmod +x first — single-file publish drops a binary
# without the executable bit on macOS in some scenarios.
chmod +x "$src"/dorknet-server || true
tar -czf "$out/dorknet-server-${{ matrix.rid }}.tar.gz" -C "$src" .
;;
esac
- uses: actions/upload-artifact@v4
with:
name: server-cli-${{ matrix.rid }}
path: artifacts/*
# ── Aggregate, checksum, attach to the release ──────────────────────
release:
needs: [resolve-version, build-launcher-windows, build-server-cli]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: List artifacts + compute SHA256SUMS
run: |
cd dist
ls -la
sha256sum * > SHA256SUMS
cat SHA256SUMS
- name: Build release notes
id: notes
run: |
v="${{ needs.resolve-version.outputs.version }}"
{
echo "## DorkNet launcher \`v$v\`"
echo ""
echo "Host-side release. For the matching server + client patcher per Rec Room build, see the per-version branch tags."
echo ""
echo "### Downloads"
echo ""
echo "| Platform | Use | Asset |"
echo "| --- | --- | --- |"
echo "| Windows | One-click installer (Start menu + auto-updates) | \`dorknet-setup-$v.exe\` |"
echo "| Windows | Portable single-file launcher | \`dorknet.exe\` |"
echo "| Linux x64 | Headless host CLI | \`dorknet-server-linux-x64.tar.gz\` |"
echo "| Linux arm64 | Headless host CLI | \`dorknet-server-linux-arm64.tar.gz\` |"
echo "| macOS Intel | Headless host CLI | \`dorknet-server-osx-x64.tar.gz\` |"
echo "| macOS Apple Silicon | Headless host CLI | \`dorknet-server-osx-arm64.tar.gz\` |"
echo "| Windows | Headless host CLI (same as GUI) | \`dorknet-server-win-x64.zip\` |"
echo ""
echo "Checksums in \`SHA256SUMS\`."
echo ""
echo "### Setup"
echo ""
echo "- Windows GUI: [easy-setup.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/easy-setup.md)"
echo "- Linux / macOS: [server-cli.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/server-cli.md)"
echo "- Photon setup: [photon-setup.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/photon-setup.md)"
} > notes.md
- name: Create / update GitHub release
uses: softprops/action-gh-release@v2
with:
# Prerelease if the tag has a -suffix (e.g. v0.2.0-rc1).
prerelease: ${{ contains(github.ref_name, '-') }}
body_path: notes.md
generate_release_notes: false
files: |
dist/*
# The installer registers HKCU\Software\DorkNet\Version on
# install — that means the auto-updater sees this version
# only after the tag actually exists on GitHub, so we let
# the action create the release on first push of the tag.
fail_on_unmatched_files: true
+26 -11
View File
@@ -31,25 +31,38 @@ branches above.
---
## Two ways to run it
## Ways to run it
### 🎮 Easy mode — desktop app
### Windows GUI — `dorknet` launcher
One download. Pick a client version on first launch. Hosts can switch
between supported builds without re-installing the launcher.
One download, two flavours: an **installer** (Start menu shortcut +
rolling auto-updates) or a **portable `dorknet.exe`** (drop anywhere).
Pick a Rec Room build on first launch, host or join.
- **Host a server** — runs a local server + Cloudflare tunnel for the
client version you choose, patches your Rec Room install, and gives
- **Host a server** — runs a local server + Tunnelto tunnel for the
client version you pick, patches your Rec Room install, and gives
you a shareable join code.
- **Join a server** — paste the code, point at your Rec Room install,
hit play. The launcher fetches the matching client patcher for the
host's server version.
hit play. The launcher fetches the matching patcher.
→ **[Easy setup guide](docs/easy-setup.md)** — screenshots, ~5 minutes.
[Download the latest Easy app →](https://github.com/DorkSquadRR/DorkNet/releases/latest)
[Download the latest launcher →](https://github.com/DorkSquadRR/DorkNet/releases/latest)
### 🛠️ Advanced mode — Docker / source
### Linux / macOS host — `dorknet-server` CLI
Headless host-side CLI for Linux (x64/arm64), macOS (Intel/Apple
Silicon), and Windows. Same server + tunnel pipeline as the GUI,
suitable for a VPS or a permanent home server. Joiners still use the
Windows launcher to apply the patch to their own Rec Room install.
```sh
dorknet-server --photon-id YOUR-APPID --name "Sunday games"
```
→ **[Server CLI guide](docs/server-cli.md)** — install, systemd, launchd.
### Advanced — Docker / source
For community-server hosts, modders, and contributors. Pick the branch
matching your client build, clone, run.
@@ -105,9 +118,11 @@ branch's `BRANCHES.md` row for the exact deltas.
## Docs
- [Branch chart](BRANCHES.md) — which branch matches your client
- [Easy setup](docs/easy-setup.md) — desktop-launcher path
- [Easy setup](docs/easy-setup.md) — Windows GUI launcher
- [Server CLI](docs/server-cli.md) — Linux / macOS / Windows headless
- [Advanced setup](docs/advanced-setup.md) — Docker / VPS path
- [Joining a server](docs/joining-a-server.md) — for players
- [Photon setup](docs/photon-setup.md) — getting your AppIds
- [Architecture](docs/architecture.md) — how the code is laid out
- [FAQ](docs/faq.md)
- [Troubleshooting](docs/troubleshooting.md)
@@ -0,0 +1,66 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<!-- Plain net9.0 (no -windows) so this publishes for Linux + macOS
alongside Windows. The shared Backend classes are written to
OperatingSystem.IsWindows()-gate any Windows-only paths. -->
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>DorkNet.ServerCli</RootNamespace>
<AssemblyName>dorknet-server</AssemblyName>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.4" />
</ItemGroup>
<!-- Link the relevant Backend files from the launcher project. They
live in the launcher's namespace (DorkNet.Launcher.Backend) and
avoid Windows-specific APIs, so they compile cleanly under the
cross-platform target framework. -->
<ItemGroup>
<Compile Include="..\..\launcher\Backend\AppPaths.cs">
<Link>Backend\AppPaths.cs</Link>
</Compile>
<Compile Include="..\..\launcher\Backend\AppState.cs">
<Link>Backend\AppState.cs</Link>
</Compile>
<Compile Include="..\..\launcher\Backend\JoinCode.cs">
<Link>Backend\JoinCode.cs</Link>
</Compile>
<Compile Include="..\..\launcher\Backend\LocalNetwork.cs">
<Link>Backend\LocalNetwork.cs</Link>
</Compile>
<Compile Include="..\..\launcher\Backend\LocalOverrides.cs">
<Link>Backend\LocalOverrides.cs</Link>
</Compile>
<Compile Include="..\..\launcher\Backend\ReleaseDownloader.cs">
<Link>Backend\ReleaseDownloader.cs</Link>
</Compile>
<Compile Include="..\..\launcher\Backend\ServerProcess.cs">
<Link>Backend\ServerProcess.cs</Link>
</Compile>
<Compile Include="..\..\launcher\Backend\TunneltoTunnel.cs">
<Link>Backend\TunneltoTunnel.cs</Link>
</Compile>
<Compile Include="..\..\launcher\Backend\VersionsManifest.cs">
<Link>Backend\VersionsManifest.cs</Link>
</Compile>
</ItemGroup>
<!-- Pull versions.json next to the binary so dev checkouts work
without env-var overrides. Same trick the launcher uses. -->
<ItemGroup>
<None Include="..\..\versions.json" Condition="Exists('..\..\versions.json')">
<Link>versions.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
+284
View File
@@ -0,0 +1,284 @@
using System.Diagnostics;
using DorkNet.Launcher.Backend;
namespace DorkNet.ServerCli;
/// <summary>Cross-platform host-side CLI for DorkNet. Drives the same
/// server + tunnel pipeline the Windows GUI launcher uses, but headless,
/// so Linux and macOS hosts can serve Windows joiners without needing
/// the WPF launcher (or any GUI). Joiners still use the Windows GUI
/// launcher to apply the patch to their copy of Rec Room.
///
/// <para>Run <c>dorknet-server --help</c> for the option list.</para></summary>
public static class Program
{
public static async Task<int> Main(string[] args)
{
var parsed = ParseArgs(args);
if (parsed is null) return 1; // ParseArgs already wrote --help or an error.
// Validate required values up front so the user sees the
// failure before any download / process spin-up.
if (string.IsNullOrWhiteSpace(parsed.PhotonAppId))
{
Console.Error.WriteLine("error: --photon-id is required.");
Console.Error.WriteLine(" Grab one free at https://dashboard.photonengine.com");
return 2;
}
AppPaths.EnsureDirectoriesExist();
var state = BuildState(parsed);
Console.WriteLine($"DorkNet server CLI · v{typeof(Program).Assembly.GetName().Version}");
Console.WriteLine($" mode: {parsed.Mode}");
Console.WriteLine($" server: {parsed.ServerName}");
Console.WriteLine($" photon: {Redact(parsed.PhotonAppId)} ({parsed.Region})");
if (parsed.Mode == HostingMode.RemoteWildcard)
Console.WriteLine($" apex: {state.RemoteWildcardApex}");
Console.WriteLine();
var manifest = await new VersionsManifest().FetchAsync();
if (manifest is null)
{
Console.Error.WriteLine("error: couldn't fetch versions.json (offline?).");
Console.Error.WriteLine(" Set DORKNET_LOCAL_MANIFEST=/path/to/versions.json to override.");
return 3;
}
var version = manifest.Branches
.FirstOrDefault(b => b.VersionKey.Equals(parsed.VersionKey, StringComparison.OrdinalIgnoreCase));
if (version is null)
{
Console.Error.WriteLine($"error: version '{parsed.VersionKey}' not found in manifest.");
Console.Error.WriteLine(" Supported versions:");
foreach (var b in manifest.Branches.Where(b => b.Supported))
Console.Error.WriteLine($" {b.VersionKey} (Rec Room {b.ClientBuild})");
return 4;
}
var releases = new ReleaseDownloader();
var server = new ServerProcess();
TunneltoTunnel? tunnel = null;
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
try
{
// Step 1 — server binary
string serverDir;
if (!string.IsNullOrEmpty(parsed.ServerDir))
{
if (!Directory.Exists(parsed.ServerDir))
{
Console.Error.WriteLine($"error: --server-dir not found: {parsed.ServerDir}");
return 5;
}
serverDir = parsed.ServerDir;
Console.WriteLine($"[server] using local build: {serverDir}");
}
else
{
Console.WriteLine("[server] downloading…");
var progress = 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) ");
});
serverDir = await releases.EnsureServerAsync(version, progress, cts.Token);
Console.WriteLine($"\n[server] cached at {serverDir}");
}
// Step 2 — tunnel (or LAN)
string apex;
if (parsed.Mode == HostingMode.LocalNetwork)
{
var lan = LocalNetwork.GetLanAddress();
apex = lan.Host;
Console.WriteLine($"[lan] bound on {lan.Ip} (apex={apex})");
}
else
{
Console.WriteLine("[tunnel] starting tunnelto…");
tunnel = new TunneltoTunnel();
var publicUrl = await tunnel.StartAsync(
state.RemoteWildcardApex,
ServerProcess.DefaultLocalPort,
cts.Token);
apex = TunneltoTunnel.NormalizeHost(state.RemoteWildcardApex);
Console.WriteLine($"[tunnel] live at {publicUrl} (apex={apex})");
}
// Step 3 — server process
Console.WriteLine("[server] starting…");
await server.StartAsync(serverDir, state, apex, parsed.Mode, cts.Token);
Console.WriteLine($"[server] listening (logs: {server.StdoutLogPath})");
// Step 4 — emit join code
var code = JoinCode.Encode(new JoinPayload
{
Host = apex,
VersionKey = version.VersionKey,
PhotonAppId = state.PhotonAppId,
PhotonVoiceAppId = state.PhotonVoiceAppId,
PhotonRegion = state.PhotonRegion,
Name = state.ServerName,
});
var scheme = parsed.Mode == HostingMode.LocalNetwork ? "http" : "https";
Console.WriteLine();
Console.WriteLine("══════════════════════════════════════════════════════");
Console.WriteLine($" Server live: {parsed.ServerName}");
Console.WriteLine($" Address: {scheme}://{apex}");
Console.WriteLine($" Admin panel: {scheme}://admin.{apex}");
Console.WriteLine(" (first account on a fresh server becomes admin)");
Console.WriteLine();
Console.WriteLine(" Join code (paste this into your friend's launcher):");
Console.WriteLine();
Console.WriteLine($" {code}");
Console.WriteLine();
Console.WriteLine(" Ctrl-C to stop.");
Console.WriteLine("══════════════════════════════════════════════════════");
try { await Task.Delay(Timeout.Infinite, cts.Token); }
catch (OperationCanceledException) { /* clean Ctrl-C */ }
}
catch (Exception ex)
{
Console.Error.WriteLine();
Console.Error.WriteLine($"error: {ex.Message}");
return 10;
}
finally
{
Console.WriteLine();
Console.WriteLine("[shutdown] stopping server…");
try { await server.StopAsync(); } catch { }
if (tunnel is not null) { try { await tunnel.DisposeAsync(); } catch { } }
Console.WriteLine("[shutdown] done.");
}
return 0;
}
/// <summary>Builds an in-memory <see cref="AppState"/> matching
/// the CLI args. The CLI is stateless — it never reads or writes
/// the launcher's state.json, so a Windows + Linux user on the
/// same machine don't fight over the same file.</summary>
private static AppState BuildState(ParsedArgs p) => new()
{
Mode = AppMode.Host,
PhotonAppId = p.PhotonAppId,
PhotonVoiceAppId = p.PhotonVoiceAppId,
PhotonRegion = p.Region,
HostingMode = p.Mode,
RemoteWildcardApex = p.Mode == HostingMode.RemoteWildcard && !string.IsNullOrEmpty(p.Apex)
? TunneltoTunnel.NormalizeHost(p.Apex)
: TunneltoTunnel.GenerateBaseHost(),
ServerName = p.ServerName,
SelectedVersion = p.VersionKey,
SetupComplete = true,
WelcomeSeen = true,
};
/// <summary>Mask all but the last 4 chars of an AppId so it's safe
/// to print to a shared terminal / CI log.</summary>
private static string Redact(string s) =>
string.IsNullOrEmpty(s) || s.Length < 8
? "(missing)"
: new string('*', s.Length - 4) + s[^4..];
// ── Arg parsing ─────────────────────────────────────────────────────
private sealed record ParsedArgs(
string PhotonAppId, string PhotonVoiceAppId, string Region,
HostingMode Mode, string Apex, string ServerName, string VersionKey,
string? ServerDir);
private static ParsedArgs? ParseArgs(string[] args)
{
if (args.Length == 0 || args.Contains("--help") || args.Contains("-h"))
{
PrintHelp();
return null;
}
string photon = "", voice = "", region = "us";
string mode = "tunnelto", apex = "", name = "DorkNet Server";
string versionKey = "march_2020_03_10";
string? serverDir = null;
for (int i = 0; i < args.Length; i++)
{
string Next()
{
if (i + 1 >= args.Length)
throw new ArgumentException($"missing value for {args[i]}");
return args[++i];
}
switch (args[i])
{
case "--photon-id": photon = Next(); break;
case "--voice-id": voice = Next(); break;
case "--region": region = Next(); break;
case "--mode": mode = Next(); break;
case "--apex": apex = Next(); break;
case "--name": name = Next(); break;
case "--version": versionKey = Next(); break;
case "--server-dir": serverDir = Next(); break;
default:
Console.Error.WriteLine($"error: unknown arg '{args[i]}' (try --help)");
return null;
}
}
HostingMode parsedMode = mode.ToLowerInvariant() switch
{
"tunnelto" or "tunnel" or "internet" => HostingMode.Internet,
"wildcard" or "remote" => HostingMode.RemoteWildcard,
"lan" or "local" or "wifi" => HostingMode.LocalNetwork,
_ => throw new ArgumentException($"unknown --mode '{mode}' (try tunnelto, wildcard, lan)"),
};
return new ParsedArgs(photon, voice, region, parsedMode, apex, name, versionKey, serverDir);
}
private static void PrintHelp()
{
Console.WriteLine("""
dorknet-server — host a private Rec Room server (Linux / macOS / Windows)
USAGE
dorknet-server --photon-id <APPID> [options]
REQUIRED
--photon-id <guid> Photon Realtime AppId (free at
dashboard.photonengine.com)
OPTIONS
--voice-id <guid> Photon Voice AppId. Empty = no voice.
--region <code> Photon cloud region (us | eu | asia | jp |
sa | kr | in | au). Default: us.
--mode <kind> tunnelto (default) — friends anywhere
wildcard — Tunnelto wildcard
base (needs --apex)
lan — same WiFi only,
binds on 0.0.0.0:80
--apex <hostname> Wildcard apex (e.g. dorknet.tunnelto.me).
Required for --mode wildcard.
--name "<text>" Server name shown in the join code.
Default: "DorkNet Server".
--version <key> Rec Room version to host for.
Default: march_2020_03_10.
--server-dir <path> Skip download, use this local server
build. Useful for dev iteration.
EXAMPLES
# Linux host, default Tunnelto mode
dorknet-server --photon-id 00000000-1111-... --name "Sunday games"
# Wildcard base, custom region
dorknet-server --photon-id ... --mode wildcard \
--apex dorknet.example.tunnelto.me --region eu
# LAN-only on the local network
dorknet-server --photon-id ... --mode lan
""");
}
}
+3 -1
View File
@@ -3,9 +3,11 @@
| Doc | For who |
| ---------------------------------------------- | ------------------------------------------------------ |
| [../BRANCHES.md](../BRANCHES.md) | Anyone picking which branch matches their Rec Room build |
| [easy-setup.md](easy-setup.md) | Hosts who want the desktop-app one-click experience |
| [easy-setup.md](easy-setup.md) | Windows hosts using the desktop launcher |
| [server-cli.md](server-cli.md) | Linux / macOS hosts using the headless `dorknet-server` |
| [advanced-setup.md](advanced-setup.md) | Hosts running a community server on Docker / a VPS |
| [joining-a-server.md](joining-a-server.md) | Players whose friend gave them a join code |
| [photon-setup.md](photon-setup.md) | How to get a free Photon Cloud account + AppIds |
| [architecture.md](architecture.md) | Contributors hacking on the code |
| [faq.md](faq.md) | Common questions before you ask in issues |
| [troubleshooting.md](troubleshooting.md) | Known failure modes + fixes |
+276 -44
View File
@@ -1,63 +1,287 @@
# Easy mode — host your own server
> Built for non-technical hosts. If you can install Discord, you can do this.
>
> **On Linux or macOS?** This guide is for the Windows GUI launcher.
> For headless hosting from a Linux box / a Mac / a VPS, see
> [server-cli.md](server-cli.md). Joiners on those platforms are not
> supported yet — Rec Room itself is a Windows game.
DorkNet bundles everything you need to run a private Rec Room server on
your own machine and hand a join code to your friends. The launcher is a
single Windows app that:
- runs the server,
- opens a public tunnel so friends anywhere can connect,
- patches your local copy of Rec Room to point at the server,
- packages a join code your friends paste into their launcher.
## What you'll need
- A Windows PC (Mac and Linux support coming later)
- A copy of **Rec Room 2020.03.10** — the specific old build DorkNet targets.
You acquire this yourself; see [Getting the 2020 client](#getting-the-2020-client) below.
- Windows 10 or 11 (Mac and Linux support are not planned)
- A copy of **Rec Room 2020.03.10** — the specific old build DorkNet
targets. You acquire this yourself; see
[Getting the 2020 client](#getting-the-2020-client) below.
- A free Photon Cloud account — the launcher walks you through this on
first run.
- ~5 GB free disk space
- About 10 minutes
- About 10 minutes for first-run setup, ~30 seconds per subsequent launch
## Step 1 — Download DorkNet
## Download
Grab `dorknet.exe` from the [latest release](https://github.com/YOUR_GH_USERNAME/dorknet/releases/latest).
Two flavours on the
[latest release](https://github.com/DorkSquadRR/DorkNet/releases/latest):
> Windows SmartScreen may warn you. The exe is code-signed; click "More info"
> → "Run anyway".
- **`dorknet-setup-<version>.exe`** *(recommended)* — installer.
Drops the launcher under `%LocalAppData%\Programs\DorkNet`, adds
Start menu + (optional) desktop shortcut, sets up the auto-updater,
registers an uninstaller. No admin rights required.
- **`dorknet.exe`** — single-file portable. Drop it anywhere; state
lives in `%APPDATA%\DorkNet`. No shortcuts, no auto-updates.
## Step 2 — First launch
Either works the same once it's running. Pick the installer if you'd
like Start-menu integration and rolling updates; pick the portable exe
if you'd rather no system changes.
When you open DorkNet for the first time, you'll see a welcome screen
asking what you want to do.
> Windows SmartScreen may warn you on first launch because the binary
> isn't EV-signed. Click "More info" → "Run anyway".
### Auto-updates
The installed launcher checks GitHub on every launch for a newer
release. If one's available, a small banner appears in the sidebar
with an **Install &amp; restart** button. Click it; the launcher
downloads the new binary, shuts itself down, swaps the exe in place,
and relaunches.
Turn this off during setup by un-ticking *Check for updates on launch*,
or any time later by editing `HKCU\Software\DorkNet\AutoUpdate` (0
disables). The portable `dorknet.exe` doesn't auto-update — pull a new
release yourself when you want one.
## First launch — the welcome screen
The very first time you open DorkNet you get a hero screen.
![first-run welcome screen](images/first-run-welcome.png)
Click **🏠 Host a server**.
Click **GET STARTED** to enter the setup wizard. The wizard only runs
once; future launches drop you straight into the main view.
## Step 3 — Patch your Rec Room install
## The setup wizard
DorkNet needs to redirect Rec Room to talk to your server instead of the
official one. This is a one-time patch.
Five steps, top stepper shows your progress. **Back** and **Next** sit
in the bottom bar; the choice cards on the first step auto-advance when
clicked.
1. Click the **Patch game** tab.
2. Click **Browse for Rec Room install...** — point it at the folder where
`Recroom_Release.exe` lives.
3. Click **Patch**. Takes 30 seconds.
### Step 1 — Pick a mode
![patch tab](images/patch-tab.png)
![mode pick — Host a server vs Join a friend](images/first-run-welcome-join.png)
✅ You're done with patching. You won't need to do this again unless you
reinstall Rec Room.
- **Run a server** — you'll host. The launcher will set up the tunnel
and hand you a join code.
- **Use a code** — a friend already hosts and gave you a join code.
See [joining-a-server.md](joining-a-server.md) for that path.
## Step 4 — Start the server + tunnel
This page is also reachable later via **Re-run setup** in the sidebar,
so don't worry about getting it wrong.
1. Click **Start server** on the Status tab.
2. After ~5 seconds you'll see a public URL like
`https://abc-def-ghi.trycloudflare.com`. Anyone with that URL + a
matching join code can connect to you.
### Step 2 — Find your Rec Room install
![status tab running](images/status-running.png)
The wizard auto-detects common manual-install locations (it never
touches your Steam library). If detection works you'll see a green
"Detected install" banner with the path; otherwise click **Browse…** and
point at your `Recroom_Release_Data` folder.
## Step 5 — Share the join code with your friends
The path should look like `…\<install>\Recroom_Release_Data` — the
*_Data sibling of `Recroom_Release.exe`. The launcher validates this
when you continue.
1. Click the **Share** tab.
2. Click **Copy join code**.
3. Paste it to your friend in Discord / iMessage / wherever.
### Step 3 — Set up your server
That's it for hosting. Your friend opens DorkNet, picks "Join a server",
pastes the code, and they're in.
- **Server name** — what shows up in your join code. Use whatever your
friends will recognise ("Adam's Sunday games", "DorkSquad Test", etc.).
- **Who can join?** — three options, only one shows extra inputs:
- **Friends anywhere · Tunnelto** *(default, recommended)* — the
launcher runs a Tunnelto tunnel that gives your server a public
`*.tunnelto.me` URL. Works for anyone, anywhere. No router config.
- **Tunnelto wildcard base** — advanced. If you've got a wildcard
Tunnelto subscription, paste the apex domain. Subdomains like
`api.<base>`, `auth.<base>`, etc. get auto-generated.
- **Same WiFi only · sslip.io** — bind on your LAN only. Friends must
be on the same network. No tunnel, lowest latency.
### Step 4 — Connect your Photon account
Photon Cloud runs the in-room realtime sync (positions, voice, room
state). DorkNet doesn't ship one — you bring your own free account.
Click **OPEN WALKTHROUGH** to launch the in-app Photon wizard. It opens
your browser to `dashboard.photonengine.com` and walks you through
creating two apps (Realtime + Voice). The wizard waits for you to paste
the AppIds back in. Voice is optional; without it, voice chat is
text-only and you can use Discord on the side.
If you've already got AppIds from a previous setup, paste them directly
into **REALTIME APPID** and **VOICE APPID** in step 4 and skip the
walkthrough.
> For a standalone screenshot-by-screenshot guide to the Photon
> dashboard, see [photon-setup.md](photon-setup.md). Useful if you
> get stuck mid-walkthrough or want to re-do it later.
### Step 5 — Done
Confirmation card. Click **Finish** to enter the main view.
## The main view
The launcher's everyday UI:
![host view with Server and Photon panels](images/patch-tab.png)
- **Sidebar** (left): **Host a server**, **Join a friend**, **Settings**,
**Re-run setup**. The brand-coloured stripe marks the active section.
The footer at the bottom shows your server's live status (Idle /
Starting / Live / Failed) and the launcher version.
- **Header** (top of content): a short eyebrow describing the view,
the title, and a **Screen mode** checkbox top-right.
- **Content**: two panels — **Server** and **Photon Cloud** — followed
by the action bar.
Everything in the wizard is editable here. If you change anything mid-
session, the new values get used the next time you hit Start hosting.
### Server panel
- **REC ROOM INSTALL** — same picker as the wizard. The path shows in
monospace so you can sanity-check it.
- **SERVER NAME · SHOWN TO JOINERS** — text input. Updates the embedded
name inside your next join code.
- **REC ROOM VERSION TO HOST FOR** — dropdown. Pre-selects the version
matching your install. Picking a different version means the launcher
will fetch that server build the next time you Start hosting.
- **WHO CAN JOIN?** — the same three options as step 3 of the wizard.
- **TUNNELTO BASE HOST** — only visible when you pick the wildcard
option. Auto-generated otherwise; not needed for the default or LAN
modes.
### Photon Cloud panel
- **REALTIME APPID** — required. Realtime is the matchmaking + room
sync layer. Click the **WALKTHROUGH** button (top-right of the
panel) any time you want to re-run the in-app Photon wizard.
- **VOICE APPID · OPTIONAL** — if set, voice chat works through Photon
Voice. If empty, players use Discord.
- **CLOUD REGION** — picks the Photon datacenter. All clients must
match. Default is US; change for European / Asian players.
### Screen mode
The **Screen mode** checkbox in the header passes `+forcemode:screen`
when launching Rec Room, opening the game in 2D desktop mode instead
of looking for a VR headset. Useful for testing or for friends who
don't have VR.
## Starting your server
Hit **START HOSTING** (orange button, bottom of the content panels).
The launcher runs through six steps in order:
1. **Download server** — fetches the matching server build into
`%LOCALAPPDATA%\DorkNet\servers\<version>\`. ~50 MB the first time;
reused on subsequent launches.
2. **Open tunnel** — starts Tunnelto and assigns a public hostname.
Skipped in LAN mode.
3. **Start server** — boots the server process. SQLite database lives
in `%APPDATA%\DorkNet\dorknet.db`.
4. **Download patcher** — fetches the client-patcher payload that
matches your version.
5. **Strip Steam DRM** — only runs if your install is a Steam build
(Steamless does the unwrap; original `Recroom_Release.exe` is backed
up next to itself).
6. **Apply patch** — rewrites the game's network URLs, Photon AppIds,
and embedded keys in place.
Progress steps show inline. If any step fails, the row turns red and
**Retry** appears under the steps list (along with **Get help** which
opens the troubleshooting docs).
## Sharing your server
When step 6 finishes, the join code panel appears with the orange
"SERVER LIVE" header.
You get:
- a **QR code** on a white tile — scan with a phone to get the join
string into your friend's clipboard,
- the join code itself in monospace,
- three share actions:
- **COPY CODE** — copies just the code,
- **Copy as message** — wraps it in a friendly Discord-ready blurb
that links back to the DorkNet download,
- **Email** — opens your mail client with subject + body filled in.
Your friend opens DorkNet, picks **Join a friend** on first launch
(or from the sidebar later), pastes the code, and gets the matching
patcher.
## Launching the game
Once your server is live, the **LAUNCH REC ROOM** button (teal, sits
next to **STOP**) becomes visible. Click it; the launcher starts your
patched copy with the right command-line args. Toggle **Screen mode**
in the header first if you want desktop play.
## Stopping the server
**STOP** kills the server process and the tunnel cleanly, hides the
join code panel, and re-enables **START HOSTING**. Your data
(`dorknet.db`) persists; relaunching restores everything.
The launcher also stops the server automatically when you close the
window. Don't quit mid-game — your friends will get disconnected.
## Settings
Sidebar → **Settings**.
- **Cache locations** — read-only paths to your state file, server
cache, patcher cache, and logs. Useful for "I want to clear the
cache" or "where did my db go".
- **Dev overrides** — only appears when one of the `DORKNET_LOCAL_*`
env vars is set. Lists which overrides are active. Unset the env
vars to return to the normal release-fetching mode.
- **About** — version, license (AGPL-3.0), and a link to the GitHub
repo.
The sidebar also has a **Re-run setup** entry. It clears the
"setup-complete" flag without losing your saved values, so the wizard
walks again from step 1 — handy if you want to change modes (host
↔ join) or re-do the Photon walkthrough.
## Switching from host to join
From the sidebar, click **Join a friend**. Your host settings are
preserved (Photon AppIds, server name, etc.) so switching back later
picks up where you left off. The SQLite database also stays put.
See [joining-a-server.md](joining-a-server.md) for the join-side flow.
---
## Hosting-mode deep-dive
| Mode | Tunnel | Public URL | Best for |
| --- | --- | --- | --- |
| Friends anywhere · Tunnelto | yes | `dorknet-<random>.tunnelto.me` | Default — works for everyone, no setup |
| Tunnelto wildcard base | yes | `<your-apex>` + `api.<apex>`, `auth.<apex>`, … | Power users with a wildcard Tunnelto plan |
| Same WiFi only · sslip.io | no | `<lan-ip>.sslip.io` | LAN games, lowest latency, no internet exposure |
Default mode is the right pick for >95% of cases. The wildcard mode is
only useful if you've already got a stable apex domain set up. LAN mode
is for when everyone's in the same room.
---
@@ -80,14 +304,22 @@ DorkNet's authors can't help you obtain the game. Search the web for
## Troubleshooting
See [troubleshooting.md](troubleshooting.md) for common issues.
See [troubleshooting.md](troubleshooting.md) for the full list.
The most common ones:
- **"This site can't be reached" when joining** — your tunnel URL changed.
Get the new one from the Status tab and share it again. (Cloudflare
Quick Tunnels give a new URL each session. See
[advanced-setup.md](advanced-setup.md) if you want a stable one.)
- **Friend can't connect** — they need to use the same Photon region as
you. Settings → Photon region. Default is `eu`.
- **Game launches but stays on the loading screen** — your friend isn't
patched. Make sure they ran the Patch tab in their copy of DorkNet.
The most common problems:
- **Server starts but friend can't connect** — Cloudflare Quick Tunnels
rotate the URL on every restart. Re-share the join code if you've
restarted hosting.
- **"Photon CustomAuth 401" in the server log** — your Realtime AppId
on the server doesn't match what the launcher patched into the
client. Make sure both sides use the same AppIds.
- **Game launches but stays on the loading screen** — your friend
didn't run the patcher. Their Join view needs to complete step 3
(Apply Patch) before launching Rec Room.
- **Game launches but goes straight back to the title screen** — your
Photon region setting differs from your friend's. Both clients must
match. Set in Photon Cloud → CLOUD REGION.
Logs live at `%LOCALAPPDATA%\DorkNet\logs\` and capture the full
server output for each session.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 129 KiB

+147 -36
View File
@@ -1,67 +1,178 @@
# Joining someone's DorkNet server
# Joining a friend's DorkNet server
> Built for players whose friend already runs a server and gave them a
> code. If you want to *host* instead, see [easy-setup.md](easy-setup.md).
A "join code" is a one-line string your host's launcher generated when
they hit Start hosting. It contains everything DorkNet needs to point
your Rec Room at their server: the public hostname, the Photon AppIds,
the version, and the server's display name. The launcher decodes it
locally — nothing is sent anywhere until you actually launch the game.
## What you'll need
- A Windows PC
- A copy of **Rec Room 2020.03.10** (your own — see
[Getting the 2020 client](easy-setup.md#getting-the-2020-client))
- The join code your friend gave you (looks like `dorknet://join?...`)
- Windows 10 or 11
- A copy of **Rec Room 2020.03.10** — your own, see
[Getting the 2020 client](easy-setup.md#getting-the-2020-client)
- The join code your friend gave you (a base64-looking blob a few hundred
characters long)
- About 2 minutes
## Step 1 — Download DorkNet
You **don't** need a Photon account of your own. The join code carries
your friend's AppIds; the launcher rewrites them into your client.
Grab `dorknet.exe` from the [latest release](https://github.com/YOUR_GH_USERNAME/dorknet/releases/latest).
## Download
## Step 2 — First launch
Grab `dorknet.exe` from the
[latest release](https://github.com/DorkSquadRR/DorkNet/releases/latest).
Same single-file binary as the host's. Drop it anywhere; state lives in
`%APPDATA%\DorkNet`.
On the welcome screen, click **🎮 Join a server**.
## First launch
![first-run welcome screen — Join selected](images/first-run-welcome-join.png)
Welcome screen → **GET STARTED** → setup wizard.
## Step 3 — Paste the join code
![welcome screen](images/first-run-welcome.png)
Your friend's code goes in the big text box. DorkNet decodes it and shows
you what you're about to connect to so you can sanity-check:
### Wizard step 1 — Pick "Use a code"
![join code preview](images/join-preview.png)
The right-hand card.
Make sure the server name matches what your friend told you. Codes from
strangers can point anywhere — only paste codes from people you trust.
![mode pick — Host vs Join](images/first-run-welcome-join.png)
## Step 4 — Patch your Rec Room install
Join mode skips the host-only wizard steps (server name + Photon), so
your wizard runs 3 steps total instead of 5.
1. Click **Browse for Rec Room install...** — point at the folder with
`Recroom_Release.exe`.
2. Click **Patch & launch**.
### Wizard step 2 — Find your Rec Room install
DorkNet patches the game (one-time), then launches it pointed at your
friend's server. You'll see the normal Rec Room login screen, log in with
the account you made on your friend's server, and you're in.
The launcher auto-detects common manual-install locations. If detection
worked you'll see a green "Detected install" banner; otherwise click
**Browse…** and point at your `Recroom_Release_Data` folder (the *_Data
sibling of `Recroom_Release.exe`).
### Wizard step 3 — Done
That's it for the wizard. Click **Finish** to land in the main view.
## The main view — Join
![join view](images/join-preview.png)
- **Sidebar** (left): same nav as the host's launcher — **Host a
server**, **Join a friend**, **Settings**, **Re-run setup**. You're
on **Join a friend**.
- **Header** (top): JOIN eyebrow, "Join a friend" title, and the
**Screen mode** checkbox top-right.
### Paste the join code
Paste the code your friend sent you into the **PASTE THE CODE YOUR
HOST SHARED** textbox. Hit **Preview** — the launcher decodes the code
and pops a "Server preview" panel showing:
- the server's display name,
- the public host (Tunnelto hostname, sslip.io address, or wildcard
apex),
- the version key (must match your install — the patcher will warn if
not).
Sanity-check it. **Codes from strangers can point anywhere — only paste
codes from people you trust.** A malicious code wouldn't compromise
your machine, but it could send your Rec Room login traffic to someone
else's server.
### Confirm your Rec Room install
Same picker as on the wizard. The path shows in monospace below the
Rec Room install header. Click **Browse…** to change it if needed.
## Applying the patch
Click **APPLY PATCH** (the orange button below the install picker; it
stays disabled until you've pasted a valid code).
The launcher runs three steps:
1. **Download patcher** — fetches the version-specific patcher payload.
~10 MB, cached for next time.
2. **Strip Steam DRM** — only runs if your install is a Steam build.
3. **Apply patch** — rewrites network URLs and Photon AppIds in place.
Progress steps show inline. If anything fails, the row turns red and
you get **Retry** + **Get help** buttons.
The original `Recroom_Release.exe` and its DRM-stripped copy are
preserved as backups next to the modified files; the patcher is
reversible.
## Launching Rec Room
When the patch completes, the **LAUNCH REC ROOM** button (teal)
appears. Click it. The launcher starts your patched Rec Room with the
right command-line.
Tick **Screen mode** in the top-right of the header first if you want
to play in 2D desktop mode instead of VR.
You'll see the regular Rec Room login screen — log in with the account
your host gave you (each DorkNet server has its own player database),
and you're in their world.
---
## I already patched for a different server. Can I switch?
## Switching to a different friend's server
Yes — DorkNet's patcher is reversible. Click **Repair / unpatch** in the
Patch tab to undo, then re-patch with a new join code. Or just re-run
**Patch & launch** with the new code; the patcher detects an existing
patch and updates the destination.
You can re-patch any time. Paste the new code, hit **Preview** to
verify, click **APPLY PATCH** again. The patcher detects the existing
patch and updates the destination — no need to repair first.
## My account doesn't exist on the new server
If you want to go back to the official Rec Room servers, the patcher's
backups are still there: rename `Recroom_Release.exe.bak` (or
`.steamless.bak`) back to `Recroom_Release.exe`. Or just reinstall.
Each DorkNet server runs its own account database. You'll need to create
a new account the first time you join a server. Your friend can sign you
up via their admin panel, or you can use the in-game "Create account"
flow if the server has signups enabled.
## Switching to host mode
Sidebar → **Host a server**. You'll need to fill in server name +
Photon AppIds before you can host. See
[easy-setup.md](easy-setup.md) for that workflow. The launcher
preserves your install path and screen-mode preference when you switch.
---
## My account doesn't exist on the server
Each DorkNet server runs its own player database. The first time you
join, you need an account on that server. Your friend can sign you up
through their admin panel, or you can use the in-game "Create account"
flow if signups are enabled.
If you've joined the same server before and switched away, your old
account is still on their database — log in with the same credentials
you used before.
## Voice chat doesn't work
Voice routes through Photon Voice, which requires a separate Photon Voice
AppId. If your friend hasn't set one up, voice chat is text-only for now.
Voice routes through Photon Voice, which is a separate AppId. If your
host hasn't set one up, voice chat won't work in-game. You can still
use Discord or any other voice tool alongside.
## "Photon CustomAuth 401" or game stuck on the loading screen
Almost always one of:
- Your host set up a new Photon AppId since your last join. Paste the
new code, re-apply the patch.
- Your Photon **region** in the code doesn't match the server's. This
is set by the host; ask them to confirm the region.
- The patch didn't actually apply — check the launcher's progress
panel; a red row means the patcher errored.
## "Check your internet connection" / black screen
Most often: your host's tunnel URL changed (Tunnelto sometimes rotates
hostnames between sessions). Get a fresh join code from them and
re-apply the patch.
## More troubleshooting
See [troubleshooting.md](troubleshooting.md).
[troubleshooting.md](troubleshooting.md) lists the rest.
+176
View File
@@ -0,0 +1,176 @@
# Getting your Photon AppIds
DorkNet uses Photon Cloud for in-room realtime sync (positions, voice,
matchmaking). It's free for small private servers and the launcher
never ships one — you bring your own. This guide walks through getting
one if the in-app walkthrough doesn't work for you (e.g. you're using
the Linux/macOS [`dorknet-server`](server-cli.md) CLI).
You only need to do this **once**. The AppIds are saved on your host
machine and embedded into the join code so your friends never see them.
## What you're getting
- **Realtime AppId** — required. Powers position sync, room state,
RPCs. Free Photon plan covers 20 concurrent users which is enough
for a friends-only server.
- **Voice AppId** — optional. Powers in-game voice chat. Skip it if
your group uses Discord on the side.
Both are GUIDs that look like `abc12345-6789-...`. Treat them like
loose credentials: they're not secret-secret (the join code carries
them to joiners), but don't paste them in public chat.
---
## Step 1 — Sign up
Go to **[dashboard.photonengine.com](https://dashboard.photonengine.com/)**
and click "Sign up". Email + password; the free tier doesn't ask for a
card.
![photon dashboard signup screen](images/photon-1-signup.png)
> If you already have an account from another project, log in instead.
> Multiple DorkNet servers can share the same Photon account, but each
> server needs its own AppId pair so concurrent player counts don't
> collide.
## Step 2 — Create a Realtime app
After login, click **CREATE A NEW APP** on the dashboard. The form asks
for:
- **Photon Type** — pick **Photon Realtime**. Not Fusion, not Quantum,
not Bolt.
- **Name** — anything you'll recognise later ("DorkNet Realtime",
"Sunday Server", etc.). Joiners don't see this.
- **Description** — optional.
Click **CREATE**.
![photon "Create a new app" form, Realtime selected](images/photon-2-create-realtime.png)
## Step 3 — Copy the Realtime AppId
You land back on the dashboard with your new app at the top. The AppId
is the long string under the app name — click it to copy.
![photon app card with AppId highlighted](images/photon-3-realtime-appid.png)
That's your **Realtime AppId**. Paste it into:
- the launcher's first-run wizard step 4, **REALTIME APPID** field, OR
- the launcher's Host view → Photon Cloud panel → **REALTIME APPID**, OR
- the CLI: `dorknet-server --photon-id <paste-here>`
## Step 4 — (Optional) Create a Voice app
If you want in-game voice chat, repeat Step 2 with **Photon Voice**
selected instead of Realtime. Same flow.
![photon "Create a new app" form, Voice selected](images/photon-4-create-voice.png)
Copy the Voice AppId from the dashboard the same way as Step 3 and
paste it into:
- the wizard's **VOICE APPID** field, OR
- the launcher's Host view → Photon Cloud panel → **VOICE APPID · OPTIONAL**, OR
- the CLI: `--voice-id <paste-here>`
## Step 5 — Pick a region
Both apps default to a global routing layer, but for the lowest latency
you want all your players hitting the same Photon datacenter.
In the launcher, **CLOUD REGION** sits below the AppId fields:
| Code | Region |
| --- | --- |
| `us` | US East (default) |
| `eu` | EU Amsterdam |
| `asia` | Asia Singapore |
| `jp` | Japan Tokyo |
| `sa` | South America São Paulo |
| `kr` | Korea Seoul |
| `in` | India Chennai |
| `au` | Australia Sydney |
Pick the region closest to most of your players. The CLI flag is
`--region <code>`.
> **All joiners must use the same region as the host.** This is baked
> into the join code, so as long as everyone uses the same code,
> they'll match. If you change the region later, regenerate the code.
## Step 6 — Paste into DorkNet
That's it. In the launcher:
1. Open the Host view.
2. Paste **Realtime AppId** under Photon Cloud.
3. Paste **Voice AppId** (if you made one).
4. Pick the **Cloud Region**.
5. Click **START HOSTING** when ready.
For the CLI:
```
dorknet-server \
--photon-id <realtime-appid> \
--voice-id <voice-appid> # optional \
--region eu # whatever region you picked \
--name "Sunday games"
```
---
## What's safe to share
| Value | Share with friends? | Why |
| --- | --- | --- |
| Realtime AppId | Yes (via join code) | The launcher embeds it into the join code so joiners can connect. |
| Voice AppId | Yes (via join code) | Same. |
| Photon dashboard login | **No** | Anyone with your login can delete your apps + see usage logs. |
| Photon billing details | **No** | Self-explanatory. |
If you accidentally publish an AppId somewhere public, you can rotate
it: delete the app in the Photon dashboard, create a new one, paste
the new AppId into DorkNet. Existing join codes stop working; share
new ones.
## Free tier limits
At time of writing the Photon free tier covers:
- 20 concurrent users (CCU) — i.e. 20 people simultaneously connected
through your Realtime app
- 60 messages/second/user
- No card required, no expiry
For a friends-and-family server this is enough. You'll see a warning on
the Photon dashboard if you start hitting limits; the paid plans bump
the CCU cap.
## Troubleshooting
### "Photon CustomAuth 401" in the server log
The Realtime AppId on the server doesn't match what got embedded in
your patched client. Either:
- You changed the AppId in the launcher after applying the patch —
re-share the join code and have joiners re-patch.
- You're running an older patched client from a previous join code —
re-patch with the current one.
### Joiner can't reach the room (game stuck loading)
Region mismatch. Both sides must use the same region. Confirm in your
launcher's **CLOUD REGION** dropdown; ask your friend to re-patch with
your latest join code.
### Voice chat is silent
You either didn't set a Voice AppId, or you set one but the joiner's
patched client doesn't have it. Re-patch with a fresh join code that
includes both AppIds.
+275
View File
@@ -0,0 +1,275 @@
# Headless host — Linux & macOS
> For hosts running a server from a Linux box, a Mac, or a VPS. Joiners
> on Windows still use the [Windows launcher](easy-setup.md) to apply
> the patch to their copy of Rec Room.
`dorknet-server` is a small command-line tool that does the host side
of what the Windows launcher does: downloads the server build, opens a
[Tunnelto](https://tunnelto.dev) tunnel, runs the server, and prints
a join code. It runs on Linux, macOS, and Windows.
## What you'll need
- A machine running Linux (x64 or arm64), macOS (Intel or Apple
Silicon), or Windows
- [Tunnelto](https://github.com/agrinman/tunnelto) on PATH (for the
default mode that exposes your server publicly)
- A Photon Realtime AppId — see [photon-setup.md](photon-setup.md)
- ~500 MB free disk (server binary + SQLite database)
You **do not** need Rec Room installed on the host machine. The server
doesn't run the game; joiners run their own (Windows) copy.
## Install
Grab the matching archive from the
[latest release](https://github.com/DorkSquadRR/DorkNet/releases/latest):
| Platform | Asset |
| --- | --- |
| Linux x64 | `dorknet-server-linux-x64.tar.gz` |
| Linux arm64 | `dorknet-server-linux-arm64.tar.gz` |
| macOS Apple Silicon | `dorknet-server-osx-arm64.tar.gz` |
| macOS Intel | `dorknet-server-osx-x64.tar.gz` |
| Windows x64 | `dorknet-server-win-x64.zip` |
Unpack, then either keep it where it is or move the `dorknet-server`
binary onto your `PATH`.
```sh
# Linux / macOS
curl -L https://github.com/DorkSquadRR/DorkNet/releases/latest/download/dorknet-server-linux-x64.tar.gz | tar xz
chmod +x dorknet-server
./dorknet-server --help
```
```pwsh
# Windows (PowerShell)
Invoke-WebRequest https://github.com/DorkSquadRR/DorkNet/releases/latest/download/dorknet-server-win-x64.zip -OutFile dorknet-server.zip
Expand-Archive dorknet-server.zip
.\dorknet-server\dorknet-server.exe --help
```
### Tunnelto
The CLI shells out to `tunnelto` for the public tunnel. Quickest install:
```sh
# macOS (Homebrew)
brew install tunnelto
# Linux (cargo)
cargo install tunnelto
# Or grab a binary from github.com/agrinman/tunnelto/releases
```
Skip Tunnelto if you're running in `--mode lan` (LAN-only, no public
URL).
## Usage
The minimum invocation is just your Photon AppId:
```sh
dorknet-server --photon-id 12345678-aaaa-bbbb-cccc-1234567890ab
```
The CLI prints progress, then a join code:
```
DorkNet server CLI · v0.1.0
mode: Internet
server: DorkNet Server
photon: ********90ab (us)
[server] downloading…
54.2% (27.1 / 50.0 MB)
[server] cached at /home/alex/.local/share/DorkNet/servers/march_2020_03_10
[tunnel] starting tunnelto…
[tunnel] live at https://dorknet-abc123.tunnelto.me (apex=dorknet-abc123.tunnelto.me)
[server] starting…
[server] listening (logs: /home/alex/.local/share/DorkNet/logs/server-…log)
══════════════════════════════════════════════════════
Server live: DorkNet Server
Address: https://dorknet-abc123.tunnelto.me
Join code (paste this into your friend's launcher):
eyJob3N0IjoiZG9ya25ldC1hYmMxMjMudHVubmVsdG8ubWUiLCJ2I...
Ctrl-C to stop.
══════════════════════════════════════════════════════
```
Copy the join code, send it to your friend, they paste it into their
Windows launcher's **Join a friend** view.
Stop the server with `Ctrl-C` — the CLI shuts the server + tunnel
down cleanly.
## Common options
```
--photon-id <guid> Required. Photon Realtime AppId.
--voice-id <guid> Optional. Photon Voice AppId (Discord on the side
works fine without this).
--region <code> Photon cloud region — us, eu, asia, jp, sa, kr,
in, au. Default: us. All joiners use the same.
--name "<text>" Server display name in the join code.
Default: "DorkNet Server".
--mode <kind> tunnelto (default) — public, friends anywhere
wildcard — your own Tunnelto apex
lan — same WiFi only, no tunnel
--apex <hostname> Wildcard apex. Required for --mode wildcard.
Example: dorknet.example.tunnelto.me
--version <key> Rec Room version key. Default: march_2020_03_10
(the only branch currently supported).
--server-dir <path> Skip download — use a local build at this path.
Useful for dev work on the server itself.
```
Run with `--help` for the canonical list.
## Examples
```sh
# Sunday games server with a custom name, EU region
dorknet-server \
--photon-id 12345678-aaaa-bbbb-cccc-1234567890ab \
--name "Sunday games" \
--region eu
# LAN party — no tunnel, joiners must be on the same WiFi
dorknet-server --photon-id ... --mode lan
# Custom Tunnelto wildcard base
dorknet-server \
--photon-id ... \
--mode wildcard \
--apex dorknet.acme.tunnelto.me
# Voice chat enabled
dorknet-server --photon-id ... --voice-id 87654321-...
# Iterate on a local server build during dev
dorknet-server --photon-id ... \
--server-dir /home/alex/src/DorkNet/DorkNet.Server/bin/Debug/net9.0
```
## Run as a systemd service (Linux)
Make a service so the server restarts on reboot:
```ini
# /etc/systemd/system/dorknet.service
[Unit]
Description=DorkNet private Rec Room server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=alex
ExecStart=/home/alex/bin/dorknet-server \
--photon-id 12345678-aaaa-bbbb-cccc-1234567890ab \
--region eu \
--name "Sunday games"
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now dorknet
journalctl -fu dorknet # follow the logs
```
> Heads-up: each new tunnel session gets a fresh `*.tunnelto.me`
> hostname, so the join code rotates on restart. Either re-share
> the new code with your friends, or use `--mode wildcard` with a
> stable apex.
## Run as a launchd agent (macOS)
```xml
<!-- ~/Library/LaunchAgents/com.dorknet.server.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.dorknet.server</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/dorknet-server</string>
<string>--photon-id</string><string>YOUR-APPID</string>
<string>--region</string><string>us</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/tmp/dorknet.log</string>
<key>StandardErrorPath</key><string>/tmp/dorknet.err</string>
</dict>
</plist>
```
```sh
launchctl load ~/Library/LaunchAgents/com.dorknet.server.plist
```
## Files & state
The CLI follows XDG-ish conventions on Linux/macOS and the standard
`%APPDATA%` / `%LOCALAPPDATA%` paths on Windows:
| Path | Contents |
| --- | --- |
| `$XDG_DATA_HOME/DorkNet/` (Linux) <br/> `~/Library/Application Support/DorkNet/` (macOS) <br/> `%APPDATA%\DorkNet\` (Windows) | Server SQLite DB (`dorknet.db`), JWT signing secret |
| same prefix `/servers/<version>/` | Downloaded server binaries (~50 MB each, cached across runs) |
| same prefix `/logs/` | Per-session server stdout / stderr |
The CLI does not read or write the Windows launcher's `state.json` —
it's stateless across runs (all config comes from CLI args).
## Limitations
- **No client patcher.** Joiners need the Windows launcher to patch
their Rec Room install — there is no patch-from-Linux flow yet
because Rec Room is a Windows game and the patcher manipulates a
Windows PE binary.
- **No GUI.** Use the Windows [`dorknet.exe`](easy-setup.md) if you
want a graphical launcher.
- **No auto-updates.** Pull a new release manually when you want one.
Watch the GitHub releases feed or run a periodic check in your
service.
## Troubleshooting
### `tunnelto was not found`
The CLI couldn't find a `tunnelto` binary. Install it (see above) or
drop the binary next to `dorknet-server`.
### `couldn't fetch versions.json`
You're offline or GitHub is blocked. Either fix the connection or
point `DORKNET_LOCAL_MANIFEST` at a local copy of versions.json.
### `No server binary found in …`
The downloaded archive doesn't contain the expected `DorkNet.Server`
file. Likely cause: the release for your platform hasn't been
published yet. Workaround: build the server from source and pass
`--server-dir /path/to/built/server`.
### Same Photon errors as the GUI launcher
Photon issues (CustomAuth 401, region mismatch, voice silent) have
the same fixes as on Windows. See
[photon-setup.md → Troubleshooting](photon-setup.md#troubleshooting).
+65
View File
@@ -0,0 +1,65 @@
# Build the DorkNet Windows installer.
#
# Usage:
# pwsh -File installer\build-installer.ps1 # uses default 0.1.0
# pwsh -File installer\build-installer.ps1 -Version 0.2.0
#
# Output:
# installer\out\dorknet-setup-<version>.exe
#
# Requirements:
# - .NET 9 SDK (for `dotnet publish`)
# - Inno Setup 6 installed; ISCC must be on PATH or at the default
# install location (C:\Program Files (x86)\Inno Setup 6\ISCC.exe).
# Grab it free from https://jrsoftware.org/isinfo.php
[CmdletBinding()]
param(
[string]$Version = "0.1.0",
[string]$Configuration = "Release"
)
$ErrorActionPreference = "Stop"
$repo = Split-Path -Parent $PSScriptRoot
$launcher = Join-Path $repo "launcher"
$installer = $PSScriptRoot
Write-Host "==> Publishing launcher (self-contained, single file)..." -ForegroundColor Cyan
& dotnet publish $launcher `
-c $Configuration `
-r win-x64 `
--self-contained true `
/p:PublishSingleFile=true `
/p:Version=$Version `
/p:AssemblyVersion="$Version.0" `
/p:FileVersion="$Version.0" `
/p:InformationalVersion=$Version
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed" }
$publishDir = Join-Path $launcher "bin\$Configuration\net9.0-windows\win-x64\publish"
$exe = Join-Path $publishDir "dorknet.exe"
if (-not (Test-Path $exe)) { throw "Expected $exe not found after publish" }
$exeSize = "{0:N1} MB" -f ((Get-Item $exe).Length / 1MB)
Write-Host "==> Published: $exe ($exeSize)" -ForegroundColor Green
# Resolve ISCC.exe — try PATH first, then the default install dir.
$iscc = (Get-Command "ISCC.exe" -ErrorAction SilentlyContinue).Source
if (-not $iscc) { $iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" }
if (-not (Test-Path $iscc)) {
throw "ISCC.exe not found. Install Inno Setup 6 from https://jrsoftware.org/isinfo.php"
}
Write-Host "==> Compiling installer..." -ForegroundColor Cyan
$iss = Join-Path $installer "dorknet.iss"
$outDir = Join-Path $installer "out"
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
& $iscc "/DAppVersion=$Version" $iss
if ($LASTEXITCODE -ne 0) { throw "ISCC failed" }
$installerExe = Join-Path $outDir "dorknet-setup-$Version.exe"
if (-not (Test-Path $installerExe)) { throw "Installer not produced at $installerExe" }
$instSize = "{0:N1} MB" -f ((Get-Item $installerExe).Length / 1MB)
Write-Host "==> Built: $installerExe ($instSize)" -ForegroundColor Green
+82
View File
@@ -0,0 +1,82 @@
; DorkNet launcher — Windows installer.
; Produces a per-user installer that drops dorknet.exe under
; %LocalAppData%\Programs\DorkNet, adds Start menu + desktop shortcuts,
; and registers an uninstaller. No admin rights required.
;
; Build: pwsh -File installer\build-installer.ps1
#ifndef AppVersion
#define AppVersion "0.1.0"
#endif
#define AppName "DorkNet"
#define AppPublisher "DorkSquadRR"
#define AppURL "https://github.com/DorkSquadRR/DorkNet"
#define AppExeName "dorknet.exe"
#define PublishDir "..\launcher\bin\Release\net9.0-windows\win-x64\publish"
[Setup]
AppId={{8C2C7E1A-6B4F-4F8D-9C0B-DorkNet000001}
AppName={#AppName}
AppVersion={#AppVersion}
AppVerName={#AppName} {#AppVersion}
AppPublisher={#AppPublisher}
AppPublisherURL={#AppURL}
AppSupportURL={#AppURL}
AppUpdatesURL={#AppURL}/releases
DefaultDirName={localappdata}\Programs\{#AppName}
DefaultGroupName={#AppName}
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
OutputDir=out
OutputBaseFilename=dorknet-setup-{#AppVersion}
Compression=lzma2/ultra
SolidCompression=yes
WizardStyle=modern
UninstallDisplayIcon={app}\{#AppExeName}
UninstallDisplayName={#AppName}
MinVersion=10.0.17763
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
CloseApplications=force
RestartApplications=no
; Skip the welcome page — single-file installer, the wizard already
; tells the user what's happening.
DisableWelcomePage=yes
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked
Name: "autorunupdates"; Description: "Check for updates on launch"; GroupDescription: "Updates:"
[Files]
Source: "{#PublishDir}\{#AppExeName}"; DestDir: "{app}"; Flags: ignoreversion
; Bundle the tunnelto helper if it's been built next to the launcher.
; The launcher auto-falls-back to PATH lookup when not bundled, so this
; is purely a convenience for users who don't already have it installed.
Source: "{#PublishDir}\tunnelto.exe"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist
[Icons]
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"
Name: "{group}\{cm:UninstallProgram,{#AppName}}"; Filename: "{uninstallexe}"
Name: "{userdesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
[Registry]
; Mark the install location + version so the launcher's updater can
; find where to swap the new exe into.
Root: HKCU; Subkey: "Software\DorkNet"; ValueType: string; ValueName: "InstallPath"; ValueData: "{app}"; Flags: uninsdeletevalue
Root: HKCU; Subkey: "Software\DorkNet"; ValueType: string; ValueName: "Version"; ValueData: "{#AppVersion}"; Flags: uninsdeletevalue
Root: HKCU; Subkey: "Software\DorkNet"; ValueType: dword; ValueName: "AutoUpdate"; ValueData: "1"; Tasks: autorunupdates; Flags: uninsdeletevalue
Root: HKCU; Subkey: "Software\DorkNet"; ValueType: dword; ValueName: "AutoUpdate"; ValueData: "0"; Tasks: not autorunupdates; Flags: uninsdeletevalue
[Run]
Filename: "{app}\{#AppExeName}"; Description: "Launch {#AppName}"; Flags: nowait postinstall skipifsilent
[UninstallDelete]
; Leave %AppData%\DorkNet alone — that's user state (Photon AppIds,
; saved server name, etc.) and the user's worlds live in dorknet.db.
; Just clean the program files dir.
Type: filesandordirs; Name: "{app}"
+626 -176
View File
@@ -5,84 +5,103 @@
StartupUri="MainWindow.xaml">
<Application.Resources>
<!-- ── Palette ─────────────────────────────────────────────────
Rec Room-adjacent — warm coral + teal on a deep purple-navy.
Avoids the cold-grey "developer tool" look in favour of the
friendly game-launcher aesthetic. -->
<SolidColorBrush x:Key="Bg" Color="#16102A" />
<SolidColorBrush x:Key="BgDeeper" Color="#0E0A1F" />
<SolidColorBrush x:Key="BgElev" Color="#221833" />
<SolidColorBrush x:Key="BgElev2" Color="#2E2245" />
<SolidColorBrush x:Key="Border" Color="#3A2C56" />
<SolidColorBrush x:Key="BorderSoft" Color="#2A2042" />
<SolidColorBrush x:Key="Ink" Color="#F7F3FF" />
<SolidColorBrush x:Key="InkSoft" Color="#B8AED4" />
<SolidColorBrush x:Key="InkFaint" Color="#6E608E" />
<!-- Slate base, one decisive accent. The previous palette stacked
coral + teal + sunshine + pink which read as designer-mockup;
a single warm accent on a near-neutral background reads as a
real tool. -->
<SolidColorBrush x:Key="Bg" Color="#0D0F14" />
<SolidColorBrush x:Key="BgDeeper" Color="#07090C" />
<SolidColorBrush x:Key="BgElev" Color="#14171F" />
<SolidColorBrush x:Key="BgElev2" Color="#1C2029" />
<SolidColorBrush x:Key="BgInset" Color="#0A0C11" />
<SolidColorBrush x:Key="Border" Color="#262B38" />
<SolidColorBrush x:Key="BorderSoft" Color="#1A1E27" />
<SolidColorBrush x:Key="BorderStrong" Color="#343A4B" />
<SolidColorBrush x:Key="Ink" Color="#ECEEF3" />
<SolidColorBrush x:Key="InkSoft" Color="#8B95AB" />
<SolidColorBrush x:Key="InkFaint" Color="#4F596F" />
<!-- Brand: warm coral, with teal as the secondary accent. -->
<SolidColorBrush x:Key="Brand" Color="#FF7B5C" />
<SolidColorBrush x:Key="BrandHover" Color="#FF9277" />
<SolidColorBrush x:Key="BrandDeep" Color="#E55F40" />
<SolidColorBrush x:Key="Teal" Color="#44D6C4" />
<SolidColorBrush x:Key="TealDeep" Color="#2DB8A6" />
<SolidColorBrush x:Key="Sunshine" Color="#FFD555" />
<SolidColorBrush x:Key="Ok" Color="#5BE0A6" />
<SolidColorBrush x:Key="Danger" Color="#FF6B8A" />
<!-- Single brand accent — vermilion, used decisively on the
primary action only. Secondary/info uses cool steel; success
a single mint. Nothing else gets a colour. -->
<SolidColorBrush x:Key="Brand" Color="#FF5C3D" />
<SolidColorBrush x:Key="BrandHover" Color="#FF7659" />
<SolidColorBrush x:Key="BrandDeep" Color="#D9482B" />
<SolidColorBrush x:Key="BrandSoft" Color="#3A1F1A" />
<SolidColorBrush x:Key="Teal" Color="#5C9BE0" />
<SolidColorBrush x:Key="TealDeep" Color="#3C7CC0" />
<SolidColorBrush x:Key="Sunshine" Color="#E5B340" />
<SolidColorBrush x:Key="Ok" Color="#34D399" />
<SolidColorBrush x:Key="Danger" Color="#F87171" />
<!-- Wordmark gradient: coral → pink → teal across the "DorkNet" letters. -->
<LinearGradientBrush x:Key="WordmarkBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FF7B5C" Offset="0" />
<GradientStop Color="#FF5C95" Offset="0.55" />
<GradientStop Color="#44D6C4" Offset="1" />
</LinearGradientBrush>
<!-- Wordmark: flat, decisive, one tone. The "·" between Dork and
Net is brand-coloured — a single small accent instead of a
rainbow gradient across the letters. -->
<SolidColorBrush x:Key="WordmarkBrush" Color="#ECEEF3" />
<!-- Subtle card-back gradient — radial illusion via linear top-left
tint into the base elev colour. -->
<LinearGradientBrush x:Key="CardBg" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#2A1F40" Offset="0" />
<GradientStop Color="#221833" Offset="1" />
</LinearGradientBrush>
<!-- Panel/card backgrounds — flat colours with subtle structure
instead of gradients that scream "designer mockup". The Host
and Join variants gain a faint accent tint so the eye lands
on the right area without the page turning into a rainbow. -->
<SolidColorBrush x:Key="CardBg" Color="#14171F" />
<SolidColorBrush x:Key="CardBgHost" Color="#181820" />
<SolidColorBrush x:Key="CardBgJoin" Color="#161A22" />
<LinearGradientBrush x:Key="CardBgHost" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#3A1F35" Offset="0" />
<GradientStop Color="#221833" Offset="1" />
</LinearGradientBrush>
<!-- Subtle shadow on cards. The previous coral-tinted shadow on
every button was another designer-mockup tell — kept this
one neutral. -->
<DropShadowEffect x:Key="CardShadow" BlurRadius="20" ShadowDepth="0"
Color="#000000" Opacity="0.55" />
<DropShadowEffect x:Key="BtnShadow" BlurRadius="14" ShadowDepth="0"
Color="#FF5C3D" Opacity="0.32" />
<LinearGradientBrush x:Key="CardBgJoin" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#1F3540" Offset="0" />
<GradientStop Color="#221833" Offset="1" />
</LinearGradientBrush>
<!-- Soft drop-shadow effect used on cards / primary buttons. -->
<DropShadowEffect x:Key="CardShadow" BlurRadius="24" ShadowDepth="0"
Color="#000000" Opacity="0.45" />
<DropShadowEffect x:Key="BtnShadow" BlurRadius="16" ShadowDepth="2"
Color="#FF5C40" Opacity="0.35" />
<!-- Shared vector glyphs. Path geometry instead of emoji
everywhere — emoji is one of the strongest "AI-generated UI"
tells, and these path strings ship at any zoom level. -->
<Geometry x:Key="IconServer">M2,4 L18,4 L18,8 L2,8 Z M2,10 L18,10 L18,14 L2,14 Z M5,6 L6,6 M5,12 L6,12 M15,6 L16,6 M15,12 L16,12</Geometry>
<Geometry x:Key="IconLink">M8,12 L12,8 M5,10 A4,4 0 0 1 9,6 L11,6 M11,14 L9,14 A4,4 0 0 1 5,10 M15,10 A4,4 0 0 1 11,14 M11,6 A4,4 0 0 1 15,10</Geometry>
<Geometry x:Key="IconGear">M10,1 L11,3.5 L13.5,2.5 L14,5 L16.5,5 L15.5,7.5 L18,9 L16,11 L17,13.5 L14.5,13.5 L14,16 L11.5,15 L10,17 L8.5,15 L6,16 L5.5,13.5 L3,13.5 L4,11 L2,9 L4.5,7.5 L3.5,5 L6,5 L6.5,2.5 L9,3.5 Z M10,7 A2,2 0 1 0 10,11 A2,2 0 1 0 10,7</Geometry>
<Geometry x:Key="IconFolder">M2,5 L7,5 L9,7 L18,7 L18,15 L2,15 Z</Geometry>
<Geometry x:Key="IconPlay">M5,3 L16,9 L5,15 Z</Geometry>
<Geometry x:Key="IconCheck">M3,9 L7,13 L15,5</Geometry>
<Geometry x:Key="IconX">M4,4 L14,14 M14,4 L4,14</Geometry>
<Geometry x:Key="IconArrowRight">M3,9 L15,9 M11,5 L15,9 L11,13</Geometry>
<Geometry x:Key="IconArrowLeft">M15,9 L3,9 M7,5 L3,9 L7,13</Geometry>
<Geometry x:Key="IconCopy">M5,4 L13,4 L13,12 L5,12 Z M3,6 L3,14 L11,14</Geometry>
<Geometry x:Key="IconMail">M2,5 L18,5 L18,15 L2,15 Z M2,5 L10,11 L18,5</Geometry>
<Geometry x:Key="IconExternal">M11,3 L17,3 L17,9 M17,3 L9,11 M15,11 L15,17 L3,17 L3,5 L9,5</Geometry>
<Geometry x:Key="IconDot">M10,8 A2,2 0 1 0 10,12 A2,2 0 1 0 10,8</Geometry>
<Geometry x:Key="IconQuestion">M7,7 A3,3 0 1 1 10,10 L10,12 M10,15 L10,15.5</Geometry>
<!-- ── TextBox ─────────────────────────────────────────────────── -->
<Style TargetType="TextBox">
<Setter Property="Background" Value="{StaticResource BgElev}" />
<Setter Property="Background" Value="{StaticResource BgInset}" />
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="CaretBrush" Value="{StaticResource Ink}" />
<Setter Property="CaretBrush" Value="{StaticResource Brand}" />
<Setter Property="SelectionBrush" Value="{StaticResource Brand}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="12,10" />
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
<Setter Property="FontSize" Value="14" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border Background="{TemplateBinding Background}"
<Border x:Name="root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="10">
CornerRadius="6">
<ScrollViewer x:Name="PART_ContentHost"
Margin="{TemplateBinding Padding}"
VerticalScrollBarVisibility="Auto" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="root" Property="BorderBrush" Value="{StaticResource BorderStrong}" />
</Trigger>
<Trigger Property="IsFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Brand}" />
<Setter TargetName="root" Property="BorderBrush" Value="{StaticResource Brand}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -92,28 +111,227 @@
<!-- ── ComboBox ────────────────────────────────────────────────── -->
<Style TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource BgElev}" />
<Setter Property="Background" Value="{StaticResource BgInset}" />
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="12,10" />
<Setter Property="FontSize" Value="14" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
<Setter Property="MinHeight" Value="40" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton x:Name="Toggle"
Background="{Binding Background, RelativeSource={RelativeSource TemplatedParent}}"
Foreground="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}"
BorderBrush="{Binding BorderBrush, RelativeSource={RelativeSource TemplatedParent}}"
BorderThickness="{Binding BorderThickness, RelativeSource={RelativeSource TemplatedParent}}"
Padding="{Binding Padding, RelativeSource={RelativeSource TemplatedParent}}"
Focusable="False"
ClickMode="Press"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="Chrome"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="6">
<Grid>
<ContentPresenter x:Name="ContentSite"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Content="{Binding SelectionBoxItem, RelativeSource={RelativeSource AncestorType=ComboBox}}"
ContentTemplate="{Binding SelectionBoxItemTemplate, RelativeSource={RelativeSource AncestorType=ComboBox}}"
ContentStringFormat="{Binding SelectionBoxItemStringFormat, RelativeSource={RelativeSource AncestorType=ComboBox}}"
RecognizesAccessKey="True" />
<Path Width="8" Height="5"
Margin="0,0,14,0"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Stroke="{StaticResource InkSoft}"
StrokeThickness="1.5"
StrokeLineJoin="Round"
Data="M 0 0 L 4 5 L 8 0" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Chrome" Property="BorderBrush" Value="{StaticResource BorderStrong}" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Chrome" Property="BorderBrush" Value="{StaticResource Brand}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<Popup x:Name="PART_Popup"
IsOpen="{TemplateBinding IsDropDownOpen}"
Placement="Bottom"
AllowsTransparency="True"
Focusable="False"
PopupAnimation="Fade">
<Grid MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}"
MaxHeight="{TemplateBinding MaxDropDownHeight}">
<Border Background="{StaticResource BgElev}"
BorderBrush="{StaticResource Border}"
BorderThickness="1"
CornerRadius="6"
Margin="0,4,0,0"
Effect="{StaticResource CardShadow}">
<ScrollViewer Margin="4"
CanContentScroll="True">
<ItemsPresenter />
</ScrollViewer>
</Border>
</Grid>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{StaticResource InkFaint}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBoxItem">
<Setter Property="Background" Value="{StaticResource BgElev}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="Padding" Value="10,8" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="ItemChrome"
Background="{TemplateBinding Background}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="ItemChrome" Property="Background" Value="{StaticResource BgElev2}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="ItemChrome" Property="Background" Value="{StaticResource BrandSoft}" />
<Setter Property="Foreground" Value="{StaticResource Ink}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{StaticResource InkFaint}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ── RadioButton ─────────────────────────────────────────────── -->
<Style TargetType="RadioButton">
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Padding" Value="10,8" />
<Setter Property="Margin" Value="0,0,0,2" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Border x:Name="row"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="1"
CornerRadius="6"
Padding="{TemplateBinding Padding}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Grid Width="16" Height="16" Margin="0,0,10,0">
<Ellipse x:Name="ring"
Stroke="{StaticResource BorderStrong}"
StrokeThickness="1.5" />
<Ellipse x:Name="dot"
Width="6" Height="6"
Fill="{StaticResource Brand}"
Opacity="0" />
</Grid>
<ContentPresenter VerticalAlignment="Center" />
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="row" Property="Background" Value="{StaticResource BgElev}" />
<Setter TargetName="ring" Property="Stroke" Value="{StaticResource Brand}" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="row" Property="Background" Value="{StaticResource BgElev}" />
<Setter TargetName="row" Property="BorderBrush" Value="{StaticResource BorderStrong}" />
<Setter TargetName="ring" Property="Stroke" Value="{StaticResource Brand}" />
<Setter TargetName="dot" Property="Opacity" Value="1" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ── CheckBox ───────────────────────────────────────────────── -->
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Border x:Name="box"
Width="16" Height="16" Margin="0,0,10,0"
Background="{StaticResource BgInset}"
BorderBrush="{StaticResource BorderStrong}"
BorderThickness="1.5"
CornerRadius="3">
<Path x:Name="tick"
Data="M 3 8 L 6.5 11 L 13 4"
Stroke="White"
StrokeThickness="1.8"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Opacity="0" />
</Border>
<ContentPresenter VerticalAlignment="Center" />
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="box" Property="BorderBrush" Value="{StaticResource Brand}" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="box" Property="Background" Value="{StaticResource Brand}" />
<Setter TargetName="box" Property="BorderBrush" Value="{StaticResource Brand}" />
<Setter TargetName="tick" Property="Opacity" Value="1" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ── Buttons ─────────────────────────────────────────────────── -->
<Style x:Key="Btn" TargetType="Button">
<Setter Property="Background" Value="{StaticResource BgElev2}" />
<Setter Property="Background" Value="{StaticResource BgElev}" />
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="Padding" Value="16,10" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="16,9" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
@@ -121,92 +339,141 @@
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
<Border x:Name="root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="10">
CornerRadius="6">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="{TemplateBinding Padding}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="root" Property="Background" Value="{StaticResource BgElev2}" />
<Setter TargetName="root" Property="BorderBrush" Value="{StaticResource BorderStrong}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="root" Property="Background" Value="{StaticResource BgInset}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{StaticResource InkFaint}" />
<Setter TargetName="root" Property="Background" Value="{StaticResource BgElev}" />
<Setter TargetName="root" Property="BorderBrush" Value="{StaticResource BorderSoft}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource Border}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderSoft}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{StaticResource InkFaint}" />
<Setter Property="Background" Value="{StaticResource BgElev}" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="PrimaryBtn" TargetType="Button" BasedOn="{StaticResource Btn}">
<Style x:Key="PrimaryBtn" TargetType="Button">
<Setter Property="Background" Value="{StaticResource Brand}" />
<Setter Property="BorderBrush" Value="{StaticResource BrandDeep}" />
<Setter Property="Foreground" Value="White" />
<Setter Property="Padding" Value="22,12" />
<Setter Property="FontSize" Value="14" />
<Setter Property="BorderBrush" Value="{StaticResource Brand}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="22,11" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Effect" Value="{StaticResource BtnShadow}" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{StaticResource BrandHover}" />
<Setter Property="BorderBrush" Value="{StaticResource Brand}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="{StaticResource BgElev2}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="Foreground" Value="{StaticResource InkFaint}" />
<Setter Property="Effect" Value="{x:Null}" />
</Trigger>
</Style.Triggers>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="6">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="{TemplateBinding Padding}"
TextElement.Foreground="{TemplateBinding Foreground}"
TextElement.FontWeight="{TemplateBinding FontWeight}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="root" Property="Background" Value="{StaticResource BrandHover}" />
<Setter TargetName="root" Property="BorderBrush" Value="{StaticResource BrandHover}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="root" Property="Background" Value="{StaticResource BrandDeep}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="root" Property="Background" Value="{StaticResource BgElev}" />
<Setter TargetName="root" Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="Foreground" Value="{StaticResource InkFaint}" />
<Setter Property="Effect" Value="{x:Null}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Big Play button — reserved for "Launch Rec Room". Bigger than
PrimaryBtn, with stronger presence — the visual anchor of the
ready-to-play view. -->
<Style x:Key="PlayBtn" TargetType="Button" BasedOn="{StaticResource PrimaryBtn}">
<Setter Property="Padding" Value="32,14" />
<Setter Property="FontSize" Value="15" />
<Setter Property="FontWeight" Value="Bold" />
</Style>
<!-- "TealBtn" key kept for PhotonWizard compatibility, but now a
secondary action style (steel blue, not teal). -->
<Style x:Key="TealBtn" TargetType="Button" BasedOn="{StaticResource Btn}">
<Setter Property="Background" Value="{StaticResource Teal}" />
<Setter Property="BorderBrush" Value="{StaticResource TealDeep}" />
<Setter Property="Foreground" Value="#0E0A1F" />
<Setter Property="Padding" Value="22,12" />
<Setter Property="FontSize" Value="14" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#5DE8D6" />
</Trigger>
</Style.Triggers>
<Setter Property="Foreground" Value="White" />
<Setter Property="Padding" Value="22,11" />
<Setter Property="FontSize" Value="13" />
</Style>
<Style x:Key="GhostBtn" TargetType="Button" BasedOn="{StaticResource Btn}">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource InkSoft}" />
<Setter Property="Padding" Value="14,8" />
</Style>
<!-- ── Card buttons for first-run wizard ───────────────────────── -->
<!-- ── Choice cards (Host vs Join wizard step) ─────────────────── -->
<Style x:Key="ChoiceCardBtn" TargetType="Button">
<Setter Property="Background" Value="{StaticResource CardBg}" />
<Setter Property="Background" Value="{StaticResource BgElev}" />
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="Padding" Value="28" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="28,26" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Effect" Value="{StaticResource CardShadow}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="cardBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="18">
<ContentPresenter Margin="{TemplateBinding Padding}" />
</Border>
<Grid>
<Border x:Name="cardBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="10">
<ContentPresenter Margin="{TemplateBinding Padding}" />
</Border>
<!-- Accent stripe on hover — appears at the
top-left, decisive single mark instead of
a glow shadow surrounding the card. -->
<Border x:Name="stripe"
Width="3" HorizontalAlignment="Left"
Margin="0,16" Background="{StaticResource Brand}"
Opacity="0" />
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="cardBorder" Property="BorderBrush"
Value="{StaticResource Brand}" />
Value="{StaticResource BorderStrong}" />
<Setter TargetName="cardBorder" Property="Background"
Value="{StaticResource BgElev2}" />
<Setter TargetName="stripe" Property="Opacity" Value="1" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -219,41 +486,86 @@
<Setter Property="Background" Value="{StaticResource BgElev}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderSoft}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Padding" Value="20" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="22" />
</Style>
<!-- ── Field label text ────────────────────────────────────────── -->
<!-- Section header used inside panels — small uppercase tag plus
title text. The eyebrow tag adds the "real product"
information density we want. -->
<Style x:Key="FieldLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource InkSoft}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="FontSize" Value="10" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,0,0,8" />
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
</Style>
<Style x:Key="SectionTitle" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="FontSize" Value="16" />
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,0,0,12" />
<Setter Property="Margin" Value="0,0,0,14" />
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
</Style>
<!-- Numbered-step bullet used in walkthrough text. -->
<Style x:Key="StepBullet" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Ink}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="LineHeight" Value="22" />
<Setter Property="LineHeight" Value="20" />
<Setter Property="Margin" Value="0,0,0,6" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<!-- Pill-style "status chip" used in section headers. -->
<!-- Status chip — small uppercase pill with letter-spacing. -->
<Style x:Key="StatusChip" TargetType="Border">
<Setter Property="Background" Value="{StaticResource BgElev2}" />
<Setter Property="CornerRadius" Value="999" />
<Setter Property="Padding" Value="10,4" />
<Setter Property="Background" Value="{StaticResource BgElev}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="8,3" />
</Style>
<!-- Sidebar nav button — a flat row with an icon + label. Active
state is a single brand-coloured edge stripe on the left, not
a filled pill. -->
<Style x:Key="NavBtn" TargetType="RadioButton">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Foreground" Value="{StaticResource InkSoft}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
<Setter Property="Margin" Value="0,0,0,2" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Grid>
<Border x:Name="row"
Background="Transparent"
CornerRadius="6"
Padding="14,9">
<ContentPresenter VerticalAlignment="Center" />
</Border>
<Border x:Name="stripe"
Width="2" HorizontalAlignment="Left"
Margin="0,8" Background="{StaticResource Brand}"
Opacity="0" />
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="row" Property="Background" Value="{StaticResource BgElev}" />
<Setter Property="Foreground" Value="{StaticResource Ink}" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="row" Property="Background" Value="{StaticResource BgElev}" />
<Setter TargetName="stripe" Property="Opacity" Value="1" />
<Setter Property="Foreground" Value="{StaticResource Ink}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ── Mini progress bar — restyled WPF ProgressBar to match the
@@ -261,21 +573,21 @@
<Style x:Key="StepProgressBar" TargetType="ProgressBar">
<Setter Property="Minimum" Value="0" />
<Setter Property="Maximum" Value="100" />
<Setter Property="Height" Value="6" />
<Setter Property="Height" Value="4" />
<Setter Property="Foreground" Value="{StaticResource Brand}" />
<Setter Property="Background" Value="{StaticResource BgDeeper}" />
<Setter Property="Background" Value="{StaticResource BgInset}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ProgressBar">
<Border Background="{TemplateBinding Background}"
CornerRadius="3" ClipToBounds="True">
CornerRadius="2" ClipToBounds="True">
<Grid HorizontalAlignment="Stretch">
<Rectangle x:Name="PART_Track" />
<Border x:Name="PART_Indicator"
Background="{TemplateBinding Foreground}"
HorizontalAlignment="Left"
CornerRadius="3" />
CornerRadius="2" />
</Grid>
</Border>
</ControlTemplate>
@@ -283,74 +595,211 @@
</Setter>
</Style>
<!-- ── Bool→Visibility converter for HasDetail/HasProgress flags ─ -->
<BooleanToVisibilityConverter x:Key="BoolToVis" />
<!-- ── ScrollBar ───────────────────────────────────────────────
Native WPF scrollbars look like Windows 95 against a dark
slate theme. Replace with a thin, flat track + capsule
thumb that fades in on hover. Same styling for the V and H
orientations. -->
<Style x:Key="ScrollThumb" TargetType="Thumb">
<Setter Property="OverridesDefaultStyle" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border x:Name="thumb"
Background="#4A5165"
CornerRadius="4"
Margin="2,2" />
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="thumb" Property="Background" Value="{StaticResource InkSoft}" />
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="thumb" Property="Background" Value="{StaticResource Brand}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Invisible repeat buttons. Default ones come with arrow
glyphs and chunky chrome that we don't want. -->
<Style x:Key="ScrollRepeatHidden" TargetType="RepeatButton">
<Setter Property="OverridesDefaultStyle" Value="True" />
<Setter Property="Focusable" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Rectangle Fill="Transparent" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ScrollBar">
<Setter Property="OverridesDefaultStyle" Value="True" />
<Setter Property="Background" Value="#0A0C11" />
<Setter Property="Width" Value="12" />
<Setter Property="MinWidth" Value="12" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Border Background="{TemplateBinding Background}"
BorderBrush="{StaticResource BorderSoft}"
BorderThickness="1,0,0,0">
<Track Name="PART_Track"
IsDirectionReversed="True"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.DecreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollRepeatHidden}"
Command="ScrollBar.PageUpCommand" />
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource ScrollThumb}" MinHeight="32" />
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollRepeatHidden}"
Command="ScrollBar.PageDownCommand" />
</Track.IncreaseRepeatButton>
</Track>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Height" Value="12" />
<Setter Property="MinHeight" Value="12" />
<Setter Property="Width" Value="Auto" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Border Background="{TemplateBinding Background}"
BorderBrush="{StaticResource BorderSoft}"
BorderThickness="0,1,0,0">
<Track Name="PART_Track"
IsDirectionReversed="False"
ViewportSize="{TemplateBinding ViewportSize}">
<Track.DecreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollRepeatHidden}"
Command="ScrollBar.PageLeftCommand" />
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource ScrollThumb}" MinWidth="32" />
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollRepeatHidden}"
Command="ScrollBar.PageRightCommand" />
</Track.IncreaseRepeatButton>
</Track>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Trigger>
</Style.Triggers>
</Style>
<!-- ScrollViewer wrapper picked up by every default ScrollViewer
(Window scrollers, the ScrollViewer in ComboBox popup,
ListBox/ItemsControl scrolling etc.). Same overlay-style
slim track on both axes. -->
<Style TargetType="ScrollViewer">
<Setter Property="VerticalScrollBarVisibility" Value="Auto" />
</Style>
<!-- ── StartupStep row template ─────────────────────────────────
A single row in the host/join step list. State drives the
status icon's colour + glyph; detail/progress are shown only
when populated (so pending steps render as a clean single
line). -->
status mark; detail/progress are shown only when populated. -->
<DataTemplate x:Key="StepRowTemplate" DataType="{x:Type backend:StartupStep}">
<Grid Margin="0,0,0,14">
<Grid Margin="0,0,0,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28" />
<ColumnDefinition Width="24" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Status bullet — circle that recolours and re-glyphs
based on the bound State. Pending = empty ring,
Active = filled brand, Done = green checkmark,
Failed = red X. -->
<Border Grid.Column="0" Width="22" Height="22" CornerRadius="11"
VerticalAlignment="Top" Margin="0,1,0,0"
BorderThickness="2">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource InkFaint}" />
<Style.Triggers>
<DataTrigger Binding="{Binding State}" Value="Active">
<Setter Property="Background" Value="{StaticResource Brand}" />
<Setter Property="BorderBrush" Value="{StaticResource Brand}" />
</DataTrigger>
<DataTrigger Binding="{Binding State}" Value="Done">
<Setter Property="Background" Value="{StaticResource Ok}" />
<Setter Property="BorderBrush" Value="{StaticResource Ok}" />
</DataTrigger>
<DataTrigger Binding="{Binding State}" Value="Failed">
<Setter Property="Background" Value="{StaticResource Danger}" />
<Setter Property="BorderBrush" Value="{StaticResource Danger}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock HorizontalAlignment="Center" VerticalAlignment="Center"
FontSize="13" FontWeight="Bold" Foreground="White">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="" />
<!-- Status indicator — empty ring for pending, filled
accent for active, mint check for done, red X for
failed. Drawn as Path geometry, not emoji. -->
<Grid Grid.Column="0" Width="18" Height="18"
VerticalAlignment="Top" Margin="0,2,0,0">
<Ellipse x:Name="ring">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Stroke" Value="{StaticResource BorderStrong}" />
<Setter Property="StrokeThickness" Value="1.5" />
<Setter Property="Fill" Value="Transparent" />
<Style.Triggers>
<DataTrigger Binding="{Binding State}" Value="Active">
<Setter Property="Text" Value="●" />
<Setter Property="FontSize" Value="9" />
<Setter Property="Stroke" Value="{StaticResource Brand}" />
</DataTrigger>
<DataTrigger Binding="{Binding State}" Value="Done">
<Setter Property="Text" Value="✓" />
<Setter Property="Foreground" Value="#0E0A1F" />
<Setter Property="Stroke" Value="{StaticResource Ok}" />
<Setter Property="Fill" Value="{StaticResource Ok}" />
</DataTrigger>
<DataTrigger Binding="{Binding State}" Value="Failed">
<Setter Property="Text" Value="✕" />
<Setter Property="Stroke" Value="{StaticResource Danger}" />
<Setter Property="Fill" Value="{StaticResource Danger}" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
</Ellipse.Style>
</Ellipse>
<Ellipse Width="6" Height="6" Fill="{StaticResource Brand}"
Visibility="Collapsed">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding State}" Value="Active">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<Path Stroke="#0D0F14" StrokeThickness="2"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 4 9 L 7.5 12 L 14 5"
Width="18" Height="18">
<Path.Style>
<Style TargetType="Path">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding State}" Value="Done">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
</Path>
<Path Stroke="White" StrokeThickness="2"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 5 5 L 13 13 M 13 5 L 5 13"
Width="18" Height="18">
<Path.Style>
<Style TargetType="Path">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding State}" Value="Failed">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
</Path>
</Grid>
<!-- Title + (optional) detail + (optional) progress bar -->
<StackPanel Grid.Column="1" Margin="12,0,0,0">
<TextBlock Text="{Binding Title}" FontSize="14">
<StackPanel Grid.Column="1" Margin="14,0,0,0">
<TextBlock Text="{Binding Title}" FontSize="13">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource InkSoft}" />
@@ -374,6 +823,7 @@
<TextBlock Text="{Binding Detail}" TextWrapping="Wrap"
FontSize="12" Margin="0,4,0,0"
FontFamily="Cascadia Mono, Consolas"
Visibility="{Binding HasDetail, Converter={StaticResource BoolToVis}}">
<TextBlock.Style>
<Style TargetType="TextBlock">
+17 -1
View File
@@ -57,7 +57,7 @@ public sealed class ReleaseDownloader
var asset = await FindAssetAsync(
tagPrefix: version.ReleaseTagPrefix,
assetName: $"dorknet-server-{version.VersionKey}-win-x64.zip",
assetName: $"dorknet-server-{version.VersionKey}-{CurrentRid}.zip",
ct);
var zipPath = Path.Combine(AppPaths.LocalRoot, $"download-{Guid.NewGuid():N}.zip");
@@ -71,6 +71,22 @@ public sealed class ReleaseDownloader
return dir;
}
/// <summary>RID for asset naming — `win-x64`, `linux-x64`,
/// `linux-arm64`, `osx-x64`, `osx-arm64`. Matches the `-r` flag
/// the per-version release workflow passes to `dotnet publish`.</summary>
private static string CurrentRid =>
(OperatingSystem.IsWindows(), OperatingSystem.IsLinux(), OperatingSystem.IsMacOS(),
System.Runtime.InteropServices.RuntimeInformation.OSArchitecture) switch
{
(true, _, _, System.Runtime.InteropServices.Architecture.X64) => "win-x64",
(true, _, _, System.Runtime.InteropServices.Architecture.Arm64) => "win-arm64",
(_, true, _, System.Runtime.InteropServices.Architecture.X64) => "linux-x64",
(_, true, _, System.Runtime.InteropServices.Architecture.Arm64) => "linux-arm64",
(_, _, true, System.Runtime.InteropServices.Architecture.X64) => "osx-x64",
(_, _, true, System.Runtime.InteropServices.Architecture.Arm64) => "osx-arm64",
_ => "win-x64",
};
/// <summary>Returns the path to the unpacked client-patcher dir
/// for <paramref name="version"/>, downloading + caching if needed.</summary>
public async Task<string> EnsurePatcherAsync(
+23 -10
View File
@@ -102,17 +102,30 @@ public sealed class ServerProcess : IAsyncDisposable
private static string FindServerExecutable(string serverDir)
{
// Self-contained .NET publish produces a single .exe per
// RuntimeIdentifier. The server's csproj sets AssemblyName so
// we know the name; fall back to scanning if the rename
// changes.
var canonical = Path.Combine(serverDir, "DorkNet.Server.exe");
// Self-contained .NET publish drops a per-RID binary; on Windows
// it's DorkNet.Server.exe, on Linux/Mac it's DorkNet.Server
// (no extension). Try both, then scan as a last resort.
var ext = OperatingSystem.IsWindows() ? ".exe" : "";
var canonical = Path.Combine(serverDir, $"DorkNet.Server{ext}");
if (File.Exists(canonical)) return canonical;
var any = Directory.GetFiles(serverDir, "*.exe").FirstOrDefault();
if (any is null)
throw new FileNotFoundException(
$"No .exe found in {serverDir}. The release artifact may be corrupt.");
return any;
// Windows scan picks any .exe; on Unix, scan for files that are
// executable + match the expected name pattern.
if (OperatingSystem.IsWindows())
{
var any = Directory.GetFiles(serverDir, "*.exe").FirstOrDefault();
if (any is not null) return any;
}
else
{
var any = Directory.GetFiles(serverDir, "DorkNet.Server*")
.Where(p => !p.EndsWith(".dll") && !p.EndsWith(".pdb") && !p.EndsWith(".json"))
.FirstOrDefault();
if (any is not null) return any;
}
throw new FileNotFoundException(
$"No server binary found in {serverDir}. The release artifact may be corrupt.");
}
private static void ApplySqliteCompatibilityPatches(string serverDir)
+231
View File
@@ -0,0 +1,231 @@
using System.Formats.Tar;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Net.Http.Json;
using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
namespace DorkNet.Launcher.Backend;
/// <summary>Fetches <c>tunnelto</c> straight from the upstream
/// release feed (agrinman/tunnelto) the first time the launcher needs
/// it, so end users don't have to set up Scoop / Cargo / brew before
/// hosting. The downloaded binary lands in <see cref="AppPaths.LocalRoot"/>,
/// which is the first path <see cref="TunneltoTunnel"/> probes after
/// the side-by-side install dir.
///
/// <para>Upstream's release assets (as of 0.1.18) are uneven:
/// <list type="bullet">
/// <item>Windows ships a direct <c>tunnelto-windows.exe</c> — easy.</item>
/// <item>Linux ships a <c>tunnelto-linux.tar.gz</c> archive — easy.</item>
/// <item>macOS only publishes Homebrew bottles, which we can't crack
/// open cleanly. macOS falls through to a clear "brew install tunnelto"
/// error instead.</item>
/// </list></para></summary>
public sealed class TunneltoInstaller
{
private const string ReleasesApi =
"https://api.github.com/repos/agrinman/tunnelto/releases/latest";
private static readonly HttpClient Http = CreateHttp();
private static HttpClient CreateHttp()
{
var c = new HttpClient { Timeout = TimeSpan.FromMinutes(3) };
c.DefaultRequestHeaders.UserAgent.ParseAdd("DorkNet-Launcher-TunneltoInstaller/1.0");
c.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
return c;
}
/// <summary>Make sure tunnelto is available somewhere
/// <see cref="TunneltoTunnel"/> will find it. If it's already
/// installed, returns immediately. Otherwise downloads the
/// upstream release into <see cref="AppPaths.LocalRoot"/>.
/// Throws with platform-specific install hints when auto-install
/// isn't possible (currently macOS).</summary>
public async Task<string> EnsureInstalledAsync(
IProgress<DownloadProgress>? progress = null,
CancellationToken ct = default)
{
// Quickly probe the existing-install paths first so a repeat
// host start doesn't hammer the GitHub API.
var existing = TryFindExisting();
if (existing is not null) return existing;
if (OperatingSystem.IsMacOS())
{
// Upstream macOS releases are Homebrew bottles, not portable
// binaries — we'd have to fish the prefix out and that's a
// mess. Tell the user to use brew instead.
throw new PlatformNotSupportedException(
"Tunnelto auto-install isn't supported on macOS — install it manually: " +
"`brew install tunnelto` (or `cargo install tunnelto`).");
}
if (OperatingSystem.IsLinux() &&
RuntimeInformation.OSArchitecture != Architecture.X64)
{
// The only Linux asset is x64; ARM users have to build
// from source or pull via cargo.
throw new PlatformNotSupportedException(
"Tunnelto auto-install only covers Linux x64. On arm64, run: " +
"`cargo install tunnelto` (or grab a build from github.com/agrinman/tunnelto/releases).");
}
var release = await Http.GetFromJsonAsync<GitHubRelease>(ReleasesApi, ct)
?? throw new InvalidOperationException("Couldn't reach github.com/agrinman/tunnelto/releases.");
var (assetName, kind) = AssetForCurrentPlatform();
var asset = release.Assets?.FirstOrDefault(a =>
string.Equals(a.Name, assetName, StringComparison.OrdinalIgnoreCase));
if (asset?.BrowserDownloadUrl is null)
throw new InvalidOperationException(
$"The upstream release doesn't ship a '{assetName}' asset. " +
$"Got assets: {string.Join(", ", release.Assets?.Select(a => a.Name) ?? Array.Empty<string>())}");
var binaryName = OperatingSystem.IsWindows() ? "tunnelto.exe" : "tunnelto";
var dest = Path.Combine(AppPaths.LocalRoot, binaryName);
// Stream to a temp file first so a half-finished download
// doesn't leave a broken binary in place.
var stagingPath = Path.Combine(AppPaths.LocalRoot, $"tunnelto-staging-{Guid.NewGuid():N}");
try
{
await DownloadAsync(asset.BrowserDownloadUrl, stagingPath, progress, ct);
switch (kind)
{
case AssetKind.RawExe:
if (File.Exists(dest)) File.Delete(dest);
File.Move(stagingPath, dest);
break;
case AssetKind.TarGz:
ExtractTunneltoFromTarGz(stagingPath, dest);
File.Delete(stagingPath);
// Mark executable on Unix — extracted files often
// come out without the mode bits set.
if (!OperatingSystem.IsWindows())
TryChmodExec(dest);
break;
}
return dest;
}
catch
{
if (File.Exists(stagingPath)) try { File.Delete(stagingPath); } catch { }
throw;
}
}
/// <summary>Mirror of <see cref="TunneltoTunnel.ResolveExecutable"/>'s
/// search order — kept here so the installer can short-circuit when
/// the user already installed tunnelto via Scoop / brew / cargo.</summary>
private static string? TryFindExisting()
{
var isWindows = OperatingSystem.IsWindows();
var name = isWindows ? "tunnelto.exe" : "tunnelto";
var candidates = new List<string>
{
Path.Combine(AppContext.BaseDirectory, name),
Path.Combine(AppPaths.LocalRoot, name),
};
if (isWindows)
{
candidates.Add(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"scoop", "shims", name));
}
foreach (var c in candidates)
if (File.Exists(c)) return c;
// PATH scan — last resort, in case the user has it somewhere
// unusual like /usr/local/bin or via cargo's bin dir.
var onPath = Environment.GetEnvironmentVariable("PATH")?
.Split(Path.PathSeparator)
.Select(p => Path.Combine(p.Trim(), name))
.FirstOrDefault(File.Exists);
return onPath;
}
private static (string assetName, AssetKind kind) AssetForCurrentPlatform()
{
if (OperatingSystem.IsWindows()) return ("tunnelto-windows.exe", AssetKind.RawExe);
if (OperatingSystem.IsLinux()) return ("tunnelto-linux.tar.gz", AssetKind.TarGz);
throw new PlatformNotSupportedException("No tunnelto auto-install for this platform.");
}
/// <summary>Walks the tarball looking for a regular file named
/// "tunnelto" (typically at the root). Writes it to
/// <paramref name="dest"/>. The archive itself is a single-binary
/// release so we don't need to faithfully recreate the directory
/// structure.</summary>
private static void ExtractTunneltoFromTarGz(string archivePath, string dest)
{
using var fs = File.OpenRead(archivePath);
using var gz = new GZipStream(fs, CompressionMode.Decompress);
using var tar = new TarReader(gz);
while (tar.GetNextEntry() is { } entry)
{
// Only interested in the binary itself. The archive may
// contain README / LICENSE which we skip.
var fname = Path.GetFileName(entry.Name);
if (entry.EntryType != TarEntryType.RegularFile) continue;
if (!fname.Equals("tunnelto", StringComparison.OrdinalIgnoreCase)) continue;
if (File.Exists(dest)) File.Delete(dest);
using var outFs = File.Create(dest);
entry.DataStream?.CopyTo(outFs);
return;
}
throw new InvalidDataException(
"tunnelto-linux.tar.gz didn't contain a 'tunnelto' binary. Asset may be malformed.");
}
/// <summary>Mark a file as executable on Unix systems. Uses
/// <see cref="File.SetUnixFileMode"/> (available since .NET 7).
/// No-op on Windows since the NTFS execute bit doesn't apply.</summary>
private static void TryChmodExec(string path)
{
try
{
var mode = File.GetUnixFileMode(path);
File.SetUnixFileMode(path,
mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute);
}
catch { /* best-effort */ }
}
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 ?? -1L;
await using var src = await resp.Content.ReadAsStreamAsync(ct);
await using var dst = File.Create(destPath);
var buf = new byte[81920];
long read = 0;
int n;
while ((n = await src.ReadAsync(buf, ct)) > 0)
{
await dst.WriteAsync(buf.AsMemory(0, n), ct);
read += n;
progress?.Report(new DownloadProgress(read, total));
}
}
private enum AssetKind { RawExe, TarGz }
private sealed class GitHubRelease
{
[JsonPropertyName("tag_name")] public string? TagName { get; set; }
[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; }
}
}
+18 -10
View File
@@ -76,26 +76,34 @@ public sealed class TunneltoTunnel : IAsyncDisposable
private static string ResolveExecutable()
{
var sideBySide = Path.Combine(AppContext.BaseDirectory, "tunnelto.exe");
var isWindows = OperatingSystem.IsWindows();
var name = isWindows ? "tunnelto.exe" : "tunnelto";
var sideBySide = Path.Combine(AppContext.BaseDirectory, name);
if (File.Exists(sideBySide)) return sideBySide;
var local = Path.Combine(AppPaths.LocalRoot, "tunnelto.exe");
var local = Path.Combine(AppPaths.LocalRoot, name);
if (File.Exists(local)) return local;
var scoopShim = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"scoop", "shims", "tunnelto.exe");
if (File.Exists(scoopShim)) return scoopShim;
if (isWindows)
{
var scoopShim = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"scoop", "shims", name);
if (File.Exists(scoopShim)) return scoopShim;
}
var onPath = Environment.GetEnvironmentVariable("PATH")?
.Split(';')
.Select(p => Path.Combine(p.Trim(), "tunnelto.exe"))
.Split(Path.PathSeparator)
.Select(p => Path.Combine(p.Trim(), name))
.FirstOrDefault(File.Exists);
if (onPath is not null) return onPath;
var hint = isWindows
? "scoop bucket add tunnelto https://github.com/asabi/scoop-tunnelto && scoop install tunnelto"
: "cargo install tunnelto # or grab a binary from github.com/agrinman/tunnelto/releases";
throw new FileNotFoundException(
"tunnelto.exe was not found. Install Tunnelto first: " +
"scoop bucket add tunnelto https://github.com/asabi/scoop-tunnelto && scoop install tunnelto");
$"{name} was not found. Install Tunnelto first: {hint}");
}
private static readonly Regex UrlRegex = new(
+166
View File
@@ -0,0 +1,166 @@
using System.IO;
using System.Net.Http;
using System.Net.Http.Json;
using System.Reflection;
using System.Text.Json.Serialization;
namespace DorkNet.Launcher.Backend;
/// <summary>Rolling-update check against GitHub releases. The launcher
/// asks for the latest release tag on startup, compares it to its own
/// assembly version, and (if newer) downloads the new self-contained
/// <c>dorknet.exe</c> next to the existing one + writes a tiny swap
/// script. The UI surfaces a banner with a button that runs the script
/// and exits — the script waits for the process to release the exe,
/// copies the new build in place, and relaunches.</summary>
public sealed class Updater
{
private const string ReleasesApi =
"https://api.github.com/repos/DorkSquadRR/DorkNet/releases/latest";
/// <summary>Asset name produced by the Inno installer's publish
/// step. Must match what's attached to GitHub releases.</summary>
private const string LauncherAssetName = "dorknet.exe";
private static readonly HttpClient Http = CreateHttp();
private static HttpClient CreateHttp()
{
var c = new HttpClient { Timeout = TimeSpan.FromMinutes(2) };
c.DefaultRequestHeaders.UserAgent.ParseAdd("DorkNet-Launcher-Updater/1.0");
c.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
return c;
}
/// <summary>Current launcher version, read from the assembly's
/// InformationalVersion / FileVersion attribute. Falls back to 0.0
/// in dev builds where neither is set.</summary>
public static Version CurrentVersion
{
get
{
var asm = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(info) && TryParseLoose(info, out var v)) return v;
var fv = asm.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version;
if (!string.IsNullOrWhiteSpace(fv) && TryParseLoose(fv, out var fvv)) return fvv;
return new Version(0, 0);
}
}
/// <summary>Looks up GitHub's latest release. Returns null on
/// network failure or if the local version is already at-or-newer
/// than what's published. Never throws — update checks must never
/// block app launch.</summary>
public async Task<UpdateInfo?> CheckAsync(CancellationToken ct = default)
{
try
{
var release = await Http.GetFromJsonAsync<GitHubRelease>(ReleasesApi, ct);
if (release is null || string.IsNullOrEmpty(release.TagName)) return null;
if (!TryParseLoose(release.TagName, out var latest)) return null;
if (latest <= CurrentVersion) return null;
var asset = release.Assets?.FirstOrDefault(a =>
string.Equals(a.Name, LauncherAssetName, StringComparison.OrdinalIgnoreCase));
if (asset?.BrowserDownloadUrl is null) return null;
return new UpdateInfo(latest, asset.BrowserDownloadUrl, release.HtmlUrl);
}
catch { return null; }
}
/// <summary>Downloads the new exe to the user's local update
/// staging dir, writes a swap .cmd next to it, and returns the
/// path to that .cmd. Caller is expected to launch it and exit.</summary>
public async Task<string> StageUpdateAsync(
UpdateInfo info,
IProgress<DownloadProgress>? progress = null,
CancellationToken ct = default)
{
var stagingDir = Path.Combine(AppPaths.LocalRoot, "update");
Directory.CreateDirectory(stagingDir);
var newExePath = Path.Combine(stagingDir, LauncherAssetName);
using (var response = await Http.GetAsync(info.DownloadUrl,
HttpCompletionOption.ResponseHeadersRead, ct))
{
response.EnsureSuccessStatusCode();
var total = response.Content.Headers.ContentLength ?? -1;
await using var src = await response.Content.ReadAsStreamAsync(ct);
await using var dst = File.Create(newExePath);
var buf = new byte[81920];
long read = 0;
int n;
while ((n = await src.ReadAsync(buf, ct)) > 0)
{
await dst.WriteAsync(buf.AsMemory(0, n), ct);
read += n;
progress?.Report(new DownloadProgress(read, total));
}
}
var currentExe = GetCurrentExePath();
var swapScript = Path.Combine(stagingDir, "swap.cmd");
// The swap script:
// 1. Pings until the running dorknet exits (we exit immediately
// after spawning it, so this resolves fast).
// 2. Copies the new exe over the old one. /Y suppresses
// confirmation; failure exits with the original errorlevel.
// 3. Starts the new exe in its install directory.
// 4. Self-deletes via the start-then-del trick.
var script =
"@echo off\r\n" +
"setlocal\r\n" +
$"set OLD=\"{currentExe}\"\r\n" +
$"set NEW=\"{newExePath}\"\r\n" +
":wait\r\n" +
"tasklist /FI \"IMAGENAME eq dorknet.exe\" 2>NUL | find /I \"dorknet.exe\" >NUL\r\n" +
"if not errorlevel 1 (timeout /t 1 /nobreak >NUL & goto wait)\r\n" +
"copy /Y %NEW% %OLD% >NUL || (echo Copy failed & exit /b 1)\r\n" +
"start \"\" %OLD%\r\n" +
"del \"%~f0\"\r\n";
await File.WriteAllTextAsync(swapScript, script, ct);
return swapScript;
}
/// <summary>Best-effort path to the currently-running exe. Used by
/// the swap script as the destination to overwrite.</summary>
public static string GetCurrentExePath()
{
// Process.MainModule.FileName is the reliable source — handles
// single-file deployments (where AppContext.BaseDirectory points
// at an extracted temp dir, not the actual exe location).
var p = System.Diagnostics.Process.GetCurrentProcess();
return p.MainModule?.FileName
?? Path.Combine(AppContext.BaseDirectory, "dorknet.exe");
}
/// <summary>Parse "v0.2.0" / "0.2.0" / "v0.2.0-rc1" loosely into a
/// <see cref="Version"/>. Pre-release suffixes are dropped.</summary>
private static bool TryParseLoose(string s, out Version version)
{
version = new Version(0, 0);
if (string.IsNullOrWhiteSpace(s)) return false;
var t = s.Trim().TrimStart('v', 'V');
// Strip pre-release / build metadata suffix (semver "-..." or "+...").
var cut = t.IndexOfAny(new[] { '-', '+' });
if (cut >= 0) t = t.Substring(0, cut);
// Pad to at least two segments so "1" → "1.0" parses cleanly.
if (!t.Contains('.')) t += ".0";
return Version.TryParse(t, out version!);
}
private sealed class GitHubRelease
{
[JsonPropertyName("tag_name")] public string? TagName { get; set; }
[JsonPropertyName("html_url")] public string? HtmlUrl { get; set; }
[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; }
}
}
public sealed record UpdateInfo(Version Version, string DownloadUrl, string? ReleaseUrl);
+1215 -413
View File
@@ -2,8 +2,8 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="DorkNet"
Width="960" Height="700"
MinWidth="720" MinHeight="540"
Width="1080" Height="720"
MinWidth="900" MinHeight="600"
WindowStartupLocation="CenterScreen"
Background="{StaticResource Bg}"
TextElement.Foreground="{StaticResource Ink}"
@@ -11,115 +11,507 @@
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="ClearType">
<!-- Soft radial-ish glow in the top-left corner of the window for
atmosphere. WPF doesn't have a true radial gradient on Background
without nested Border tricks, so we stack a linear-gradient
overlay on top of the solid Bg. -->
<Grid>
<!-- Window background: a faint vignette in the top-left from the
brand colour into the slate base. No multi-stop rainbow — a
single warm corner anchored on near-neutral. -->
<Grid.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#2A1E48" Offset="0" />
<GradientStop Color="#16102A" Offset="0.6" />
<GradientStop Color="#16102A" Offset="1" />
</LinearGradientBrush>
<RadialGradientBrush GradientOrigin="0,0" Center="0,0"
RadiusX="0.9" RadiusY="1.1">
<GradientStop Color="#1A1820" Offset="0" />
<GradientStop Color="#0D0F14" Offset="0.8" />
</RadialGradientBrush>
</Grid.Background>
<Grid Margin="36,28">
<!-- View slots — only one Visible at a time; code-behind toggles. -->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- ── Welcome screen ─────────────────────────────────────
Only shown on truly first launch. After "Get started"
the user enters the setup wizard; subsequent launches
skip straight to Setup or HostView/JoinView. -->
<StackPanel x:Name="WelcomeView" Visibility="Collapsed"
VerticalAlignment="Center" HorizontalAlignment="Center"
MaxWidth="520">
<TextBlock Text="DorkNet"
FontSize="72" FontWeight="Bold"
Foreground="{StaticResource WordmarkBrush}"
HorizontalAlignment="Center"
FontFamily="Segoe UI Variable, Segoe UI" />
<TextBlock Text="Play together on your own Rec Room server."
Foreground="{StaticResource Ink}"
FontSize="18"
HorizontalAlignment="Center" Margin="0,8,0,32"
TextAlignment="Center" />
<Border Style="{StaticResource Panel}"
Background="{StaticResource CardBg}" Padding="28,24"
Margin="0,0,0,28">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
<TextBlock Text="✨" FontSize="20" Margin="0,0,12,0" />
<TextBlock Foreground="{StaticResource Ink}" FontSize="14"
VerticalAlignment="Center" TextWrapping="Wrap"
Text="Set up in about two minutes" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
<TextBlock Text="💸" FontSize="20" Margin="0,0,12,0" />
<TextBlock Foreground="{StaticResource Ink}" FontSize="14"
VerticalAlignment="Center" TextWrapping="Wrap"
Text="Host for free — no credit card, no subscription" />
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="🎮" FontSize="20" Margin="0,0,12,0" />
<TextBlock Foreground="{StaticResource Ink}" FontSize="14"
VerticalAlignment="Center" TextWrapping="Wrap"
Text="Works with the regular Rec Room game — no rebuild needed" />
</StackPanel>
</StackPanel>
</Border>
<Button Style="{StaticResource PrimaryBtn}"
Content="Get started →"
Click="OnWelcomeContinue"
HorizontalAlignment="Center"
Padding="34,14" FontSize="15" />
<TextBlock Text="Not affiliated with Rec Room Inc. or Against Gravity."
HorizontalAlignment="Center"
Foreground="{StaticResource InkFaint}"
FontSize="11" Margin="0,28,0,0" />
</StackPanel>
<!-- ── Setup wizard ────────────────────────────────────────
Shown on first launch after Welcome. Walks through mode
pick, install location, server config + Photon. Once
the user finishes the last step, AppState.SetupComplete
flips and future launches go straight to HostView /
JoinView (which still hold the same fields for tweaks). -->
<Grid x:Name="SetupView" Visibility="Collapsed">
<!-- ── Sidebar ─────────────────────────────────────────────────
Persistent in Host/Join/Settings views. Hidden via code-behind
during Welcome/Setup so those screens can hero the brand. -->
<Border x:Name="MainNav" Grid.Column="0"
Width="232" Visibility="Collapsed"
Background="{StaticResource BgDeeper}"
BorderBrush="{StaticResource BorderSoft}"
BorderThickness="0,0,1,0">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,24">
<!-- Brand mark: brand-coloured square with a small notch
cut out, plus the wordmark in caps. Distinctive geometric
mark — no rainbow gradient on the text. -->
<Border Grid.Row="0" Padding="20,22,20,18"
BorderBrush="{StaticResource BorderSoft}"
BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal">
<TextBlock Text="DorkNet" FontSize="22" FontWeight="Bold"
Foreground="{StaticResource WordmarkBrush}" />
<Border Style="{StaticResource StatusChip}" Margin="14,0,0,0"
VerticalAlignment="Center">
<TextBlock x:Name="SetupStepIndicator"
Text="STEP 1" FontSize="10"
FontWeight="Bold" Foreground="{StaticResource Brand}" />
<Grid Width="28" Height="28" Margin="0,0,12,0">
<Border Background="{StaticResource Brand}"
CornerRadius="6" />
<Path Fill="White"
Data="M 8 8 L 14 8 L 14 14 L 20 14 L 20 20 L 8 20 Z"
Width="28" Height="28" />
</Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="DORKNET" FontSize="13"
FontWeight="Bold"
Foreground="{StaticResource Ink}">
<TextBlock.Resources>
<Style TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe UI Variable, Segoe UI" />
</Style>
</TextBlock.Resources>
</TextBlock>
<TextBlock Text="PRIVATE SERVER" FontSize="9"
FontWeight="SemiBold"
Foreground="{StaticResource InkFaint}"
Margin="0,2,0,0" />
</StackPanel>
</StackPanel>
</Border>
<!-- Nav rows — Host, Join, Settings. Each routes to the
same handlers the legacy mode-switcher uses. Visual
"active" state is set by the code-behind via IsChecked
to match whichever view is showing. -->
<StackPanel Grid.Row="1" Margin="12,16">
<TextBlock Text="MODE" Style="{StaticResource FieldLabel}"
Margin="6,0,0,8" />
<RadioButton x:Name="NavHostBtn" Style="{StaticResource NavBtn}"
AutomationProperties.AutomationId="NavHostBtn"
GroupName="MainNav" Click="OnPickHost">
<StackPanel Orientation="Horizontal">
<Path Width="18" Height="18"
Stroke="{Binding Foreground, RelativeSource={RelativeSource AncestorType=RadioButton}}"
StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
Data="M 2 4 L 16 4 L 16 8 L 2 8 Z M 2 10 L 16 10 L 16 14 L 2 14 Z M 4 6 L 5 6 M 4 12 L 5 12 M 13 6 L 14 6 M 13 12 L 14 12"
VerticalAlignment="Center" Margin="2,0,12,0" />
<TextBlock Text="Host a server" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
<RadioButton x:Name="NavJoinBtn" Style="{StaticResource NavBtn}"
AutomationProperties.AutomationId="NavJoinBtn"
GroupName="MainNav" Click="OnPickJoin">
<StackPanel Orientation="Horizontal">
<Path Width="18" Height="18"
Stroke="{Binding Foreground, RelativeSource={RelativeSource AncestorType=RadioButton}}"
StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 7 11 L 11 7 M 5 9 A 3 3 0 0 1 9 5 L 11 5 M 11 13 L 9 13 A 3 3 0 0 1 5 9 M 15 9 A 3 3 0 0 1 11 13 M 11 5 A 3 3 0 0 1 15 9"
VerticalAlignment="Center" Margin="2,0,12,0" />
<TextBlock Text="Join a friend" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
<TextBlock Text="SYSTEM" Style="{StaticResource FieldLabel}"
Margin="6,20,0,8" />
<RadioButton x:Name="NavSettingsBtn" Style="{StaticResource NavBtn}"
AutomationProperties.AutomationId="NavSettingsBtn"
GroupName="MainNav" Click="OnOpenSettings">
<StackPanel Orientation="Horizontal">
<Path Width="18" Height="18"
Stroke="{Binding Foreground, RelativeSource={RelativeSource AncestorType=RadioButton}}"
StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
Data="M 9 2 L 10 4 L 12 3 L 12.5 5 L 14.5 5 L 14 7 L 16 8 L 14.5 9.5 L 15 11.5 L 13 11.5 L 12.5 13.5 L 10.5 13 L 9 14.5 L 7.5 13 L 5.5 13.5 L 5 11.5 L 3 11.5 L 3.5 9.5 L 2 8 L 4 7 L 3.5 5 L 5.5 5 L 6 3 L 8 4 Z M 9 6.5 A 1.5 1.5 0 1 0 9 9.5 A 1.5 1.5 0 1 0 9 6.5"
VerticalAlignment="Center" Margin="2,0,12,0" />
<TextBlock Text="Settings" VerticalAlignment="Center" />
</StackPanel>
</RadioButton>
<Button x:Name="NavRerunSetupBtn"
ToolTip="Re-run setup wizard"
Click="OnRerunSetup"
HorizontalContentAlignment="Stretch"
Background="Transparent" BorderBrush="Transparent"
BorderThickness="0" Padding="14,9"
Cursor="Hand"
Margin="0,0,0,2">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="root" Background="{TemplateBinding Background}"
CornerRadius="6" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Stretch"
VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="root" Property="Background" Value="{StaticResource BgElev}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Path Width="18" Height="18"
Stroke="{StaticResource InkSoft}"
StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 4 9 A 5 5 0 1 1 6 13 M 4 5 L 4 9 L 8 9"
VerticalAlignment="Center" Margin="2,0,12,0" />
<TextBlock Text="Re-run setup" VerticalAlignment="Center"
Foreground="{StaticResource InkSoft}"
FontSize="13" FontWeight="SemiBold" />
</StackPanel>
</Button>
</StackPanel>
<!-- Footer: status indicator + version. Information
density a real tool wears. -->
<Border Grid.Row="2" Padding="20,16"
BorderBrush="{StaticResource BorderSoft}"
BorderThickness="0,1,0,0">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<Ellipse x:Name="SidebarStatusDot" Width="8" Height="8"
Fill="{StaticResource InkFaint}"
VerticalAlignment="Center" Margin="0,0,8,0" />
<TextBlock x:Name="SidebarStatusText"
Text="Idle" FontSize="11"
FontWeight="SemiBold"
Foreground="{StaticResource InkSoft}"
VerticalAlignment="Center" />
</StackPanel>
<TextBlock x:Name="SidebarVersionText"
Text="DORKNET LAUNCHER · v0.1"
FontSize="9" FontWeight="SemiBold"
Foreground="{StaticResource InkFaint}"
FontFamily="Cascadia Mono, Consolas" />
<!-- Update banner — only visible after the
startup check finds a newer release. Clicking
stages the download + spawns the swap script. -->
<Border x:Name="UpdateBanner" Visibility="Collapsed"
Background="{StaticResource BrandSoft}"
BorderBrush="{StaticResource Brand}"
BorderThickness="1"
CornerRadius="6"
Padding="10,8"
Margin="0,12,0,0">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<Path Width="11" Height="11" Margin="0,0,6,0"
Stroke="{StaticResource Brand}"
StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 5.5 1 L 5.5 8 M 2 5 L 5.5 8 L 9 5 M 1.5 10 L 9.5 10"
VerticalAlignment="Center" />
<TextBlock x:Name="UpdateBannerTitle"
Text="Update available"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brand}"
VerticalAlignment="Center" />
</StackPanel>
<TextBlock x:Name="UpdateBannerVersionText"
Text=""
FontSize="10"
Foreground="{StaticResource InkSoft}"
FontFamily="Cascadia Mono, Consolas"
Margin="0,0,0,6" />
<Button x:Name="UpdateInstallBtn"
Click="OnInstallUpdate"
Content="Install &amp; restart"
Cursor="Hand"
HorizontalContentAlignment="Center"
Background="{StaticResource Brand}"
Foreground="White"
BorderBrush="{StaticResource Brand}"
BorderThickness="1"
Padding="0,6"
FontSize="10"
FontWeight="Bold">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="{TemplateBinding Padding}"
TextElement.Foreground="{TemplateBinding Foreground}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="root" Property="Background" Value="{StaticResource BrandHover}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</Border>
</Grid>
</Border>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"
Padding="0,0,8,0">
<!-- ── Content area ───────────────────────────────────────────── -->
<Grid Grid.Column="1">
<!-- ── Welcome screen ─────────────────────────────────────
Sidebar hidden during this view. Hero treatment with
bold geometry — no emoji marketing card. -->
<Grid x:Name="WelcomeView" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0"
VerticalAlignment="Center" HorizontalAlignment="Center"
MaxWidth="640" Margin="48,0">
<!-- Geometric brand mark, oversized. -->
<Grid Width="84" Height="84" HorizontalAlignment="Center"
Margin="0,0,0,28">
<Border Background="{StaticResource Brand}"
CornerRadius="16"
Effect="{StaticResource BtnShadow}" />
<Path Fill="White"
Data="M 24 24 L 42 24 L 42 42 L 60 42 L 60 60 L 24 60 Z"
Width="84" Height="84" />
</Grid>
<TextBlock Text="DORKNET"
FontSize="64" FontWeight="Bold"
Foreground="{StaticResource Ink}"
HorizontalAlignment="Center"
FontFamily="Segoe UI Variable, Segoe UI" />
<TextBlock HorizontalAlignment="Center" Margin="0,4,0,0">
<Run Text="A private server for"
Foreground="{StaticResource InkSoft}" FontSize="16" />
<Run Text=" Rec Room. "
Foreground="{StaticResource Ink}" FontSize="16" FontWeight="SemiBold" />
<Run Text="Hosted on your machine, played with your friends."
Foreground="{StaticResource InkSoft}" FontSize="16" />
</TextBlock>
<!-- Feature row — no marketing card, just three flat
items with a small left rule. Reads like docs, not
a hero card. -->
<Grid Margin="0,44,0,40" HorizontalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="40" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="40" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" MaxWidth="180">
<Border Width="3" Height="20" Background="{StaticResource Brand}"
HorizontalAlignment="Left" Margin="0,0,0,10" />
<TextBlock Text="2-MIN SETUP" FontSize="10" FontWeight="Bold"
Foreground="{StaticResource Ink}"
Margin="0,0,0,6" />
<TextBlock Text="Auto-detects your install, downloads everything else."
FontSize="12" Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" LineHeight="18" />
</StackPanel>
<StackPanel Grid.Column="2" MaxWidth="180">
<Border Width="3" Height="20" Background="{StaticResource Brand}"
HorizontalAlignment="Left" Margin="0,0,0,10" />
<TextBlock Text="ZERO COST" FontSize="10" FontWeight="Bold"
Foreground="{StaticResource Ink}"
Margin="0,0,0,6" />
<TextBlock Text="No subscription, no card. Tunnelto handles the public address."
FontSize="12" Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" LineHeight="18" />
</StackPanel>
<StackPanel Grid.Column="4" MaxWidth="180">
<Border Width="3" Height="20" Background="{StaticResource Brand}"
HorizontalAlignment="Left" Margin="0,0,0,10" />
<TextBlock Text="STOCK CLIENT" FontSize="10" FontWeight="Bold"
Foreground="{StaticResource Ink}"
Margin="0,0,0,6" />
<TextBlock Text="Patches the official Rec Room install in place. No rebuild."
FontSize="12" Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" LineHeight="18" />
</StackPanel>
</Grid>
<Button Style="{StaticResource PrimaryBtn}"
Click="OnWelcomeContinue"
HorizontalAlignment="Center"
Padding="40,14" FontSize="14" FontWeight="Bold">
<StackPanel Orientation="Horizontal">
<TextBlock Text="GET STARTED" VerticalAlignment="Center"
Foreground="White" />
<Path Width="14" Height="14" Margin="14,0,0,0"
Stroke="White" StrokeThickness="1.8"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 2 7 L 12 7 M 8 3 L 12 7 L 8 11"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
<TextBlock Grid.Row="1"
Text="Not affiliated with Rec Room Inc. or Against Gravity. · AGPL-3.0"
HorizontalAlignment="Center"
Foreground="{StaticResource InkFaint}"
FontSize="10" FontWeight="SemiBold"
Margin="0,0,0,28" />
</Grid>
<!-- ── Setup wizard ─────────────────────────────────────── -->
<Grid x:Name="SetupView" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Header bar: wordmark + step pill. Matches the
in-product header used by Host/Join. -->
<Border Grid.Row="0" Padding="40,26,40,18"
BorderBrush="{StaticResource BorderSoft}"
BorderThickness="0,0,0,1">
<Grid>
<StackPanel Orientation="Horizontal">
<Grid Width="22" Height="22" Margin="0,0,12,0">
<Border Background="{StaticResource Brand}" CornerRadius="5" />
<Path Fill="White"
Data="M 6 6 L 11 6 L 11 11 L 16 11 L 16 16 L 6 16 Z"
Width="22" Height="22" />
</Grid>
<TextBlock Text="DORKNET" FontSize="14"
FontWeight="Bold"
Foreground="{StaticResource Ink}"
VerticalAlignment="Center" />
<Border Background="{StaticResource BorderSoft}" Width="1"
Margin="14,4,14,4" />
<TextBlock Text="FIRST-RUN SETUP" FontSize="11"
FontWeight="SemiBold"
Foreground="{StaticResource InkSoft}"
VerticalAlignment="Center" />
</StackPanel>
<Border Style="{StaticResource StatusChip}" HorizontalAlignment="Right"
VerticalAlignment="Center">
<TextBlock x:Name="SetupStepIndicator"
Text="STEP 1 OF 5" FontSize="10"
FontWeight="Bold"
Foreground="{StaticResource Brand}" />
</Border>
</Grid>
</Border>
<!-- Stepper bar showing all five steps as a progress
rail. The active dot pulses on the brand colour;
completed segments fill. -->
<Border Grid.Row="1" Padding="40,18,40,18">
<Grid x:Name="SetupStepperGrid">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" x:Name="StepBar1"
Height="3" Margin="0,0,4,0"
Background="{StaticResource Brand}"
CornerRadius="2"
VerticalAlignment="Top" />
<Border Grid.Column="1" x:Name="StepBar2"
Height="3" Margin="4,0,4,0"
Background="{StaticResource Border}"
CornerRadius="2"
VerticalAlignment="Top" />
<Border Grid.Column="2" x:Name="StepBar3"
Height="3" Margin="4,0,4,0"
Background="{StaticResource Border}"
CornerRadius="2"
VerticalAlignment="Top" />
<Border Grid.Column="3" x:Name="StepBar4"
Height="3" Margin="4,0,4,0"
Background="{StaticResource Border}"
CornerRadius="2"
VerticalAlignment="Top" />
<Border Grid.Column="4" x:Name="StepBar5"
Height="3" Margin="4,0,0,0"
Background="{StaticResource Border}"
CornerRadius="2"
VerticalAlignment="Top" />
<StackPanel Grid.Column="0" Margin="0,12,0,0">
<TextBlock Text="01" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brand}"
FontFamily="Cascadia Mono, Consolas" />
<TextBlock Text="Mode" FontSize="11"
Foreground="{StaticResource InkSoft}"
Margin="0,2,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="0,12,0,0">
<TextBlock Text="02" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource InkFaint}"
FontFamily="Cascadia Mono, Consolas" />
<TextBlock Text="Install" FontSize="11"
Foreground="{StaticResource InkFaint}"
Margin="0,2,0,0" />
</StackPanel>
<StackPanel Grid.Column="2" Margin="0,12,0,0">
<TextBlock Text="03" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource InkFaint}"
FontFamily="Cascadia Mono, Consolas" />
<TextBlock Text="Server" FontSize="11"
Foreground="{StaticResource InkFaint}"
Margin="0,2,0,0" />
</StackPanel>
<StackPanel Grid.Column="3" Margin="0,12,0,0">
<TextBlock Text="04" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource InkFaint}"
FontFamily="Cascadia Mono, Consolas" />
<TextBlock Text="Photon" FontSize="11"
Foreground="{StaticResource InkFaint}"
Margin="0,2,0,0" />
</StackPanel>
<StackPanel Grid.Column="4" Margin="0,12,0,0">
<TextBlock Text="05" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource InkFaint}"
FontFamily="Cascadia Mono, Consolas" />
<TextBlock Text="Done" FontSize="11"
Foreground="{StaticResource InkFaint}"
Margin="0,2,0,0" />
</StackPanel>
</Grid>
</Border>
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto"
Padding="40,16,32,16">
<Grid MaxWidth="780" HorizontalAlignment="Stretch">
<!-- Setup step 1: Host or Join? -->
<StackPanel x:Name="SetupStep1" Visibility="Visible">
<TextBlock Text="What do you want to do?" FontSize="26"
FontWeight="Bold" Margin="0,0,0,8" />
<TextBlock Text="Pick a mode. You can change this later under Settings."
Foreground="{StaticResource InkSoft}" FontSize="14"
<TextBlock Text="What do you want to do?" FontSize="28"
FontWeight="Bold" Margin="0,12,0,8" />
<TextBlock Text="Pick a mode. You can switch later from the sidebar."
Foreground="{StaticResource InkSoft}" FontSize="13"
Margin="0,0,0,28" />
<Grid MaxWidth="780" HorizontalAlignment="Left">
<Grid HorizontalAlignment="Stretch">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="20" />
@@ -130,18 +522,30 @@
Background="{StaticResource CardBgHost}"
Click="OnSetupPickHost">
<StackPanel>
<Border CornerRadius="14" Width="56" Height="56"
Background="#3D2B57" Margin="0,0,0,18"
HorizontalAlignment="Left">
<TextBlock Text="🛠" FontSize="32"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<TextBlock Text="Host a server" FontSize="22"
FontWeight="Bold" Margin="0,0,0,10" />
<Grid Width="44" Height="44" Margin="0,0,0,18"
HorizontalAlignment="Left">
<Border Background="{StaticResource BgInset}"
BorderBrush="{StaticResource Border}"
BorderThickness="1"
CornerRadius="10" />
<Path Width="44" Height="44"
Stroke="{StaticResource Brand}"
StrokeThickness="1.6"
Fill="Transparent"
StrokeLineJoin="Round"
Data="M 14 16 L 30 16 L 30 20 L 14 20 Z M 14 22 L 30 22 L 30 26 L 14 26 Z M 14 28 L 30 28 L 30 32 L 14 32 Z M 17 18 L 18 18 M 17 24 L 18 24 M 17 30 L 18 30" />
</Grid>
<TextBlock Text="HOST" FontSize="11"
FontWeight="Bold"
Foreground="{StaticResource Brand}"
Margin="0,0,0,6" />
<TextBlock Text="Run a server" FontSize="22"
FontWeight="Bold"
Foreground="{StaticResource Ink}"
Margin="0,0,0,10" />
<TextBlock Foreground="{StaticResource InkSoft}"
FontSize="13" TextWrapping="Wrap" LineHeight="20"
Text="Run a server for you and your friends. The launcher handles the tunnel, gives you a shareable join code." />
FontSize="12" TextWrapping="Wrap" LineHeight="20"
Text="The launcher runs the server, opens a tunnel, and hands you a join code your friends can paste." />
</StackPanel>
</Button>
@@ -149,18 +553,32 @@
Background="{StaticResource CardBgJoin}"
Click="OnSetupPickJoin">
<StackPanel>
<Border CornerRadius="14" Width="56" Height="56"
Background="#1C3C4A" Margin="0,0,0,18"
HorizontalAlignment="Left">
<TextBlock Text="🎮" FontSize="32"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<TextBlock Text="Join a friend" FontSize="22"
FontWeight="Bold" Margin="0,0,0,10" />
<Grid Width="44" Height="44" Margin="0,0,0,18"
HorizontalAlignment="Left">
<Border Background="{StaticResource BgInset}"
BorderBrush="{StaticResource Border}"
BorderThickness="1"
CornerRadius="10" />
<Path Width="44" Height="44"
Stroke="{StaticResource Teal}"
StrokeThickness="1.6"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 18 26 L 26 18 M 14 22 A 6 6 0 0 1 22 14 L 24 14 M 24 30 L 22 30 A 6 6 0 0 1 14 22 M 32 22 A 6 6 0 0 1 24 30 M 24 14 A 6 6 0 0 1 32 22" />
</Grid>
<TextBlock Text="JOIN" FontSize="11"
FontWeight="Bold"
Foreground="{StaticResource Teal}"
Margin="0,0,0,6" />
<TextBlock Text="Use a code" FontSize="22"
FontWeight="Bold"
Foreground="{StaticResource Ink}"
Margin="0,0,0,10" />
<TextBlock Foreground="{StaticResource InkSoft}"
FontSize="13" TextWrapping="Wrap" LineHeight="20"
Text="You have a join code from someone. Paste it and the launcher fetches the matching patcher." />
FontSize="12" TextWrapping="Wrap" LineHeight="20"
Text="Paste a join code from a friend. The launcher fetches the matching patcher and points your client at their server." />
</StackPanel>
</Button>
</Grid>
@@ -168,115 +586,142 @@
<!-- Setup step 2: Rec Room install -->
<StackPanel x:Name="SetupStep2" Visibility="Collapsed">
<TextBlock Text="Where's Rec Room installed?" FontSize="26"
FontWeight="Bold" Margin="0,0,0,8" />
<TextBlock Text="The launcher needs to find your Rec Room install folder. Pick the *_Data folder inside the install (e.g. Recroom_Release_Data)."
Foreground="{StaticResource InkSoft}" FontSize="14"
TextWrapping="Wrap" Margin="0,0,0,24" />
<TextBlock Text="Find your Rec Room install" FontSize="28"
FontWeight="Bold" Margin="0,12,0,8" />
<TextBlock TextWrapping="Wrap"
Foreground="{StaticResource InkSoft}" FontSize="13"
Margin="0,0,0,24"
Text="Point the launcher at the *_Data folder inside your Rec Room install (e.g. Recroom_Release_Data). Auto-detection runs first; pick manually if it misses." />
<Border x:Name="SetupDetectedBanner" Visibility="Collapsed"
Style="{StaticResource Panel}"
Background="{StaticResource CardBgJoin}"
Background="{StaticResource BgElev}"
BorderBrush="{StaticResource Ok}"
BorderThickness="1"
CornerRadius="8" Padding="18,14"
Margin="0,0,0,16">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="✓" FontSize="18"
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid Grid.Column="0" Width="22" Height="22"
Margin="0,0,14,0" VerticalAlignment="Top">
<Ellipse Fill="{StaticResource Ok}" />
<Path Stroke="#0D0F14" StrokeThickness="2.2"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 6 11 L 9.5 14.5 L 16 7"
Width="22" Height="22" />
</Grid>
<StackPanel Grid.Column="1">
<TextBlock Text="DETECTED INSTALL" FontSize="10"
FontWeight="Bold"
Foreground="{StaticResource Ok}"
Margin="0,0,10,0" />
<TextBlock Text="We found a Rec Room install"
FontSize="15" FontWeight="SemiBold"
VerticalAlignment="Center" />
Margin="0,0,0,4" />
<TextBlock x:Name="SetupDetectedPath"
Foreground="{StaticResource Ink}"
FontSize="12"
FontFamily="Cascadia Mono, Consolas"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock x:Name="SetupDetectedPath"
Foreground="{StaticResource InkSoft}"
FontSize="12"
FontFamily="Cascadia Mono, Consolas"
TextWrapping="Wrap" Margin="28,0,0,0" />
</StackPanel>
</Grid>
</Border>
<TextBlock Style="{StaticResource FieldLabel}" Text="INSTALL FOLDER" />
<DockPanel Margin="0,0,0,12">
<Button DockPanel.Dock="Right" Style="{StaticResource Btn}"
Content="Pick…" Click="OnPickRecRoom" Margin="8,0,0,0" />
<Border Background="{StaticResource BgDeeper}"
BorderBrush="{StaticResource BorderSoft}" BorderThickness="1"
CornerRadius="10" Padding="12,10">
Content="Browse…" Click="OnPickRecRoom" Margin="8,0,0,0" />
<Border Background="{StaticResource BgInset}"
BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="6" Padding="12,10">
<TextBlock x:Name="SetupRecRoomPath"
Text="(not picked)"
Foreground="{StaticResource InkSoft}"
TextTrimming="CharacterEllipsis" FontSize="13" />
FontFamily="Cascadia Mono, Consolas"
TextTrimming="CharacterEllipsis" FontSize="12" />
</Border>
</DockPanel>
<TextBlock Foreground="{StaticResource InkFaint}" FontSize="11"
TextWrapping="Wrap"
Text="Don't have Rec Room installed yet? Grab it from the Rec Room website (or wherever you usually get it). DorkNet works with any official install." />
TextWrapping="Wrap" LineHeight="18"
Text="DorkNet works with any official Rec Room install. If you don't have one, grab the regular client first." />
</StackPanel>
<!-- Setup step 3 (HOST only): Server name + WHO CAN JOIN -->
<StackPanel x:Name="SetupStep3" Visibility="Collapsed">
<TextBlock Text="Set up your server" FontSize="26"
FontWeight="Bold" Margin="0,0,0,8" />
<TextBlock Text="Name your server so friends know what they're joining."
Foreground="{StaticResource InkSoft}" FontSize="14"
<TextBlock Text="Set up your server" FontSize="28"
FontWeight="Bold" Margin="0,12,0,8" />
<TextBlock Text="A name your friends will see, and how they reach you."
Foreground="{StaticResource InkSoft}" FontSize="13"
Margin="0,0,0,24" />
<TextBlock Style="{StaticResource FieldLabel}" Text="SERVER NAME" />
<TextBox x:Name="SetupServerName" Margin="0,0,0,20"
<TextBox x:Name="SetupServerName" Margin="0,0,0,24"
LostFocus="OnSetupServerNameChanged" />
<TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" />
<StackPanel Margin="0,0,0,16">
<RadioButton x:Name="SetupHostInternet" GroupName="SetupHostingMode"
Content="Friends anywhere (Tunnelto)"
IsChecked="True" Margin="0,0,0,6"
Checked="OnSetupHostingModeChanged"
Foreground="{StaticResource Ink}" />
Content="Friends anywhere · Tunnelto"
IsChecked="True"
Checked="OnSetupHostingModeChanged" />
<RadioButton x:Name="SetupHostRemote" GroupName="SetupHostingMode"
Content="Friends anywhere (Tunnelto wildcard base)"
Margin="0,0,0,6"
Checked="OnSetupHostingModeChanged"
Foreground="{StaticResource Ink}" />
Content="Friends anywhere · Tunnelto wildcard base"
Checked="OnSetupHostingModeChanged" />
<RadioButton x:Name="SetupHostLocal" GroupName="SetupHostingMode"
Content="Same WiFi only (sslip.io)"
Checked="OnSetupHostingModeChanged"
Foreground="{StaticResource Ink}" />
Content="Same WiFi only · sslip.io"
Checked="OnSetupHostingModeChanged" />
</StackPanel>
<StackPanel x:Name="SetupRemoteWildcardSection" Visibility="Collapsed">
<TextBlock Style="{StaticResource FieldLabel}" Text="TUNNELTO BASE HOST" />
<TextBox x:Name="SetupRemoteWildcardInput" Margin="0,0,0,8"
FontFamily="Cascadia Mono, Consolas" FontSize="12"
LostFocus="OnRemoteWildcardChanged"
ToolTip="Service URLs become api.base, auth.base, etc." />
<TextBlock Foreground="{StaticResource InkFaint}" FontSize="11"
TextWrapping="Wrap" LineHeight="18"
Text="Pick a base host whose provider routes wildcard subdomains to the same tunnel. The launcher derives api.base, auth.base, photon.base from it." />
</StackPanel>
<TextBlock Style="{StaticResource FieldLabel}" Text="TUNNELTO BASE HOST" />
<TextBox x:Name="SetupRemoteWildcardInput" Margin="0,0,0,12"
LostFocus="OnRemoteWildcardChanged"
ToolTip="Auto-generated. Service URLs become api.base, auth.base, etc." />
<TextBlock Foreground="{StaticResource InkFaint}" FontSize="11"
TextWrapping="Wrap"
Text="Tunnelto mode works for friends anywhere. For subdomain support, use a base host whose provider routes wildcard subdomains to the same tunnel." />
</StackPanel>
<!-- Setup step 4 (HOST only): Photon AppIds -->
<StackPanel x:Name="SetupStep4" Visibility="Collapsed">
<TextBlock Text="Connect your Photon account" FontSize="26"
FontWeight="Bold" Margin="0,0,0,8" />
<TextBlock Text="Connect your Photon account" FontSize="28"
FontWeight="Bold" Margin="0,12,0,8" />
<TextBlock TextWrapping="Wrap"
Foreground="{StaticResource InkSoft}" FontSize="14"
Margin="0,0,0,24"
Text="DorkNet needs your own Photon account (free, takes ~2 min) to handle in-room sync. Click the walkthrough below — it'll open the Photon website, tell you what to click, and bring you back here." />
Foreground="{StaticResource InkSoft}" FontSize="13"
Margin="0,0,0,20"
Text="Photon Cloud handles in-room realtime sync. Free to register; DorkNet never ships one of its own. The walkthrough does the registration in 2 minutes." />
<Button Style="{StaticResource PrimaryBtn}"
Content="Open setup walkthrough"
Click="OnOpenPhotonWizard"
HorizontalAlignment="Left" Margin="0,0,0,20" />
HorizontalAlignment="Left" Margin="0,0,0,24">
<StackPanel Orientation="Horizontal">
<Path Width="14" Height="14" Margin="0,0,10,0"
Stroke="White" StrokeThickness="1.6"
Fill="Transparent"
StrokeLineJoin="Round"
Data="M 4 3 L 10 3 L 10 9 M 10 3 L 5 8 M 8 8 L 8 11 L 2 11 L 2 5 L 5 5"
VerticalAlignment="Center" />
<TextBlock Text="OPEN WALKTHROUGH" FontWeight="Bold"
Foreground="White" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Border Style="{StaticResource Panel}"
Background="{StaticResource BgDeeper}"
Margin="0,0,0,12">
<Border Background="{StaticResource BgInset}"
BorderBrush="{StaticResource BorderSoft}"
BorderThickness="1" CornerRadius="8"
Padding="18" Margin="0,0,0,12">
<StackPanel>
<TextBlock Foreground="{StaticResource InkSoft}" FontSize="12"
Margin="0,0,0,8"
Text="Already have AppIds? Paste them here." />
Margin="0,0,0,14"
Text="Already have AppIds? Paste them directly." />
<TextBlock Style="{StaticResource FieldLabel}" Text="REALTIME APPID" />
<TextBox x:Name="SetupPhotonAppId" Margin="0,0,0,12"
<TextBox x:Name="SetupPhotonAppId" Margin="0,0,0,14"
FontFamily="Cascadia Mono, Consolas" FontSize="12"
LostFocus="OnSetupPhotonChanged" />
<TextBlock Style="{StaticResource FieldLabel}" Text="VOICE APPID (OPTIONAL)" />
<TextBox x:Name="SetupPhotonVoiceAppId" Margin="0,0,0,0"
<TextBlock Style="{StaticResource FieldLabel}" Text="VOICE APPID · OPTIONAL" />
<TextBox x:Name="SetupPhotonVoiceAppId"
FontFamily="Cascadia Mono, Consolas" FontSize="12"
LostFocus="OnSetupPhotonChanged" />
</StackPanel>
@@ -286,27 +731,68 @@
Text="Realtime AppId required before you can host." />
</StackPanel>
<!-- Setup done: short confirmation card -->
<!-- Setup done -->
<StackPanel x:Name="SetupStepDone" Visibility="Collapsed">
<TextBlock Text="You're all set! 🎉" FontSize="26"
<Grid Width="64" Height="64" Margin="0,12,0,24" HorizontalAlignment="Left">
<Ellipse Fill="{StaticResource BrandSoft}" />
<Path Stroke="{StaticResource Brand}" StrokeThickness="2.5"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 22 32 L 30 40 L 44 26"
Width="64" Height="64" />
</Grid>
<TextBlock Text="You're ready to ship." FontSize="28"
FontWeight="Bold" Margin="0,0,0,12" />
<TextBlock TextWrapping="Wrap"
Foreground="{StaticResource Ink}" FontSize="14"
LineHeight="22"
Text="Setup's done. Hit Finish to open the main view — everything you just configured is saved, so you can hit Start hosting straight away." />
<TextBlock TextWrapping="Wrap" MaxWidth="520"
HorizontalAlignment="Left"
Foreground="{StaticResource InkSoft}" FontSize="13"
LineHeight="20"
Text="Setup is saved. Hit Finish to open the main view — your install, server name, and Photon AppIds are remembered for next launch." />
</StackPanel>
</Grid>
</ScrollViewer>
<!-- Footer nav -->
<Grid Grid.Row="2" Margin="0,20,0,0">
<Button x:Name="SetupBackBtn" HorizontalAlignment="Left"
Style="{StaticResource GhostBtn}"
Content="← Back" Click="OnSetupBack" Visibility="Collapsed" />
<Button x:Name="SetupNextBtn" HorizontalAlignment="Right"
Style="{StaticResource PrimaryBtn}"
Content="Next →" Click="OnSetupNext" Visibility="Collapsed" />
</Grid>
<Border Grid.Row="3" Padding="40,16,40,20"
BorderBrush="{StaticResource BorderSoft}"
BorderThickness="0,1,0,0"
Background="{StaticResource BgDeeper}">
<Grid>
<Button x:Name="SetupBackBtn" HorizontalAlignment="Left"
Style="{StaticResource Btn}"
Click="OnSetupBack" Visibility="Collapsed">
<StackPanel Orientation="Horizontal">
<Path Width="14" Height="14" Margin="0,0,8,0"
Stroke="{StaticResource Ink}" StrokeThickness="1.8"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 12 7 L 2 7 M 6 3 L 2 7 L 6 11"
VerticalAlignment="Center" />
<TextBlock Text="Back" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button x:Name="SetupNextBtn" HorizontalAlignment="Right"
Style="{StaticResource PrimaryBtn}"
Click="OnSetupNext" Visibility="Collapsed">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="SetupNextLabel" Text="Next"
VerticalAlignment="Center" Foreground="White"
FontWeight="Bold" />
<Path Width="14" Height="14" Margin="10,0,0,0"
Stroke="White" StrokeThickness="1.8"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 2 7 L 12 7 M 8 3 L 12 7 L 8 11"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
</Border>
</Grid>
<!-- ── Host view ──────────────────────────────────────────── -->
@@ -316,98 +802,155 @@
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Header bar with wordmark + view label + settings button -->
<Grid Grid.Row="0" Margin="0,0,0,28">
<StackPanel Orientation="Horizontal">
<TextBlock Text="DorkNet" FontSize="22" FontWeight="Bold"
Foreground="{StaticResource WordmarkBrush}" />
<Border Style="{StaticResource StatusChip}" Margin="14,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="HOST MODE" FontSize="10"
FontWeight="Bold" Foreground="{StaticResource Brand}" />
</Border>
</StackPanel>
<Button HorizontalAlignment="Right" Style="{StaticResource GhostBtn}"
Content="Settings" Click="OnOpenSettings" />
</Grid>
<Border Grid.Row="0" Padding="32,22"
BorderBrush="{StaticResource BorderSoft}"
BorderThickness="0,0,0,1">
<Grid>
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="HOST" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brand}"
FontFamily="Cascadia Mono, Consolas"
VerticalAlignment="Center" />
<Border Background="{StaticResource Brand}" Width="14" Height="1"
Margin="10,0,10,0" VerticalAlignment="Center" />
<TextBlock Text="Run a private Rec Room server" FontSize="11"
Foreground="{StaticResource InkSoft}" FontWeight="SemiBold"
VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="Host a server" FontSize="22" FontWeight="Bold"
Foreground="{StaticResource Ink}" Margin="0,4,0,0" />
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right"
VerticalAlignment="Center">
<CheckBox x:Name="HostScreenModeChk" Content="Screen mode"
VerticalAlignment="Center" Margin="0,0,8,0"
ToolTip="Launch in 2D desktop mode (+forcemode:screen) instead of VR."
Checked="OnScreenModeChanged" Unchecked="OnScreenModeChanged" />
</StackPanel>
</Grid>
</Border>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"
Padding="0,0,8,0">
Padding="32,24,24,24">
<StackPanel>
<!-- Setup panel -->
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<!-- Server config panel -->
<Border Style="{StaticResource Panel}" Margin="0,0,0,18">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Setup" />
<Grid Margin="0,0,0,16">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16"
Stroke="{StaticResource Brand}"
StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
Data="M 2 4 L 14 4 L 14 7 L 2 7 Z M 2 9 L 14 9 L 14 12 L 2 12 Z M 4 5.5 L 5 5.5 M 4 10.5 L 5 10.5 M 11 5.5 L 12 5.5 M 11 10.5 L 12 10.5"
VerticalAlignment="Center" Margin="0,0,10,0" />
<TextBlock Text="Server" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource Ink}"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
<TextBlock Style="{StaticResource FieldLabel}" Text="REC ROOM INSTALL" />
<DockPanel Margin="0,0,0,16">
<Button DockPanel.Dock="Right" Style="{StaticResource Btn}"
Content="Pick…" Click="OnPickRecRoom" Margin="8,0,0,0" />
<Border Background="{StaticResource BgDeeper}"
BorderBrush="{StaticResource BorderSoft}" BorderThickness="1"
CornerRadius="10" Padding="12,10">
Content="Browse…" Click="OnPickRecRoom" Margin="8,0,0,0" />
<Border Background="{StaticResource BgInset}"
BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="6" Padding="12,10">
<TextBlock x:Name="HostRecRoomPath"
Text="(not picked)"
Foreground="{StaticResource InkSoft}"
TextTrimming="CharacterEllipsis" FontSize="13" />
FontFamily="Cascadia Mono, Consolas"
TextTrimming="CharacterEllipsis" FontSize="12" />
</Border>
</DockPanel>
<TextBlock Style="{StaticResource FieldLabel}" Text="SERVER NAME (SHOWN TO JOINERS)" />
<TextBlock Style="{StaticResource FieldLabel}" Text="SERVER NAME · SHOWN TO JOINERS" />
<TextBox x:Name="ServerNameInput" Margin="0,0,0,16"
LostFocus="OnServerNameChanged" />
<TextBlock Style="{StaticResource FieldLabel}" Text="REC ROOM VERSION TO HOST FOR" />
<ComboBox x:Name="VersionSelect" Margin="0,0,0,16" />
<ComboBox x:Name="VersionSelect" Margin="0,0,0,18" />
<Border Background="{StaticResource BorderSoft}" Height="1"
Margin="0,4,0,16" />
<TextBlock Style="{StaticResource FieldLabel}" Text="WHO CAN JOIN?" />
<StackPanel>
<RadioButton x:Name="HostModeInternet" GroupName="HostingMode"
Content="Friends anywhere (Tunnelto)"
Margin="0,0,0,6"
IsChecked="True" Checked="OnHostingModeChanged"
Foreground="{StaticResource Ink}" />
Content="Friends anywhere · Tunnelto"
IsChecked="True" Checked="OnHostingModeChanged" />
<RadioButton x:Name="HostModeRemote" GroupName="HostingMode"
Content="Tunnelto wildcard base"
Margin="0,0,0,6" Checked="OnHostingModeChanged"
Foreground="{StaticResource Ink}" />
Checked="OnHostingModeChanged" />
<RadioButton x:Name="HostModeLocal" GroupName="HostingMode"
Content="Same WiFi only (sslip.io)"
Checked="OnHostingModeChanged"
Foreground="{StaticResource Ink}" />
Content="Same WiFi only · sslip.io"
Checked="OnHostingModeChanged" />
</StackPanel>
<StackPanel x:Name="HostRemoteWildcardSection" Visibility="Collapsed"
Margin="0,16,0,0">
<TextBlock Style="{StaticResource FieldLabel}" Text="TUNNELTO BASE HOST" />
<TextBox x:Name="RemoteWildcardInput"
FontFamily="Cascadia Mono, Consolas" FontSize="12"
LostFocus="OnRemoteWildcardChanged"
ToolTip="Service URLs become api.base, auth.base, etc." />
</StackPanel>
<TextBlock Style="{StaticResource FieldLabel}" Text="TUNNELTO BASE HOST" Margin="0,14,0,6" />
<TextBox x:Name="RemoteWildcardInput" Margin="0,0,0,0"
LostFocus="OnRemoteWildcardChanged"
ToolTip="Auto-generated. Service URLs become api.base, auth.base, etc." />
</StackPanel>
</Border>
<!-- Photon panel -->
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<!-- Photon config panel -->
<Border Style="{StaticResource Panel}" Margin="0,0,0,18">
<StackPanel>
<Grid Margin="0,0,0,12">
<TextBlock Style="{StaticResource SectionTitle}"
Text="Photon AppIds" Margin="0" />
<Grid Margin="0,0,0,16">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16"
Stroke="{StaticResource Brand}"
StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 8 1 L 12 7 L 8 7 L 8 15 L 4 9 L 8 9 Z"
VerticalAlignment="Center" Margin="0,0,10,0" />
<TextBlock Text="Photon Cloud" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource Ink}"
VerticalAlignment="Center" />
</StackPanel>
<Button HorizontalAlignment="Right" Style="{StaticResource Btn}"
Content="Setup walkthrough" Click="OnOpenPhotonWizard"
Padding="14,6" FontSize="12" />
Click="OnOpenPhotonWizard"
Padding="12,5" FontSize="11">
<StackPanel Orientation="Horizontal">
<Path Width="11" Height="11" Margin="0,0,6,0"
Stroke="{StaticResource Ink}" StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
Data="M 4 2 L 9 2 L 9 7 M 9 2 L 4 7"
VerticalAlignment="Center" />
<TextBlock Text="WALKTHROUGH" FontSize="11" FontWeight="SemiBold"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
<TextBlock Foreground="{StaticResource InkSoft}" FontSize="12"
Margin="0,0,0,12" TextWrapping="Wrap">
Photon Cloud powers in-room realtime sync. Free to register at
Margin="0,0,0,16" TextWrapping="Wrap" LineHeight="18">
Powers in-room realtime sync. Free at
<Hyperlink NavigateUri="https://dashboard.photonengine.com"
Foreground="{StaticResource Brand}"
TextDecorations="None"
RequestNavigate="OnNavigate">dashboard.photonengine.com</Hyperlink>;
DorkNet never bundles one. First time? Click "Setup walkthrough" above.
your AppIds stay local.
</TextBlock>
<TextBlock Style="{StaticResource FieldLabel}" Text="REALTIME APPID" />
<TextBox x:Name="PhotonAppIdInput" Margin="0,0,0,12"
<TextBox x:Name="PhotonAppIdInput" Margin="0,0,0,14"
FontFamily="Cascadia Mono, Consolas" FontSize="12"
LostFocus="OnPhotonChanged" />
<TextBlock Style="{StaticResource FieldLabel}" Text="VOICE APPID (OPTIONAL)" />
<TextBox x:Name="PhotonVoiceAppIdInput" Margin="0,0,0,12"
<TextBlock Style="{StaticResource FieldLabel}" Text="VOICE APPID · OPTIONAL" />
<TextBox x:Name="PhotonVoiceAppIdInput" Margin="0,0,0,14"
FontFamily="Cascadia Mono, Consolas" FontSize="12"
LostFocus="OnPhotonChanged" />
<TextBlock Style="{StaticResource FieldLabel}" Text="CLOUD REGION" />
@@ -425,38 +968,94 @@
</StackPanel>
</Border>
<!-- Action area -->
<StackPanel Orientation="Horizontal" Margin="0,4,0,16">
<Button x:Name="HostStartBtn" Style="{StaticResource PrimaryBtn}"
Content="Start hosting" Click="OnHostStart" />
<Button x:Name="HostStopBtn" Style="{StaticResource Btn}"
Content="Stop" Click="OnHostStop"
Margin="10,0,0,0" Visibility="Collapsed" />
<Button x:Name="HostLaunchBtn" Style="{StaticResource TealBtn}"
Content="Launch Rec Room" Click="OnLaunchRecRoom"
Margin="10,0,0,0" Visibility="Collapsed" />
<CheckBox x:Name="HostScreenModeChk" Content="Screen mode"
VerticalAlignment="Center" Margin="14,0,0,0"
Foreground="{StaticResource Ink}"
ToolTip="Launch in 2D desktop mode (passes +forcemode:screen) instead of VR."
Checked="OnScreenModeChanged" Unchecked="OnScreenModeChanged" />
</StackPanel>
<!-- Primary action row -->
<Border Background="{StaticResource BgElev}"
BorderBrush="{StaticResource Border}"
BorderThickness="1"
CornerRadius="8" Padding="22"
Margin="0,0,0,18">
<StackPanel Orientation="Horizontal">
<Button x:Name="HostStartBtn" Style="{StaticResource PrimaryBtn}"
Click="OnHostStart">
<StackPanel Orientation="Horizontal">
<Path Width="11" Height="11" Margin="0,0,10,0"
Fill="White"
Data="M 0 0 L 11 5.5 L 0 11 Z"
VerticalAlignment="Center" />
<TextBlock Text="START HOSTING" FontWeight="Bold"
Foreground="White" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button x:Name="HostStopBtn" Style="{StaticResource Btn}"
Click="OnHostStop"
Margin="10,0,0,0" Visibility="Collapsed">
<StackPanel Orientation="Horizontal">
<Rectangle Width="10" Height="10" Margin="0,0,8,0"
Fill="{StaticResource Danger}"
VerticalAlignment="Center" />
<TextBlock Text="STOP" FontWeight="Bold"
VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button x:Name="HostLaunchBtn" Style="{StaticResource PlayBtn}"
Click="OnLaunchRecRoom"
Margin="10,0,0,0" Visibility="Collapsed">
<StackPanel Orientation="Horizontal">
<Path Width="13" Height="13" Margin="0,0,12,0"
Fill="White"
Data="M 0 0 L 13 6.5 L 0 13 Z"
VerticalAlignment="Center" />
<TextBlock Text="LAUNCH REC ROOM" FontWeight="Bold"
Foreground="White" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button x:Name="HostAdminBtn" Style="{StaticResource Btn}"
Click="OnOpenAdminPanel"
ToolTip="Open the server's admin web panel in your browser. First account on a fresh server becomes the admin automatically."
Margin="10,0,0,0" Visibility="Collapsed">
<StackPanel Orientation="Horizontal">
<Path Width="13" Height="13" Margin="0,0,10,0"
Stroke="{StaticResource Ink}" StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 4 3 L 11 3 L 11 10 M 11 3 L 5 9 M 9 9 L 9 12 L 2 12 L 2 5 L 5 5"
VerticalAlignment="Center" />
<TextBlock Text="ADMIN PANEL" FontWeight="Bold"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</Border>
<!-- Progress steps — populated when the user hits
"Start hosting". Hidden until then so the
idle Host view stays clean. -->
<!-- Progress steps panel -->
<Border x:Name="HostStepsPanel" Style="{StaticResource Panel}"
Margin="0,0,0,16" Visibility="Collapsed">
Margin="0,0,0,18" Visibility="Collapsed">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Progress" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Path Width="16" Height="16"
Stroke="{StaticResource Brand}"
StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 2 8 L 6 12 L 14 4"
VerticalAlignment="Center" Margin="0,0,10,0" />
<TextBlock Text="Progress" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource Ink}"
VerticalAlignment="Center" />
</StackPanel>
<ItemsControl x:Name="HostStepsList"
ItemTemplate="{StaticResource StepRowTemplate}" />
<TextBlock x:Name="HostStatusText"
Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" FontSize="13"
TextWrapping="Wrap" FontSize="12"
LineHeight="18"
Margin="0,4,0,0" />
<StackPanel x:Name="HostRetryPanel" Visibility="Collapsed"
Orientation="Horizontal" Margin="0,12,0,0">
Orientation="Horizontal" Margin="0,16,0,0">
<Button x:Name="HostRetryBtn" Style="{StaticResource PrimaryBtn}"
Content="Retry" Click="OnHostRetry" />
<Button Style="{StaticResource GhostBtn}"
@@ -467,28 +1066,51 @@
</StackPanel>
</Border>
<!-- Join code panel — appears once the host is up.
QR encodes the same code text; friends can
scan with a phone to grab it without typing. -->
<!-- Join code "ticket" — visible once host is up.
Designed as a distinct artefact: monospace
join code, QR alongside, three share actions. -->
<Border x:Name="JoinCodePanel" Visibility="Collapsed"
Style="{StaticResource Panel}"
Background="{StaticResource CardBgHost}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}"
Text="🎉 Share this with friends" />
<Grid>
Background="{StaticResource BgElev}"
BorderBrush="{StaticResource Brand}"
BorderThickness="1"
CornerRadius="8" Padding="0"
Margin="0,0,0,18">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="{StaticResource Brand}"
Padding="22,12">
<Grid>
<StackPanel Orientation="Horizontal">
<Path Width="14" Height="14" Margin="0,0,10,0"
Stroke="White" StrokeThickness="1.8"
Fill="Transparent"
StrokeLineJoin="Round"
Data="M 3 4 L 11 4 L 11 12 L 3 12 Z M 1 6 L 1 14 L 9 14"
VerticalAlignment="Center" />
<TextBlock Text="SERVER LIVE · SHARE THIS WITH FRIENDS"
FontSize="11" FontWeight="Bold"
Foreground="White"
VerticalAlignment="Center" />
</StackPanel>
<Ellipse HorizontalAlignment="Right" VerticalAlignment="Center"
Width="8" Height="8" Fill="White" />
</Grid>
</Border>
<Grid Grid.Row="1" Margin="22">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="White"
CornerRadius="10" Padding="8"
CornerRadius="6" Padding="8"
VerticalAlignment="Top"
Margin="0,0,16,0">
Margin="0,0,22,0">
<Image x:Name="JoinCodeQrImage"
Width="180" Height="180"
Width="172" Height="172"
RenderOptions.BitmapScalingMode="NearestNeighbor"
SnapsToDevicePixels="True"
UseLayoutRounding="True" />
@@ -497,34 +1119,55 @@
<StackPanel Grid.Column="1">
<TextBlock Style="{StaticResource FieldLabel}"
Text="JOIN CODE" />
<Border Background="{StaticResource BgDeeper}"
<Border Background="{StaticResource BgInset}"
BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="10" Padding="12,10">
CornerRadius="6" Padding="12,10">
<TextBox x:Name="JoinCodeText" IsReadOnly="True"
FontFamily="Cascadia Mono, Consolas" FontSize="12"
FontFamily="Cascadia Mono, Consolas" FontSize="11"
Background="Transparent" BorderThickness="0"
TextWrapping="Wrap" />
</Border>
<WrapPanel Margin="0,12,0,0">
<WrapPanel Margin="0,14,0,0">
<Button Style="{StaticResource PrimaryBtn}"
Content="Copy code" Click="OnCopyJoinCode" />
Click="OnCopyJoinCode" Padding="16,8">
<StackPanel Orientation="Horizontal">
<Path Width="12" Height="12" Margin="0,0,8,0"
Stroke="White" StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
Data="M 3 2 L 9 2 L 9 8 L 3 8 Z M 1 4 L 1 10 L 7 10"
VerticalAlignment="Center" />
<TextBlock Text="COPY CODE" FontWeight="Bold"
Foreground="White" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource Btn}"
Content="Copy as message"
Click="OnCopyJoinCodeMessage"
Margin="10,0,0,0" />
Margin="8,0,0,0" Padding="14,7">
<TextBlock Text="Copy as message" />
</Button>
<Button Style="{StaticResource Btn}"
Content="Email to friend"
Click="OnEmailJoinCode"
Margin="10,0,0,0" />
Margin="8,0,0,0" Padding="14,7">
<StackPanel Orientation="Horizontal">
<Path Width="12" Height="9" Margin="0,0,8,0"
Stroke="{StaticResource Ink}" StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
Data="M 1 1 L 11 1 L 11 8 L 1 8 Z M 1 1 L 6 5 L 11 1"
VerticalAlignment="Center" />
<TextBlock Text="Email" VerticalAlignment="Center" />
</StackPanel>
</Button>
</WrapPanel>
<TextBlock Foreground="{StaticResource InkFaint}" FontSize="11"
TextWrapping="Wrap" Margin="0,12,0,0"
Text="Your friend opens DorkNet, picks &quot;Join a friend&quot;, and pastes the code. The QR encodes the same text — handy for getting it onto a phone for forwarding." />
TextWrapping="Wrap" Margin="0,14,0,0" LineHeight="18"
Text="Friend opens DorkNet → Join a friend → pastes. QR encodes the same string for phone forwarding." />
</StackPanel>
</Grid>
</StackPanel>
</Grid>
</Border>
</StackPanel>
</ScrollViewer>
@@ -537,97 +1180,188 @@
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,28">
<StackPanel Orientation="Horizontal">
<TextBlock Text="DorkNet" FontSize="22" FontWeight="Bold"
Foreground="{StaticResource WordmarkBrush}" />
<Border Style="{StaticResource StatusChip}" Margin="14,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="JOIN MODE" FontSize="10"
FontWeight="Bold" Foreground="{StaticResource Teal}" />
</Border>
</StackPanel>
<Button HorizontalAlignment="Right" Style="{StaticResource GhostBtn}"
Content="Settings" Click="OnOpenSettings" />
</Grid>
<Border Grid.Row="0" Padding="32,22"
BorderBrush="{StaticResource BorderSoft}"
BorderThickness="0,0,0,1">
<Grid>
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="JOIN" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Teal}"
FontFamily="Cascadia Mono, Consolas"
VerticalAlignment="Center" />
<Border Background="{StaticResource Teal}" Width="14" Height="1"
Margin="10,0,10,0" VerticalAlignment="Center" />
<TextBlock Text="Connect to a friend's private server" FontSize="11"
Foreground="{StaticResource InkSoft}" FontWeight="SemiBold"
VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="Join a friend" FontSize="22" FontWeight="Bold"
Foreground="{StaticResource Ink}" Margin="0,4,0,0" />
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right"
VerticalAlignment="Center">
<CheckBox x:Name="JoinScreenModeChk" Content="Screen mode"
VerticalAlignment="Center"
ToolTip="Launch in 2D desktop mode (+forcemode:screen) instead of VR."
Checked="OnScreenModeChanged" Unchecked="OnScreenModeChanged" />
</StackPanel>
</Grid>
</Border>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"
Padding="0,0,8,0">
Padding="32,24,24,24">
<StackPanel>
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<Border Style="{StaticResource Panel}" Margin="0,0,0,18">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Join code" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Path Width="16" Height="16"
Stroke="{StaticResource Teal}"
StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 6 10 L 10 6 M 4 8 A 3 3 0 0 1 8 4 L 10 4 M 10 12 L 8 12 A 3 3 0 0 1 4 8 M 14 8 A 3 3 0 0 1 10 12 M 10 4 A 3 3 0 0 1 14 8"
VerticalAlignment="Center" Margin="0,0,10,0" />
<TextBlock Text="Join code" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource Ink}"
VerticalAlignment="Center" />
</StackPanel>
<TextBlock Style="{StaticResource FieldLabel}"
Text="PASTE THE JOIN CODE YOUR HOST SHARED" />
Text="PASTE THE CODE YOUR HOST SHARED" />
<TextBox x:Name="JoinCodeInput"
AcceptsReturn="True" TextWrapping="Wrap"
Height="80" VerticalContentAlignment="Top"
FontFamily="Cascadia Mono, Consolas" FontSize="12"
Height="92" VerticalContentAlignment="Top"
FontFamily="Cascadia Mono, Consolas" FontSize="11"
TextChanged="OnJoinCodeInputChanged" />
<Button Style="{StaticResource Btn}"
Content="Preview" Click="OnDecodeJoinCode"
HorizontalAlignment="Left" Margin="0,10,0,0" />
HorizontalAlignment="Left" Margin="0,12,0,0" />
</StackPanel>
</Border>
<Border x:Name="JoinPreviewBorder" Visibility="Collapsed"
Style="{StaticResource Panel}"
Background="{StaticResource CardBgJoin}"
Margin="0,0,0,16">
Background="{StaticResource BgElev}"
BorderBrush="{StaticResource Teal}"
BorderThickness="1"
CornerRadius="8" Padding="22"
Margin="0,0,0,18">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Server preview" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<Path Width="16" Height="16"
Stroke="{StaticResource Teal}"
StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
Data="M 2 8 L 6 12 L 14 4"
VerticalAlignment="Center" Margin="0,0,10,0" />
<TextBlock Text="Server preview" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource Ink}"
VerticalAlignment="Center" />
</StackPanel>
<TextBlock x:Name="JoinPreviewText"
Foreground="{StaticResource Ink}"
TextWrapping="Wrap" FontSize="14" />
TextWrapping="Wrap" FontSize="12"
FontFamily="Cascadia Mono, Consolas"
LineHeight="20" />
</StackPanel>
</Border>
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<Border Style="{StaticResource Panel}" Margin="0,0,0,18">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Your Rec Room install" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Path Width="16" Height="16"
Stroke="{StaticResource Teal}"
StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
Data="M 2 5 L 6 5 L 8 7 L 14 7 L 14 13 L 2 13 Z"
VerticalAlignment="Center" Margin="0,0,10,0" />
<TextBlock Text="Rec Room install" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource Ink}"
VerticalAlignment="Center" />
</StackPanel>
<TextBlock Style="{StaticResource FieldLabel}" Text="LOCATION" />
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource Btn}"
Content="Pick…" Click="OnPickRecRoom" Margin="8,0,0,0" />
<Border Background="{StaticResource BgDeeper}"
BorderBrush="{StaticResource BorderSoft}" BorderThickness="1"
CornerRadius="10" Padding="12,10">
Content="Browse…" Click="OnPickRecRoom" Margin="8,0,0,0" />
<Border Background="{StaticResource BgInset}"
BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="6" Padding="12,10">
<TextBlock x:Name="JoinRecRoomPath"
Text="(not picked)"
Foreground="{StaticResource InkSoft}"
TextTrimming="CharacterEllipsis" FontSize="13" />
FontFamily="Cascadia Mono, Consolas"
TextTrimming="CharacterEllipsis" FontSize="12" />
</Border>
</DockPanel>
</StackPanel>
</Border>
<StackPanel Orientation="Horizontal" Margin="0,4,0,16">
<Button x:Name="JoinApplyBtn" Style="{StaticResource PrimaryBtn}"
Content="Patch" Click="OnJoinApply"
IsEnabled="False" />
<Button x:Name="JoinLaunchBtn" Style="{StaticResource TealBtn}"
Content="Launch Rec Room" Click="OnLaunchRecRoom"
Margin="10,0,0,0" Visibility="Collapsed" />
<CheckBox x:Name="JoinScreenModeChk" Content="Screen mode"
VerticalAlignment="Center" Margin="14,0,0,0"
Foreground="{StaticResource Ink}"
ToolTip="Launch in 2D desktop mode (passes +forcemode:screen) instead of VR."
Checked="OnScreenModeChanged" Unchecked="OnScreenModeChanged" />
</StackPanel>
<Border Background="{StaticResource BgElev}"
BorderBrush="{StaticResource Border}"
BorderThickness="1"
CornerRadius="8" Padding="22"
Margin="0,0,0,18">
<StackPanel Orientation="Horizontal">
<Button x:Name="JoinApplyBtn" Style="{StaticResource PrimaryBtn}"
Click="OnJoinApply" IsEnabled="False">
<StackPanel Orientation="Horizontal">
<Path Width="12" Height="12" Margin="0,0,10,0"
Stroke="White" StrokeThickness="1.7"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 2 6 L 5 9 L 11 2"
VerticalAlignment="Center" />
<TextBlock Text="APPLY PATCH" FontWeight="Bold"
Foreground="White" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button x:Name="JoinLaunchBtn" Style="{StaticResource PlayBtn}"
Click="OnLaunchRecRoom"
Margin="10,0,0,0" Visibility="Collapsed">
<StackPanel Orientation="Horizontal">
<Path Width="13" Height="13" Margin="0,0,12,0"
Fill="White"
Data="M 0 0 L 13 6.5 L 0 13 Z"
VerticalAlignment="Center" />
<TextBlock Text="LAUNCH REC ROOM" FontWeight="Bold"
Foreground="White" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</Border>
<Border x:Name="JoinStepsPanel" Style="{StaticResource Panel}"
Visibility="Collapsed">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Progress" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Path Width="16" Height="16"
Stroke="{StaticResource Teal}"
StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 2 8 L 6 12 L 14 4"
VerticalAlignment="Center" Margin="0,0,10,0" />
<TextBlock Text="Progress" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource Ink}"
VerticalAlignment="Center" />
</StackPanel>
<ItemsControl x:Name="JoinStepsList"
ItemTemplate="{StaticResource StepRowTemplate}" />
<TextBlock x:Name="JoinStatusText"
Foreground="{StaticResource InkSoft}"
TextWrapping="Wrap" FontSize="13"
TextWrapping="Wrap" FontSize="12"
LineHeight="18"
Margin="0,4,0,0" />
<StackPanel x:Name="JoinRetryPanel" Visibility="Collapsed"
Orientation="Horizontal" Margin="0,12,0,0">
Orientation="Horizontal" Margin="0,16,0,0">
<Button x:Name="JoinRetryBtn" Style="{StaticResource PrimaryBtn}"
Content="Retry" Click="OnJoinRetry" />
<Button Style="{StaticResource GhostBtn}"
@@ -648,82 +1382,150 @@
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,28">
<StackPanel Orientation="Horizontal">
<TextBlock Text="DorkNet" FontSize="22" FontWeight="Bold"
Foreground="{StaticResource WordmarkBrush}" />
<Border Style="{StaticResource StatusChip}" Margin="14,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="SETTINGS" FontSize="10"
FontWeight="Bold" Foreground="{StaticResource Sunshine}" />
<Border Grid.Row="0" Padding="32,22"
BorderBrush="{StaticResource BorderSoft}"
BorderThickness="0,0,0,1">
<Grid>
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="SYSTEM" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Sunshine}"
FontFamily="Cascadia Mono, Consolas"
VerticalAlignment="Center" />
<Border Background="{StaticResource Sunshine}" Width="14" Height="1"
Margin="10,0,10,0" VerticalAlignment="Center" />
<TextBlock Text="Cache locations &amp; advanced options" FontSize="11"
Foreground="{StaticResource InkSoft}" FontWeight="SemiBold"
VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="Settings" FontSize="22" FontWeight="Bold"
Foreground="{StaticResource Ink}" Margin="0,4,0,0" />
</StackPanel>
<Button HorizontalAlignment="Right" Style="{StaticResource GhostBtn}"
Click="OnBackFromSettings" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<Path Width="12" Height="12" Margin="0,0,8,0"
Stroke="{StaticResource InkSoft}" StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 11 6 L 1 6 M 5 2 L 1 6 L 5 10"
VerticalAlignment="Center" />
<TextBlock Text="Back" VerticalAlignment="Center" />
</StackPanel>
</Button>
</Grid>
</Border>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"
Padding="32,24,24,24">
<StackPanel>
<Border Style="{StaticResource Panel}" Margin="0,0,0,18">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Cache locations" />
<Border Background="{StaticResource BgInset}"
BorderBrush="{StaticResource BorderSoft}"
BorderThickness="1" CornerRadius="6"
Padding="14,12">
<TextBlock x:Name="CachePathsText"
Foreground="{StaticResource InkSoft}"
FontSize="11" TextWrapping="Wrap"
LineHeight="18"
FontFamily="Cascadia Mono, Consolas" />
</Border>
</StackPanel>
</Border>
<Border x:Name="LocalOverridesPanel" Visibility="Collapsed"
Background="{StaticResource BgElev}"
BorderBrush="{StaticResource Sunshine}"
BorderThickness="1"
CornerRadius="8" Padding="22"
Margin="0,0,0,18">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<Path Width="16" Height="16"
Stroke="{StaticResource Sunshine}"
StrokeThickness="1.5"
Fill="Transparent"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
Data="M 8 1 L 15 14 L 1 14 Z M 8 6 L 8 10 M 8 12 L 8 12.5"
VerticalAlignment="Center" Margin="0,0,10,0" />
<TextBlock Text="Dev overrides active" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource Ink}"
VerticalAlignment="Center" />
</StackPanel>
<TextBlock Foreground="{StaticResource InkSoft}" FontSize="12"
Margin="0,0,0,10" TextWrapping="Wrap" LineHeight="18"
Text="The launcher is reading from local paths instead of GitHub. Unset DORKNET_LOCAL_MANIFEST / DORKNET_LOCAL_RELEASES to return to normal mode." />
<Border Background="{StaticResource BgInset}"
BorderBrush="{StaticResource BorderSoft}"
BorderThickness="1" CornerRadius="6"
Padding="14,12">
<TextBlock x:Name="LocalOverridesText"
Foreground="{StaticResource Ink}"
FontSize="11" TextWrapping="Wrap"
LineHeight="18"
FontFamily="Cascadia Mono, Consolas" />
</Border>
</StackPanel>
</Border>
<Border Style="{StaticResource Panel}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="About" />
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="VERSION"
FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource InkFaint}"
Width="100" />
<TextBlock Grid.Column="1" Text="v0.1"
FontSize="12" Foreground="{StaticResource Ink}"
FontFamily="Cascadia Mono, Consolas" />
</Grid>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="LICENSE"
FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource InkFaint}"
Width="100" />
<TextBlock Grid.Column="1" Text="AGPL-3.0"
FontSize="12" Foreground="{StaticResource Ink}"
FontFamily="Cascadia Mono, Consolas" />
</Grid>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="SOURCE"
FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource InkFaint}"
Width="100" />
<TextBlock Grid.Column="1" FontSize="12" FontFamily="Cascadia Mono, Consolas">
<Hyperlink NavigateUri="https://github.com/DorkSquadRR/DorkNet"
Foreground="{StaticResource Brand}"
TextDecorations="None"
RequestNavigate="OnNavigate">github.com/DorkSquadRR/DorkNet</Hyperlink>
</TextBlock>
</Grid>
<TextBlock Foreground="{StaticResource InkFaint}" FontSize="10"
Margin="0,12,0,0"
Text="Not affiliated with Rec Room Inc. or Against Gravity." />
</StackPanel>
</Border>
</StackPanel>
<Button HorizontalAlignment="Right" Style="{StaticResource GhostBtn}"
Content="← Back" Click="OnBackFromSettings" />
</Grid>
<StackPanel Grid.Row="1">
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Switch mode" />
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource Btn}"
Content="Host" Click="OnPickHost" />
<Button Style="{StaticResource Btn}"
Content="Join" Click="OnPickJoin"
Margin="10,0,0,0" />
<Button Style="{StaticResource GhostBtn}"
Content="Re-run setup" Click="OnRerunSetup"
Margin="10,0,0,0" />
</StackPanel>
</StackPanel>
</Border>
<Border Style="{StaticResource Panel}" Margin="0,0,0,16">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="Cache locations" />
<TextBlock x:Name="CachePathsText"
Foreground="{StaticResource InkSoft}"
FontSize="12" TextWrapping="Wrap"
FontFamily="Cascadia Mono, Consolas" />
</StackPanel>
</Border>
<!-- Dev-mode overrides — visible only when at least one
env var is set so end users never see it. -->
<Border x:Name="LocalOverridesPanel" Visibility="Collapsed"
Style="{StaticResource Panel}" Margin="0,0,0,16"
Background="{StaticResource CardBgHost}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}"
Text="🛠 Dev overrides active" />
<TextBlock Foreground="{StaticResource InkSoft}" FontSize="12"
Margin="0,-6,0,8" TextWrapping="Wrap"
Text="The launcher is reading from local paths instead of GitHub. Unset DORKNET_LOCAL_MANIFEST / DORKNET_LOCAL_RELEASES to return to normal mode." />
<TextBlock x:Name="LocalOverridesText"
Foreground="{StaticResource Ink}"
FontSize="12" TextWrapping="Wrap"
FontFamily="Cascadia Mono, Consolas" />
</StackPanel>
</Border>
<Border Style="{StaticResource Panel}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitle}" Text="About" />
<TextBlock Foreground="{StaticResource InkSoft}" FontSize="13">
DorkNet Launcher v0.1 ·
<Hyperlink NavigateUri="https://github.com/DorkSquadRR/DorkNet"
Foreground="{StaticResource Brand}"
RequestNavigate="OnNavigate">github.com/DorkSquadRR/DorkNet</Hyperlink>
· AGPL-3.0
</TextBlock>
<TextBlock Foreground="{StaticResource InkFaint}" FontSize="11"
Margin="0,8,0,0">
Not affiliated with Rec Room Inc. or Against Gravity.
</TextBlock>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Grid>
</Grid>
+154 -1
View File
@@ -17,6 +17,7 @@ public partial class MainWindow : Window
private readonly ReleaseDownloader _releases = new();
private readonly ClientPatcher _patcher = new();
private readonly ServerProcess _server = new();
private readonly Updater _updater = new();
private IAsyncDisposable? _tunnel;
private VersionsManifest? _manifest;
private string? _hostApex;
@@ -53,6 +54,59 @@ public partial class MainWindow : Window
ReflectStateInUi();
await RefreshManifestAsync();
AutoSelectVersionFromInstall();
// Update check runs last, async fire-and-forget. Failures are
// swallowed inside Updater.CheckAsync — the launcher must never
// block on a slow GitHub roundtrip.
SidebarVersionText.Text = $"DORKNET LAUNCHER · v{Updater.CurrentVersion}";
_ = CheckForUpdatesAsync();
}
private UpdateInfo? _pendingUpdate;
private async Task CheckForUpdatesAsync()
{
var info = await _updater.CheckAsync();
if (info is null) return;
_pendingUpdate = info;
UpdateBannerVersionText.Text = $"v{Updater.CurrentVersion} → v{info.Version}";
UpdateBanner.Visibility = Visibility.Visible;
}
private async void OnInstallUpdate(object sender, RoutedEventArgs e)
{
if (_pendingUpdate is null) return;
UpdateInstallBtn.IsEnabled = false;
UpdateBannerTitle.Text = "Downloading…";
try
{
var progress = new Progress<DownloadProgress>(p =>
{
var mb = p.BytesRead / (1024.0 * 1024.0);
UpdateBannerVersionText.Text = p.TotalBytes > 0
? $"{mb:F1} / {p.TotalBytes / (1024.0 * 1024.0):F1} MB"
: $"{mb:F1} MB";
});
var script = await _updater.StageUpdateAsync(_pendingUpdate, progress);
UpdateBannerTitle.Text = "Restarting…";
// Launch the swap script detached, then bail out so it can
// overwrite the running exe.
await ShutdownServerAsync();
Process.Start(new ProcessStartInfo("cmd", $"/c \"{script}\"")
{
CreateNoWindow = true,
UseShellExecute = false,
});
Application.Current.Shutdown();
}
catch (Exception ex)
{
UpdateBannerTitle.Text = "Update failed";
UpdateBannerVersionText.Text = ex.Message.Length > 80
? ex.Message.Substring(0, 80) + "…" : ex.Message;
UpdateInstallBtn.IsEnabled = true;
}
}
/// <summary>If the user's install path resolves to a known build,
@@ -87,6 +141,17 @@ public partial class MainWindow : Window
HostView.Visibility = name == "host" ? Visibility.Visible : Visibility.Collapsed;
JoinView.Visibility = name == "join" ? Visibility.Visible : Visibility.Collapsed;
SettingsView.Visibility = name == "settings" ? Visibility.Visible : Visibility.Collapsed;
// Sidebar is part of the in-product chrome — hide it for the
// hero-style Welcome and the dedicated Setup wizard.
if (MainNav is not null)
MainNav.Visibility = (name == "host" || name == "join" || name == "settings")
? Visibility.Visible : Visibility.Collapsed;
if (NavHostBtn is not null) NavHostBtn.IsChecked = name == "host";
if (NavJoinBtn is not null) NavJoinBtn.IsChecked = name == "join";
if (NavSettingsBtn is not null) NavSettingsBtn.IsChecked = name == "settings";
if (name == "setup") RenderSetupStep();
}
@@ -164,6 +229,7 @@ public partial class MainWindow : Window
}
UpdateSetupPhotonStatus();
UpdateRemoteWildcardVisibility();
CachePathsText.Text =
$"State: {AppPaths.StateFile}\n" +
@@ -321,10 +387,14 @@ public partial class MainWindow : Window
var displayed = (_state.Mode == AppMode.Join && _setupStep == 5) ? 3 : _setupStep;
SetupStepIndicator.Text = $"STEP {displayed} OF {total}";
// Drive the horizontal stepper bars + numbered labels under each.
// A bar is "filled" when its step is the current step or behind it.
UpdateSetupStepperVisuals();
// Step 1 (mode pick) has no Back / Next — the cards self-advance.
SetupBackBtn.Visibility = _setupStep > 1 ? Visibility.Visible : Visibility.Collapsed;
SetupNextBtn.Visibility = _setupStep == 1 ? Visibility.Collapsed : Visibility.Visible;
SetupNextBtn.Content = _setupStep == 5 ? "Finish →" : "Next →";
SetupNextLabel.Text = _setupStep == 5 ? "Finish" : "Next";
// Refresh the install banner + Photon status each render — the
// user may have hit "Pick…" mid-wizard and we want the new path
@@ -335,6 +405,33 @@ public partial class MainWindow : Window
? Visibility.Collapsed : Visibility.Visible;
if (!string.IsNullOrEmpty(_state.RecRoomPath)) SetupDetectedPath.Text = _state.RecRoomPath;
UpdateSetupPhotonStatus();
// Re-sync the hosting-mode radios + base-host visibility every
// time we render. The Checked event during ReflectStateInUi
// doesn't always fire (XAML default IsChecked="True" on the
// first radio can race the initial sync), so re-asserting here
// guarantees step 3 shows the right state when the user lands
// on it via Next or Re-run setup.
if (SetupHostInternet is not null && SetupHostRemote is not null && SetupHostLocal is not null)
{
SetupHostInternet.IsChecked = _state.HostingMode == HostingMode.Internet;
SetupHostRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
SetupHostLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
}
UpdateRemoteWildcardVisibility();
}
/// <summary>Repaint the 5-segment stepper rail to reflect the current
/// step. Filled bars trail behind the active step; the active bar
/// itself is brand-coloured. This is the visual replacement for the
/// old single chip.</summary>
private void UpdateSetupStepperVisuals()
{
var brand = (System.Windows.Media.Brush)FindResource("Brand");
var dim = (System.Windows.Media.Brush)FindResource("Border");
var bars = new[] { StepBar1, StepBar2, StepBar3, StepBar4, StepBar5 };
for (int i = 0; i < bars.Length; i++)
bars[i].Background = (i + 1) <= _setupStep ? brand : dim;
}
private void UpdateSetupPhotonStatus()
@@ -377,6 +474,20 @@ public partial class MainWindow : Window
HostModeLocal.IsChecked = _state.HostingMode == HostingMode.LocalNetwork;
HostModeRemote.IsChecked = _state.HostingMode == HostingMode.RemoteWildcard;
}
UpdateRemoteWildcardVisibility();
}
/// <summary>The Tunnelto base host field only matters when the user
/// picks the wildcard-base option. Plain Tunnelto auto-generates it;
/// LAN doesn't use it at all. Hide it everywhere else.</summary>
private void UpdateRemoteWildcardVisibility()
{
var show = _state.HostingMode == HostingMode.RemoteWildcard
? Visibility.Visible : Visibility.Collapsed;
if (SetupRemoteWildcardSection is not null)
SetupRemoteWildcardSection.Visibility = show;
if (HostRemoteWildcardSection is not null)
HostRemoteWildcardSection.Visibility = show;
}
private void OnSetupPhotonChanged(object sender, RoutedEventArgs e)
@@ -494,9 +605,21 @@ public partial class MainWindow : Window
? HostingMode.LocalNetwork
: HostingMode.Internet;
_state.Save();
UpdateRemoteWildcardVisibility();
}
// ── Host flow ───────────────────────────────────────────────────────
/// <summary>Reflect the running state on the sidebar status pill.
/// "Idle"/grey when nothing's running, "Starting"/amber while the
/// host steps are in flight, "Live"/mint once the server is up,
/// "Failed"/red on error.</summary>
private void SetSidebarStatus(string label, string colorKey)
{
if (SidebarStatusText is null || SidebarStatusDot is null) return;
SidebarStatusText.Text = label;
SidebarStatusDot.Fill = (System.Windows.Media.Brush)FindResource(colorKey);
}
private async void OnHostStart(object sender, RoutedEventArgs e)
{
if (string.IsNullOrEmpty(_state.RecRoomPath))
@@ -525,6 +648,7 @@ public partial class MainWindow : Window
HostStartBtn.IsEnabled = false;
HostStopBtn.Visibility = Visibility.Visible;
JoinCodePanel.Visibility = Visibility.Collapsed;
SetSidebarStatus("Starting…", "Sunshine");
// Build a fresh step list so progress is visible the moment the
// user clicks. Steps advance via StartStep/CompleteStep helpers
@@ -631,6 +755,8 @@ public partial class MainWindow : Window
? $"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.";
HostLaunchBtn.Visibility = Visibility.Visible;
HostAdminBtn.Visibility = Visibility.Visible;
SetSidebarStatus("Live", "Ok");
}
catch (Exception ex)
{
@@ -640,6 +766,7 @@ public partial class MainWindow : Window
ShowHostRetry();
HostStartBtn.IsEnabled = true;
HostStopBtn.Visibility = Visibility.Collapsed;
SetSidebarStatus("Failed", "Danger");
}
}
@@ -697,12 +824,38 @@ public partial class MainWindow : Window
HostStartBtn.IsEnabled = true;
HostStopBtn.Visibility = Visibility.Collapsed;
HostLaunchBtn.Visibility = Visibility.Collapsed;
HostAdminBtn.Visibility = Visibility.Collapsed;
JoinCodePanel.Visibility = Visibility.Collapsed;
HostStepsPanel.Visibility = Visibility.Collapsed;
HostStepsList.ItemsSource = null;
_hostSteps = null;
HostStatusText.Text = "";
HostRetryPanel.Visibility = Visibility.Collapsed;
SetSidebarStatus("Idle", "InkFaint");
}
/// <summary>Open the server's admin web panel in the user's default
/// browser. The admin SPA is published at <c>admin.&lt;apex&gt;</c>
/// over the same scheme the server uses (https for tunnel modes,
/// http for LAN). First account created on a fresh server is
/// auto-promoted; subsequent admins are added from inside the panel.</summary>
private void OnOpenAdminPanel(object sender, RoutedEventArgs e)
{
if (string.IsNullOrEmpty(_hostApex))
{
ShowError("Server isn't running. Start hosting first, then the admin panel becomes reachable.");
return;
}
var scheme = _state.HostingMode == HostingMode.LocalNetwork ? "http" : "https";
var url = $"{scheme}://admin.{_hostApex}";
try
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
catch (Exception ex)
{
ShowError($"Couldn't open {url}: {ex.Message}");
}
}
private async Task ShutdownServerAsync()