Add installer updater and server CLI
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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,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 |
|
||||
|
||||
@@ -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 & 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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||

|
||||
|
||||
✅ 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
|
||||
|
||||

|
||||
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:
|
||||
|
||||

|
||||
|
||||
- **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.
|
||||
|
||||
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 129 KiB |
@@ -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
|
||||
|
||||

|
||||
Welcome screen → **GET STARTED** → setup wizard.
|
||||
|
||||
## Step 3 — Paste the join code
|
||||

|
||||
|
||||
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"
|
||||
|
||||

|
||||
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.
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
- **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.
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
> 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**.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
@@ -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 & 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 "Join a friend", 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 & 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>
|
||||
|
||||
@@ -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.<apex></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()
|
||||
|
||||