mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-06 05:32:06 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbc709463f | |||
| 955a1f7116 |
@@ -14,3 +14,11 @@ SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true
|
||||
ELECTRON_LOAD_DELAY_MS=10000
|
||||
NODECG_STARTUP_TIMEOUT_MS=30000
|
||||
NODECG_KILL_TIMEOUT_MS=2500
|
||||
|
||||
# Updates
|
||||
SCOREKO_UPDATES_ENABLED=true
|
||||
# SCOREKO_UPDATE_API_URL=http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest
|
||||
# SCOREKO_UPDATE_RELEASE_PAGE_URL=http://gitea.local/OWNER/REPO/releases
|
||||
SCOREKO_UPDATE_ASSET_PATTERN=Scoreko-setup-.*\.exe$
|
||||
SCOREKO_UPDATE_CHECK_DELAY_MS=5000
|
||||
# SCOREKO_UPDATE_CONFIG_PATH=static/updates.json
|
||||
|
||||
@@ -29,7 +29,7 @@ The installer is written to `scoreko-electron-dev/release/Scoreko-setup-0.1.0.ex
|
||||
|
||||
## Runtime behavior
|
||||
|
||||
On first launch, Scoreko copies the packaged NodeCG runtime to the user's app data folder and runs it from there. This keeps `cfg`, `db`, and `logs` writable on Windows even when the app is installed under `Program Files`.
|
||||
On first launch, Scoreko copies the packaged NodeCG runtime to the user's app data folder and then relaunches itself before starting NodeCG. This keeps `cfg`, `db`, and `logs` writable on Windows even when the app is installed under `Program Files`, and avoids transient startup failures caused by freshly copied runtime files.
|
||||
|
||||
## Useful scripts
|
||||
|
||||
@@ -39,6 +39,21 @@ On first launch, Scoreko copies the packaged NodeCG runtime to the user's app da
|
||||
- `npm run dist:win`: create the Windows installer.
|
||||
- `npm run doctor`: check the prepared runtime and the configured port.
|
||||
|
||||
## Updates from Gitea
|
||||
|
||||
Scoreko can check a Gitea release feed without forcing the user to update. Edit `static/updates.json` before building:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"apiUrl": "http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest",
|
||||
"releasePageUrl": "http://gitea.local/OWNER/REPO/releases",
|
||||
"assetPattern": "Scoreko-setup-.*\\.exe$"
|
||||
}
|
||||
```
|
||||
|
||||
For each release, bump `package.json` version, build with `npm run dist:win`, create a Gitea release tagged like `v0.2.0`, and attach `release/Scoreko-setup-0.2.0.exe`. When Scoreko sees a newer tag, it asks whether to download and install it.
|
||||
|
||||
## Configuration
|
||||
|
||||
The defaults match the parent bundle:
|
||||
@@ -47,5 +62,7 @@ The defaults match the parent bundle:
|
||||
- `NODECG_PORT=9090`
|
||||
- `SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true`
|
||||
- `SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true`
|
||||
- `SCOREKO_UPDATES_ENABLED=true`
|
||||
- `SCOREKO_UPDATE_ASSET_PATTERN=Scoreko-setup-.*\.exe$`
|
||||
|
||||
Copy `.env.example` only if you need local overrides while developing.
|
||||
|
||||
@@ -3,17 +3,21 @@
|
||||
## Startup flow
|
||||
|
||||
1. `src/main/main.ts` loads `appConfig` from `config/runtime-config.ts`.
|
||||
2. Copies the packaged NodeCG runtime from app resources to user data when needed (`nodecg/runtime-provisioner.ts`).
|
||||
2. Installs or refreshes the packaged NodeCG runtime in user data when needed (`nodecg/runtime-provisioner.ts`).
|
||||
3. Creates windows (`windows/window-factory.ts`).
|
||||
4. Starts NodeCG with `nodecg/process-manager.ts`.
|
||||
5. Waits for HTTP readiness and shows loading -> main dashboard.
|
||||
6. On shutdown, runs a single graceful-stop flow to avoid orphan processes.
|
||||
4. In packaged builds, relaunches once after a fresh runtime install so NodeCG starts from a settled user-data runtime.
|
||||
5. Starts NodeCG with `nodecg/process-manager.ts`.
|
||||
6. Waits for HTTP readiness and shows loading -> main dashboard.
|
||||
7. Checks the configured Gitea latest-release endpoint for optional updates.
|
||||
8. On shutdown, runs a single graceful-stop flow to avoid orphan processes.
|
||||
|
||||
## Main modules
|
||||
|
||||
- `config/runtime-config.ts`: read/validate env vars.
|
||||
- `nodecg/runtime-provisioner.ts`: install/refresh the managed runtime in the writable user data folder.
|
||||
- `nodecg/runtime-provisioner.ts`: install/refresh the managed runtime in the writable user data folder and report whether it changed.
|
||||
- `nodecg/process-manager.ts`: start, readiness, and stop for NodeCG; install/permission/port validation.
|
||||
- `updates/update-manager.ts`: optional Gitea release checks, installer download, and user-controlled install.
|
||||
- `updates/update-utils.ts`: release version comparison and installer asset selection.
|
||||
- `windows/window-factory.ts`: window creation and navigation policy.
|
||||
- `windows/navigation-security.ts`: internal navigation allowlist and safe external schemes.
|
||||
- `errors/error-presenter.ts`: fatal error presentation.
|
||||
|
||||
@@ -26,7 +26,20 @@
|
||||
- Increase `NODECG_STARTUP_TIMEOUT_MS` if the environment is slow.
|
||||
- Recreate the runtime with `npm run prepare:runtime` if the bundle changed.
|
||||
|
||||
## First launch after install fails
|
||||
|
||||
- Scoreko relaunches itself automatically after a fresh runtime install.
|
||||
- If it still fails, check whether antivirus or file indexing is locking `%AppData%\scoreko\nodecg`.
|
||||
- Rebuild the installer with `npm run dist:win` after running `npm run rebuild:native`.
|
||||
|
||||
## macOS build fails because of icon
|
||||
|
||||
- The configuration expects `static/icons/icon.icns`.
|
||||
- Create that file before running macOS packaging.
|
||||
|
||||
## Updates do not appear
|
||||
|
||||
- Check that `static/updates.json` has `"enabled": true` before building the installer.
|
||||
- The `apiUrl` must point to Gitea's latest release API: `/api/v1/repos/<owner>/<repo>/releases/latest`.
|
||||
- The release tag must be newer than the installed `package.json` version, for example `v0.2.0`.
|
||||
- The release must include an installer asset matching `assetPattern`, by default `Scoreko-setup-.*\.exe$`.
|
||||
|
||||
+1
-2
@@ -13,7 +13,7 @@
|
||||
"clean": "rimraf dist release lib",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build:bundle": "node scripts/build-scoreko-bundle.mjs",
|
||||
"build:main": "tsc -p tsconfig.json && node scripts/copy-assets.mjs",
|
||||
"build:main": "tsc -p tsconfig.json",
|
||||
"prepare:runtime": "node scripts/prepare-nodecg-runtime.mjs",
|
||||
"build": "npm run clean && npm run build:bundle && npm run build:main && npm run prepare:runtime",
|
||||
"start": "npm run build && electron .",
|
||||
@@ -22,7 +22,6 @@
|
||||
"dev:electron": "wait-on dist/main/main.js && electron .",
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
"rebuild:native": "node scripts/rebuild-nodecg-native.mjs",
|
||||
"rebuild:better-sqlite3": "electron-rebuild --version 39.5.1 --module-dir lib/nodecg/workspaces/database-adapter-sqlite-legacy --only better-sqlite3 -f",
|
||||
"test": "rimraf dist && npm run build:main && node --test dist/tests/**/*.test.js",
|
||||
"doctor": "node scripts/doctor.mjs",
|
||||
"lint": "eslint . --ext .ts,.js,.mjs",
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -10,6 +10,12 @@ export type AppRuntimeConfig = {
|
||||
loadDelayMs: number;
|
||||
startupTimeoutMs: number;
|
||||
nodecgKillTimeoutMs: number;
|
||||
updatesEnabled: boolean;
|
||||
updateApiUrl?: string;
|
||||
updateReleasePageUrl?: string;
|
||||
updateAssetPattern?: string;
|
||||
updateConfigPathOverride?: string;
|
||||
updateCheckDelayMs: number;
|
||||
};
|
||||
|
||||
const MIN_TCP_PORT = 1;
|
||||
@@ -29,6 +35,12 @@ export function getRuntimeConfig(): AppRuntimeConfig {
|
||||
loadDelayMs: parseEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000),
|
||||
startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000),
|
||||
nodecgKillTimeoutMs: parseEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000),
|
||||
updatesEnabled: parseEnvBool("SCOREKO_UPDATES_ENABLED", true),
|
||||
updateApiUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_API_URL"),
|
||||
updateReleasePageUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_RELEASE_PAGE_URL"),
|
||||
updateAssetPattern: getOptionalEnv("SCOREKO_UPDATE_ASSET_PATTERN"),
|
||||
updateConfigPathOverride: getOptionalEnv("SCOREKO_UPDATE_CONFIG_PATH"),
|
||||
updateCheckDelayMs: parseEnvIntInRange("SCOREKO_UPDATE_CHECK_DELAY_MS", 5000, 0, 600000),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,12 +72,49 @@ export function parseEnvIntInRange(name: string, fallback: number, min: number,
|
||||
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) {
|
||||
throw new Error(`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`);
|
||||
throw new Error(
|
||||
`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
export function parseEnvBool(name: string, fallback: boolean): boolean {
|
||||
const rawValue = process.env[name]?.trim().toLowerCase();
|
||||
if (!rawValue) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (["1", "true", "yes", "on"].includes(rawValue)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (["0", "false", "no", "off"].includes(rawValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(`The ${name} variable must be a boolean. Received value: '${process.env[name]}'.`);
|
||||
}
|
||||
|
||||
export function parseOptionalHttpUrl(name: string): string | undefined {
|
||||
const rawValue = getOptionalEnv(name);
|
||||
if (!rawValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(rawValue);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("unsupported protocol");
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
} catch {
|
||||
throw new Error(`The ${name} variable must be a valid HTTP(S) URL. Received value: '${rawValue}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEnvPort(name: string, fallback: string): string {
|
||||
const rawValue = getEnv(name, fallback);
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
|
||||
+27
-5
@@ -5,6 +5,7 @@ import { getRuntimeConfig } from "./config/runtime-config";
|
||||
import { showFatalError, log } from "./errors/error-presenter";
|
||||
import { createNodecgProcessManager, NodecgProcessManager } from "./nodecg/process-manager";
|
||||
import { prepareUserNodecgRuntime } from "./nodecg/runtime-provisioner";
|
||||
import { scheduleUpdateCheck } from "./updates/update-manager";
|
||||
import { getRemainingDelayMs } from "./utils/timing";
|
||||
import { createLoadingWindow, createMainWindow } from "./windows/window-factory";
|
||||
|
||||
@@ -50,7 +51,7 @@ function focusExistingWindow(): void {
|
||||
}
|
||||
|
||||
async function launchApplication(): Promise<void> {
|
||||
const nodecgRootPath = prepareUserNodecgRuntime({
|
||||
const preparedRuntime = prepareUserNodecgRuntime({
|
||||
sourceRuntimePath: sourceNodecgRuntimePath,
|
||||
userDataPath: app.getPath("userData"),
|
||||
appVersion: app.getVersion(),
|
||||
@@ -58,9 +59,16 @@ async function launchApplication(): Promise<void> {
|
||||
log,
|
||||
});
|
||||
|
||||
if (preparedRuntime.installed && app.isPackaged) {
|
||||
log("Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG.");
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
nodecgManager = createNodecgProcessManager({
|
||||
isDev,
|
||||
nodecgRootPath,
|
||||
nodecgRootPath: preparedRuntime.runtimePath,
|
||||
nodecgBaseUrl,
|
||||
appConfig,
|
||||
log,
|
||||
@@ -70,9 +78,7 @@ async function launchApplication(): Promise<void> {
|
||||
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
|
||||
loadingWindow = createLoadingWindow({ appConfig, rootPath });
|
||||
|
||||
await nodecgManager.startNodecgProcess();
|
||||
|
||||
await nodecgManager.waitForNodecgReady(Date.now());
|
||||
await startNodecg();
|
||||
|
||||
if (!loadingWindow || loadingWindow.isDestroyed()) {
|
||||
return;
|
||||
@@ -97,6 +103,22 @@ async function launchApplication(): Promise<void> {
|
||||
|
||||
mainWindow.show();
|
||||
closeLoadingWindow();
|
||||
scheduleUpdateCheck({
|
||||
appConfig,
|
||||
rootPath,
|
||||
getParentWindow: () => mainWindow,
|
||||
beforeInstall: stopNodecgGracefully,
|
||||
log,
|
||||
});
|
||||
}
|
||||
|
||||
async function startNodecg(): Promise<void> {
|
||||
if (!nodecgManager) {
|
||||
throw new Error("NodeCG process manager is not initialized.");
|
||||
}
|
||||
|
||||
await nodecgManager.startNodecgProcess();
|
||||
await nodecgManager.waitForNodecgReady(Date.now());
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
|
||||
@@ -80,7 +80,8 @@ export function createNodecgProcessManager({
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: resolvedDeps.platform !== "win32",
|
||||
shell: resolvedDeps.platform === "win32",
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
@@ -163,7 +164,15 @@ export function createNodecgProcessManager({
|
||||
killNodecgProcessTree(pid, "SIGTERM", log, resolvedDeps);
|
||||
|
||||
stopNodecgPromise = new Promise((resolve) => {
|
||||
let completed = false;
|
||||
|
||||
const complete = () => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
|
||||
completed = true;
|
||||
|
||||
if (nodecgProcess === processToStop) {
|
||||
nodecgProcess = null;
|
||||
}
|
||||
@@ -181,6 +190,7 @@ export function createNodecgProcessManager({
|
||||
if (processToStop.exitCode === null && processToStop.signalCode === null) {
|
||||
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
|
||||
killNodecgProcessTree(pid, "SIGKILL", log, resolvedDeps);
|
||||
complete();
|
||||
}
|
||||
},
|
||||
Math.max(0, appConfig.nodecgKillTimeoutMs),
|
||||
|
||||
@@ -28,6 +28,11 @@ type RuntimeProvisionerDeps = {
|
||||
writeFileSync: (filePath: string, content: string) => unknown;
|
||||
};
|
||||
|
||||
export type PreparedNodecgRuntime = {
|
||||
runtimePath: string;
|
||||
installed: boolean;
|
||||
};
|
||||
|
||||
type RuntimeManifest = {
|
||||
appVersion?: unknown;
|
||||
bundleName?: unknown;
|
||||
@@ -47,14 +52,16 @@ export function prepareUserNodecgRuntime({
|
||||
bundleName,
|
||||
log,
|
||||
deps,
|
||||
}: RuntimeProvisionerConfig): string {
|
||||
}: RuntimeProvisionerConfig): PreparedNodecgRuntime {
|
||||
const resolvedDeps = resolveDeps(deps);
|
||||
const targetRuntimePath = path.join(userDataPath, "nodecg");
|
||||
|
||||
validateSourceRuntime(sourceRuntimePath, bundleName, resolvedDeps.existsSync);
|
||||
resolvedDeps.mkdirSync(targetRuntimePath, { recursive: true });
|
||||
|
||||
if (shouldInstallRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps)) {
|
||||
const installed = shouldInstallRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps);
|
||||
|
||||
if (installed) {
|
||||
log(`Installing managed NodeCG runtime into ${targetRuntimePath}`);
|
||||
installManagedRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps);
|
||||
}
|
||||
@@ -63,7 +70,7 @@ export function prepareUserNodecgRuntime({
|
||||
resolvedDeps.mkdirSync(path.join(targetRuntimePath, writableDir), { recursive: true });
|
||||
}
|
||||
|
||||
return targetRuntimePath;
|
||||
return { runtimePath: targetRuntimePath, installed };
|
||||
}
|
||||
|
||||
function resolveDeps(deps?: Partial<RuntimeProvisionerDeps>): RuntimeProvisionerDeps {
|
||||
@@ -166,7 +173,10 @@ function installManagedRuntime(
|
||||
);
|
||||
}
|
||||
|
||||
function readJson(filePath: string, deps: Pick<RuntimeProvisionerDeps, "existsSync" | "readFileSync">): RuntimeManifest | null {
|
||||
function readJson(
|
||||
filePath: string,
|
||||
deps: Pick<RuntimeProvisionerDeps, "existsSync" | "readFileSync">,
|
||||
): RuntimeManifest | null {
|
||||
if (!deps.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { app, BrowserWindow, dialog, shell } from "electron";
|
||||
import type { MessageBoxOptions } from "electron";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||
import { buildReleaseUpdate, GiteaRelease, ReleaseUpdate, sanitizeFileName, UpdateFileConfig } from "./update-utils";
|
||||
|
||||
type UpdateManagerConfig = {
|
||||
appConfig: AppRuntimeConfig;
|
||||
rootPath: string;
|
||||
getParentWindow: () => BrowserWindow | null;
|
||||
beforeInstall: () => Promise<void>;
|
||||
log: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
type UpdateSettings = {
|
||||
enabled: boolean;
|
||||
apiUrl?: string;
|
||||
releasePageUrl?: string;
|
||||
assetPattern: string;
|
||||
};
|
||||
|
||||
export function scheduleUpdateCheck({
|
||||
appConfig,
|
||||
rootPath,
|
||||
getParentWindow,
|
||||
beforeInstall,
|
||||
log,
|
||||
}: UpdateManagerConfig): void {
|
||||
const settings = loadUpdateSettings(appConfig, rootPath, log);
|
||||
|
||||
if (!settings.enabled || !settings.apiUrl) {
|
||||
log("Update checks disabled or not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
void checkForUpdates({ settings, getParentWindow, beforeInstall, log });
|
||||
}, appConfig.updateCheckDelayMs);
|
||||
}
|
||||
|
||||
async function checkForUpdates({
|
||||
settings,
|
||||
getParentWindow,
|
||||
beforeInstall,
|
||||
log,
|
||||
}: {
|
||||
settings: UpdateSettings;
|
||||
getParentWindow: () => BrowserWindow | null;
|
||||
beforeInstall: () => Promise<void>;
|
||||
log: (...args: unknown[]) => void;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
if (!settings.apiUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const release = await fetchLatestRelease(settings.apiUrl);
|
||||
const update = buildReleaseUpdate(release, app.getVersion(), settings.assetPattern);
|
||||
|
||||
if (!update) {
|
||||
log("No Scoreko update available.");
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldDownload = await askToDownloadUpdate(
|
||||
update,
|
||||
settings.releasePageUrl ?? update.pageUrl,
|
||||
getParentWindow(),
|
||||
);
|
||||
if (!shouldDownload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const installerPath = await downloadInstaller(update);
|
||||
const shouldInstall = await askToInstallUpdate(update, getParentWindow());
|
||||
if (!shouldInstall) {
|
||||
await shell.showItemInFolder(installerPath);
|
||||
return;
|
||||
}
|
||||
|
||||
await beforeInstall();
|
||||
const openError = await shell.openPath(installerPath);
|
||||
if (openError) {
|
||||
throw new Error(openError);
|
||||
}
|
||||
|
||||
app.exit(0);
|
||||
} catch (error) {
|
||||
log("Update check failed.", error);
|
||||
}
|
||||
}
|
||||
|
||||
function loadUpdateSettings(
|
||||
appConfig: AppRuntimeConfig,
|
||||
rootPath: string,
|
||||
log: (...args: unknown[]) => void,
|
||||
): UpdateSettings {
|
||||
const fileConfig = readUpdateFileConfig(appConfig, rootPath, log);
|
||||
|
||||
return {
|
||||
enabled: appConfig.updatesEnabled && (Boolean(fileConfig.enabled) || Boolean(appConfig.updateApiUrl)),
|
||||
apiUrl: appConfig.updateApiUrl ?? readOptionalString(fileConfig.apiUrl),
|
||||
releasePageUrl: appConfig.updateReleasePageUrl ?? readOptionalString(fileConfig.releasePageUrl),
|
||||
assetPattern:
|
||||
appConfig.updateAssetPattern || readOptionalString(fileConfig.assetPattern) || "Scoreko-setup-.*\\.exe$",
|
||||
};
|
||||
}
|
||||
|
||||
function readUpdateFileConfig(
|
||||
appConfig: AppRuntimeConfig,
|
||||
rootPath: string,
|
||||
log: (...args: unknown[]) => void,
|
||||
): UpdateFileConfig {
|
||||
const configPath = appConfig.updateConfigPathOverride ?? path.join(rootPath, "static", "updates.json");
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(configPath, "utf8")) as UpdateFileConfig;
|
||||
} catch (error) {
|
||||
log(`Could not read update config at ${configPath}.`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(apiUrl: string): Promise<GiteaRelease> {
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gitea update check failed with HTTP ${response.status}.`);
|
||||
}
|
||||
|
||||
return (await response.json()) as GiteaRelease;
|
||||
}
|
||||
|
||||
async function askToDownloadUpdate(
|
||||
update: ReleaseUpdate,
|
||||
releasePageUrl: string | undefined,
|
||||
parentWindow: BrowserWindow | null,
|
||||
): Promise<boolean> {
|
||||
const result = await showMessageBox(parentWindow, {
|
||||
type: "info",
|
||||
title: "Actualización disponible",
|
||||
message: `Scoreko ${update.version} está disponible.`,
|
||||
detail: "Puedes descargarla ahora o seguir usando esta versión.",
|
||||
buttons: releasePageUrl ? ["Descargar", "Ver release", "Ahora no"] : ["Descargar", "Ahora no"],
|
||||
defaultId: 0,
|
||||
cancelId: releasePageUrl ? 2 : 1,
|
||||
});
|
||||
|
||||
if (releasePageUrl && result.response === 1) {
|
||||
await shell.openExternal(releasePageUrl);
|
||||
return false;
|
||||
}
|
||||
|
||||
return result.response === 0;
|
||||
}
|
||||
|
||||
async function askToInstallUpdate(update: ReleaseUpdate, parentWindow: BrowserWindow | null): Promise<boolean> {
|
||||
const result = await showMessageBox(parentWindow, {
|
||||
type: "question",
|
||||
title: "Actualización descargada",
|
||||
message: `Scoreko ${update.version} se ha descargado.`,
|
||||
detail: "Para instalarla se cerrará Scoreko y se abrirá el instalador.",
|
||||
buttons: ["Instalar y cerrar", "Luego"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
return result.response === 0;
|
||||
}
|
||||
|
||||
async function downloadInstaller(update: ReleaseUpdate): Promise<string> {
|
||||
const safeFileName = sanitizeFileName(update.installer.name);
|
||||
const downloadDirectory = path.join(app.getPath("temp"), "scoreko-updates");
|
||||
const targetPath = path.join(downloadDirectory, safeFileName);
|
||||
|
||||
fs.mkdirSync(downloadDirectory, { recursive: true });
|
||||
|
||||
const response = await fetch(update.installer.downloadUrl);
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Could not download update installer. HTTP ${response.status}.`);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const fileStream = fs.createWriteStream(targetPath);
|
||||
const responseStream = Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
|
||||
responseStream.on("error", reject);
|
||||
fileStream.on("error", reject);
|
||||
fileStream.on("finish", resolve);
|
||||
responseStream.pipe(fileStream);
|
||||
});
|
||||
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function showMessageBox(parentWindow: BrowserWindow | null, options: MessageBoxOptions) {
|
||||
return parentWindow ? dialog.showMessageBox(parentWindow, options) : dialog.showMessageBox(options);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
export type GiteaReleaseAsset = {
|
||||
name?: unknown;
|
||||
browser_download_url?: unknown;
|
||||
size?: unknown;
|
||||
};
|
||||
|
||||
export type GiteaRelease = {
|
||||
tag_name?: unknown;
|
||||
name?: unknown;
|
||||
html_url?: unknown;
|
||||
assets?: unknown;
|
||||
};
|
||||
|
||||
export type InstallerAsset = {
|
||||
name: string;
|
||||
downloadUrl: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export type ReleaseUpdate = {
|
||||
version: string;
|
||||
title: string;
|
||||
pageUrl?: string;
|
||||
installer: InstallerAsset;
|
||||
};
|
||||
|
||||
export type UpdateFileConfig = {
|
||||
enabled?: unknown;
|
||||
apiUrl?: unknown;
|
||||
releasePageUrl?: unknown;
|
||||
assetPattern?: unknown;
|
||||
};
|
||||
|
||||
export function isVersionNewer(candidateVersion: string, currentVersion: string): boolean {
|
||||
const candidate = normalizeVersion(candidateVersion);
|
||||
const current = normalizeVersion(currentVersion);
|
||||
|
||||
for (let index = 0; index < Math.max(candidate.length, current.length); index += 1) {
|
||||
const candidatePart = candidate[index] ?? 0;
|
||||
const currentPart = current[index] ?? 0;
|
||||
|
||||
if (candidatePart > currentPart) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (candidatePart < currentPart) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getReleaseVersion(release: GiteaRelease): string | null {
|
||||
const tagName = typeof release.tag_name === "string" ? release.tag_name.trim() : "";
|
||||
return tagName.length > 0 ? tagName.replace(/^v/i, "") : null;
|
||||
}
|
||||
|
||||
export function getReleaseTitle(release: GiteaRelease, version: string): string {
|
||||
const releaseName = typeof release.name === "string" ? release.name.trim() : "";
|
||||
return releaseName.length > 0 ? releaseName : `Scoreko ${version}`;
|
||||
}
|
||||
|
||||
export function selectInstallerAsset(release: GiteaRelease, assetPattern: string): InstallerAsset | null {
|
||||
const assets = Array.isArray(release.assets) ? release.assets : [];
|
||||
const matcher = new RegExp(assetPattern, "i");
|
||||
|
||||
for (const asset of assets as GiteaReleaseAsset[]) {
|
||||
const name = typeof asset.name === "string" ? asset.name : "";
|
||||
const downloadUrl = typeof asset.browser_download_url === "string" ? asset.browser_download_url : "";
|
||||
|
||||
if (!name || !downloadUrl || !matcher.test(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
downloadUrl,
|
||||
...(typeof asset.size === "number" ? { size: asset.size } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildReleaseUpdate(
|
||||
release: GiteaRelease,
|
||||
currentVersion: string,
|
||||
assetPattern: string,
|
||||
): ReleaseUpdate | null {
|
||||
const version = getReleaseVersion(release);
|
||||
if (!version || !isVersionNewer(version, currentVersion)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const installer = selectInstallerAsset(release, assetPattern);
|
||||
if (!installer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pageUrl = typeof release.html_url === "string" && release.html_url.length > 0 ? release.html_url : undefined;
|
||||
|
||||
return {
|
||||
version,
|
||||
title: getReleaseTitle(release, version),
|
||||
pageUrl,
|
||||
installer,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeFileName(fileName: string): string {
|
||||
return fileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_");
|
||||
}
|
||||
|
||||
function normalizeVersion(version: string): number[] {
|
||||
return version
|
||||
.trim()
|
||||
.replace(/^v/i, "")
|
||||
.split(/[+-]/)[0]
|
||||
.split(".")
|
||||
.map((part) => Number.parseInt(part, 10))
|
||||
.map((part) => (Number.isFinite(part) ? part : 0));
|
||||
}
|
||||
@@ -17,6 +17,9 @@ function getBaseConfig(): AppRuntimeConfig {
|
||||
loadDelayMs: 10000,
|
||||
startupTimeoutMs: 30000,
|
||||
nodecgKillTimeoutMs: 2500,
|
||||
updatesEnabled: true,
|
||||
updateAssetPattern: "Scoreko-setup-.*\\.exe$",
|
||||
updateCheckDelayMs: 5000,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { SpawnOptions } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
import { AppRuntimeConfig } from "../main/config/runtime-config";
|
||||
@@ -29,6 +30,9 @@ function getBaseConfig(): AppRuntimeConfig {
|
||||
loadDelayMs: 10000,
|
||||
startupTimeoutMs: 100,
|
||||
nodecgKillTimeoutMs: 10,
|
||||
updatesEnabled: true,
|
||||
updateAssetPattern: "Scoreko-setup-.*\\.exe$",
|
||||
updateCheckDelayMs: 5000,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,6 +242,44 @@ test("startNodeCG fails if the port is already in use", async () => {
|
||||
}, /is already in use/);
|
||||
});
|
||||
|
||||
test("startNodeCG spawns Electron directly on Windows", async () => {
|
||||
const child = new MockChildProcess(3210);
|
||||
let capturedCommand: string | null = null;
|
||||
let capturedArgs: string[] | null = null;
|
||||
const capturedOptions: SpawnOptions[] = [];
|
||||
|
||||
const manager = createNodecgProcessManager({
|
||||
isDev: false,
|
||||
nodecgRootPath: "C:\\Users\\tester\\AppData\\Roaming\\scoreko\\nodecg",
|
||||
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||
appConfig: getBaseConfig(),
|
||||
log: () => undefined,
|
||||
deps: {
|
||||
platform: "win32",
|
||||
execPath: "C:\\Program Files\\Scoreko\\scoreko.exe",
|
||||
pathExists: () => true,
|
||||
hasReadWriteAccess: () => true,
|
||||
probePortAvailable: async () => true,
|
||||
spawnProcess: (command, args, options) => {
|
||||
capturedCommand = command;
|
||||
capturedArgs = args;
|
||||
capturedOptions.push(options);
|
||||
return child as unknown as import("node:child_process").ChildProcess;
|
||||
},
|
||||
stdoutWrite: () => undefined,
|
||||
stderrWrite: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await manager.startNodecgProcess();
|
||||
|
||||
assert.equal(capturedCommand, "C:\\Program Files\\Scoreko\\scoreko.exe");
|
||||
assert.deepEqual(capturedArgs, ["C:\\Users\\tester\\AppData\\Roaming\\scoreko\\nodecg\\index.js"]);
|
||||
assert.equal(capturedOptions[0]?.shell, false);
|
||||
assert.equal(capturedOptions[0]?.windowsHide, true);
|
||||
assert.equal(capturedOptions[0]?.env?.ELECTRON_RUN_AS_NODE, "1");
|
||||
});
|
||||
|
||||
test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness", async () => {
|
||||
const child = new MockChildProcess(4242);
|
||||
const manager = createNodecgProcessManager({
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getEnv, getOptionalEnv, parseEnvInt, parseEnvIntInRange, parseEnvPort } from "../main/config/runtime-config";
|
||||
import {
|
||||
getEnv,
|
||||
getOptionalEnv,
|
||||
parseEnvBool,
|
||||
parseEnvInt,
|
||||
parseEnvIntInRange,
|
||||
parseEnvPort,
|
||||
parseOptionalHttpUrl,
|
||||
} from "../main/config/runtime-config";
|
||||
|
||||
function withEnv(name: string, value: string | undefined, run: () => void): void {
|
||||
const previousValue = process.env[name];
|
||||
@@ -83,3 +91,31 @@ test("parseEnvPort normalizes valid port", () => {
|
||||
assert.equal(parseEnvPort("TEST_ENV_PORT", "9090"), "9090");
|
||||
});
|
||||
});
|
||||
|
||||
test("parseEnvBool accepts common true and false values", () => {
|
||||
withEnv("TEST_ENV_BOOL", "yes", () => {
|
||||
assert.equal(parseEnvBool("TEST_ENV_BOOL", false), true);
|
||||
});
|
||||
|
||||
withEnv("TEST_ENV_BOOL", "off", () => {
|
||||
assert.equal(parseEnvBool("TEST_ENV_BOOL", true), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("parseEnvBool rejects invalid values", () => {
|
||||
withEnv("TEST_ENV_BOOL", "maybe", () => {
|
||||
assert.throws(() => parseEnvBool("TEST_ENV_BOOL", true), /must be a boolean/);
|
||||
});
|
||||
});
|
||||
|
||||
test("parseOptionalHttpUrl accepts HTTP and HTTPS urls", () => {
|
||||
withEnv("TEST_UPDATE_URL", "http://gitea.local/api/v1/repos/owner/repo/releases/latest", () => {
|
||||
assert.equal(parseOptionalHttpUrl("TEST_UPDATE_URL"), "http://gitea.local/api/v1/repos/owner/repo/releases/latest");
|
||||
});
|
||||
});
|
||||
|
||||
test("parseOptionalHttpUrl rejects unsupported protocols", () => {
|
||||
withEnv("TEST_UPDATE_URL", "file:///tmp/latest", () => {
|
||||
assert.throws(() => parseOptionalHttpUrl("TEST_UPDATE_URL"), /valid HTTP\(S\) URL/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ test("prepareUserNodecgRuntime copies the packaged runtime into userData", () =>
|
||||
[path.join(source, ".scoreko-runtime.json")]: JSON.stringify({ bundleVersion: "0.1.0", nodecgVersion: "2.6.4" }),
|
||||
});
|
||||
|
||||
const runtimePath = prepareUserNodecgRuntime({
|
||||
const preparedRuntime = prepareUserNodecgRuntime({
|
||||
sourceRuntimePath: source,
|
||||
userDataPath: userData,
|
||||
appVersion: "0.1.0",
|
||||
@@ -79,7 +79,8 @@ test("prepareUserNodecgRuntime copies the packaged runtime into userData", () =>
|
||||
deps,
|
||||
});
|
||||
|
||||
assert.equal(runtimePath, path.join(userData, "nodecg"));
|
||||
assert.equal(preparedRuntime.runtimePath, path.join(userData, "nodecg"));
|
||||
assert.equal(preparedRuntime.installed, true);
|
||||
assert.equal(state.copied.length, 1);
|
||||
assert.ok(state.paths.has(path.join(userData, "nodecg", "cfg")));
|
||||
assert.ok(state.paths.has(path.join(userData, "nodecg", "db")));
|
||||
@@ -106,7 +107,7 @@ test("prepareUserNodecgRuntime keeps an up-to-date runtime in place", () => {
|
||||
},
|
||||
);
|
||||
|
||||
prepareUserNodecgRuntime({
|
||||
const preparedRuntime = prepareUserNodecgRuntime({
|
||||
sourceRuntimePath: source,
|
||||
userDataPath: userData,
|
||||
appVersion: "0.1.0",
|
||||
@@ -115,6 +116,7 @@ test("prepareUserNodecgRuntime keeps an up-to-date runtime in place", () => {
|
||||
deps,
|
||||
});
|
||||
|
||||
assert.equal(preparedRuntime.installed, false);
|
||||
assert.equal(state.copied.length, 0);
|
||||
assert.equal(state.removed.length, 0);
|
||||
});
|
||||
@@ -138,7 +140,7 @@ test("prepareUserNodecgRuntime refreshes managed files when the app version chan
|
||||
},
|
||||
);
|
||||
|
||||
prepareUserNodecgRuntime({
|
||||
const preparedRuntime = prepareUserNodecgRuntime({
|
||||
sourceRuntimePath: source,
|
||||
userDataPath: userData,
|
||||
appVersion: "0.1.0",
|
||||
@@ -147,6 +149,7 @@ test("prepareUserNodecgRuntime refreshes managed files when the app version chan
|
||||
deps,
|
||||
});
|
||||
|
||||
assert.equal(preparedRuntime.installed, true);
|
||||
assert.equal(state.copied.length, 1);
|
||||
assert.ok(state.removed.includes(path.join(target, "node_modules")));
|
||||
assert.ok(state.removed.includes(path.join(target, "bundles")));
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildReleaseUpdate,
|
||||
isVersionNewer,
|
||||
sanitizeFileName,
|
||||
selectInstallerAsset,
|
||||
} from "../main/updates/update-utils";
|
||||
|
||||
test("isVersionNewer compares semantic versions with optional v prefix", () => {
|
||||
assert.equal(isVersionNewer("v0.2.0", "0.1.9"), true);
|
||||
assert.equal(isVersionNewer("0.1.0", "0.1.0"), false);
|
||||
assert.equal(isVersionNewer("0.1.0", "0.2.0"), false);
|
||||
});
|
||||
|
||||
test("selectInstallerAsset picks the first matching exe asset", () => {
|
||||
const asset = selectInstallerAsset(
|
||||
{
|
||||
assets: [
|
||||
{ name: "latest.yml", browser_download_url: "http://gitea/latest.yml" },
|
||||
{ name: "Scoreko-setup-0.2.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.2.0.exe", size: 100 },
|
||||
],
|
||||
},
|
||||
"Scoreko-setup-.*\\.exe$",
|
||||
);
|
||||
|
||||
assert.deepEqual(asset, {
|
||||
name: "Scoreko-setup-0.2.0.exe",
|
||||
downloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe",
|
||||
size: 100,
|
||||
});
|
||||
});
|
||||
|
||||
test("buildReleaseUpdate returns null when the release is not newer", () => {
|
||||
const update = buildReleaseUpdate(
|
||||
{
|
||||
tag_name: "v0.1.0",
|
||||
assets: [{ name: "Scoreko-setup-0.1.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.1.0.exe" }],
|
||||
},
|
||||
"0.1.0",
|
||||
"Scoreko-setup-.*\\.exe$",
|
||||
);
|
||||
|
||||
assert.equal(update, null);
|
||||
});
|
||||
|
||||
test("buildReleaseUpdate builds update info for newer releases", () => {
|
||||
const update = buildReleaseUpdate(
|
||||
{
|
||||
tag_name: "v0.2.0",
|
||||
name: "Scoreko 0.2.0",
|
||||
html_url: "http://gitea/releases/v0.2.0",
|
||||
assets: [{ name: "Scoreko-setup-0.2.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.2.0.exe" }],
|
||||
},
|
||||
"0.1.0",
|
||||
"Scoreko-setup-.*\\.exe$",
|
||||
);
|
||||
|
||||
assert.equal(update?.version, "0.2.0");
|
||||
assert.equal(update?.title, "Scoreko 0.2.0");
|
||||
assert.equal(update?.pageUrl, "http://gitea/releases/v0.2.0");
|
||||
assert.equal(update?.installer.name, "Scoreko-setup-0.2.0.exe");
|
||||
});
|
||||
|
||||
test("sanitizeFileName removes Windows-unsafe characters", () => {
|
||||
assert.equal(sanitizeFileName('Scoreko:setup*"0.2.0.exe'), "Scoreko_setup__0.2.0.exe");
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"apiUrl": "http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest",
|
||||
"releasePageUrl": "http://gitea.local/OWNER/REPO/releases",
|
||||
"assetPattern": "Scoreko-setup-.*\\.exe$"
|
||||
}
|
||||
Reference in New Issue
Block a user