mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
df9dac053d
Single-download .NET 9 self-contained launcher for hosting / joining
DorkNet servers. Reads versions.json from main to discover supported
client builds, fetches the matching server binary + client patcher
from GitHub Releases per branch, manages the server subprocess +
Cloudflare quick-tunnel, and wraps it all in a PhotinoNET window.
Code layout:
- Program.cs PhotinoNET window + message bridge bootstrap
- Backend/ AppPaths, AppState, VersionsManifest,
ReleaseDownloader, ServerProcess, ClientPatcher,
Tunnel, RecRoomPicker, JoinCode, MessageBridge
- ui/ index.html + style.css + app.js (no build step)
- RELEASES.md GitHub Releases artifact contract per-version
branches must publish to
Per-version branches need to ship two release assets per tag:
- dorknet-server-{version_key}-win-x64.zip
- dorknet-clientpatch-{version_key}.zip
(see launcher/RELEASES.md for the full naming + arg contract)
Photon AppIds, JWT secret, and the picked Rec Room path stay
per-machine; the launcher never bundles them.
286 lines
11 KiB
JavaScript
286 lines
11 KiB
JavaScript
// DorkNet Launcher UI — single-file JS, no framework. State held in
|
|
// `appState`; views toggled via `showView(name)`. C# is reached through
|
|
// `bridge.send({type, payload})`; C# pushes events via
|
|
// `window.external.receiveMessage(handler)`.
|
|
|
|
'use strict';
|
|
|
|
let appState = null; // mirrors the C# AppState payload
|
|
let versions = null; // versions.json contents
|
|
let previousView = null; // for the settings 'back' button
|
|
|
|
// ── Message bridge ───────────────────────────────────────────────────
|
|
const bridge = {
|
|
send(envelope) {
|
|
// PhotinoNET exposes window.external.sendMessage(string) — the C#
|
|
// side parses the JSON envelope and routes by `type`.
|
|
window.external.sendMessage(JSON.stringify(envelope));
|
|
}
|
|
};
|
|
|
|
window.external.receiveMessage(raw => {
|
|
let env;
|
|
try { env = JSON.parse(raw); }
|
|
catch { console.error('bridge: non-JSON message', raw); return; }
|
|
handleEvent(env.type, env.payload);
|
|
});
|
|
|
|
function handleEvent(type, payload) {
|
|
switch (type) {
|
|
case 'state-changed':
|
|
appState = payload;
|
|
reflectStateInUi();
|
|
break;
|
|
case 'versions':
|
|
versions = payload;
|
|
reflectVersionsInUi();
|
|
break;
|
|
case 'host-status':
|
|
renderHostStatus(payload);
|
|
break;
|
|
case 'join-status':
|
|
renderJoinStatus(payload);
|
|
break;
|
|
case 'download-progress':
|
|
renderDownloadProgress(payload);
|
|
break;
|
|
case 'join-code-decoded':
|
|
renderJoinPreview(payload);
|
|
break;
|
|
case 'error':
|
|
showToast(payload?.message ?? 'Unknown error', 'error');
|
|
break;
|
|
default:
|
|
console.warn('bridge: unhandled event', type, payload);
|
|
}
|
|
}
|
|
|
|
// ── View routing ─────────────────────────────────────────────────────
|
|
function showView(name) {
|
|
for (const sec of document.querySelectorAll('section[data-view]')) {
|
|
const matches = sec.dataset.view === name;
|
|
sec.hidden = !matches;
|
|
}
|
|
}
|
|
|
|
function reflectStateInUi() {
|
|
if (!appState) return;
|
|
|
|
// First-run check.
|
|
if (appState.mode === 'unset') { showView('first-run'); return; }
|
|
|
|
// Mirror persisted values into the right view's inputs.
|
|
const recDisplay = document.getElementById('recroom-path-display');
|
|
const recDisplayJoin = document.getElementById('recroom-path-display-join');
|
|
const path = appState.recRoomPath || '(not picked)';
|
|
if (recDisplay) recDisplay.textContent = path;
|
|
if (recDisplayJoin) recDisplayJoin.textContent = path;
|
|
|
|
document.getElementById('photon-app-id').value = appState.photonAppId || '';
|
|
document.getElementById('photon-voice-app-id').value = appState.photonVoiceAppId || '';
|
|
document.getElementById('photon-region').value = appState.photonRegion || 'us';
|
|
document.getElementById('server-name').value = appState.serverName || '';
|
|
|
|
// Enable join button only when prerequisites are met.
|
|
refreshJoinReadiness();
|
|
|
|
// Show the mode the user picked unless they're in settings.
|
|
if (!isSettingsOpen()) showView(appState.mode);
|
|
}
|
|
|
|
function reflectVersionsInUi() {
|
|
const sel = document.getElementById('version-select');
|
|
if (!sel || !versions) return;
|
|
sel.innerHTML = '';
|
|
for (const v of versions.branches.filter(b => b.supported)) {
|
|
const opt = document.createElement('option');
|
|
opt.value = v.versionKey;
|
|
opt.textContent = `Rec Room ${v.clientBuild} (${v.branch})`;
|
|
sel.appendChild(opt);
|
|
}
|
|
if (appState?.selectedVersion) sel.value = appState.selectedVersion;
|
|
}
|
|
|
|
function isSettingsOpen() {
|
|
const s = document.querySelector('section[data-view="settings"]');
|
|
return s && !s.hidden;
|
|
}
|
|
|
|
// ── Toasts ───────────────────────────────────────────────────────────
|
|
function showToast(msg, kind = '') {
|
|
const el = document.createElement('div');
|
|
el.className = `toast ${kind}`.trim();
|
|
el.textContent = msg;
|
|
document.getElementById('toasts').appendChild(el);
|
|
setTimeout(() => el.remove(), 6000);
|
|
}
|
|
|
|
// ── Event wiring ─────────────────────────────────────────────────────
|
|
document.addEventListener('click', e => {
|
|
const t = e.target.closest('[data-action], [data-mode-pick]');
|
|
if (!t) return;
|
|
|
|
if (t.dataset.modePick) {
|
|
bridge.send({ type: 'set-mode', payload: { mode: t.dataset.modePick } });
|
|
return;
|
|
}
|
|
|
|
switch (t.dataset.action) {
|
|
case 'pick-recroom':
|
|
bridge.send({ type: 'pick-recroom' });
|
|
break;
|
|
case 'open-settings':
|
|
previousView = document.querySelector('section[data-view]:not([hidden])')?.dataset.view;
|
|
showView('settings');
|
|
break;
|
|
case 'back-from-settings':
|
|
showView(previousView || appState?.mode || 'first-run');
|
|
break;
|
|
case 'copy-join-code':
|
|
const code = document.getElementById('join-code-value').textContent;
|
|
navigator.clipboard.writeText(code).then(
|
|
() => showToast('Join code copied', 'ok'),
|
|
() => showToast('Copy failed — select + Ctrl+C manually', 'error'));
|
|
break;
|
|
case 'decode-join-code':
|
|
const codeRaw = document.getElementById('join-code-input').value.trim();
|
|
bridge.send({ type: 'decode-join-code', payload: { code: codeRaw } });
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Photon inputs auto-persist on blur.
|
|
['photon-app-id', 'photon-voice-app-id', 'photon-region'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.addEventListener('change', () => {
|
|
bridge.send({
|
|
type: 'set-photon',
|
|
payload: {
|
|
appId: document.getElementById('photon-app-id').value.trim(),
|
|
voiceAppId: document.getElementById('photon-voice-app-id').value.trim(),
|
|
region: document.getElementById('photon-region').value,
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
document.getElementById('server-name').addEventListener('change', e => {
|
|
bridge.send({ type: 'set-server-name', payload: { name: e.target.value.trim() } });
|
|
});
|
|
|
|
// Host start/stop.
|
|
document.getElementById('host-start').addEventListener('click', () => {
|
|
if (!appState?.recRoomPath) { showToast('Pick your Rec Room install first', 'error'); return; }
|
|
if (!appState?.photonAppId) { showToast('Photon AppId required (free at dashboard.photonengine.com)', 'error'); return; }
|
|
const versionKey = document.getElementById('version-select').value;
|
|
document.getElementById('host-start').disabled = true;
|
|
document.getElementById('host-stop').hidden = false;
|
|
bridge.send({ type: 'host-start', payload: { versionKey } });
|
|
});
|
|
|
|
document.getElementById('host-stop').addEventListener('click', () => {
|
|
bridge.send({ type: 'host-stop' });
|
|
document.getElementById('host-stop').hidden = true;
|
|
document.getElementById('host-start').disabled = false;
|
|
document.getElementById('join-code-row').hidden = true;
|
|
});
|
|
|
|
// Join apply.
|
|
document.getElementById('join-apply').addEventListener('click', () => {
|
|
const code = document.getElementById('join-code-input').value.trim();
|
|
bridge.send({ type: 'join-apply', payload: { code } });
|
|
});
|
|
|
|
document.getElementById('join-code-input').addEventListener('input', refreshJoinReadiness);
|
|
|
|
function refreshJoinReadiness() {
|
|
const btn = document.getElementById('join-apply');
|
|
if (!btn) return;
|
|
const hasCode = document.getElementById('join-code-input').value.trim().length > 10;
|
|
const hasPath = !!appState?.recRoomPath;
|
|
btn.disabled = !(hasCode && hasPath);
|
|
}
|
|
|
|
// ── Render helpers for incoming events ───────────────────────────────
|
|
function renderHostStatus(p) {
|
|
const el = document.getElementById('host-status');
|
|
if (!p) return;
|
|
if (p.stage === 'ready') {
|
|
el.className = 'status ok';
|
|
el.textContent = `Hosting at ${p.publicUrl}`;
|
|
document.getElementById('join-code-value').textContent = p.joinCode;
|
|
document.getElementById('join-code-row').hidden = false;
|
|
} else if (p.stage === 'stopped') {
|
|
el.className = 'status';
|
|
el.textContent = 'Stopped.';
|
|
document.getElementById('host-stop').hidden = true;
|
|
document.getElementById('host-start').disabled = false;
|
|
document.getElementById('join-code-row').hidden = true;
|
|
} else {
|
|
el.className = 'status';
|
|
el.textContent = humanStage(p.stage);
|
|
}
|
|
}
|
|
|
|
function renderJoinStatus(p) {
|
|
const el = document.getElementById('join-status');
|
|
if (!p) return;
|
|
el.className = p.stage === 'ready' ? 'status ok' : 'status';
|
|
el.textContent = p.stage === 'ready'
|
|
? 'Patched. Launch Rec Room from Steam to connect.'
|
|
: humanStage(p.stage);
|
|
}
|
|
|
|
function renderJoinPreview(p) {
|
|
const el = document.getElementById('join-preview');
|
|
if (!el) return;
|
|
if (!p) {
|
|
el.className = 'status error';
|
|
el.textContent = 'Invalid join code.';
|
|
el.hidden = false;
|
|
return;
|
|
}
|
|
el.className = 'status';
|
|
el.innerHTML = `Connecting to <b>${escapeHtml(p.name || '(unnamed server)')}</b><br>` +
|
|
`Host: <code>${escapeHtml(p.host)}</code><br>` +
|
|
`Version: <code>${escapeHtml(p.v)}</code>`;
|
|
el.hidden = false;
|
|
}
|
|
|
|
function renderDownloadProgress(p) {
|
|
// Could render a progress bar; for v0.1 just flash a status line.
|
|
if (!p || !p.total || p.total <= 0) return;
|
|
const pct = Math.round(p.fraction * 100);
|
|
const mb = (p.bytes / (1024 * 1024)).toFixed(1);
|
|
const totalMb = (p.total / (1024 * 1024)).toFixed(1);
|
|
const hostEl = document.getElementById('host-status');
|
|
const joinEl = document.getElementById('join-status');
|
|
const text = `Downloading… ${mb} / ${totalMb} MB (${pct}%)`;
|
|
if (hostEl && !hostEl.classList.contains('ok')) hostEl.textContent = text;
|
|
if (joinEl && !joinEl.classList.contains('ok')) joinEl.textContent = text;
|
|
}
|
|
|
|
function humanStage(stage) {
|
|
return ({
|
|
'downloading-server': 'Downloading server binary…',
|
|
'opening-tunnel': 'Opening Cloudflare tunnel…',
|
|
'starting-server': 'Starting the server…',
|
|
'patching-client': 'Patching your Rec Room client…',
|
|
'downloading-patcher': 'Downloading client patcher…',
|
|
'ready': 'Ready.',
|
|
'stopped': 'Stopped.',
|
|
}[stage] || stage);
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
// ── Boot ─────────────────────────────────────────────────────────────
|
|
// Render an empty 'first-run' until the C# init reply arrives.
|
|
showView('first-run');
|
|
bridge.send({ type: 'init' });
|