From f87db975ba4d6324ab06877df5582663b0b96331 Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:15:35 +0100 Subject: [PATCH] Remove binary icon asset and align icon config/docs (#18) --- README.md | 84 +++++++++++-------- package.json | 24 +++++- src/main/main.ts | 190 ++++++++++++++++++++++++++---------------- static/icons/icon.svg | 11 +++ 4 files changed, 199 insertions(+), 110 deletions(-) create mode 100644 static/icons/icon.svg diff --git a/README.md b/README.md index db933d5..809a73a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Wrapper de Electron para empaquetar una instalación de NodeCG que incluya el bu ## Qué hace - Arranca `lib/nodecg/index.js` como proceso hijo desde Electron. -- Muestra la ruta de dashboard de carga del bundle (`/bundles//dashboard/loading.html`) servida por NodeCG mientras inicia (no usa un archivo local del wrapper). +- Muestra la ruta de dashboard de carga del bundle (`/bundles//dashboard/loading.html`) servida por NodeCG mientras inicia. - Carga el dashboard del bundle en `http://localhost:/bundles//`. - Empaqueta NodeCG + assets dentro de la app final con `electron-builder`. @@ -24,6 +24,7 @@ scoreko-electron-dev/ │ └─ bundles/ │ └─ scoreko-dev/ ├─ src/main/main.ts +├─ static/icons/ └─ package.json ``` @@ -35,61 +36,74 @@ scoreko-electron-dev/ - `npm run pack`: genera app sin instalador. - `npm run dist`: genera instalador. - `npm run rebuild:native`: rebuild nativo auxiliar en `lib/nodecg`. + ## Variables de entorno útiles - `NODECG_BUNDLE_NAME` (default: `scoreko-dev`) +- `NODECG_PORT` (default: `9090`) - `SCOREKO_DASHBOARD_ROUTE` (default: `dashboard/example/main.html?standalone=true`) - `SCOREKO_LOADING_ROUTE` (default: `dashboard/loading/main.html?standalone=true`) - `SCOREKO_APP_TITLE` (default: `Scoreko`) +- `SCOREKO_APP_USER_MODEL_ID` (default: `com.scoreko.desktop`) +- `SCOREKO_APP_ICON_PATH` (default: búsqueda automática en `static/icons/icon.ico` y `static/icons/icon.png`) +- `ELECTRON_LOAD_DELAY_MS` (default: `10000`) +- `NODECG_STARTUP_TIMEOUT_MS` (default: `30000`) +- `NODECG_KILL_TIMEOUT_MS` (default: `2500`) + +## Assets de íconos incluidos + +Se incluye `static/icons/` con placeholder editable: + +- `static/icons/icon.svg` + +> En este repo no se versionan binarios. Para distribución final agrega localmente `static/icons/icon.ico` (Windows) y/o `static/icons/icon.png` (Linux/macOS runtime). ## Personalización (Windows / Electron) ### 1) Título de la app -- **Durante ejecución**: cambia `SCOREKO_APP_TITLE` para sobreescribir el título de ventana. -- **En instalador y ejecutable**: cambia `build.productName` en `package.json`. +- **Runtime (ventanas):** `SCOREKO_APP_TITLE`. +- **Build (instalador y ejecutable):** `build.productName`. -### 2) Ícono en barra de tareas y esquina superior de la ventana +### 2) Ícono en barra de tareas y esquina superior -Coloca tu icono en uno de estos paths (prioridad de arriba hacia abajo): +Electron toma automáticamente el primer archivo existente en este orden: -- `static/icons/icon.ico` -- `static/icons/icon.png` -- `static/icon.ico` -- `static/icon.png` +1. `SCOREKO_APP_ICON_PATH` (si lo defines) +2. `static/icons/icon.ico` +3. `static/icons/icon.png` +4. `static/icon.ico` +5. `static/icon.png` -Electron tomará automáticamente el primero que exista. +### 3) Ícono del `.exe` y accesos directos -### 3) Ícono del `.exe` (instalador/app empaquetada) +Quedó configurado en `package.json` con `build.win.icon` + `build.nsis.installerIcon`/`uninstallerIcon`. -En `package.json` dentro de `build.win` agrega: +### 4) Nombre/autor del popup de Firewall de Windows -```json -"icon": "static/icons/icon.ico" -``` +Ese diálogo usa metadata del ejecutable firmado: -> Recomendado: `.ico` multi-resolución (16/24/32/48/64/128/256). +- Nombre de app: normalmente `build.productName` y metadata del binario. +- Publisher/autor: certificado de firma de código (si no firmas puede verse `Unknown publisher`). -### 4) Texto y autor que muestra el popup del Firewall de Windows +Campos clave a revisar: -Ese diálogo toma datos del ejecutable final: +- `description` +- `author` +- `build.productName` +- `build.win.executableName` +- `build.appId` -- **Nombre de app**: suele venir de `productName` y metadatos del ejecutable. -- **Publisher/Autor**: viene de la **firma de código** (certificado). Sin firma, suele salir `Unknown publisher`. +Además, firma el `.exe` con certificado (`CSC_LINK` / `CSC_KEY_PASSWORD` en `electron-builder`). -Para personalizarlo correctamente en builds de distribución: +## Checklist de personalización extra -1. Ajusta en `package.json`: - - `description` - - `author` - - `build.productName` -2. Firma el `.exe` con un certificado de tu empresa/persona (`CSC_LINK`/`CSC_KEY_PASSWORD` en `electron-builder`). - -> Nota: en desarrollo (`npm run start` / `npm run dev`) puedes ver `GitHub, Inc.` porque estás ejecutando el binario de Electron oficial. - -### 5) Otros campos personalizables que te conviene revisar - -- `build.appId` (identificador único de la app). -- `build.win.executableName` (nombre del ejecutable sin extensión). -- `build.nsis` (nombre del instalador, iconos de instalador/desinstalador, etc.). -- `build.artifactName` (patrón de nombre del archivo de salida). +- `build.appId` +- `build.artifactName` +- `build.win.executableName` +- `build.win.icon` +- `build.nsis` (nombre del setup, íconos, shortcut) +- `build.mac.icon` +- `build.linux.icon` +- `SCOREKO_APP_USER_MODEL_ID` +- `SCOREKO_APP_ICON_PATH` diff --git a/package.json b/package.json index 3ffa94f..02173e9 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,10 @@ "name": "scoreko-electron-dev", "version": "0.2.0", "description": "Electron wrapper to run NodeCG with the scoreko-dev bundle", + "author": { + "name": "Scoreko Team", + "email": "dev@scoreko.local" + }, "license": "MIT", "private": true, "main": "dist/main/main.js", @@ -21,6 +25,7 @@ "build": { "appId": "com.scoreko.desktop", "productName": "Scoreko", + "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", "directories": { "output": "release", "buildResources": "static" @@ -42,17 +47,30 @@ "mac": { "target": [ "dmg" - ] + ], + "icon": "static/icons/icon.ico" }, "linux": { "target": [ "AppImage" - ] + ], + "icon": "static/icons" }, "win": { "target": [ "nsis" - ] + ], + "icon": "static/icons/icon.ico", + "executableName": "Scoreko" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "artifactName": "${productName}-setup-${version}.${ext}", + "installerIcon": "static/icons/icon.ico", + "uninstallerIcon": "static/icons/icon.ico", + "installerHeaderIcon": "static/icons/icon.ico", + "shortcutName": "Scoreko" } }, "engines": { diff --git a/src/main/main.ts b/src/main/main.ts index 8c2c9fc..38cd94b 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,24 +1,53 @@ -import { app, BrowserWindow, shell } from "electron"; +import { app, BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron"; import { ChildProcess, spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -const APP_TITLE = process.env.SCOREKO_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/example/main.html?standalone=true"; -const DEFAULT_LOADING_ROUTE = process.env.SCOREKO_LOADING_ROUTE ?? "dashboard/loading/main.html?standalone=true"; -const LOAD_DELAY_MS = parseEnvInt("ELECTRON_LOAD_DELAY_MS", 10000); -const STARTUP_TIMEOUT_MS = parseEnvInt("NODECG_STARTUP_TIMEOUT_MS", 30000); -const NODECG_KILL_TIMEOUT_MS = parseEnvInt("NODECG_KILL_TIMEOUT_MS", 2500); -const NODECG_RUNTIME_NAME = "electron internal node"; +type AppRuntimeConfig = { + title: string; + userModelId: string; + iconPathOverride?: string; + nodecgPort: string; + bundleName: string; + dashboardRoute: string; + loadingRoute: string; + loadDelayMs: number; + startupTimeoutMs: number; + nodecgKillTimeoutMs: number; +}; + +const RUNTIME_NAME = "electron internal node"; +const DEFAULT_WINDOW_BACKGROUND = "#0f0f0f"; +const DEFAULT_WINDOW_SIZE = { + width: 1280, + height: 800, + minWidth: 800, + minHeight: 500, +}; +const LOADING_WINDOW_SIZE = { + width: 300, + height: 300, +}; + +const runtimeConfig: AppRuntimeConfig = { + title: getEnv("SCOREKO_APP_TITLE", "Scoreko"), + userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"), + iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"), + nodecgPort: getEnv("NODECG_PORT", "9090"), + bundleName: getEnv("NODECG_BUNDLE_NAME", "scoreko-dev"), + dashboardRoute: getEnv("SCOREKO_DASHBOARD_ROUTE", "dashboard/example/main.html?standalone=true"), + loadingRoute: getEnv("SCOREKO_LOADING_ROUTE", "dashboard/loading/main.html?standalone=true"), + loadDelayMs: parseEnvInt("ELECTRON_LOAD_DELAY_MS", 10000), + startupTimeoutMs: parseEnvInt("NODECG_STARTUP_TIMEOUT_MS", 30000), + nodecgKillTimeoutMs: parseEnvInt("NODECG_KILL_TIMEOUT_MS", 2500), +}; const isDev = !app.isPackaged; const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath; const nodecgPath = path.resolve(rootPath, "lib", "nodecg"); -const dashboardUrl = `http://localhost:${DEFAULT_NODECG_PORT}/bundles/${DEFAULT_BUNDLE_NAME}/${DEFAULT_DASHBOARD_ROUTE}`; -const loadingUrl = `http://localhost:${DEFAULT_NODECG_PORT}/bundles/${DEFAULT_BUNDLE_NAME}/${DEFAULT_LOADING_ROUTE}`; -const baseUrl = `http://127.0.0.1:${DEFAULT_NODECG_PORT}`; +const dashboardUrl = `http://localhost:${runtimeConfig.nodecgPort}/bundles/${runtimeConfig.bundleName}/${runtimeConfig.dashboardRoute}`; +const loadingUrl = `http://localhost:${runtimeConfig.nodecgPort}/bundles/${runtimeConfig.bundleName}/${runtimeConfig.loadingRoute}`; +const baseUrl = `http://127.0.0.1:${runtimeConfig.nodecgPort}`; let mainWindow: BrowserWindow | null = null; let loadingWindow: BrowserWindow | null = null; @@ -27,89 +56,93 @@ let stopNodeCGPromise: Promise | null = null; let isQuitting = false; function createMainWindow(): BrowserWindow { - const iconPath = resolveAppIconPath(); + const windowOptions = createWindowOptions({ isLoadingWindow: false }); + const window = new BrowserWindow(windowOptions); - const win = new BrowserWindow({ - show: false, - title: APP_TITLE, - ...(iconPath ? { icon: iconPath } : {}), - width: 1280, - height: 800, - minWidth: 800, - minHeight: 500, - backgroundColor: "#0f0f0f", - webPreferences: { - contextIsolation: true, - sandbox: true, - nodeIntegration: false, - }, - }); + window.setMenuBarVisibility(false); - win.setMenuBarVisibility(false); - - win.webContents.setWindowOpenHandler(({ url }) => { + window.webContents.setWindowOpenHandler(({ url }) => { void shell.openExternal(url); - return { action: "deny" }; }); - win.webContents.on("will-navigate", (event, url) => { + window.webContents.on("will-navigate", (event, url) => { if (url !== dashboardUrl) { event.preventDefault(); void shell.openExternal(url); } }); - win.on("page-title-updated", (event) => { + window.on("page-title-updated", (event) => { event.preventDefault(); }); - return win; + return window; } function createLoadingWindow(): BrowserWindow { - const iconPath = resolveAppIconPath(); + const window = new BrowserWindow(createWindowOptions({ isLoadingWindow: true })); - const win = new BrowserWindow({ - show: false, - frame: false, - title: APP_TITLE, - ...(iconPath ? { icon: iconPath } : {}), - width: 300, - height: 300, - resizable: false, - movable: true, - minimizable: false, - maximizable: false, - backgroundColor: "#0f0f0f", - webPreferences: { - contextIsolation: true, - sandbox: true, - }, - }); - - win.on("page-title-updated", (event) => { + window.on("page-title-updated", (event) => { event.preventDefault(); }); - return win; + return window; +} + +function createWindowOptions({ isLoadingWindow }: { isLoadingWindow: boolean }): BrowserWindowConstructorOptions { + const iconPath = resolveAppIconPath(); + + const baseOptions: BrowserWindowConstructorOptions = { + show: false, + title: runtimeConfig.title, + ...(iconPath ? { icon: iconPath } : {}), + backgroundColor: DEFAULT_WINDOW_BACKGROUND, + webPreferences: { + contextIsolation: true, + sandbox: true, + ...(isLoadingWindow ? {} : { nodeIntegration: false }), + }, + }; + + if (isLoadingWindow) { + return { + ...baseOptions, + frame: false, + width: LOADING_WINDOW_SIZE.width, + height: LOADING_WINDOW_SIZE.height, + resizable: false, + movable: true, + minimizable: false, + maximizable: false, + }; + } + + return { + ...baseOptions, + width: DEFAULT_WINDOW_SIZE.width, + height: DEFAULT_WINDOW_SIZE.height, + minWidth: DEFAULT_WINDOW_SIZE.minWidth, + minHeight: DEFAULT_WINDOW_SIZE.minHeight, + }; } function resolveAppIconPath(): string | undefined { - const candidates = [ + const iconCandidates = [ + runtimeConfig.iconPathOverride, path.join(rootPath, "static", "icons", "icon.ico"), path.join(rootPath, "static", "icons", "icon.png"), path.join(rootPath, "static", "icon.ico"), path.join(rootPath, "static", "icon.png"), - ]; + ].filter((candidate): candidate is string => Boolean(candidate)); - return candidates.find((candidate) => fs.existsSync(candidate)); + return iconCandidates.find((candidate) => fs.existsSync(candidate)); } function validateNodeCGInstall(): void { const indexPath = path.join(nodecgPath, "index.js"); const nodecgBootstrapPath = path.join(nodecgPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js"); - const bundlePath = path.join(nodecgPath, "bundles", DEFAULT_BUNDLE_NAME); + const bundlePath = path.join(nodecgPath, "bundles", runtimeConfig.bundleName); if (!fs.existsSync(nodecgPath)) { throw new Error(`No existe la carpeta NodeCG: ${nodecgPath}`); @@ -133,7 +166,7 @@ function validateNodeCGInstall(): void { if (!fs.existsSync(bundlePath)) { throw new Error( [ - `No se encontró el bundle '${DEFAULT_BUNDLE_NAME}'.`, + `No se encontró el bundle '${runtimeConfig.bundleName}'.`, `Ruta esperada: ${bundlePath}`, "Copia/clona tu bundle dentro de lib/nodecg/bundles antes de ejecutar Electron.", ].join("\n"), @@ -150,7 +183,7 @@ function startNodeCG(): ChildProcess { env: { ...process.env, NODE_ENV: isDev ? "development" : "production", - NODECG_PORT: DEFAULT_NODECG_PORT, + NODECG_PORT: runtimeConfig.nodecgPort, ELECTRON_RUN_AS_NODE: "1", }, stdio: ["ignore", "pipe", "pipe"], @@ -159,16 +192,14 @@ function startNodeCG(): ChildProcess { }); child.stdout?.on("data", (chunk) => { - const text = String(chunk); - process.stdout.write(text); + process.stdout.write(String(chunk)); }); child.stderr?.on("data", (chunk) => { - const text = String(chunk); - process.stderr.write(text); + process.stderr.write(String(chunk)); }); - log(`NodeCG started with pid=${child.pid} using ${NODECG_RUNTIME_NAME}`); + log(`NodeCG started with pid=${child.pid} using ${RUNTIME_NAME}`); child.on("exit", (code, signal) => { log(`NodeCG exited code=${code} signal=${signal ?? "none"}`); @@ -179,7 +210,7 @@ function startNodeCG(): ChildProcess { } async function waitForNodeCGReady(startTime: number): Promise { - while (Date.now() - startTime < STARTUP_TIMEOUT_MS) { + while (Date.now() - startTime < runtimeConfig.startupTimeoutMs) { if (!nodecgProcess) { throw new Error("NodeCG terminó antes de estar listo."); } @@ -196,7 +227,7 @@ async function waitForNodeCGReady(startTime: number): Promise { await sleep(500); } - throw new Error(`Timeout esperando NodeCG en ${baseUrl} (${STARTUP_TIMEOUT_MS}ms).`); + throw new Error(`Timeout esperando NodeCG en ${baseUrl} (${runtimeConfig.startupTimeoutMs}ms).`); } function sleep(ms: number): Promise { @@ -228,7 +259,7 @@ async function launch(): Promise { await mainWindow.loadURL(dashboardUrl); - const remainingLoadingDelay = Math.max(0, LOAD_DELAY_MS - (Date.now() - loadingShownAt)); + const remainingLoadingDelay = Math.max(0, runtimeConfig.loadDelayMs - (Date.now() - loadingShownAt)); if (remainingLoadingDelay > 0) { await sleep(remainingLoadingDelay); } @@ -303,7 +334,7 @@ function stopNodeCG(): Promise { log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`); killNodeCGProcessTree(pid, "SIGKILL"); } - }, Math.max(0, NODECG_KILL_TIMEOUT_MS)); + }, Math.max(0, runtimeConfig.nodecgKillTimeoutMs)); }); return stopNodeCGPromise; @@ -313,6 +344,15 @@ function log(...args: unknown[]): void { console.log("[scoreko-electron]", ...args); } +function getOptionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim(); + return value && value.length > 0 ? value : undefined; +} + +function getEnv(name: string, fallback: string): string { + return getOptionalEnv(name) ?? fallback; +} + function parseEnvInt(name: string, fallback: number): number { const rawValue = process.env[name]; if (!rawValue) { @@ -333,6 +373,12 @@ function closeLoadingWindow(): void { } app.on("ready", () => { + app.setName(runtimeConfig.title); + + if (process.platform === "win32") { + app.setAppUserModelId(runtimeConfig.userModelId); + } + launch().catch(async (error: unknown) => { console.error("Failed to launch Scoreko wrapper", error); closeLoadingWindow(); diff --git a/static/icons/icon.svg b/static/icons/icon.svg new file mode 100644 index 0000000..d58bfef --- /dev/null +++ b/static/icons/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + +