mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-06 05:32:06 +00:00
feat: scaffold modern electron wrapper for scoreko nodecg bundle
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
# scoreko-electron-dev
|
||||||
|
|
||||||
|
Wrapper de Electron para empaquetar una instalación de NodeCG que incluya el bundle `scoreko-dev`, inspirado en `opeik/runback-electron` pero actualizado a Electron + TypeScript moderno.
|
||||||
|
|
||||||
|
## Qué hace
|
||||||
|
|
||||||
|
- Arranca `lib/nodecg/index.js` como proceso hijo desde Electron.
|
||||||
|
- Muestra una ventana de carga mientras NodeCG inicia.
|
||||||
|
- Carga el dashboard del bundle en `http://localhost:<puerto>/bundles/<bundle>/<ruta-dashboard>`.
|
||||||
|
- Empaqueta NodeCG + assets dentro de la app final con `electron-builder`.
|
||||||
|
|
||||||
|
## Estructura esperada
|
||||||
|
|
||||||
|
```text
|
||||||
|
scoreko-electron-dev/
|
||||||
|
├─ lib/
|
||||||
|
│ └─ nodecg/
|
||||||
|
│ ├─ index.js
|
||||||
|
│ └─ bundles/
|
||||||
|
│ └─ scoreko-dev/ # clonado/copiado desde tu repo scoreko-dev
|
||||||
|
├─ src/main/main.ts
|
||||||
|
├─ static/loading.html
|
||||||
|
└─ package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preparación con tu repo `scoreko-dev`
|
||||||
|
|
||||||
|
1. Copia o clona tu instalación de NodeCG en `lib/nodecg`.
|
||||||
|
2. Copia tu bundle `scoreko-dev` a `lib/nodecg/bundles/scoreko-dev`.
|
||||||
|
3. Instala dependencias de NodeCG dentro de `lib/nodecg` (si aplica).
|
||||||
|
4. Instala dependencias del wrapper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables de entorno opcionales
|
||||||
|
|
||||||
|
- `NODECG_PORT` (default: `9090`)
|
||||||
|
- `NODECG_BUNDLE_NAME` (default: `scoreko-dev`)
|
||||||
|
- `SCOREKO_DASHBOARD_ROUTE` (default: `dashboard/index.html`)
|
||||||
|
- `ELECTRON_LOAD_DELAY_MS` (default: `5000`)
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `npm run dev`: modo desarrollo (watch + relanzado de Electron).
|
||||||
|
- `npm run start`: build y ejecución local.
|
||||||
|
- `npm run build`: compila TypeScript y copia assets.
|
||||||
|
- `npm run pack`: genera app sin instalador.
|
||||||
|
- `npm run dist`: genera instalador/plataformas configuradas.
|
||||||
|
|
||||||
|
## Nota de seguridad
|
||||||
|
|
||||||
|
La ventana principal usa `contextIsolation: true`, `sandbox: true` y `nodeIntegration: false`. Los enlaces externos se abren en el navegador del sistema.
|
||||||
Generated
+5600
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"name": "scoreko-electron-dev",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Electron wrapper to run a NodeCG install with the scoreko-dev bundle",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
|
"main": "dist/main/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"clean": "rimraf dist release",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"build": "npm run clean && tsc -p tsconfig.json && node scripts/copy-assets.mjs",
|
||||||
|
"start": "npm run build && electron .",
|
||||||
|
"dev": "concurrently -k \"npm:watch\" \"npm:dev:electron\"",
|
||||||
|
"watch": "tsc -p tsconfig.json --watch",
|
||||||
|
"dev:electron": "wait-on dist/main/main.js && electron .",
|
||||||
|
"pack": "npm run build && electron-builder --dir",
|
||||||
|
"dist": "npm run build && electron-builder"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.scoreko.desktop",
|
||||||
|
"productName": "Scoreko",
|
||||||
|
"directories": {
|
||||||
|
"output": "release",
|
||||||
|
"buildResources": "static"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "lib/nodecg",
|
||||||
|
"to": "lib/nodecg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "static",
|
||||||
|
"to": "static"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mac": {
|
||||||
|
"target": [
|
||||||
|
"dmg"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
"AppImage"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
"nsis"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"source-map-support": "^0.5.21"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
|
"electron": "^34.2.0",
|
||||||
|
"electron-builder": "^25.1.8",
|
||||||
|
"rimraf": "^6.0.1",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"wait-on": "^8.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { cpSync, existsSync, mkdirSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const distStatic = path.join(root, "dist", "static");
|
||||||
|
const sourceStatic = path.join(root, "static");
|
||||||
|
|
||||||
|
mkdirSync(distStatic, { recursive: true });
|
||||||
|
|
||||||
|
if (existsSync(sourceStatic)) {
|
||||||
|
cpSync(sourceStatic, distStatic, { recursive: true });
|
||||||
|
console.log("Copied static assets to dist/static");
|
||||||
|
} else {
|
||||||
|
console.warn("No static folder found, skipping copy-assets step");
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { app, BrowserWindow, shell } from "electron";
|
||||||
|
import { fork, ChildProcess } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const APP_TITLE = "Scoreko";
|
||||||
|
const DEFAULT_NODECG_PORT = process.env.NODECG_PORT ?? "9090";
|
||||||
|
const DEFAULT_BUNDLE_NAME = process.env.NODECG_BUNDLE_NAME ?? "scoreko-dev";
|
||||||
|
const DEFAULT_DASHBOARD_ROUTE = process.env.SCOREKO_DASHBOARD_ROUTE ?? "dashboard/index.html";
|
||||||
|
const LOAD_DELAY_MS = Number.parseInt(process.env.ELECTRON_LOAD_DELAY_MS ?? "5000", 10);
|
||||||
|
|
||||||
|
const isDev = !app.isPackaged;
|
||||||
|
const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath;
|
||||||
|
const nodecgPath = path.resolve(rootPath, "lib", "nodecg");
|
||||||
|
const loadingPath = path.resolve(rootPath, "static", "loading.html");
|
||||||
|
|
||||||
|
const dashboardUrl = `http://localhost:${DEFAULT_NODECG_PORT}/bundles/${DEFAULT_BUNDLE_NAME}/${DEFAULT_DASHBOARD_ROUTE}`;
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
let loadingWindow: BrowserWindow | null = null;
|
||||||
|
let nodecgProcess: ChildProcess | null = null;
|
||||||
|
|
||||||
|
function createMainWindow(): BrowserWindow {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
title: APP_TITLE,
|
||||||
|
width: 1440,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 960,
|
||||||
|
minHeight: 640,
|
||||||
|
backgroundColor: "#0f0f0f",
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
win.setMenuBarVisibility(false);
|
||||||
|
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url).catch((error) => {
|
||||||
|
log("Error opening external url", url, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { action: "deny" };
|
||||||
|
});
|
||||||
|
|
||||||
|
win.webContents.on("will-navigate", (event, url) => {
|
||||||
|
if (url !== dashboardUrl) {
|
||||||
|
event.preventDefault();
|
||||||
|
shell.openExternal(url).catch((error) => {
|
||||||
|
log("Error opening navigation url", url, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
win.on("page-title-updated", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLoadingWindow(): BrowserWindow {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
frame: false,
|
||||||
|
title: APP_TITLE,
|
||||||
|
width: 420,
|
||||||
|
height: 280,
|
||||||
|
resizable: false,
|
||||||
|
movable: true,
|
||||||
|
minimizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
backgroundColor: "#0f0f0f",
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
win.on("page-title-updated", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNodeCG(): ChildProcess {
|
||||||
|
const indexPath = path.join(nodecgPath, "index.js");
|
||||||
|
|
||||||
|
if (!fs.existsSync(indexPath)) {
|
||||||
|
throw new Error(`NodeCG entrypoint not found: ${indexPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = fork(indexPath, {
|
||||||
|
cwd: nodecgPath,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
ELECTRON_RUN_AS_NODE: "1",
|
||||||
|
NODE_ENV: isDev ? "development" : "production",
|
||||||
|
NODECG_PORT: DEFAULT_NODECG_PORT,
|
||||||
|
},
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
log(`NodeCG started with pid=${child.pid}`);
|
||||||
|
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`);
|
||||||
|
nodecgProcess = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launch(): Promise<void> {
|
||||||
|
mainWindow = createMainWindow();
|
||||||
|
loadingWindow = createLoadingWindow();
|
||||||
|
|
||||||
|
await loadingWindow.loadFile(loadingPath);
|
||||||
|
loadingWindow.show();
|
||||||
|
|
||||||
|
nodecgProcess = startNodeCG();
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
if (!mainWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mainWindow.loadURL(dashboardUrl);
|
||||||
|
mainWindow.show();
|
||||||
|
|
||||||
|
if (loadingWindow && !loadingWindow.isDestroyed()) {
|
||||||
|
loadingWindow.close();
|
||||||
|
loadingWindow = null;
|
||||||
|
}
|
||||||
|
}, LOAD_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopNodeCG(): void {
|
||||||
|
if (!nodecgProcess || nodecgProcess.killed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Stopping NodeCG pid=${nodecgProcess.pid}`);
|
||||||
|
nodecgProcess.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(...args: unknown[]): void {
|
||||||
|
console.log("[scoreko-electron]", ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on("ready", () => {
|
||||||
|
launch().catch((error: unknown) => {
|
||||||
|
console.error("Failed to launch Scoreko wrapper", error);
|
||||||
|
app.exit(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("activate", async () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
mainWindow = createMainWindow();
|
||||||
|
await mainWindow.loadURL(dashboardUrl);
|
||||||
|
mainWindow.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("before-quit", () => {
|
||||||
|
stopNodeCG();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("exit", () => {
|
||||||
|
stopNodeCG();
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Scoreko</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #d6d6d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #9a9a9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
margin: 24px auto 0;
|
||||||
|
width: 30px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 3px solid #2f2f2f;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="title">Scoreko</div>
|
||||||
|
<div class="subtitle">Inicializando NodeCG y dashboard…</div>
|
||||||
|
<div class="spinner" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user