openvibers/source.openvibe.games Commits
Add embedded OpenVibe HTML menu
Changes
@@@ -1,8 +1,4 @@--// OpenVibe: Source client fallback aliases.++// OpenVibe: Source client startup config.//--// The Linux64 SDK build provides real C++ implementations for these commands.--// Proton/Windows clients may still load older DLLs until a Windows GameDLL is--// built, so these aliases prevent noisy "Unknown command" console output.--alias "openvibe_menu" "echo OpenVibe: Source menu is available through GameMenu.res or the launcher."--alias "ov_menu" "openvibe_menu"--alias "ov_join" "echo OpenVibe: authenticated ov_join requires the custom OpenVibe client DLL."++// Do not define openvibe_menu, ov_menu, or ov_join aliases here. The custom++// client DLL owns those commands, and aliases would mask the real C++ bridge.@@@ -2,8 +2,8 @@{"1"{-- "label" "OpenVibe.Games"-- "command" "engine echo OpenVibe.Games"++ "label" "OpenVibe.Games Menu"++ "command" "engine openvibe_menu""OnlyInGame" "0"}"2"@@@ -32,6 +32,7 @@<span id="server-count">0 servers</span><span class="status-sep">|</span><span class="status-right" id="game-status-label">Game: idle</span>++ <button class="embedded-close-btn" id="embedded-close" title="Close menu">Close</button></div><!-- ── Main layout ──────────────────────────────────────────────────── -->@@@ -11,9 +11,12 @@ const https = require('https');const API_BASE = 'http://127.0.0.1:3000';const DEV = process.env.ELECTRON_IS_DEV === '1';++const CLIENT_UI_PORT = Number(process.env.OPENVIBE_CLIENT_UI_PORT || 5173);let mainWindow = null;let gameProcess = null;++let uiServerProcess = null;++let appIsQuitting = false;// ── API helper ────────────────────────────────────────────────────────────────function apiGet(urlPath) {@@@ -58,8 +61,63 @@ function apiPost(urlPath, body) {});}++function waitForHttp(url, timeoutMs = 5000) {++ const started = Date.now();++ return new Promise((resolve) => {++ const tick = () => {++ http.get(url, { timeout: 1000 }, (res) => {++ res.resume();++ resolve(res.statusCode >= 200 && res.statusCode < 500);++ }).on('error', () => {++ if (Date.now() - started >= timeoutMs) {++ resolve(false);++ return;++ }++ setTimeout(tick, 200);++ }).on('timeout', () => {++ if (Date.now() - started >= timeoutMs) {++ resolve(false);++ return;++ }++ setTimeout(tick, 200);++ });++ };++ tick();++ });++}++++async function ensureClientUiServer(root) {++ const healthUrl = `http://127.0.0.1:${CLIENT_UI_PORT}/health`;++ if (await waitForHttp(healthUrl, 500)) return true;++++ const serverScript = path.join(root, 'tools/serve-client-ui.mjs');++ if (!fs.existsSync(serverScript)) {++ dialog.showErrorBox('OpenVibe Menu Server Missing', `Could not find:\n${serverScript}`);++ return false;++ }++++ uiServerProcess = spawn(process.execPath, [serverScript], {++ cwd: root,++ env: {++ ...process.env,++ OPENVIBE_ROOT: root,++ OPENVIBE_CLIENT_UI_PORT: String(CLIENT_UI_PORT),++ },++ stdio: ['ignore', 'pipe', 'pipe'],++ });++++ uiServerProcess.stdout.on('data', (d) => console.log('[client-ui]', d.toString().trim()));++ uiServerProcess.stderr.on('data', (d) => console.error('[client-ui]', d.toString().trim()));++ uiServerProcess.on('exit', (code) => {++ console.log('[launcher] client UI server exited with code', code);++ uiServerProcess = null;++ });++++ return waitForHttp(healthUrl, 5000);++}++// ── Game launch ────────────────────────────────────────────────────────────────--function launchGame(serverIp, serverPort) {++async function launchGame(serverIp, serverPort) {const root = path.resolve(__dirname, '..');const mod = path.join(root, 'game/openvibe.games');@@@ -79,6 +137,13 @@ function launchGame(serverIp, serverPort) {return false;}++ const uiReady = await ensureClientUiServer(root);++ if (!uiReady) {++ dialog.showErrorBox('OpenVibe Menu Server Failed',++ `Could not start http://127.0.0.1:${CLIENT_UI_PORT}/client for the in-game HTML menu.`);++ return false;++ }++// Build arguments to pass through the shell scriptconst scriptArgs = serverIp && serverPort ? [serverIp, String(serverPort)] : [];@@@ -100,8 +165,15 @@ function launchGame(serverIp, serverPort) {console.log('[launcher] game exited with code', code);gameProcess = null;mainWindow?.webContents.send('game-exited', code);++ mainWindow?.show();++ mainWindow?.focus();});++ mainWindow?.webContents.send('game-started', gameProcess.pid);++ setTimeout(() => {++ if (gameProcess && mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();++ }, 750);++// Detach so the game keeps running if Electron is closedgameProcess.unref();@@@ -131,11 +203,11 @@ ipcMain.handle('api:travel', async (_e, { steamId, mode }) => {catch (e) { return { error: e.message }; }});--ipcMain.handle('game:launch', (_e, { ip, port }) => {++ipcMain.handle('game:launch', async (_e, { ip, port }) => {return launchGame(ip, port);});--ipcMain.handle('game:launch-direct', (_e, mode) => {++ipcMain.handle('game:launch-direct', async (_e, mode) => {// Look up the mode's port from local server listconst serverMap = {hub: { ip: '127.0.0.1', port: 27015 },@@@ -181,6 +253,13 @@ function createWindow() {if (DEV) mainWindow.webContents.openDevTools({ mode: 'detach' });});++ mainWindow.on('close', (event) => {++ if (gameProcess && !appIsQuitting) {++ event.preventDefault();++ mainWindow.hide();++ }++ });++mainWindow.on('closed', () => {mainWindow = null;});@@@ -196,9 +275,18 @@ app.whenReady().then(() => {});app.on('window-all-closed', () => {++ if (gameProcess) return;if (process.platform !== 'darwin') app.quit();});++app.on('before-quit', () => {++ appIsQuitting = true;++ if (uiServerProcess) {++ uiServerProcess.kill();++ uiServerProcess = null;++ }++});++// Titlebar controls via IPCipcMain.on('window:minimize', () => mainWindow?.minimize());ipcMain.on('window:maximize', () => {@@@ -5,7 +5,8 @@"main": "main.js","scripts": {"start": "electron .",-- "dev": "ELECTRON_IS_DEV=1 electron ."++ "dev": "ELECTRON_IS_DEV=1 electron .",++ "serve": "node ../tools/serve-client-ui.mjs"},"keywords": [],"author": "OpenVibe",@@@ -21,5 +21,6 @@ contextBridge.exposeInMainWorld('OV', {openUrl: (url) => ipcRenderer.send('open-url', url),// Events from main → renderer++ onGameStart: (cb) => ipcRenderer.on('game-started', (_e, pid) => cb(pid)),onGameExit: (cb) => ipcRenderer.on('game-exited', (_e, code) => cb(code)),});@@@ -11,6 +11,13 @@ const MODES = {traitortown: { label: 'Traitor Town', port: 27019, color: '#a855f7' },};++const API_BASE = 'http://127.0.0.1:3000';++const isElectron = !!window.OV;++const isEmbedded = new URLSearchParams(window.location.search).get('embedded') === '1' || !isElectron;++++document.documentElement.classList.toggle('embedded', isEmbedded);++document.body.classList.toggle('embedded', isEmbedded);++const PLAYER_IDS = {hub: 'hub-players', prophunt: 'ph-players',deathrun: 'dr-players', fortwars: 'fw-players', traitortown: 'tt-players',@@@ -20,6 +27,45 @@ let currentTab = 'portal';let serverData = [];let apiOnline = false;++async function apiFetch(path, options = {}) {++ const res = await fetch(`${API_BASE}${path}`, {++ ...options,++ headers: {++ 'Accept': 'application/json',++ ...(options.body ? { 'Content-Type': 'application/json' } : {}),++ ...(options.headers || {}),++ },++ });++ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);++ return res.json();++}++++const Bridge = {++ health: () => isElectron ? window.OV.health() : apiFetch('/health'),++ servers: async () => {++ if (isElectron) return window.OV.servers();++ const data = await apiFetch('/v1/servers');++ return Array.isArray(data) ? data : (data.servers ?? []);++ },++ leaderboard: async (limit) => {++ if (isElectron) return window.OV.leaderboard(limit);++ const data = await apiFetch(`/v1/leaderboard?limit=${limit}`);++ return Array.isArray(data) ? data : (data.players ?? []);++ },++ launchMode: async (mode) => {++ if (isElectron) return window.OV.launchMode(mode);++ window.location.href = `openvibe://join?mode=${encodeURIComponent(mode)}`;++ return true;++ },++ gameStatus: () => isElectron ? window.OV.gameStatus() : Promise.resolve({ running: true, pid: null }),++ close: () => {++ if (isElectron) window.OV.close();++ else window.location.href = 'openvibe://close';++ },++ minimize: () => { if (isElectron) window.OV.minimize(); },++ maximize: () => { if (isElectron) window.OV.maximize(); },++};++// ── Toast helper ─────────────────────────────────────────────────────────────const toastEl = document.getElementById('toast');let toastTimer;@@@ -52,9 +98,10 @@ function setTab(tab) {}// ── Titlebar controls ────────────────────────────────────────────────────────--document.getElementById('btn-min')?.addEventListener('click', () => window.OV.minimize());--document.getElementById('btn-max')?.addEventListener('click', () => window.OV.maximize());--document.getElementById('btn-close')?.addEventListener('click', () => window.OV.close());++document.getElementById('btn-min')?.addEventListener('click', () => Bridge.minimize());++document.getElementById('btn-max')?.addEventListener('click', () => Bridge.maximize());++document.getElementById('btn-close')?.addEventListener('click', () => Bridge.close());++document.getElementById('embedded-close')?.addEventListener('click', () => Bridge.close());// ── Launch overlay ───────────────────────────────────────────────────────────const launchOverlay = document.getElementById('launch-overlay');@@@ -81,7 +128,7 @@ async function launchMode(mode) {toast(`Connecting to ${info.label}…`);try {-- const ok = await window.OV.launchMode(mode);++ const ok = await Bridge.launchMode(mode);if (ok) {toast(`✓ ${info.label} launched — port ${info.port}`);updateGameStatus(true);@@@ -115,11 +162,16 @@ document.getElementById('settings-launch')?.addEventListener('click', () => {});// Game exit callback--window.OV.onGameExit((code) => {-- hideLaunchOverlay();-- updateGameStatus(false);-- toast(`Game exited (code ${code})`);--});++if (isElectron) {++ window.OV.onGameStart?.(() => {++ updateGameStatus(true);++ });++ window.OV.onGameExit((code) => {++ hideLaunchOverlay();++ updateGameStatus(false);++ toast(`Game exited (code ${code})`);++ });++}// ── API health ────────────────────────────────────────────────────────────────const apiDot = document.getElementById('api-dot');@@@ -128,7 +180,7 @@ const srvCount = document.getElementById('server-count');async function checkApiHealth() {try {-- const data = await window.OV.health();++ const data = await Bridge.health();if (data.ok) {apiOnline = true;apiDot.className = 'status-dot up';@@@ -155,7 +207,7 @@ async function refreshServers() {tbody.innerHTML = '<tr><td colspan="6" class="loading-row">Loading…</td></tr>';try {-- const servers = await window.OV.servers();++ const servers = await Bridge.servers();serverData = servers;if (!servers || servers.length === 0) {@@@ -220,7 +272,7 @@ async function refreshLeaderboard() {list.innerHTML = '<li class="loading-row">Loading…</li>';try {-- const data = await window.OV.leaderboard(20);++ const data = await Bridge.leaderboard(20);if (!data || data.length === 0) {list.innerHTML = '<li class="loading-row">No rankings yet — play some games!</li>';@@@ -262,7 +314,7 @@ async function init() {// Load server player counts for portal cardstry {-- const servers = await window.OV.servers();++ const servers = await Bridge.servers();serverData = servers;srvCount.textContent = `${servers.length} server${servers.length !== 1 ? 's' : ''}`;servers.forEach((s) => {@@@ -279,7 +331,7 @@ async function init() {setInterval(async () => {await checkApiHealth();try {-- const status = await window.OV.gameStatus();++ const status = await Bridge.gameStatus();updateGameStatus(status.running);} catch {}}, 10_000);@@@ -120,6 +120,24 @@ html, body {.status-sep { color: var(--text-muted); }.status-right { margin-left: auto; }++.embedded-close-btn {++ display: none;++ padding: 4px 10px;++ background: rgba(255,255,255,.04);++ border: 1px solid var(--border-lit);++ border-radius: 6px;++ color: var(--text-dim);++ font-family: var(--font-ui);++ font-size: 12px;++ font-weight: 700;++ cursor: pointer;++}++.embedded-close-btn:hover {++ border-color: var(--accent);++ color: var(--text);++ background: rgba(0,200,255,.12);++}++/* ── App layout ──────────────────────────────────────────────────── */.app-layout {display: flex;@@@ -677,3 +695,61 @@ html, body {.ping-ok { color: var(--accent3); }.ping-mid { color: #ffaa00; }.ping-bad { color: var(--danger); }++++/* ── Embedded Source browser mode ───────────────────────────────── */++html.embedded body {++ background:++ linear-gradient(180deg, rgba(5,7,12,.97), rgba(7,8,16,.94)),++ var(--bg-deep);++}++++html.embedded .titlebar {++ display: none;++}++++html.embedded .statusbar {++ height: 34px;++ padding: 0 18px;++ background: rgba(6, 8, 15, .9);++ border-bottom-color: rgba(0,200,255,.22);++}++++html.embedded .embedded-close-btn {++ display: inline-flex;++ align-items: center;++ justify-content: center;++}++++html.embedded .app-layout {++ height: calc(100vh - 34px);++}++++html.embedded .sidebar {++ width: 210px;++ min-width: 210px;++ background: rgba(9, 10, 19, .84);++ border-right-color: rgba(0,200,255,.18);++}++++html.embedded .content {++ background:++ radial-gradient(ellipse at 18% 10%, rgba(0,200,255,.08), transparent 48%),++ radial-gradient(ellipse at 88% 88%, rgba(6,255,165,.045), transparent 42%),++ rgba(9, 10, 19, .76);++}++++html.embedded .portal-card,++html.embedded .shop-item,++html.embedded .server-table,++html.embedded .leaderboard-list li {++ background: rgba(17, 18, 32, .82);++ backdrop-filter: blur(10px);++}++++html.embedded .portal-card {++ border-radius: 8px;++}++++html.embedded .hero-sub {++ color: #8aa0b8;++}@@@ -73,10 +73,10 @@ static bool OV_IsModeAllowed( const char *pszMode )static const char *OV_LocalConnectForMode( const char *pszMode ){if ( !Q_stricmp( pszMode, "hub" ) ) return "127.0.0.1:27015";-- if ( !Q_stricmp( pszMode, "prophunt" ) ) return "127.0.0.1:27016";-- if ( !Q_stricmp( pszMode, "deathrun" ) ) return "127.0.0.1:27017";-- if ( !Q_stricmp( pszMode, "fortwars" ) ) return "127.0.0.1:27018";-- if ( !Q_stricmp( pszMode, "traitortown" ) ) return "127.0.0.1:27019";++ if ( !Q_stricmp( pszMode, "prophunt" ) ) return "127.0.0.2:27016";++ if ( !Q_stricmp( pszMode, "deathrun" ) ) return "127.0.0.3:27017";++ if ( !Q_stricmp( pszMode, "fortwars" ) ) return "127.0.0.4:27018";++ if ( !Q_stricmp( pszMode, "traitortown" ) ) return "127.0.0.5:27019";return "127.0.0.1:27015";}@@@ -1,143 +1,373 @@--// OpenVibe: Source VGUI portal panel.++// OpenVibe: Source HTML menu host.//--// This is intentionally small and native. The Electron launcher is still the--// rich desktop menu, while this panel gives the Source client an in-game--// OpenVibe-first menu that can replace the normal server-browser workflow.++// This is the only native menu shell the game needs. It embeds Steam's++// CEF-backed vgui::HTML control full-screen, loads the OpenVibe web client,++// and exposes a small allowlisted openvibe:// bridge back into Source.#include "cbase.h"#include "cdll_client_int.h"#include "ienginevgui.h"--#include <vgui/IScheme.h>#include <vgui/ISurface.h>--#include <vgui_controls/Button.h>#include <vgui_controls/Frame.h>--#include <vgui_controls/Label.h>++#include <vgui_controls/HTML.h>++++#include <ctype.h>++#include <stdlib.h>// memdbgon must be the last include file in a .cpp file.#include "tier0/memdbgon.h"using namespace vgui;--class COpenVibeMenuPanel : public Frame++static ConVar ov_menu_url(++ "ov_menu_url",++ "http://127.0.0.1:5173/client/?embedded=1",++ FCVAR_ARCHIVE,++ "OpenVibe HTML menu URL." );++++static ConVar ov_menu_auto_open(++ "ov_menu_auto_open",++ "1",++ FCVAR_ARCHIVE,++ "Automatically opens the OpenVibe HTML menu when the client UI initializes." );++++static ConVar ov_menu_allow_remote(++ "ov_menu_allow_remote",++ "1",++ FCVAR_ARCHIVE,++ "Allow loading https://openvibe.games in the embedded menu." );++++static bool OV_URLStartsWith( const char *pszUrl, const char *pszPrefix ){-- DECLARE_CLASS_SIMPLE( COpenVibeMenuPanel, Frame );++ return pszUrl && pszPrefix && !Q_strnicmp( pszUrl, pszPrefix, Q_strlen( pszPrefix ) );++}++++static bool OV_IsAllowedWebURL( const char *pszUrl )++{++ if ( !pszUrl || !pszUrl[0] )++ return false;++++ if ( OV_URLStartsWith( pszUrl, "about:blank" ) )++ return true;++++ if ( OV_URLStartsWith( pszUrl, "http://127.0.0.1:" ) ||++ OV_URLStartsWith( pszUrl, "http://localhost:" ) )++ return true;++++ if ( ov_menu_allow_remote.GetBool() &&++ ( OV_URLStartsWith( pszUrl, "https://openvibe.games/" ) ||++ OV_URLStartsWith( pszUrl, "https://www.openvibe.games/" ) ) )++ return true;++++ return false;++}++++static bool OV_IsSafeMode( const char *pszMode )++{++ return pszMode &&++ ( !Q_stricmp( pszMode, "hub" ) ||++ !Q_stricmp( pszMode, "prophunt" ) ||++ !Q_stricmp( pszMode, "deathrun" ) ||++ !Q_stricmp( pszMode, "fortwars" ) ||++ !Q_stricmp( pszMode, "traitortown" ) );++}++++static void OV_URLDecodeInPlace( char *pszValue )++{++ if ( !pszValue )++ return;++++ char *pszRead = pszValue;++ char *pszWrite = pszValue;++ while ( *pszRead )++ {++ if ( pszRead[0] == '%' && isxdigit( pszRead[1] ) && isxdigit( pszRead[2] ) )++ {++ char szHex[3] = { pszRead[1], pszRead[2], '\0' };++ *pszWrite++ = (char)strtol( szHex, NULL, 16 );++ pszRead += 3;++ continue;++ }++++ *pszWrite++ = ( pszRead[0] == '+' ) ? ' ' : pszRead[0];++ ++pszRead;++ }++ *pszWrite = '\0';++}++++static bool OV_ReadQueryValue( const char *pszUrl, const char *pszKey, char *pszOut, size_t nOut )++{++ if ( !pszUrl || !pszKey || !pszOut || nOut == 0 )++ return false;++++ pszOut[0] = '\0';++++ char szNeedle[64];++ Q_snprintf( szNeedle, sizeof( szNeedle ), "%s=", pszKey );++++ const char *pszQuery = Q_strstr( pszUrl, "?" );++ if ( !pszQuery )++ return false;++++ const char *pszCursor = pszQuery + 1;++ while ( pszCursor && pszCursor[0] )++ {++ if ( !Q_strnicmp( pszCursor, szNeedle, Q_strlen( szNeedle ) ) )++ {++ pszCursor += Q_strlen( szNeedle );++ const char *pszEnd = pszCursor;++ while ( pszEnd[0] && pszEnd[0] != '&' && pszEnd[0] != '#' )++ ++pszEnd;++++ const int nLen = MIN( (int)( pszEnd - pszCursor ), (int)nOut - 1 );++ if ( nLen <= 0 )++ return false;++++ Q_strncpy( pszOut, pszCursor, nLen + 1 );++ pszOut[nLen] = '\0';++ OV_URLDecodeInPlace( pszOut );++ return true;++ }++++ pszCursor = Q_strstr( pszCursor, "&" );++ if ( pszCursor )++ ++pszCursor;++ }++++ return false;++}++++class IOpenVibeHTMLBridge++{++public:++ virtual bool OnOpenVibeHTMLStartRequest( const char *pszURL, const char *pszTarget, const char *pszPostData, bool bIsRedirect ) = 0;++};++++class COpenVibeHTMLControl : public HTML++{++ DECLARE_CLASS_SIMPLE( COpenVibeHTMLControl, HTML );++public:++ COpenVibeHTMLControl( IOpenVibeHTMLBridge *pBridge, Panel *pParent, const char *pszName ) :++ BaseClass( pParent, pszName, true ),++ m_pBridge( pBridge )++ {++ }++++ bool OnStartRequest( const char *pszURL, const char *pszTarget, const char *pszPostData, bool bIsRedirect ) OVERRIDE++ {++ return m_pBridge ? m_pBridge->OnOpenVibeHTMLStartRequest( pszURL, pszTarget, pszPostData, bIsRedirect ) : false;++ }++++private:++ IOpenVibeHTMLBridge *m_pBridge;++};++++class COpenVibeHTMLPanel : public Frame, public IOpenVibeHTMLBridge++{++ DECLARE_CLASS_SIMPLE( COpenVibeHTMLPanel, Frame );public:-- COpenVibeMenuPanel( VPANEL parent ) :-- BaseClass( NULL, "OpenVibeMenuPanel" )++ COpenVibeHTMLPanel( VPANEL parent ) :++ BaseClass( NULL, "OpenVibeHTMLPanel" ){SetParent( parent );-- SetTitle( "OpenVibe: Source", true );-- SetSize( 760, 500 );-- SetMinimumSize( 640, 420 );++ SetTitle( "OpenVibe: Source", false );++ SetTitleBarVisible( false );++ SetMinimizeButtonVisible( false );++ SetMaximizeButtonVisible( false );++ SetCloseButtonVisible( false );SetSizeable( false );-- SetMoveable( true );++ SetMoveable( false );SetDeleteSelfOnClose( false );-- SetBgColor( Color( 18, 20, 28, 245 ) );++ SetPaintBackgroundEnabled( true );++ SetBgColor( Color( 7, 8, 14, 255 ) );++++ m_pHTML = new COpenVibeHTMLControl( this, this, "OpenVibeHTML" );++ m_pHTML->SetScrollbarsEnabled( false );++ m_pHTML->SetContextMenuEnabled( false );++ m_pHTML->NewWindowsOnly( false );++ MakeFullScreen();++ SetVisible( false );++ }++++ void Open( const char *pszURL = NULL )++ {++ const char *pszTarget = ( pszURL && pszURL[0] ) ? pszURL : ov_menu_url.GetString();++ MakeFullScreen();++ SetVisible( true );++ MoveToFront();++ RequestFocus();++ m_pHTML->RequestFocus();++ m_pHTML->OpenURL( pszTarget, NULL, true );++ engine->ClientCmd_Unrestricted( "gameui_hide\n" );++ }++++ void CloseMenu()++ {++ SetVisible( false );++ engine->ClientCmd_Unrestricted( "gameui_hide\n" );++ }++++ void Reload()++ {++ if ( IsVisible() )++ m_pHTML->Refresh();++ }++++ void RunJS( const char *pszScript )++ {++ if ( pszScript && pszScript[0] )++ m_pHTML->RunJavascript( pszScript );++ }++++ void OnSizeChanged( int wide, int tall ) OVERRIDE++ {++ BaseClass::OnSizeChanged( wide, tall );++ if ( m_pHTML )++ m_pHTML->SetBounds( 0, 0, wide, tall );++ }++++ void PerformLayout() OVERRIDE++ {++ BaseClass::PerformLayout();++ MakeFullScreen();++ }++++ bool OnOpenVibeHTMLStartRequest( const char *pszURL, const char *pszTarget, const char *pszPostData, bool bIsRedirect ) OVERRIDE++ {++ if ( OV_URLStartsWith( pszURL, "openvibe://" ) )++ {++ HandleBridgeURL( pszURL );++ return false;++ }++++ if ( !OV_IsAllowedWebURL( pszURL ) )++ {++ Warning( "[OV HTML] Blocked navigation to %s\n", pszURL ? pszURL : "<null>" );++ return false;++ }++++ return true;++ }++++private:++ void MakeFullScreen()++ {int sw = 0;int sh = 0;surface()->GetScreenSize( sw, sh );-- SetPos( ( sw - GetWide() ) / 2, ( sh - GetTall() ) / 2 );---- Label *pTitle = new Label( this, "OpenVibeTitle", "OpenVibe.Games" );-- pTitle->SetPos( 28, 36 );-- pTitle->SetSize( 420, 32 );-- pTitle->SetFgColor( Color( 0, 210, 255, 255 ) );---- Label *pSubtitle = new Label( this, "OpenVibeSubtitle", "Choose a hub or minigame server. Travel is routed through the OpenVibe backend." );-- pSubtitle->SetPos( 28, 70 );-- pSubtitle->SetSize( 680, 24 );-- pSubtitle->SetFgColor( Color( 220, 230, 240, 255 ) );---- AddModeButton( "Hub", "Social lobby, portals, NPCs, shops", "hub", 28, 120 );-- AddModeButton( "Prop Hunt", "Props disguise and hide from hunters", "prophunt", 390, 120 );-- AddModeButton( "Deathrun", "Runners survive traps and reach the finish", "deathrun", 28, 220 );-- AddModeButton( "Fort Wars", "Build phase, prop forts, combat phase", "fortwars", 390, 220 );-- AddModeButton( "Traitor Town", "Social deduction with traitors and innocents", "traitortown", 28, 320 );---- Button *pWebsite = new Button( this, "OpenWebsite", "Open openvibe.games", this, "website" );-- pWebsite->SetPos( 390, 320 );-- pWebsite->SetSize( 320, 44 );---- Button *pClose = new Button( this, "Close", "Close", this, "close" );-- pClose->SetPos( 590, 430 );-- pClose->SetSize( 120, 36 );++ SetBounds( 0, 0, sw, sh );++ if ( m_pHTML )++ m_pHTML->SetBounds( 0, 0, sw, sh );}-- void OnCommand( const char *pszCommand ) OVERRIDE++ void HandleBridgeURL( const char *pszURL ){-- if ( !Q_strncmp( pszCommand, "join:", 5 ) )++ if ( OV_URLStartsWith( pszURL, "openvibe://join" ) ){-- engine->ClientCmd_Unrestricted( VarArgs( "gameui_hide\nov_join %s\n", pszCommand + 5 ) );-- SetVisible( false );++ char szMode[64];++ if ( OV_ReadQueryValue( pszURL, "mode", szMode, sizeof( szMode ) ) && OV_IsSafeMode( szMode ) )++ {++ CloseMenu();++ engine->ClientCmd_Unrestricted( VarArgs( "ov_join %s\n", szMode ) );++ return;++ }++ }++++ if ( OV_URLStartsWith( pszURL, "openvibe://close" ) )++ {++ CloseMenu();return;}-- if ( !Q_stricmp( pszCommand, "website" ) )++ if ( OV_URLStartsWith( pszURL, "openvibe://reload" ) ){-- engine->ClientCmd_Unrestricted( "ov_open_url https://openvibe.games\n" );++ Reload();return;}-- if ( !Q_stricmp( pszCommand, "close" ) )++ if ( OV_URLStartsWith( pszURL, "openvibe://external" ) ){-- SetVisible( false );++ char szUrl[512];++ if ( OV_ReadQueryValue( pszURL, "url", szUrl, sizeof( szUrl ) ) && OV_IsAllowedWebURL( szUrl ) )++ engine->ClientCmd_Unrestricted( VarArgs( "ov_open_url %s\n", szUrl ) );return;}-- BaseClass::OnCommand( pszCommand );++ Warning( "[OV HTML] Ignored bridge URL: %s\n", pszURL ? pszURL : "<null>" );}--private:-- void AddModeButton( const char *pszName, const char *pszDescription, const char *pszMode, int x, int y )-- {-- char szButtonName[64];-- char szCommand[64];-- char szDescriptionName[64];-- Q_snprintf( szButtonName, sizeof( szButtonName ), "%sButton", pszMode );-- Q_snprintf( szCommand, sizeof( szCommand ), "join:%s", pszMode );-- Q_snprintf( szDescriptionName, sizeof( szDescriptionName ), "%sDescription", pszMode );---- Button *pButton = new Button( this, szButtonName, pszName, this, szCommand );-- pButton->SetPos( x, y );-- pButton->SetSize( 320, 48 );---- Label *pDescription = new Label( this, szDescriptionName, pszDescription );-- pDescription->SetPos( x + 2, y + 52 );-- pDescription->SetSize( 320, 26 );-- pDescription->SetFgColor( Color( 185, 195, 205, 255 ) );-- }++ HTML *m_pHTML;};--static COpenVibeMenuPanel *s_pOpenVibeMenu = NULL;++static COpenVibeHTMLPanel *s_pOpenVibeMenu = NULL;--static void OV_OpenMenu()++static COpenVibeHTMLPanel *OV_GetHTMLMenu(){if ( !s_pOpenVibeMenu )-- {-- s_pOpenVibeMenu = new COpenVibeMenuPanel( enginevgui->GetPanel( PANEL_GAMEUIDLL ) );-- }++ s_pOpenVibeMenu = new COpenVibeHTMLPanel( enginevgui->GetPanel( PANEL_GAMEUIDLL ) );-- s_pOpenVibeMenu->SetVisible( true );-- s_pOpenVibeMenu->MoveToFront();-- s_pOpenVibeMenu->RequestFocus();++ return s_pOpenVibeMenu;}static void OV_OpenMenu_f( const CCommand &args ){-- OV_OpenMenu();++ const char *pszURL = args.ArgC() >= 2 ? args[1] : NULL;++ OV_GetHTMLMenu()->Open( pszURL );++}++++static void OV_CloseMenu_f( const CCommand &args )++{++ if ( s_pOpenVibeMenu )++ s_pOpenVibeMenu->CloseMenu();++}++++static void OV_ReloadMenu_f( const CCommand &args )++{++ if ( s_pOpenVibeMenu )++ s_pOpenVibeMenu->Reload();++}++++static void OV_MenuJS_f( const CCommand &args )++{++ if ( s_pOpenVibeMenu && args.ArgC() >= 2 )++ s_pOpenVibeMenu->RunJS( args.ArgS() );++}++++void OpenVibe_OnClientModeInit()++{++ if ( ov_menu_auto_open.GetBool() )++ OV_GetHTMLMenu()->Open();}static ConCommand openvibe_menu_cmd("openvibe_menu",OV_OpenMenu_f,-- "Open the OpenVibe hub/minigame selection menu.",++ "Open the OpenVibe embedded HTML menu.",FCVAR_CLIENTDLL );static ConCommand ov_menu_cmd("ov_menu",OV_OpenMenu_f,-- "Open the OpenVibe hub/minigame selection menu.",++ "Open the OpenVibe embedded HTML menu.",++ FCVAR_CLIENTDLL );++++static ConCommand ov_menu_close_cmd(++ "ov_menu_close",++ OV_CloseMenu_f,++ "Close the OpenVibe embedded HTML menu.",++ FCVAR_CLIENTDLL );++++static ConCommand ov_menu_reload_cmd(++ "ov_menu_reload",++ OV_ReloadMenu_f,++ "Reload the OpenVibe embedded HTML menu.",++ FCVAR_CLIENTDLL );++++static ConCommand ov_menu_js_cmd(++ "ov_menu_js",++ OV_MenuJS_f,++ "Run JavaScript in the OpenVibe embedded HTML menu.",FCVAR_CLIENTDLL );@@@ -27,6 +27,7 @@ copy_file "$ROOT/sdk/openvibe/server/hl2mp/openvibe_server.cpp" \CLIENT_VPC="$SDK/src/game/client/client_hl2mp.vpc"SERVER_VPC="$SDK/src/game/server/server_hl2mp.vpc"HL2MP_CLIENT="$SDK/src/game/server/hl2mp/hl2mp_client.cpp"++HL2MP_CLIENTMODE="$SDK/src/game/client/hl2mp/clientmode_hl2mpnormal.cpp"perl -0pi -e 's/^.*hl2mp\\openvibe_client\.cpp.*\n//mg; s/^.*hl2mp\\vgui_openvibe_menu\.cpp.*\n//mg; s/(\$File\s+"hl2mp\\clientmode_hl2mpnormal\.h"\n)/$1\t\t\t\$File\t"hl2mp\\openvibe_client.cpp"\n\t\t\t\$File\t"hl2mp\\vgui_openvibe_menu.cpp"\n/s' "$CLIENT_VPC"echo "[openvibe-sdk] patched client_hl2mp.vpc"@@@ -42,4 +43,12 @@ fiperl -0pi -e 's/[^\S\r\n]*\x0boid OpenVibe_OnClientActive/void OpenVibe_OnClientActive/g; s/\\tOpenVibe_OnClientActive/\tOpenVibe_OnClientActive/g; s/^[ \t]*void OpenVibe_OnClientActive/void OpenVibe_OnClientActive/m' "$HL2MP_CLIENT"++if ! grep -q 'OpenVibe_OnClientModeInit' "$HL2MP_CLIENTMODE"; then++ sed -i '/#include "ienginevgui.h"/a void OpenVibe_OnClientModeInit();' "$HL2MP_CLIENTMODE"++ sed -i '/BaseClass::Init();/a \ OpenVibe_OnClientModeInit();' "$HL2MP_CLIENTMODE"++ echo "[openvibe-sdk] patched clientmode_hl2mpnormal.cpp OpenVibe menu hook"++fi++++perl -0pi -e 's/^[ \t]*tOpenVibe_OnClientModeInit\(\);/ OpenVibe_OnClientModeInit();/m' "$HL2MP_CLIENTMODE"++echo "[openvibe-sdk] Source SDK OpenVibe patch applied"@@@ -6,6 +6,7 @@ MOD="$ROOT/game/openvibe.games"sessions=(ov-api++ ov-client-uiov-hubov-prophuntov-deathrun@@@ -35,6 +36,7 @@ kill_matching() {kill_matching TERM "srcds_linux64 -game $MOD"kill_matching TERM "$ROOT/tools/ov-sidecar.mjs"kill_matching TERM "$ROOT/tools/dev-api.sh"++kill_matching TERM "$ROOT/tools/serve-client-ui.mjs"kill_matching TERM "$ROOT/backend/node_modules/.bin/tsx watch src/index.ts"sleep 1@@@ -42,6 +44,7 @@ sleep 1kill_matching KILL "srcds_linux64 -game $MOD"kill_matching KILL "$ROOT/tools/ov-sidecar.mjs"kill_matching KILL "$ROOT/tools/dev-api.sh"++kill_matching KILL "$ROOT/tools/serve-client-ui.mjs"kill_matching KILL "$ROOT/backend/node_modules/.bin/tsx watch src/index.ts""$ROOT/tools/dev-db-down.sh"@@@ -13,8 +13,12 @@ fiif tmux has-session -t ov-api 2>/dev/null; thentmux kill-session -t ov-apifi++if tmux has-session -t ov-client-ui 2>/dev/null; then++ tmux kill-session -t ov-client-ui++fitmux new-session -d -s ov-api "$ROOT/tools/dev-api.sh"++tmux new-session -d -s ov-client-ui "$ROOT/tools/run-client-ui.sh"echo "[openvibe] waiting for api"for _ in {1..60}; donnew file mode 100755@@@ -0,0 +1,9 @@++#!/usr/bin/env bash++set -euo pipefail++++ROOT="${OPENVIBE_ROOT:-$HOME/src/openvibe-source}"++export OPENVIBE_ROOT="$ROOT"++export OPENVIBE_CLIENT_UI_HOST="${OPENVIBE_CLIENT_UI_HOST:-127.0.0.1}"++export OPENVIBE_CLIENT_UI_PORT="${OPENVIBE_CLIENT_UI_PORT:-5173}"++++exec node "$ROOT/tools/serve-client-ui.mjs"nnew file mode 100755@@@ -0,0 +1,108 @@++#!/usr/bin/env node++import { createReadStream, existsSync, statSync } from 'node:fs';++import { createServer } from 'node:http';++import { extname, join, normalize, resolve, sep } from 'node:path';++++const root = resolve(process.env.OPENVIBE_ROOT || join(process.env.HOME || '.', 'src/openvibe-source'));++const launcherRoot = join(root, 'launcher');++const host = process.env.OPENVIBE_CLIENT_UI_HOST || '127.0.0.1';++const port = Number(process.env.OPENVIBE_CLIENT_UI_PORT || 5173);++++const mime = {++ '.html': 'text/html; charset=utf-8',++ '.css': 'text/css; charset=utf-8',++ '.js': 'text/javascript; charset=utf-8',++ '.json': 'application/json; charset=utf-8',++ '.png': 'image/png',++ '.jpg': 'image/jpeg',++ '.jpeg': 'image/jpeg',++ '.svg': 'image/svg+xml',++ '.ico': 'image/x-icon',++ '.woff': 'font/woff',++ '.woff2': 'font/woff2',++ '.ttf': 'font/ttf',++};++++function send(res, status, body, type = 'text/plain; charset=utf-8') {++ res.writeHead(status, {++ 'Content-Type': type,++ 'Cache-Control': 'no-store',++ 'X-Content-Type-Options': 'nosniff',++ });++ res.end(body);++}++++function sendHeaders(res, status, type) {++ res.writeHead(status, {++ 'Content-Type': type,++ 'Cache-Control': 'no-store',++ 'X-Content-Type-Options': 'nosniff',++ });++ res.end();++}++++function fileForUrl(url) {++ const parsed = new URL(url, `http://${host}:${port}`);++ let pathname = decodeURIComponent(parsed.pathname);++++ if (pathname === '/' || pathname === '/client' || pathname === '/client/') {++ return join(launcherRoot, 'index.html');++ }++++ if (!pathname.startsWith('/client/')) {++ if (pathname === '/styles.css' || pathname === '/renderer.js' || pathname === '/preload.js') {++ return join(launcherRoot, pathname.slice(1));++ }++ if (pathname.startsWith('/assets/')) {++ const asset = normalize(join(launcherRoot, pathname.slice(1)));++ return asset.startsWith(launcherRoot + sep) ? asset : null;++ }++ return null;++ }++++ pathname = pathname.slice('/client/'.length);++ const file = normalize(join(launcherRoot, pathname));++ if (!file.startsWith(launcherRoot + sep)) {++ return null;++ }++++ return file;++}++++const server = createServer((req, res) => {++ if (!req.url || (req.method !== 'GET' && req.method !== 'HEAD')) {++ send(res, 405, 'Method not allowed');++ return;++ }++++ if (req.url === '/health') {++ if (req.method === 'HEAD') {++ sendHeaders(res, 200, 'application/json; charset=utf-8');++ return;++ }++ send(res, 200, JSON.stringify({ ok: true, service: 'openvibe-client-ui' }), 'application/json; charset=utf-8');++ return;++ }++++ const file = fileForUrl(req.url);++ if (!file || !existsSync(file) || !statSync(file).isFile()) {++ send(res, 404, 'Not found');++ return;++ }++++ if (req.method === 'HEAD') {++ sendHeaders(res, 200, mime[extname(file).toLowerCase()] || 'application/octet-stream');++ return;++ }++++ res.writeHead(200, {++ 'Content-Type': mime[extname(file).toLowerCase()] || 'application/octet-stream',++ 'Cache-Control': 'no-store',++ 'X-Content-Type-Options': 'nosniff',++ });++ createReadStream(file).pipe(res);++});++++server.listen(port, host, () => {++ console.log(`[openvibe-ui] serving ${launcherRoot} at http://${host}:${port}/client`);++});
@@ -1,8 +1,4 @@
@@ -1,8 +1,4 @@
-// OpenVibe: Source client fallback aliases.
+// OpenVibe: Source client startup config.
//
//
-// The Linux64 SDK build provides real C++ implementations for these commands.
-// Proton/Windows clients may still load older DLLs until a Windows GameDLL is
-// built, so these aliases prevent noisy "Unknown command" console output.
-alias "openvibe_menu" "echo OpenVibe: Source menu is available through GameMenu.res or the launcher."
-alias "ov_menu" "openvibe_menu"
-alias "ov_join" "echo OpenVibe: authenticated ov_join requires the custom OpenVibe client DLL."
+// Do not define openvibe_menu, ov_menu, or ov_join aliases here. The custom
+// client DLL owns those commands, and aliases would mask the real C++ bridge.
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
{
{
"1"
"1"
{
{
- "label" "OpenVibe.Games"
- "command" "engine echo OpenVibe.Games"
+ "label" "OpenVibe.Games Menu"
+ "command" "engine openvibe_menu"
"OnlyInGame" "0"
"OnlyInGame" "0"
}
}
"2"
"2"
@@ -32,6 +32,7 @@
@@ -32,6 +32,7 @@
<span id="server-count">0 servers</span>
<span id="server-count">0 servers</span>
<span class="status-sep">|</span>
<span class="status-sep">|</span>
<span class="status-right" id="game-status-label">Game: idle</span>
<span class="status-right" id="game-status-label">Game: idle</span>
+ <button class="embedded-close-btn" id="embedded-close" title="Close menu">Close</button>
</div>
</div>
<!-- ── Main layout ──────────────────────────────────────────────────── -->
<!-- ── Main layout ──────────────────────────────────────────────────── -->
@@ -11,9 +11,12 @@ const https = require('https');
@@ -11,9 +11,12 @@ const https = require('https');
const API_BASE = 'http://127.0.0.1:3000';
const API_BASE = 'http://127.0.0.1:3000';
const DEV = process.env.ELECTRON_IS_DEV === '1';
const DEV = process.env.ELECTRON_IS_DEV === '1';
+const CLIENT_UI_PORT = Number(process.env.OPENVIBE_CLIENT_UI_PORT || 5173);
let mainWindow = null;
let mainWindow = null;
let gameProcess = null;
let gameProcess = null;
+let uiServerProcess = null;
+let appIsQuitting = false;
// ── API helper ────────────────────────────────────────────────────────────────
// ── API helper ────────────────────────────────────────────────────────────────
function apiGet(urlPath) {
function apiGet(urlPath) {
@@ -58,8 +61,63 @@ function apiPost(urlPath, body) {
@@ -58,8 +61,63 @@ function apiPost(urlPath, body) {
});
});
}
}
+function waitForHttp(url, timeoutMs = 5000) {
+ const started = Date.now();
+ return new Promise((resolve) => {
+ const tick = () => {
+ http.get(url, { timeout: 1000 }, (res) => {
+ res.resume();
+ resolve(res.statusCode >= 200 && res.statusCode < 500);
+ }).on('error', () => {
+ if (Date.now() - started >= timeoutMs) {
+ resolve(false);
+ return;
+ }
+ setTimeout(tick, 200);
+ }).on('timeout', () => {
+ if (Date.now() - started >= timeoutMs) {
+ resolve(false);
+ return;
+ }
+ setTimeout(tick, 200);
+ });
+ };
+ tick();
+ });
+}
+
+async function ensureClientUiServer(root) {
+ const healthUrl = `http://127.0.0.1:${CLIENT_UI_PORT}/health`;
+ if (await waitForHttp(healthUrl, 500)) return true;
+
+ const serverScript = path.join(root, 'tools/serve-client-ui.mjs');
+ if (!fs.existsSync(serverScript)) {
+ dialog.showErrorBox('OpenVibe Menu Server Missing', `Could not find:\n${serverScript}`);
+ return false;
+ }
+
+ uiServerProcess = spawn(process.execPath, [serverScript], {
+ cwd: root,
+ env: {
+ ...process.env,
+ OPENVIBE_ROOT: root,
+ OPENVIBE_CLIENT_UI_PORT: String(CLIENT_UI_PORT),
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ uiServerProcess.stdout.on('data', (d) => console.log('[client-ui]', d.toString().trim()));
+ uiServerProcess.stderr.on('data', (d) => console.error('[client-ui]', d.toString().trim()));
+ uiServerProcess.on('exit', (code) => {
+ console.log('[launcher] client UI server exited with code', code);
+ uiServerProcess = null;
+ });
+
+ return waitForHttp(healthUrl, 5000);
+}
+
// ── Game launch ────────────────────────────────────────────────────────────────
// ── Game launch ────────────────────────────────────────────────────────────────
-function launchGame(serverIp, serverPort) {
+async function launchGame(serverIp, serverPort) {
const root = path.resolve(__dirname, '..');
const root = path.resolve(__dirname, '..');
const mod = path.join(root, 'game/openvibe.games');
const mod = path.join(root, 'game/openvibe.games');
@@ -79,6 +137,13 @@ function launchGame(serverIp, serverPort) {
@@ -79,6 +137,13 @@ function launchGame(serverIp, serverPort) {
return false;
return false;
}
}
+ const uiReady = await ensureClientUiServer(root);
+ if (!uiReady) {
+ dialog.showErrorBox('OpenVibe Menu Server Failed',
+ `Could not start http://127.0.0.1:${CLIENT_UI_PORT}/client for the in-game HTML menu.`);
+ return false;
+ }
+
// Build arguments to pass through the shell script
// Build arguments to pass through the shell script
const scriptArgs = serverIp && serverPort ? [serverIp, String(serverPort)] : [];
const scriptArgs = serverIp && serverPort ? [serverIp, String(serverPort)] : [];
@@ -100,8 +165,15 @@ function launchGame(serverIp, serverPort) {
@@ -100,8 +165,15 @@ function launchGame(serverIp, serverPort) {
console.log('[launcher] game exited with code', code);
console.log('[launcher] game exited with code', code);
gameProcess = null;
gameProcess = null;
mainWindow?.webContents.send('game-exited', code);
mainWindow?.webContents.send('game-exited', code);
+ mainWindow?.show();
+ mainWindow?.focus();
});
});
+ mainWindow?.webContents.send('game-started', gameProcess.pid);
+ setTimeout(() => {
+ if (gameProcess && mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();
+ }, 750);
+
// Detach so the game keeps running if Electron is closed
// Detach so the game keeps running if Electron is closed
gameProcess.unref();
gameProcess.unref();
@@ -131,11 +203,11 @@ ipcMain.handle('api:travel', async (_e, { steamId, mode }) => {
@@ -131,11 +203,11 @@ ipcMain.handle('api:travel', async (_e, { steamId, mode }) => {
catch (e) { return { error: e.message }; }
catch (e) { return { error: e.message }; }
});
});
-ipcMain.handle('game:launch', (_e, { ip, port }) => {
+ipcMain.handle('game:launch', async (_e, { ip, port }) => {
return launchGame(ip, port);
return launchGame(ip, port);
});
});
-ipcMain.handle('game:launch-direct', (_e, mode) => {
+ipcMain.handle('game:launch-direct', async (_e, mode) => {
// Look up the mode's port from local server list
// Look up the mode's port from local server list
const serverMap = {
const serverMap = {
hub: { ip: '127.0.0.1', port: 27015 },
hub: { ip: '127.0.0.1', port: 27015 },
@@ -181,6 +253,13 @@ function createWindow() {
@@ -181,6 +253,13 @@ function createWindow() {
if (DEV) mainWindow.webContents.openDevTools({ mode: 'detach' });
if (DEV) mainWindow.webContents.openDevTools({ mode: 'detach' });
});
});
+ mainWindow.on('close', (event) => {
+ if (gameProcess && !appIsQuitting) {
+ event.preventDefault();
+ mainWindow.hide();
+ }
+ });
+
mainWindow.on('closed', () => {
mainWindow.on('closed', () => {
mainWindow = null;
mainWindow = null;
});
});
@@ -196,9 +275,18 @@ app.whenReady().then(() => {
@@ -196,9 +275,18 @@ app.whenReady().then(() => {
});
});
app.on('window-all-closed', () => {
app.on('window-all-closed', () => {
+ if (gameProcess) return;
if (process.platform !== 'darwin') app.quit();
if (process.platform !== 'darwin') app.quit();
});
});
+app.on('before-quit', () => {
+ appIsQuitting = true;
+ if (uiServerProcess) {
+ uiServerProcess.kill();
+ uiServerProcess = null;
+ }
+});
+
// Titlebar controls via IPC
// Titlebar controls via IPC
ipcMain.on('window:minimize', () => mainWindow?.minimize());
ipcMain.on('window:minimize', () => mainWindow?.minimize());
ipcMain.on('window:maximize', () => {
ipcMain.on('window:maximize', () => {
@@ -5,7 +5,8 @@
@@ -5,7 +5,8 @@
"main": "main.js",
"main": "main.js",
"scripts": {
"scripts": {
"start": "electron .",
"start": "electron .",
- "dev": "ELECTRON_IS_DEV=1 electron ."
+ "dev": "ELECTRON_IS_DEV=1 electron .",
+ "serve": "node ../tools/serve-client-ui.mjs"
},
},
"keywords": [],
"keywords": [],
"author": "OpenVibe",
"author": "OpenVibe",
@@ -21,5 +21,6 @@ contextBridge.exposeInMainWorld('OV', {
@@ -21,5 +21,6 @@ contextBridge.exposeInMainWorld('OV', {
openUrl: (url) => ipcRenderer.send('open-url', url),
openUrl: (url) => ipcRenderer.send('open-url', url),
// Events from main → renderer
// Events from main → renderer
+ onGameStart: (cb) => ipcRenderer.on('game-started', (_e, pid) => cb(pid)),
onGameExit: (cb) => ipcRenderer.on('game-exited', (_e, code) => cb(code)),
onGameExit: (cb) => ipcRenderer.on('game-exited', (_e, code) => cb(code)),
});
});
@@ -11,6 +11,13 @@ const MODES = {
@@ -11,6 +11,13 @@ const MODES = {
traitortown: { label: 'Traitor Town', port: 27019, color: '#a855f7' },
traitortown: { label: 'Traitor Town', port: 27019, color: '#a855f7' },
};
};
+const API_BASE = 'http://127.0.0.1:3000';
+const isElectron = !!window.OV;
+const isEmbedded = new URLSearchParams(window.location.search).get('embedded') === '1' || !isElectron;
+
+document.documentElement.classList.toggle('embedded', isEmbedded);
+document.body.classList.toggle('embedded', isEmbedded);
+
const PLAYER_IDS = {
const PLAYER_IDS = {
hub: 'hub-players', prophunt: 'ph-players',
hub: 'hub-players', prophunt: 'ph-players',
deathrun: 'dr-players', fortwars: 'fw-players', traitortown: 'tt-players',
deathrun: 'dr-players', fortwars: 'fw-players', traitortown: 'tt-players',
@@ -20,6 +27,45 @@ let currentTab = 'portal';
@@ -20,6 +27,45 @@ let currentTab = 'portal';
let serverData = [];
let serverData = [];
let apiOnline = false;
let apiOnline = false;
+async function apiFetch(path, options = {}) {
+ const res = await fetch(`${API_BASE}${path}`, {
+ ...options,
+ headers: {
+ 'Accept': 'application/json',
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
+ ...(options.headers || {}),
+ },
+ });
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+ return res.json();
+}
+
+const Bridge = {
+ health: () => isElectron ? window.OV.health() : apiFetch('/health'),
+ servers: async () => {
+ if (isElectron) return window.OV.servers();
+ const data = await apiFetch('/v1/servers');
+ return Array.isArray(data) ? data : (data.servers ?? []);
+ },
+ leaderboard: async (limit) => {
+ if (isElectron) return window.OV.leaderboard(limit);
+ const data = await apiFetch(`/v1/leaderboard?limit=${limit}`);
+ return Array.isArray(data) ? data : (data.players ?? []);
+ },
+ launchMode: async (mode) => {
+ if (isElectron) return window.OV.launchMode(mode);
+ window.location.href = `openvibe://join?mode=${encodeURIComponent(mode)}`;
+ return true;
+ },
+ gameStatus: () => isElectron ? window.OV.gameStatus() : Promise.resolve({ running: true, pid: null }),
+ close: () => {
+ if (isElectron) window.OV.close();
+ else window.location.href = 'openvibe://close';
+ },
+ minimize: () => { if (isElectron) window.OV.minimize(); },
+ maximize: () => { if (isElectron) window.OV.maximize(); },
+};
+
// ── Toast helper ─────────────────────────────────────────────────────────────
// ── Toast helper ─────────────────────────────────────────────────────────────
const toastEl = document.getElementById('toast');
const toastEl = document.getElementById('toast');
let toastTimer;
let toastTimer;
@@ -52,9 +98,10 @@ function setTab(tab) {
@@ -52,9 +98,10 @@ function setTab(tab) {
}
}
// ── Titlebar controls ────────────────────────────────────────────────────────
// ── Titlebar controls ────────────────────────────────────────────────────────
-document.getElementById('btn-min')?.addEventListener('click', () => window.OV.minimize());
-document.getElementById('btn-max')?.addEventListener('click', () => window.OV.maximize());
-document.getElementById('btn-close')?.addEventListener('click', () => window.OV.close());
+document.getElementById('btn-min')?.addEventListener('click', () => Bridge.minimize());
+document.getElementById('btn-max')?.addEventListener('click', () => Bridge.maximize());
+document.getElementById('btn-close')?.addEventListener('click', () => Bridge.close());
+document.getElementById('embedded-close')?.addEventListener('click', () => Bridge.close());
// ── Launch overlay ───────────────────────────────────────────────────────────
// ── Launch overlay ───────────────────────────────────────────────────────────
const launchOverlay = document.getElementById('launch-overlay');
const launchOverlay = document.getElementById('launch-overlay');
@@ -81,7 +128,7 @@ async function launchMode(mode) {
@@ -81,7 +128,7 @@ async function launchMode(mode) {
toast(`Connecting to ${info.label}…`);
toast(`Connecting to ${info.label}…`);
try {
try {
- const ok = await window.OV.launchMode(mode);
+ const ok = await Bridge.launchMode(mode);
if (ok) {
if (ok) {
toast(`✓ ${info.label} launched — port ${info.port}`);
toast(`✓ ${info.label} launched — port ${info.port}`);
updateGameStatus(true);
updateGameStatus(true);
@@ -115,11 +162,16 @@ document.getElementById('settings-launch')?.addEventListener('click', () => {
@@ -115,11 +162,16 @@ document.getElementById('settings-launch')?.addEventListener('click', () => {
});
});
// Game exit callback
// Game exit callback
-window.OV.onGameExit((code) => {
- hideLaunchOverlay();
- updateGameStatus(false);
- toast(`Game exited (code ${code})`);
-});
+if (isElectron) {
+ window.OV.onGameStart?.(() => {
+ updateGameStatus(true);
+ });
+ window.OV.onGameExit((code) => {
+ hideLaunchOverlay();
+ updateGameStatus(false);
+ toast(`Game exited (code ${code})`);
+ });
+}
// ── API health ────────────────────────────────────────────────────────────────
// ── API health ────────────────────────────────────────────────────────────────
const apiDot = document.getElementById('api-dot');
const apiDot = document.getElementById('api-dot');
@@ -128,7 +180,7 @@ const srvCount = document.getElementById('server-count');
@@ -128,7 +180,7 @@ const srvCount = document.getElementById('server-count');
async function checkApiHealth() {
async function checkApiHealth() {
try {
try {
- const data = await window.OV.health();
+ const data = await Bridge.health();
if (data.ok) {
if (data.ok) {
apiOnline = true;
apiOnline = true;
apiDot.className = 'status-dot up';
apiDot.className = 'status-dot up';
@@ -155,7 +207,7 @@ async function refreshServers() {
@@ -155,7 +207,7 @@ async function refreshServers() {
tbody.innerHTML = '<tr><td colspan="6" class="loading-row">Loading…</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" class="loading-row">Loading…</td></tr>';
try {
try {
- const servers = await window.OV.servers();
+ const servers = await Bridge.servers();
serverData = servers;
serverData = servers;
if (!servers || servers.length === 0) {
if (!servers || servers.length === 0) {
@@ -220,7 +272,7 @@ async function refreshLeaderboard() {
@@ -220,7 +272,7 @@ async function refreshLeaderboard() {
list.innerHTML = '<li class="loading-row">Loading…</li>';
list.innerHTML = '<li class="loading-row">Loading…</li>';
try {
try {
- const data = await window.OV.leaderboard(20);
+ const data = await Bridge.leaderboard(20);
if (!data || data.length === 0) {
if (!data || data.length === 0) {
list.innerHTML = '<li class="loading-row">No rankings yet — play some games!</li>';
list.innerHTML = '<li class="loading-row">No rankings yet — play some games!</li>';
@@ -262,7 +314,7 @@ async function init() {
@@ -262,7 +314,7 @@ async function init() {
// Load server player counts for portal cards
// Load server player counts for portal cards
try {
try {
- const servers = await window.OV.servers();
+ const servers = await Bridge.servers();
serverData = servers;
serverData = servers;
srvCount.textContent = `${servers.length} server${servers.length !== 1 ? 's' : ''}`;
srvCount.textContent = `${servers.length} server${servers.length !== 1 ? 's' : ''}`;
servers.forEach((s) => {
servers.forEach((s) => {
@@ -279,7 +331,7 @@ async function init() {
@@ -279,7 +331,7 @@ async function init() {
setInterval(async () => {
setInterval(async () => {
await checkApiHealth();
await checkApiHealth();
try {
try {
- const status = await window.OV.gameStatus();
+ const status = await Bridge.gameStatus();
updateGameStatus(status.running);
updateGameStatus(status.running);
} catch {}
} catch {}
}, 10_000);
}, 10_000);
@@ -120,6 +120,24 @@ html, body {
@@ -120,6 +120,24 @@ html, body {
.status-sep { color: var(--text-muted); }
.status-sep { color: var(--text-muted); }
.status-right { margin-left: auto; }
.status-right { margin-left: auto; }
+.embedded-close-btn {
+ display: none;
+ padding: 4px 10px;
+ background: rgba(255,255,255,.04);
+ border: 1px solid var(--border-lit);
+ border-radius: 6px;
+ color: var(--text-dim);
+ font-family: var(--font-ui);
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+}
+.embedded-close-btn:hover {
+ border-color: var(--accent);
+ color: var(--text);
+ background: rgba(0,200,255,.12);
+}
+
/* ── App layout ──────────────────────────────────────────────────── */
/* ── App layout ──────────────────────────────────────────────────── */
.app-layout {
.app-layout {
display: flex;
display: flex;
@@ -677,3 +695,61 @@ html, body {
@@ -677,3 +695,61 @@ html, body {
.ping-ok { color: var(--accent3); }
.ping-ok { color: var(--accent3); }
.ping-mid { color: #ffaa00; }
.ping-mid { color: #ffaa00; }
.ping-bad { color: var(--danger); }
.ping-bad { color: var(--danger); }
+
+/* ── Embedded Source browser mode ───────────────────────────────── */
+html.embedded body {
+ background:
+ linear-gradient(180deg, rgba(5,7,12,.97), rgba(7,8,16,.94)),
+ var(--bg-deep);
+}
+
+html.embedded .titlebar {
+ display: none;
+}
+
+html.embedded .statusbar {
+ height: 34px;
+ padding: 0 18px;
+ background: rgba(6, 8, 15, .9);
+ border-bottom-color: rgba(0,200,255,.22);
+}
+
+html.embedded .embedded-close-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+html.embedded .app-layout {
+ height: calc(100vh - 34px);
+}
+
+html.embedded .sidebar {
+ width: 210px;
+ min-width: 210px;
+ background: rgba(9, 10, 19, .84);
+ border-right-color: rgba(0,200,255,.18);
+}
+
+html.embedded .content {
+ background:
+ radial-gradient(ellipse at 18% 10%, rgba(0,200,255,.08), transparent 48%),
+ radial-gradient(ellipse at 88% 88%, rgba(6,255,165,.045), transparent 42%),
+ rgba(9, 10, 19, .76);
+}
+
+html.embedded .portal-card,
+html.embedded .shop-item,
+html.embedded .server-table,
+html.embedded .leaderboard-list li {
+ background: rgba(17, 18, 32, .82);
+ backdrop-filter: blur(10px);
+}
+
+html.embedded .portal-card {
+ border-radius: 8px;
+}
+
+html.embedded .hero-sub {
+ color: #8aa0b8;
+}
@@ -73,10 +73,10 @@ static bool OV_IsModeAllowed( const char *pszMode )
@@ -73,10 +73,10 @@ static bool OV_IsModeAllowed( const char *pszMode )
static const char *OV_LocalConnectForMode( const char *pszMode )
static const char *OV_LocalConnectForMode( const char *pszMode )
{
{
if ( !Q_stricmp( pszMode, "hub" ) ) return "127.0.0.1:27015";
if ( !Q_stricmp( pszMode, "hub" ) ) return "127.0.0.1:27015";
- if ( !Q_stricmp( pszMode, "prophunt" ) ) return "127.0.0.1:27016";
- if ( !Q_stricmp( pszMode, "deathrun" ) ) return "127.0.0.1:27017";
- if ( !Q_stricmp( pszMode, "fortwars" ) ) return "127.0.0.1:27018";
- if ( !Q_stricmp( pszMode, "traitortown" ) ) return "127.0.0.1:27019";
+ if ( !Q_stricmp( pszMode, "prophunt" ) ) return "127.0.0.2:27016";
+ if ( !Q_stricmp( pszMode, "deathrun" ) ) return "127.0.0.3:27017";
+ if ( !Q_stricmp( pszMode, "fortwars" ) ) return "127.0.0.4:27018";
+ if ( !Q_stricmp( pszMode, "traitortown" ) ) return "127.0.0.5:27019";
return "127.0.0.1:27015";
return "127.0.0.1:27015";
}
}
@@ -1,143 +1,373 @@
@@ -1,143 +1,373 @@
-// OpenVibe: Source VGUI portal panel.
+// OpenVibe: Source HTML menu host.
//
//
-// This is intentionally small and native. The Electron launcher is still the
-// rich desktop menu, while this panel gives the Source client an in-game
-// OpenVibe-first menu that can replace the normal server-browser workflow.
+// This is the only native menu shell the game needs. It embeds Steam's
+// CEF-backed vgui::HTML control full-screen, loads the OpenVibe web client,
+// and exposes a small allowlisted openvibe:// bridge back into Source.
#include "cbase.h"
#include "cbase.h"
#include "cdll_client_int.h"
#include "cdll_client_int.h"
#include "ienginevgui.h"
#include "ienginevgui.h"
-#include <vgui/IScheme.h>
#include <vgui/ISurface.h>
#include <vgui/ISurface.h>
-#include <vgui_controls/Button.h>
#include <vgui_controls/Frame.h>
#include <vgui_controls/Frame.h>
-#include <vgui_controls/Label.h>
+#include <vgui_controls/HTML.h>
+
+#include <ctype.h>
+#include <stdlib.h>
// memdbgon must be the last include file in a .cpp file.
// memdbgon must be the last include file in a .cpp file.
#include "tier0/memdbgon.h"
#include "tier0/memdbgon.h"
using namespace vgui;
using namespace vgui;
-class COpenVibeMenuPanel : public Frame
+static ConVar ov_menu_url(
+ "ov_menu_url",
+ "http://127.0.0.1:5173/client/?embedded=1",
+ FCVAR_ARCHIVE,
+ "OpenVibe HTML menu URL." );
+
+static ConVar ov_menu_auto_open(
+ "ov_menu_auto_open",
+ "1",
+ FCVAR_ARCHIVE,
+ "Automatically opens the OpenVibe HTML menu when the client UI initializes." );
+
+static ConVar ov_menu_allow_remote(
+ "ov_menu_allow_remote",
+ "1",
+ FCVAR_ARCHIVE,
+ "Allow loading https://openvibe.games in the embedded menu." );
+
+static bool OV_URLStartsWith( const char *pszUrl, const char *pszPrefix )
{
{
- DECLARE_CLASS_SIMPLE( COpenVibeMenuPanel, Frame );
+ return pszUrl && pszPrefix && !Q_strnicmp( pszUrl, pszPrefix, Q_strlen( pszPrefix ) );
+}
+
+static bool OV_IsAllowedWebURL( const char *pszUrl )
+{
+ if ( !pszUrl || !pszUrl[0] )
+ return false;
+
+ if ( OV_URLStartsWith( pszUrl, "about:blank" ) )
+ return true;
+
+ if ( OV_URLStartsWith( pszUrl, "http://127.0.0.1:" ) ||
+ OV_URLStartsWith( pszUrl, "http://localhost:" ) )
+ return true;
+
+ if ( ov_menu_allow_remote.GetBool() &&
+ ( OV_URLStartsWith( pszUrl, "https://openvibe.games/" ) ||
+ OV_URLStartsWith( pszUrl, "https://www.openvibe.games/" ) ) )
+ return true;
+
+ return false;
+}
+
+static bool OV_IsSafeMode( const char *pszMode )
+{
+ return pszMode &&
+ ( !Q_stricmp( pszMode, "hub" ) ||
+ !Q_stricmp( pszMode, "prophunt" ) ||
+ !Q_stricmp( pszMode, "deathrun" ) ||
+ !Q_stricmp( pszMode, "fortwars" ) ||
+ !Q_stricmp( pszMode, "traitortown" ) );
+}
+
+static void OV_URLDecodeInPlace( char *pszValue )
+{
+ if ( !pszValue )
+ return;
+
+ char *pszRead = pszValue;
+ char *pszWrite = pszValue;
+ while ( *pszRead )
+ {
+ if ( pszRead[0] == '%' && isxdigit( pszRead[1] ) && isxdigit( pszRead[2] ) )
+ {
+ char szHex[3] = { pszRead[1], pszRead[2], '\0' };
+ *pszWrite++ = (char)strtol( szHex, NULL, 16 );
+ pszRead += 3;
+ continue;
+ }
+
+ *pszWrite++ = ( pszRead[0] == '+' ) ? ' ' : pszRead[0];
+ ++pszRead;
+ }
+ *pszWrite = '\0';
+}
+
+static bool OV_ReadQueryValue( const char *pszUrl, const char *pszKey, char *pszOut, size_t nOut )
+{
+ if ( !pszUrl || !pszKey || !pszOut || nOut == 0 )
+ return false;
+
+ pszOut[0] = '\0';
+
+ char szNeedle[64];
+ Q_snprintf( szNeedle, sizeof( szNeedle ), "%s=", pszKey );
+
+ const char *pszQuery = Q_strstr( pszUrl, "?" );
+ if ( !pszQuery )
+ return false;
+
+ const char *pszCursor = pszQuery + 1;
+ while ( pszCursor && pszCursor[0] )
+ {
+ if ( !Q_strnicmp( pszCursor, szNeedle, Q_strlen( szNeedle ) ) )
+ {
+ pszCursor += Q_strlen( szNeedle );
+ const char *pszEnd = pszCursor;
+ while ( pszEnd[0] && pszEnd[0] != '&' && pszEnd[0] != '#' )
+ ++pszEnd;
+
+ const int nLen = MIN( (int)( pszEnd - pszCursor ), (int)nOut - 1 );
+ if ( nLen <= 0 )
+ return false;
+
+ Q_strncpy( pszOut, pszCursor, nLen + 1 );
+ pszOut[nLen] = '\0';
+ OV_URLDecodeInPlace( pszOut );
+ return true;
+ }
+
+ pszCursor = Q_strstr( pszCursor, "&" );
+ if ( pszCursor )
+ ++pszCursor;
+ }
+
+ return false;
+}
+
+class IOpenVibeHTMLBridge
+{
+public:
+ virtual bool OnOpenVibeHTMLStartRequest( const char *pszURL, const char *pszTarget, const char *pszPostData, bool bIsRedirect ) = 0;
+};
+
+class COpenVibeHTMLControl : public HTML
+{
+ DECLARE_CLASS_SIMPLE( COpenVibeHTMLControl, HTML );
+public:
+ COpenVibeHTMLControl( IOpenVibeHTMLBridge *pBridge, Panel *pParent, const char *pszName ) :
+ BaseClass( pParent, pszName, true ),
+ m_pBridge( pBridge )
+ {
+ }
+
+ bool OnStartRequest( const char *pszURL, const char *pszTarget, const char *pszPostData, bool bIsRedirect ) OVERRIDE
+ {
+ return m_pBridge ? m_pBridge->OnOpenVibeHTMLStartRequest( pszURL, pszTarget, pszPostData, bIsRedirect ) : false;
+ }
+
+private:
+ IOpenVibeHTMLBridge *m_pBridge;
+};
+
+class COpenVibeHTMLPanel : public Frame, public IOpenVibeHTMLBridge
+{
+ DECLARE_CLASS_SIMPLE( COpenVibeHTMLPanel, Frame );
public:
public:
- COpenVibeMenuPanel( VPANEL parent ) :
- BaseClass( NULL, "OpenVibeMenuPanel" )
+ COpenVibeHTMLPanel( VPANEL parent ) :
+ BaseClass( NULL, "OpenVibeHTMLPanel" )
{
{
SetParent( parent );
SetParent( parent );
- SetTitle( "OpenVibe: Source", true );
- SetSize( 760, 500 );
- SetMinimumSize( 640, 420 );
+ SetTitle( "OpenVibe: Source", false );
+ SetTitleBarVisible( false );
+ SetMinimizeButtonVisible( false );
+ SetMaximizeButtonVisible( false );
+ SetCloseButtonVisible( false );
SetSizeable( false );
SetSizeable( false );
- SetMoveable( true );
+ SetMoveable( false );
SetDeleteSelfOnClose( false );
SetDeleteSelfOnClose( false );
- SetBgColor( Color( 18, 20, 28, 245 ) );
+ SetPaintBackgroundEnabled( true );
+ SetBgColor( Color( 7, 8, 14, 255 ) );
+
+ m_pHTML = new COpenVibeHTMLControl( this, this, "OpenVibeHTML" );
+ m_pHTML->SetScrollbarsEnabled( false );
+ m_pHTML->SetContextMenuEnabled( false );
+ m_pHTML->NewWindowsOnly( false );
+ MakeFullScreen();
+ SetVisible( false );
+ }
+
+ void Open( const char *pszURL = NULL )
+ {
+ const char *pszTarget = ( pszURL && pszURL[0] ) ? pszURL : ov_menu_url.GetString();
+ MakeFullScreen();
+ SetVisible( true );
+ MoveToFront();
+ RequestFocus();
+ m_pHTML->RequestFocus();
+ m_pHTML->OpenURL( pszTarget, NULL, true );
+ engine->ClientCmd_Unrestricted( "gameui_hide\n" );
+ }
+
+ void CloseMenu()
+ {
+ SetVisible( false );
+ engine->ClientCmd_Unrestricted( "gameui_hide\n" );
+ }
+
+ void Reload()
+ {
+ if ( IsVisible() )
+ m_pHTML->Refresh();
+ }
+
+ void RunJS( const char *pszScript )
+ {
+ if ( pszScript && pszScript[0] )
+ m_pHTML->RunJavascript( pszScript );
+ }
+
+ void OnSizeChanged( int wide, int tall ) OVERRIDE
+ {
+ BaseClass::OnSizeChanged( wide, tall );
+ if ( m_pHTML )
+ m_pHTML->SetBounds( 0, 0, wide, tall );
+ }
+
+ void PerformLayout() OVERRIDE
+ {
+ BaseClass::PerformLayout();
+ MakeFullScreen();
+ }
+
+ bool OnOpenVibeHTMLStartRequest( const char *pszURL, const char *pszTarget, const char *pszPostData, bool bIsRedirect ) OVERRIDE
+ {
+ if ( OV_URLStartsWith( pszURL, "openvibe://" ) )
+ {
+ HandleBridgeURL( pszURL );
+ return false;
+ }
+
+ if ( !OV_IsAllowedWebURL( pszURL ) )
+ {
+ Warning( "[OV HTML] Blocked navigation to %s\n", pszURL ? pszURL : "<null>" );
+ return false;
+ }
+
+ return true;
+ }
+
+private:
+ void MakeFullScreen()
+ {
int sw = 0;
int sw = 0;
int sh = 0;
int sh = 0;
surface()->GetScreenSize( sw, sh );
surface()->GetScreenSize( sw, sh );
- SetPos( ( sw - GetWide() ) / 2, ( sh - GetTall() ) / 2 );
-
- Label *pTitle = new Label( this, "OpenVibeTitle", "OpenVibe.Games" );
- pTitle->SetPos( 28, 36 );
- pTitle->SetSize( 420, 32 );
- pTitle->SetFgColor( Color( 0, 210, 255, 255 ) );
-
- Label *pSubtitle = new Label( this, "OpenVibeSubtitle", "Choose a hub or minigame server. Travel is routed through the OpenVibe backend." );
- pSubtitle->SetPos( 28, 70 );
- pSubtitle->SetSize( 680, 24 );
- pSubtitle->SetFgColor( Color( 220, 230, 240, 255 ) );
-
- AddModeButton( "Hub", "Social lobby, portals, NPCs, shops", "hub", 28, 120 );
- AddModeButton( "Prop Hunt", "Props disguise and hide from hunters", "prophunt", 390, 120 );
- AddModeButton( "Deathrun", "Runners survive traps and reach the finish", "deathrun", 28, 220 );
- AddModeButton( "Fort Wars", "Build phase, prop forts, combat phase", "fortwars", 390, 220 );
- AddModeButton( "Traitor Town", "Social deduction with traitors and innocents", "traitortown", 28, 320 );
-
- Button *pWebsite = new Button( this, "OpenWebsite", "Open openvibe.games", this, "website" );
- pWebsite->SetPos( 390, 320 );
- pWebsite->SetSize( 320, 44 );
-
- Button *pClose = new Button( this, "Close", "Close", this, "close" );
- pClose->SetPos( 590, 430 );
- pClose->SetSize( 120, 36 );
+ SetBounds( 0, 0, sw, sh );
+ if ( m_pHTML )
+ m_pHTML->SetBounds( 0, 0, sw, sh );
}
}
- void OnCommand( const char *pszCommand ) OVERRIDE
+ void HandleBridgeURL( const char *pszURL )
{
{
- if ( !Q_strncmp( pszCommand, "join:", 5 ) )
+ if ( OV_URLStartsWith( pszURL, "openvibe://join" ) )
{
{
- engine->ClientCmd_Unrestricted( VarArgs( "gameui_hide\nov_join %s\n", pszCommand + 5 ) );
- SetVisible( false );
+ char szMode[64];
+ if ( OV_ReadQueryValue( pszURL, "mode", szMode, sizeof( szMode ) ) && OV_IsSafeMode( szMode ) )
+ {
+ CloseMenu();
+ engine->ClientCmd_Unrestricted( VarArgs( "ov_join %s\n", szMode ) );
+ return;
+ }
+ }
+
+ if ( OV_URLStartsWith( pszURL, "openvibe://close" ) )
+ {
+ CloseMenu();
return;
return;
}
}
- if ( !Q_stricmp( pszCommand, "website" ) )
+ if ( OV_URLStartsWith( pszURL, "openvibe://reload" ) )
{
{
- engine->ClientCmd_Unrestricted( "ov_open_url https://openvibe.games\n" );
+ Reload();
return;
return;
}
}
- if ( !Q_stricmp( pszCommand, "close" ) )
+ if ( OV_URLStartsWith( pszURL, "openvibe://external" ) )
{
{
- SetVisible( false );
+ char szUrl[512];
+ if ( OV_ReadQueryValue( pszURL, "url", szUrl, sizeof( szUrl ) ) && OV_IsAllowedWebURL( szUrl ) )
+ engine->ClientCmd_Unrestricted( VarArgs( "ov_open_url %s\n", szUrl ) );
return;
return;
}
}
- BaseClass::OnCommand( pszCommand );
+ Warning( "[OV HTML] Ignored bridge URL: %s\n", pszURL ? pszURL : "<null>" );
}
}
-private:
- void AddModeButton( const char *pszName, const char *pszDescription, const char *pszMode, int x, int y )
- {
- char szButtonName[64];
- char szCommand[64];
- char szDescriptionName[64];
- Q_snprintf( szButtonName, sizeof( szButtonName ), "%sButton", pszMode );
- Q_snprintf( szCommand, sizeof( szCommand ), "join:%s", pszMode );
- Q_snprintf( szDescriptionName, sizeof( szDescriptionName ), "%sDescription", pszMode );
-
- Button *pButton = new Button( this, szButtonName, pszName, this, szCommand );
- pButton->SetPos( x, y );
- pButton->SetSize( 320, 48 );
-
- Label *pDescription = new Label( this, szDescriptionName, pszDescription );
- pDescription->SetPos( x + 2, y + 52 );
- pDescription->SetSize( 320, 26 );
- pDescription->SetFgColor( Color( 185, 195, 205, 255 ) );
- }
+ HTML *m_pHTML;
};
};
-static COpenVibeMenuPanel *s_pOpenVibeMenu = NULL;
+static COpenVibeHTMLPanel *s_pOpenVibeMenu = NULL;
-static void OV_OpenMenu()
+static COpenVibeHTMLPanel *OV_GetHTMLMenu()
{
{
if ( !s_pOpenVibeMenu )
if ( !s_pOpenVibeMenu )
- {
- s_pOpenVibeMenu = new COpenVibeMenuPanel( enginevgui->GetPanel( PANEL_GAMEUIDLL ) );
- }
+ s_pOpenVibeMenu = new COpenVibeHTMLPanel( enginevgui->GetPanel( PANEL_GAMEUIDLL ) );
- s_pOpenVibeMenu->SetVisible( true );
- s_pOpenVibeMenu->MoveToFront();
- s_pOpenVibeMenu->RequestFocus();
+ return s_pOpenVibeMenu;
}
}
static void OV_OpenMenu_f( const CCommand &args )
static void OV_OpenMenu_f( const CCommand &args )
{
{
- OV_OpenMenu();
+ const char *pszURL = args.ArgC() >= 2 ? args[1] : NULL;
+ OV_GetHTMLMenu()->Open( pszURL );
+}
+
+static void OV_CloseMenu_f( const CCommand &args )
+{
+ if ( s_pOpenVibeMenu )
+ s_pOpenVibeMenu->CloseMenu();
+}
+
+static void OV_ReloadMenu_f( const CCommand &args )
+{
+ if ( s_pOpenVibeMenu )
+ s_pOpenVibeMenu->Reload();
+}
+
+static void OV_MenuJS_f( const CCommand &args )
+{
+ if ( s_pOpenVibeMenu && args.ArgC() >= 2 )
+ s_pOpenVibeMenu->RunJS( args.ArgS() );
+}
+
+void OpenVibe_OnClientModeInit()
+{
+ if ( ov_menu_auto_open.GetBool() )
+ OV_GetHTMLMenu()->Open();
}
}
static ConCommand openvibe_menu_cmd(
static ConCommand openvibe_menu_cmd(
"openvibe_menu",
"openvibe_menu",
OV_OpenMenu_f,
OV_OpenMenu_f,
- "Open the OpenVibe hub/minigame selection menu.",
+ "Open the OpenVibe embedded HTML menu.",
FCVAR_CLIENTDLL );
FCVAR_CLIENTDLL );
static ConCommand ov_menu_cmd(
static ConCommand ov_menu_cmd(
"ov_menu",
"ov_menu",
OV_OpenMenu_f,
OV_OpenMenu_f,
- "Open the OpenVibe hub/minigame selection menu.",
+ "Open the OpenVibe embedded HTML menu.",
+ FCVAR_CLIENTDLL );
+
+static ConCommand ov_menu_close_cmd(
+ "ov_menu_close",
+ OV_CloseMenu_f,
+ "Close the OpenVibe embedded HTML menu.",
+ FCVAR_CLIENTDLL );
+
+static ConCommand ov_menu_reload_cmd(
+ "ov_menu_reload",
+ OV_ReloadMenu_f,
+ "Reload the OpenVibe embedded HTML menu.",
+ FCVAR_CLIENTDLL );
+
+static ConCommand ov_menu_js_cmd(
+ "ov_menu_js",
+ OV_MenuJS_f,
+ "Run JavaScript in the OpenVibe embedded HTML menu.",
FCVAR_CLIENTDLL );
FCVAR_CLIENTDLL );
@@ -27,6 +27,7 @@ copy_file "$ROOT/sdk/openvibe/server/hl2mp/openvibe_server.cpp" \
@@ -27,6 +27,7 @@ copy_file "$ROOT/sdk/openvibe/server/hl2mp/openvibe_server.cpp" \
CLIENT_VPC="$SDK/src/game/client/client_hl2mp.vpc"
CLIENT_VPC="$SDK/src/game/client/client_hl2mp.vpc"
SERVER_VPC="$SDK/src/game/server/server_hl2mp.vpc"
SERVER_VPC="$SDK/src/game/server/server_hl2mp.vpc"
HL2MP_CLIENT="$SDK/src/game/server/hl2mp/hl2mp_client.cpp"
HL2MP_CLIENT="$SDK/src/game/server/hl2mp/hl2mp_client.cpp"
+HL2MP_CLIENTMODE="$SDK/src/game/client/hl2mp/clientmode_hl2mpnormal.cpp"
perl -0pi -e 's/^.*hl2mp\\openvibe_client\.cpp.*\n//mg; s/^.*hl2mp\\vgui_openvibe_menu\.cpp.*\n//mg; s/(\$File\s+"hl2mp\\clientmode_hl2mpnormal\.h"\n)/$1\t\t\t\$File\t"hl2mp\\openvibe_client.cpp"\n\t\t\t\$File\t"hl2mp\\vgui_openvibe_menu.cpp"\n/s' "$CLIENT_VPC"
perl -0pi -e 's/^.*hl2mp\\openvibe_client\.cpp.*\n//mg; s/^.*hl2mp\\vgui_openvibe_menu\.cpp.*\n//mg; s/(\$File\s+"hl2mp\\clientmode_hl2mpnormal\.h"\n)/$1\t\t\t\$File\t"hl2mp\\openvibe_client.cpp"\n\t\t\t\$File\t"hl2mp\\vgui_openvibe_menu.cpp"\n/s' "$CLIENT_VPC"
echo "[openvibe-sdk] patched client_hl2mp.vpc"
echo "[openvibe-sdk] patched client_hl2mp.vpc"
@@ -42,4 +43,12 @@ fi
@@ -42,4 +43,12 @@ fi
perl -0pi -e 's/[^\S\r\n]*\x0boid OpenVibe_OnClientActive/void OpenVibe_OnClientActive/g; s/\\tOpenVibe_OnClientActive/\tOpenVibe_OnClientActive/g; s/^[ \t]*void OpenVibe_OnClientActive/void OpenVibe_OnClientActive/m' "$HL2MP_CLIENT"
perl -0pi -e 's/[^\S\r\n]*\x0boid OpenVibe_OnClientActive/void OpenVibe_OnClientActive/g; s/\\tOpenVibe_OnClientActive/\tOpenVibe_OnClientActive/g; s/^[ \t]*void OpenVibe_OnClientActive/void OpenVibe_OnClientActive/m' "$HL2MP_CLIENT"
+if ! grep -q 'OpenVibe_OnClientModeInit' "$HL2MP_CLIENTMODE"; then
+ sed -i '/#include "ienginevgui.h"/a void OpenVibe_OnClientModeInit();' "$HL2MP_CLIENTMODE"
+ sed -i '/BaseClass::Init();/a \ OpenVibe_OnClientModeInit();' "$HL2MP_CLIENTMODE"
+ echo "[openvibe-sdk] patched clientmode_hl2mpnormal.cpp OpenVibe menu hook"
+fi
+
+perl -0pi -e 's/^[ \t]*tOpenVibe_OnClientModeInit\(\);/ OpenVibe_OnClientModeInit();/m' "$HL2MP_CLIENTMODE"
+
echo "[openvibe-sdk] Source SDK OpenVibe patch applied"
echo "[openvibe-sdk] Source SDK OpenVibe patch applied"
@@ -6,6 +6,7 @@ MOD="$ROOT/game/openvibe.games"
@@ -6,6 +6,7 @@ MOD="$ROOT/game/openvibe.games"
sessions=(
sessions=(
ov-api
ov-api
+ ov-client-ui
ov-hub
ov-hub
ov-prophunt
ov-prophunt
ov-deathrun
ov-deathrun
@@ -35,6 +36,7 @@ kill_matching() {
@@ -35,6 +36,7 @@ kill_matching() {
kill_matching TERM "srcds_linux64 -game $MOD"
kill_matching TERM "srcds_linux64 -game $MOD"
kill_matching TERM "$ROOT/tools/ov-sidecar.mjs"
kill_matching TERM "$ROOT/tools/ov-sidecar.mjs"
kill_matching TERM "$ROOT/tools/dev-api.sh"
kill_matching TERM "$ROOT/tools/dev-api.sh"
+kill_matching TERM "$ROOT/tools/serve-client-ui.mjs"
kill_matching TERM "$ROOT/backend/node_modules/.bin/tsx watch src/index.ts"
kill_matching TERM "$ROOT/backend/node_modules/.bin/tsx watch src/index.ts"
sleep 1
sleep 1
@@ -42,6 +44,7 @@ sleep 1
@@ -42,6 +44,7 @@ sleep 1
kill_matching KILL "srcds_linux64 -game $MOD"
kill_matching KILL "srcds_linux64 -game $MOD"
kill_matching KILL "$ROOT/tools/ov-sidecar.mjs"
kill_matching KILL "$ROOT/tools/ov-sidecar.mjs"
kill_matching KILL "$ROOT/tools/dev-api.sh"
kill_matching KILL "$ROOT/tools/dev-api.sh"
+kill_matching KILL "$ROOT/tools/serve-client-ui.mjs"
kill_matching KILL "$ROOT/backend/node_modules/.bin/tsx watch src/index.ts"
kill_matching KILL "$ROOT/backend/node_modules/.bin/tsx watch src/index.ts"
"$ROOT/tools/dev-db-down.sh"
"$ROOT/tools/dev-db-down.sh"
@@ -13,8 +13,12 @@ fi
@@ -13,8 +13,12 @@ fi
if tmux has-session -t ov-api 2>/dev/null; then
if tmux has-session -t ov-api 2>/dev/null; then
tmux kill-session -t ov-api
tmux kill-session -t ov-api
fi
fi
+if tmux has-session -t ov-client-ui 2>/dev/null; then
+ tmux kill-session -t ov-client-ui
+fi
tmux new-session -d -s ov-api "$ROOT/tools/dev-api.sh"
tmux new-session -d -s ov-api "$ROOT/tools/dev-api.sh"
+tmux new-session -d -s ov-client-ui "$ROOT/tools/run-client-ui.sh"
echo "[openvibe] waiting for api"
echo "[openvibe] waiting for api"
for _ in {1..60}; do
for _ in {1..60}; do
new file mode 100755
new file mode 100755
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="${OPENVIBE_ROOT:-$HOME/src/openvibe-source}"
+export OPENVIBE_ROOT="$ROOT"
+export OPENVIBE_CLIENT_UI_HOST="${OPENVIBE_CLIENT_UI_HOST:-127.0.0.1}"
+export OPENVIBE_CLIENT_UI_PORT="${OPENVIBE_CLIENT_UI_PORT:-5173}"
+
+exec node "$ROOT/tools/serve-client-ui.mjs"
new file mode 100755
new file mode 100755
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+import { createReadStream, existsSync, statSync } from 'node:fs';
+import { createServer } from 'node:http';
+import { extname, join, normalize, resolve, sep } from 'node:path';
+
+const root = resolve(process.env.OPENVIBE_ROOT || join(process.env.HOME || '.', 'src/openvibe-source'));
+const launcherRoot = join(root, 'launcher');
+const host = process.env.OPENVIBE_CLIENT_UI_HOST || '127.0.0.1';
+const port = Number(process.env.OPENVIBE_CLIENT_UI_PORT || 5173);
+
+const mime = {
+ '.html': 'text/html; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.svg': 'image/svg+xml',
+ '.ico': 'image/x-icon',
+ '.woff': 'font/woff',
+ '.woff2': 'font/woff2',
+ '.ttf': 'font/ttf',
+};
+
+function send(res, status, body, type = 'text/plain; charset=utf-8') {
+ res.writeHead(status, {
+ 'Content-Type': type,
+ 'Cache-Control': 'no-store',
+ 'X-Content-Type-Options': 'nosniff',
+ });
+ res.end(body);
+}
+
+function sendHeaders(res, status, type) {
+ res.writeHead(status, {
+ 'Content-Type': type,
+ 'Cache-Control': 'no-store',
+ 'X-Content-Type-Options': 'nosniff',
+ });
+ res.end();
+}
+
+function fileForUrl(url) {
+ const parsed = new URL(url, `http://${host}:${port}`);
+ let pathname = decodeURIComponent(parsed.pathname);
+
+ if (pathname === '/' || pathname === '/client' || pathname === '/client/') {
+ return join(launcherRoot, 'index.html');
+ }
+
+ if (!pathname.startsWith('/client/')) {
+ if (pathname === '/styles.css' || pathname === '/renderer.js' || pathname === '/preload.js') {
+ return join(launcherRoot, pathname.slice(1));
+ }
+ if (pathname.startsWith('/assets/')) {
+ const asset = normalize(join(launcherRoot, pathname.slice(1)));
+ return asset.startsWith(launcherRoot + sep) ? asset : null;
+ }
+ return null;
+ }
+
+ pathname = pathname.slice('/client/'.length);
+ const file = normalize(join(launcherRoot, pathname));
+ if (!file.startsWith(launcherRoot + sep)) {
+ return null;
+ }
+
+ return file;
+}
+
+const server = createServer((req, res) => {
+ if (!req.url || (req.method !== 'GET' && req.method !== 'HEAD')) {
+ send(res, 405, 'Method not allowed');
+ return;
+ }
+
+ if (req.url === '/health') {
+ if (req.method === 'HEAD') {
+ sendHeaders(res, 200, 'application/json; charset=utf-8');
+ return;
+ }
+ send(res, 200, JSON.stringify({ ok: true, service: 'openvibe-client-ui' }), 'application/json; charset=utf-8');
+ return;
+ }
+
+ const file = fileForUrl(req.url);
+ if (!file || !existsSync(file) || !statSync(file).isFile()) {
+ send(res, 404, 'Not found');
+ return;
+ }
+
+ if (req.method === 'HEAD') {
+ sendHeaders(res, 200, mime[extname(file).toLowerCase()] || 'application/octet-stream');
+ return;
+ }
+
+ res.writeHead(200, {
+ 'Content-Type': mime[extname(file).toLowerCase()] || 'application/octet-stream',
+ 'Cache-Control': 'no-store',
+ 'X-Content-Type-Options': 'nosniff',
+ });
+ createReadStream(file).pipe(res);
+});
+
+server.listen(port, host, () => {
+ console.log(`[openvibe-ui] serving ${launcherRoot} at http://${host}:${port}/client`);
+});