openvibers/source.openvibe.games Commits

Improve launcher loading guard and document client DLL mode

CO Copilot committed 29 Jun 2026 · 74ada32afa · 12 files, +623 −10


Changes

ddiff --git a/docs/CLIENT_DLL_AND_LOADING.md b/docs/CLIENT_DLL_AND_LOADING.md
nnew file mode 100644
iindex 0000000..aa5448c
---- /dev/null
++++ b/docs/CLIENT_DLL_AND_LOADING.md
@@@ -0,0 +1,13 @@
++# OpenVibe client DLL + loading screen notes
++
++The Proton launcher runs Windows `hl2.exe`. That Windows executable will not load the Linux
++`game/openvibe.games/bin/linux64/client.so` produced by the current Source SDK Linux build.
++
++That means native client commands such as `ov_join`, `ov_menu`, `ov_ui`, and the in-game
++CEF/HTML menu only exist when a matching client DLL is actually loaded by the game client.
++Until we build a Windows `client.dll` or switch to a native Linux client path, the Electron
++launcher is the reliable custom Chromium shell.
++
++This phase keeps the Electron launcher visible during Source startup, shows a clear launch
++state, and focuses the Source window only after it has appeared and stayed stable for a few
++seconds. It also installs best-effort Source resource overrides for the classic loading dialog.
ddiff --git a/game/openvibe.games/cfg/autoexec.cfg b/game/openvibe.games/cfg/autoexec.cfg
iindex 9ab1d37..a75954c 100644
---- a/game/openvibe.games/cfg/autoexec.cfg
++++ b/game/openvibe.games/cfg/autoexec.cfg
@@@ -1,4 +1,8 @@
--// OpenVibe: Source client startup config.
--//
--// 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.
++// OpenVibe: Source client defaults
++con_enable 1
++developer 1
++con_timestamp 1
++cl_showfps 0
++
++// These are server-side commands unless a matching client DLL is loaded.
++echo "[OpenVibe] autoexec loaded. If ov_join/ov_menu are unknown, the custom client DLL is not loaded."
ddiff --git a/game/openvibe.games/resource/LoadingDialog.res b/game/openvibe.games/resource/LoadingDialog.res
nnew file mode 100644
iindex 0000000..a736eb4
---- /dev/null
++++ b/game/openvibe.games/resource/LoadingDialog.res
@@@ -0,0 +1,52 @@
++"Resource/LoadingDialog.res"
++{
++ "LoadingDialog"
++ {
++ "ControlName" "Frame"
++ "fieldName" "LoadingDialog"
++ "xpos" "0"
++ "ypos" "0"
++ "wide" "f0"
++ "tall" "f0"
++ "autoResize" "0"
++ "pinCorner" "0"
++ "visible" "1"
++ "enabled" "1"
++ "tabPosition" "0"
++ "settitlebarvisible" "0"
++ "PaintBackgroundType" "2"
++ "bgcolor_override" "7 8 14 255"
++ }
++
++ "OpenVibeTitle"
++ {
++ "ControlName" "Label"
++ "fieldName" "OpenVibeTitle"
++ "xpos" "c-260"
++ "ypos" "c-72"
++ "wide" "520"
++ "tall" "42"
++ "visible" "1"
++ "enabled" "1"
++ "labelText" "OPENVIBE: SOURCE"
++ "textAlignment" "center"
++ "font" "DefaultVeryLarge"
++ "fgcolor_override" "0 220 255 255"
++ }
++
++ "OpenVibeStatus"
++ {
++ "ControlName" "Label"
++ "fieldName" "OpenVibeStatus"
++ "xpos" "c-300"
++ "ypos" "c-20"
++ "wide" "600"
++ "tall" "36"
++ "visible" "1"
++ "enabled" "1"
++ "labelText" "Loading OpenVibe shell, servers, and scripts..."
++ "textAlignment" "center"
++ "font" "DefaultLarge"
++ "fgcolor_override" "220 235 255 255"
++ }
++}
ddiff --git a/game/openvibe.games/resource/LoadingProgress.res b/game/openvibe.games/resource/LoadingProgress.res
nnew file mode 100644
iindex 0000000..2347889
---- /dev/null
++++ b/game/openvibe.games/resource/LoadingProgress.res
@@@ -0,0 +1,17 @@
++"Resource/LoadingProgress.res"
++{
++ "LoadingProgress"
++ {
++ "ControlName" "Frame"
++ "fieldName" "LoadingProgress"
++ "xpos" "0"
++ "ypos" "0"
++ "wide" "f0"
++ "tall" "f0"
++ "visible" "1"
++ "enabled" "1"
++ "settitlebarvisible" "0"
++ "PaintBackgroundType" "2"
++ "bgcolor_override" "7 8 14 255"
++ }
++}
ddiff --git a/launcher/index.html b/launcher/index.html
iindex b6590e3..e0f0dc8 100644
---- a/launcher/index.html
++++ b/launcher/index.html
@@@ -278,7 +278,11 @@
<div class="launch-overlay" id="launch-overlay">
<div class="launch-spinner"></div>
<div class="launch-label" id="launch-label">Launching…</div>
-- <button class="launch-cancel" id="launch-cancel">Cancel</button>
++ <div class="launch-help" id="launch-help">OpenVibe will keep this launcher visible until Source is actually ready.</div>
++ <div class="launch-actions">
++ <button class="launch-cancel" id="launch-focus-game">Focus Game Window</button>
++ <button class="launch-cancel" id="launch-cancel">Dismiss</button>
++ </div>
</div>
<script src="renderer.js"></script>
ddiff --git a/launcher/main.js b/launcher/main.js
iindex 36dfe3e..f72824a 100644
---- a/launcher/main.js
++++ b/launcher/main.js
@@@ -18,6 +18,67 @@ let gameProcess = null;
let uiServerProcess = null;
let appIsQuitting = false;
++
++const GAME_WINDOW_TITLE = process.env.OPENVIBE_GAME_WINDOW_TITLE || 'OpenVibe: Source';
++const GAME_READY_TIMEOUT_MS = Number(process.env.OPENVIBE_GAME_WINDOW_READY_TIMEOUT_MS || 45000);
++const GAME_STABLE_MS = Number(process.env.OPENVIBE_GAME_WINDOW_STABLE_MS || 8000);
++const HIDE_LAUNCHER_ON_GAME_READY = process.env.OPENVIBE_HIDE_LAUNCHER_ON_GAME_READY === '1';
++
++function sleep(ms) {
++ return new Promise((resolve) => setTimeout(resolve, ms));
++}
++
++function emitLaunchPhase(phase, message, extra = {}) {
++ if (mainWindow && !mainWindow.isDestroyed()) {
++ mainWindow.webContents.send('game-launch-phase', { phase, message, ...extra });
++ }
++}
++
++function safeExec(command) {
++ try {
++ return execSync(command, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
++ } catch {
++ return '';
++ }
++}
++
++function findGameWindowId() {
++ // Linux desktop helper. If xdotool is not installed, the launcher simply stays visible.
++ const escaped = GAME_WINDOW_TITLE.replace(/"/g, '\\"');
++ const out = safeExec(`xdotool search --name "${escaped}" 2>/dev/null | tail -n 1`);
++ return out || null;
++}
++
++function focusGameWindow() {
++ const win = findGameWindowId();
++ if (!win) return false;
++ safeExec(`xdotool windowactivate ${win} 2>/dev/null || true`);
++ return true;
++}
++
++async function waitForGameWindowReady(timeoutMs = GAME_READY_TIMEOUT_MS) {
++ const started = Date.now();
++ let firstSeen = 0;
++
++ while (Date.now() - started < timeoutMs) {
++ const win = findGameWindowId();
++ if (win) {
++ if (!firstSeen) {
++ firstSeen = Date.now();
++ emitLaunchPhase('window-found', 'Source window found. Waiting for it to stop thrashing before switching focus...', { windowId: win });
++ }
++
++ if (Date.now() - firstSeen >= GAME_STABLE_MS) {
++ return { ready: true, windowId: win, reason: 'window-stable' };
++ }
++ }
++
++ await sleep(500);
++ }
++
++ return { ready: false, windowId: findGameWindowId(), reason: 'timeout' };
++}
++
// ── API helper ────────────────────────────────────────────────────────────────
function apiGet(urlPath) {
return new Promise((resolve, reject) => {
@@@ -179,10 +240,45 @@ async function launchGame(serverIp, serverPort) {
mainWindow?.focus();
});
++ emitLaunchPhase(
++ 'spawned',
++ 'Source has started. OpenVibe will keep this launcher visible until the game window is detected and stable.'
++ );
mainWindow?.webContents.send('game-started', gameProcess.pid);
-- setTimeout(() => {
-- if (gameProcess && mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();
-- }, 750);
++
++ waitForGameWindowReady().then((state) => {
++ if (!gameProcess) return;
++
++ if (state.ready) {
++ emitLaunchPhase(
++ 'ready',
++ HIDE_LAUNCHER_ON_GAME_READY
++ ? 'Source window looks ready. Focusing game and hiding launcher.'
++ : 'Source window looks ready. Launcher will stay open so you are not stuck behind a frozen loading screen.',
++ state
++ );
++ focusGameWindow();
++
++ if (HIDE_LAUNCHER_ON_GAME_READY) {
++ if (mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();
++ } else {
++ if (mainWindow && !mainWindow.isDestroyed()) {
++ mainWindow.show();
++ mainWindow.focus();
++ }
++ }
++ } else {
++ emitLaunchPhase(
++ 'slow',
++ 'Source is taking longer than expected. The launcher is staying visible; use Focus Game when the window appears.',
++ state
++ );
++ if (mainWindow && !mainWindow.isDestroyed()) {
++ mainWindow.show();
++ mainWindow.focus();
++ }
++ }
++ });
// Detach so the game keeps running if Electron is closed
gameProcess.unref();
@@@ -235,6 +331,19 @@ ipcMain.handle('game:status', () => ({
pid: gameProcess?.pid ?? null,
}));
++
++ipcMain.handle('game:focus', async () => {
++ return focusGameWindow();
++});
++
++ipcMain.handle('launcher:show', async () => {
++ if (mainWindow && !mainWindow.isDestroyed()) {
++ mainWindow.show();
++ mainWindow.focus();
++ }
++ return true;
++});
++
ipcMain.on('open-url', (_e, url) => shell.openExternal(url));
ipcMain.on('ui:set-route', (_e, route) => {
ddiff --git a/launcher/preload.js b/launcher/preload.js
iindex 0dbdd68..5b6e3e7 100644
---- a/launcher/preload.js
++++ b/launcher/preload.js
@@@ -13,6 +13,8 @@ contextBridge.exposeInMainWorld('OV', {
launchGame: (ip, port) => ipcRenderer.invoke('game:launch', { ip, port }),
launchMode: (mode) => ipcRenderer.invoke('game:launch-direct', mode),
gameStatus: () => ipcRenderer.invoke('game:status'),
++ focusGame: () => ipcRenderer.invoke('game:focus'),
++ showLauncher: () => ipcRenderer.invoke('launcher:show'),
// Window controls
minimize: () => ipcRenderer.send('window:minimize'),
@@@ -23,6 +25,7 @@ contextBridge.exposeInMainWorld('OV', {
// Events from main → renderer
onGameStart: (cb) => ipcRenderer.on('game-started', (_e, pid) => cb(pid)),
++ onLaunchPhase: (cb) => ipcRenderer.on('game-launch-phase', (_e, info) => cb(info)),
onGameExit: (cb) => ipcRenderer.on('game-exited', (_e, code) => cb(code)),
onRoute: (cb) => ipcRenderer.on('ui:set-route', (_e, route) => cb(route)),
});
ddiff --git a/launcher/renderer.js b/launcher/renderer.js
iindex 1964a62..3c8cdae 100644
---- a/launcher/renderer.js
++++ b/launcher/renderer.js
@@@ -58,6 +58,7 @@ const Bridge = {
return true;
},
gameStatus: () => isElectron ? window.OV.gameStatus() : Promise.resolve({ running: true, pid: null }),
++ focusGame: () => isElectron ? window.OV.focusGame() : Promise.resolve(false),
close: () => {
if (isElectron) window.OV.close();
else window.location.href = 'openvibe://close';
@@@ -111,6 +112,11 @@ document.getElementById('launch-cancel')?.addEventListener('click', () => {
launchOverlay.classList.remove('show');
});
++document.getElementById('launch-focus-game')?.addEventListener('click', async () => {
++ const ok = await Bridge.focusGame();
++ toast(ok ? 'Focused Source window.' : 'Source window not found yet.', !ok);
++});
++
function showLaunchOverlay(msg) {
launchLabel.textContent = msg;
launchOverlay.classList.add('show');
@@@ -166,6 +172,21 @@ if (isElectron) {
window.OV.onGameStart?.(() => {
updateGameStatus(true);
});
++ window.OV.onLaunchPhase?.((info) => {
++ const label = document.getElementById('launch-label');
++ const help = document.getElementById('launch-help');
++ if (label && info?.message) label.textContent = info.message;
++ if (help) {
++ if (info?.phase === 'ready') {
++ help.textContent = 'The game window is available. Because the Proton client may not load the custom client DLL, keep this launcher open as the reliable OpenVibe UI.';
++ } else if (info?.phase === 'slow') {
++ help.textContent = 'The game may still be loading or frozen. The launcher is staying visible so you can retry, focus the game, or inspect logs.';
++ } else {
++ help.textContent = 'OpenVibe is waiting for the Source window to appear and stabilize before switching focus.';
++ }
++ }
++ launchOverlay?.classList.add('show');
++ });
window.OV.onGameExit((code) => {
hideLaunchOverlay();
updateGameStatus(false);
ddiff --git a/launcher/styles.css b/launcher/styles.css
iindex 1f90de1..4404049 100644
---- a/launcher/styles.css
++++ b/launcher/styles.css
@@@ -753,3 +753,21 @@ html.embedded .portal-card {
html.embedded .hero-sub {
color: #8aa0b8;
}
++
++
++/* OpenVibe launch guard */
++.launch-help {
++ margin-top: 10px;
++ max-width: 620px;
++ color: rgba(220, 235, 255, 0.78);
++ font-size: 14px;
++ line-height: 1.35;
++ text-align: center;
++}
++.launch-actions {
++ display: flex;
++ gap: 10px;
++ justify-content: center;
++ flex-wrap: wrap;
++ margin-top: 18px;
++}
ddiff --git a/tools/check-openvibe-client-mode.sh b/tools/check-openvibe-client-mode.sh
nnew file mode 100755
iindex 0000000..4925a35
---- /dev/null
++++ b/tools/check-openvibe-client-mode.sh
@@@ -0,0 +1,27 @@
++#!/usr/bin/env bash
++set -euo pipefail
++ROOT="${OPENVIBE_ROOT:-$HOME/src/openvibe-source}"
++
++echo "[openvibe] client mode diagnostic"
++echo "Windows/Proton launcher: $ROOT/tools/run-client-proton.sh"
++echo "Linux client module expected at: $ROOT/game/openvibe.games/bin/linux64/client.so"
++
++if [[ -f "$ROOT/game/openvibe.games/bin/linux64/client.so" ]]; then
++ echo "[ok] Linux client.so exists"
++else
++ echo "[warn] Linux client.so missing; run tools/build-sdk-linux.sh and tools/setup-openvibe-bin.sh"
++fi
++
++cat <<'MSG'
++
++If the in-game console says unknown command "ov_join" or "ov_menu", the OpenVibe client DLL is not loaded.
++That is expected when launching Windows hl2.exe through Proton while only Linux client.so exists.
++
++Current reliable UI path:
++ - Electron launcher / local Chromium UI
++ - server-side GameDLL commands
++
++Full in-game HTML menu requires one of:
++ 1. Run a native Linux Source client that loads bin/linux64/client.so, or
++ 2. Build a Windows client.dll/server.dll and place them in game/openvibe.games/bin/ for Proton hl2.exe.
++MSG
ddiff --git a/tools/fix-launch-loading-and-explain-clientdll.sh b/tools/fix-launch-loading-and-explain-clientdll.sh
nnew file mode 100755
iindex 0000000..a4db536
---- /dev/null
++++ b/tools/fix-launch-loading-and-explain-clientdll.sh
@@@ -0,0 +1,344 @@
++#!/usr/bin/env bash
++set -euo pipefail
++
++ROOT="${OPENVIBE_ROOT:-$HOME/src/openvibe-source}"
++cd "$ROOT"
++
++echo "[openvibe] next phase: launcher-aware Source loading + client DLL reality guard"
++echo "[openvibe] root=$ROOT"
++
++backup() {
++ local f="$1"
++ [[ -f "$f" ]] && cp "$f" "$f.bak.$(date +%Y%m%d-%H%M%S)"
++}
++
++backup launcher/main.js
++backup launcher/preload.js
++backup launcher/renderer.js
++backup launcher/index.html
++backup launcher/styles.css
++backup tools/run-client-proton.sh
++
++mkdir -p docs game/openvibe.games/resource game/openvibe.games/cfg launcher/assets
++
++cat > docs/CLIENT_DLL_AND_LOADING.md <<'DOC'
++# OpenVibe client DLL + loading screen notes
++
++The Proton launcher runs Windows `hl2.exe`. That Windows executable will not load the Linux
++`game/openvibe.games/bin/linux64/client.so` produced by the current Source SDK Linux build.
++
++That means native client commands such as `ov_join`, `ov_menu`, `ov_ui`, and the in-game
++CEF/HTML menu only exist when a matching client DLL is actually loaded by the game client.
++Until we build a Windows `client.dll` or switch to a native Linux client path, the Electron
++launcher is the reliable custom Chromium shell.
++
++This phase keeps the Electron launcher visible during Source startup, shows a clear launch
++state, and focuses the Source window only after it has appeared and stayed stable for a few
++seconds. It also installs best-effort Source resource overrides for the classic loading dialog.
++DOC
++
++cat > game/openvibe.games/cfg/autoexec.cfg <<'CFG'
++// OpenVibe: Source client defaults
++con_enable 1
++developer 1
++con_timestamp 1
++cl_showfps 0
++
++// These are server-side commands unless a matching client DLL is loaded.
++echo "[OpenVibe] autoexec loaded. If ov_join/ov_menu are unknown, the custom client DLL is not loaded."
++CFG
++
++cat > game/openvibe.games/resource/LoadingDialog.res <<'RES'
++"Resource/LoadingDialog.res"
++{
++ "LoadingDialog"
++ {
++ "ControlName" "Frame"
++ "fieldName" "LoadingDialog"
++ "xpos" "0"
++ "ypos" "0"
++ "wide" "f0"
++ "tall" "f0"
++ "autoResize" "0"
++ "pinCorner" "0"
++ "visible" "1"
++ "enabled" "1"
++ "tabPosition" "0"
++ "settitlebarvisible" "0"
++ "PaintBackgroundType" "2"
++ "bgcolor_override" "7 8 14 255"
++ }
++
++ "OpenVibeTitle"
++ {
++ "ControlName" "Label"
++ "fieldName" "OpenVibeTitle"
++ "xpos" "c-260"
++ "ypos" "c-72"
++ "wide" "520"
++ "tall" "42"
++ "visible" "1"
++ "enabled" "1"
++ "labelText" "OPENVIBE: SOURCE"
++ "textAlignment" "center"
++ "font" "DefaultVeryLarge"
++ "fgcolor_override" "0 220 255 255"
++ }
++
++ "OpenVibeStatus"
++ {
++ "ControlName" "Label"
++ "fieldName" "OpenVibeStatus"
++ "xpos" "c-300"
++ "ypos" "c-20"
++ "wide" "600"
++ "tall" "36"
++ "visible" "1"
++ "enabled" "1"
++ "labelText" "Loading OpenVibe shell, servers, and scripts..."
++ "textAlignment" "center"
++ "font" "DefaultLarge"
++ "fgcolor_override" "220 235 255 255"
++ }
++}
++RES
++
++cat > game/openvibe.games/resource/LoadingProgress.res <<'RES'
++"Resource/LoadingProgress.res"
++{
++ "LoadingProgress"
++ {
++ "ControlName" "Frame"
++ "fieldName" "LoadingProgress"
++ "xpos" "0"
++ "ypos" "0"
++ "wide" "f0"
++ "tall" "f0"
++ "visible" "1"
++ "enabled" "1"
++ "settitlebarvisible" "0"
++ "PaintBackgroundType" "2"
++ "bgcolor_override" "7 8 14 255"
++ }
++}
++RES
++
++python3 <<'PY'
++from pathlib import Path
++import re
++
++p = Path('launcher/main.js')
++s = p.read_text()
++
++if 'OPENVIBE_GAME_WINDOW_READY_TIMEOUT_MS' not in s:
++ marker = "let appIsQuitting = false;\n"
++ helpers = r'''
++
++const GAME_WINDOW_TITLE = process.env.OPENVIBE_GAME_WINDOW_TITLE || 'OpenVibe: Source';
++const GAME_READY_TIMEOUT_MS = Number(process.env.OPENVIBE_GAME_WINDOW_READY_TIMEOUT_MS || 45000);
++const GAME_STABLE_MS = Number(process.env.OPENVIBE_GAME_WINDOW_STABLE_MS || 8000);
++const HIDE_LAUNCHER_ON_GAME_READY = process.env.OPENVIBE_HIDE_LAUNCHER_ON_GAME_READY === '1';
++
++function sleep(ms) {
++ return new Promise((resolve) => setTimeout(resolve, ms));
++}
++
++function emitLaunchPhase(phase, message, extra = {}) {
++ if (mainWindow && !mainWindow.isDestroyed()) {
++ mainWindow.webContents.send('game-launch-phase', { phase, message, ...extra });
++ }
++}
++
++function safeExec(command) {
++ try {
++ return execSync(command, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
++ } catch {
++ return '';
++ }
++}
++
++function findGameWindowId() {
++ // Linux desktop helper. If xdotool is not installed, the launcher simply stays visible.
++ const escaped = GAME_WINDOW_TITLE.replace(/"/g, '\\"');
++ const out = safeExec(`xdotool search --name "${escaped}" 2>/dev/null | tail -n 1`);
++ return out || null;
++}
++
++function focusGameWindow() {
++ const win = findGameWindowId();
++ if (!win) return false;
++ safeExec(`xdotool windowactivate ${win} 2>/dev/null || true`);
++ return true;
++}
++
++async function waitForGameWindowReady(timeoutMs = GAME_READY_TIMEOUT_MS) {
++ const started = Date.now();
++ let firstSeen = 0;
++
++ while (Date.now() - started < timeoutMs) {
++ const win = findGameWindowId();
++ if (win) {
++ if (!firstSeen) {
++ firstSeen = Date.now();
++ emitLaunchPhase('window-found', 'Source window found. Waiting for it to stop thrashing before switching focus...', { windowId: win });
++ }
++
++ if (Date.now() - firstSeen >= GAME_STABLE_MS) {
++ return { ready: true, windowId: win, reason: 'window-stable' };
++ }
++ }
++
++ await sleep(500);
++ }
++
++ return { ready: false, windowId: findGameWindowId(), reason: 'timeout' };
++}
++'''
++ s = s.replace(marker, marker + helpers)
++
++old = """ mainWindow?.webContents.send('game-started', gameProcess.pid);\n setTimeout(() => {\n if (gameProcess && mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();\n }, 750);\n"""
++new = """ emitLaunchPhase(\n 'spawned',\n 'Source has started. OpenVibe will keep this launcher visible until the game window is detected and stable.'\n );\n mainWindow?.webContents.send('game-started', gameProcess.pid);\n\n waitForGameWindowReady().then((state) => {\n if (!gameProcess) return;\n\n if (state.ready) {\n emitLaunchPhase(\n 'ready',\n HIDE_LAUNCHER_ON_GAME_READY\n ? 'Source window looks ready. Focusing game and hiding launcher.'\n : 'Source window looks ready. Launcher will stay open so you are not stuck behind a frozen loading screen.',\n state\n );\n focusGameWindow();\n\n if (HIDE_LAUNCHER_ON_GAME_READY) {\n if (mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();\n } else {\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.show();\n mainWindow.focus();\n }\n }\n } else {\n emitLaunchPhase(\n 'slow',\n 'Source is taking longer than expected. The launcher is staying visible; use Focus Game when the window appears.',\n state\n );\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.show();\n mainWindow.focus();\n }\n }\n });\n"""
++if old in s:
++ s = s.replace(old, new)
++elif "setTimeout(() =>" in s and "mainWindow.hide" in s:
++ s = re.sub(r"\s*mainWindow\?\.webContents\.send\('game-started', gameProcess\.pid\);\s*setTimeout\(\(\) => \{\s*if \(gameProcess && mainWindow && !mainWindow\.isDestroyed\(\)\) mainWindow\.hide\(\);\s*\}, 750\);", "\n" + new, s, count=1, flags=re.S)
++
++if "ipcMain.handle('game:focus'" not in s:
++ insert_after = "ipcMain.handle('game:status', () => ({\n running: gameProcess !== null,\n pid: gameProcess?.pid ?? null,\n}));\n"
++ addition = """
++
++ipcMain.handle('game:focus', async () => {
++ return focusGameWindow();
++});
++
++ipcMain.handle('launcher:show', async () => {
++ if (mainWindow && !mainWindow.isDestroyed()) {
++ mainWindow.show();
++ mainWindow.focus();
++ }
++ return true;
++});
++"""
++ if insert_after in s:
++ s = s.replace(insert_after, insert_after + addition)
++ else:
++ s += addition
++
++p.write_text(s)
++
++p = Path('launcher/preload.js')
++s = p.read_text()
++if 'focusGame:' not in s:
++ s = s.replace(" gameStatus: () => ipcRenderer.invoke('game:status'),", " gameStatus: () => ipcRenderer.invoke('game:status'),\n focusGame: () => ipcRenderer.invoke('game:focus'),")
++if 'showLauncher:' not in s:
++ s = s.replace(" focusGame: () => ipcRenderer.invoke('game:focus'),", " focusGame: () => ipcRenderer.invoke('game:focus'),\n showLauncher: () => ipcRenderer.invoke('launcher:show'),")
++if 'onLaunchPhase:' not in s:
++ s = s.replace(" onGameStart: (cb) => ipcRenderer.on('game-started', (_e, pid) => cb(pid)),", " onGameStart: (cb) => ipcRenderer.on('game-started', (_e, pid) => cb(pid)),\n onLaunchPhase: (cb) => ipcRenderer.on('game-launch-phase', (_e, info) => cb(info)),")
++p.write_text(s)
++
++p = Path('launcher/index.html')
++s = p.read_text()
++if 'launch-help' not in s:
++ s = s.replace(
++ '<div class="launch-label" id="launch-label">Launching…</div>\n <button class="launch-cancel" id="launch-cancel">Cancel</button>',
++ '<div class="launch-label" id="launch-label">Launching…</div>\n <div class="launch-help" id="launch-help">OpenVibe will keep this launcher visible until Source is actually ready.</div>\n <div class="launch-actions">\n <button class="launch-cancel" id="launch-focus-game">Focus Game Window</button>\n <button class="launch-cancel" id="launch-cancel">Dismiss</button>\n </div>'
++ )
++p.write_text(s)
++
++p = Path('launcher/styles.css')
++s = p.read_text()
++if '.launch-help' not in s:
++ s += r'''
++
++/* OpenVibe launch guard */
++.launch-help {
++ margin-top: 10px;
++ max-width: 620px;
++ color: rgba(220, 235, 255, 0.78);
++ font-size: 14px;
++ line-height: 1.35;
++ text-align: center;
++}
++.launch-actions {
++ display: flex;
++ gap: 10px;
++ justify-content: center;
++ flex-wrap: wrap;
++ margin-top: 18px;
++}
++'''
++p.write_text(s)
++
++p = Path('launcher/renderer.js')
++s = p.read_text()
++if 'focusGame: () =>' not in s:
++ s = s.replace(" gameStatus: () => isElectron ? window.OV.gameStatus() : Promise.resolve({ running: true, pid: null }),", " gameStatus: () => isElectron ? window.OV.gameStatus() : Promise.resolve({ running: true, pid: null }),\n focusGame: () => isElectron ? window.OV.focusGame() : Promise.resolve(false),")
++
++if "launch-focus-game" not in s:
++ s = s.replace("document.getElementById('launch-cancel')?.addEventListener('click', () => {\n launchOverlay.classList.remove('show');\n});", "document.getElementById('launch-cancel')?.addEventListener('click', () => {\n launchOverlay.classList.remove('show');\n});\n\ndocument.getElementById('launch-focus-game')?.addEventListener('click', async () => {\n const ok = await Bridge.focusGame();\n toast(ok ? 'Focused Source window.' : 'Source window not found yet.', !ok);\n});")
++
++if 'onLaunchPhase' not in s:
++ s = s.replace(" window.OV.onGameStart?.(() => {\n updateGameStatus(true);\n });", " window.OV.onGameStart?.(() => {\n updateGameStatus(true);\n });\n window.OV.onLaunchPhase?.((info) => {\n const label = document.getElementById('launch-label');\n const help = document.getElementById('launch-help');\n if (label && info?.message) label.textContent = info.message;\n if (help) {\n if (info?.phase === 'ready') {\n help.textContent = 'The game window is available. Because the Proton client may not load the custom client DLL, keep this launcher open as the reliable OpenVibe UI.';\n } else if (info?.phase === 'slow') {\n help.textContent = 'The game may still be loading or frozen. The launcher is staying visible so you can retry, focus the game, or inspect logs.';\n } else {\n help.textContent = 'OpenVibe is waiting for the Source window to appear and stabilize before switching focus.';\n }\n }\n launchOverlay?.classList.add('show');\n });")
++p.write_text(s)
++PY
++
++python3 <<'PY'
++from pathlib import Path
++p = Path('tools/run-client-proton.sh')
++s = p.read_text()
++if '-condebug' not in s:
++ s = s.replace('-console -dev -novid -sw -w 1280 -h 720 \\\n', '-console -condebug -dev -novid -sw -w 1280 -h 720 \\\n')
++if '+con_enable 1' not in s:
++ s = s.replace('-port 27115 -clientport 27105 \\\n', '-port 27115 -clientport 27105 +con_enable 1 +developer 1 \\\n')
++if 'client.so' not in s and 'Windows hl2.exe will not load Linux client.so' not in s:
++ s = s.replace('echo "Launching OpenVibe: Source..."', 'echo "Launching OpenVibe: Source..."\necho "NOTE: Proton Windows hl2.exe will not load Linux client.so; Electron launcher remains the reliable custom UI."')
++p.write_text(s)
++PY
++
++cat > tools/check-openvibe-client-mode.sh <<'CHK'
++#!/usr/bin/env bash
++set -euo pipefail
++ROOT="${OPENVIBE_ROOT:-$HOME/src/openvibe-source}"
++
++echo "[openvibe] client mode diagnostic"
++echo "Windows/Proton launcher: $ROOT/tools/run-client-proton.sh"
++echo "Linux client module expected at: $ROOT/game/openvibe.games/bin/linux64/client.so"
++
++if [[ -f "$ROOT/game/openvibe.games/bin/linux64/client.so" ]]; then
++ echo "[ok] Linux client.so exists"
++else
++ echo "[warn] Linux client.so missing; run tools/build-sdk-linux.sh and tools/setup-openvibe-bin.sh"
++fi
++
++cat <<'MSG'
++
++If the in-game console says unknown command "ov_join" or "ov_menu", the OpenVibe client DLL is not loaded.
++That is expected when launching Windows hl2.exe through Proton while only Linux client.so exists.
++
++Current reliable UI path:
++ - Electron launcher / local Chromium UI
++ - server-side GameDLL commands
++
++Full in-game HTML menu requires one of:
++ 1. Run a native Linux Source client that loads bin/linux64/client.so, or
++ 2. Build a Windows client.dll/server.dll and place them in game/openvibe.games/bin/ for Proton hl2.exe.
++MSG
++CHK
++chmod +x tools/check-openvibe-client-mode.sh
++
++if [[ -x tools/setup-openvibe-bin.sh ]]; then
++ tools/setup-openvibe-bin.sh || true
++fi
++
++echo
++node -c launcher/main.js
++node -c launcher/preload.js
++node -c launcher/renderer.js
++
++echo
++echo "[openvibe] done. Run diagnostics:"
++echo " tools/check-openvibe-client-mode.sh"
++echo
++echo "[openvibe] restart Electron/game. By default the launcher will stay visible."
++echo "[openvibe] To auto-hide only after Source window is stable:"
++echo " OPENVIBE_HIDE_LAUNCHER_ON_GAME_READY=1 npm run dev"
ddiff --git a/tools/run-client-proton.sh b/tools/run-client-proton.sh
iindex 80bce74..581e153 100755
---- a/tools/run-client-proton.sh
++++ b/tools/run-client-proton.sh
@@@ -28,6 +28,7 @@ if [ -n "$1" ] && [ -n "$2" ]; then
fi
echo "Launching OpenVibe: Source..."
++echo "NOTE: Proton Windows hl2.exe will not load Linux client.so; Electron launcher remains the reliable custom UI."
echo " Game: $GAME_DIR"
echo " Proton: $GE_PROTON"
[ -n "$CONNECT_ARGS" ] && echo " Connect: $CONNECT_ARGS"
@@@ -44,6 +45,6 @@ export DXVK_ASYNC=1
exec "$GE_PROTON/proton" waitforexitandrun \
"$HL2_EXE" \
-game "$GAME_DIR" \
-- -console -dev -novid -sw -w 1280 -h 720 \
-- -port 27115 -clientport 27105 \
++ -console -condebug -dev -novid -sw -w 1280 -h 720 \
++ -port 27115 -clientport 27105 +con_enable 1 +developer 1 \
$CONNECT_ARGS