mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-06 05:32:06 +00:00
Cleanup final
This commit is contained in:
@@ -2,6 +2,7 @@ import { AppRuntimeConfig } from "../config/runtime-config";
|
||||
import { NodecgProcessManager } from "../nodecg/process-manager";
|
||||
import { PreparedNodecgRuntime } from "../nodecg/runtime-provisioner";
|
||||
import { getRemainingDelayMs } from "../utils/timing";
|
||||
import { ApplicationPaths } from "./paths";
|
||||
import { createShutdownService, ShutdownService } from "./shutdown-service";
|
||||
|
||||
export type ApplicationState = "idle" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed";
|
||||
@@ -21,14 +22,7 @@ export type ApplicationControllerConfig = {
|
||||
appVersion: string;
|
||||
isPackaged: boolean;
|
||||
isWindows: boolean;
|
||||
paths: {
|
||||
rootPath: string;
|
||||
sourceNodecgRuntimePath: string;
|
||||
userDataPath: string;
|
||||
nodecgBaseUrl: string;
|
||||
mainDashboardUrl: string;
|
||||
loadingDashboardUrl: string;
|
||||
};
|
||||
paths: ApplicationPaths;
|
||||
deps: {
|
||||
createLoadingWindow: () => ApplicationWindow;
|
||||
createMainWindow: () => ApplicationWindow;
|
||||
|
||||
@@ -53,16 +53,6 @@ export function getEnv(name: string, fallback: string): string {
|
||||
return getOptionalEnv(name) ?? fallback;
|
||||
}
|
||||
|
||||
export function parseEnvInt(name: string, fallback: number): number {
|
||||
const rawValue = process.env[name];
|
||||
if (!rawValue) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : fallback;
|
||||
}
|
||||
|
||||
export function parseEnvIntInRange(name: string, fallback: number, min: number, max: number): number {
|
||||
// We throw here instead of silently coercing to avoid hidden misconfiguration in production.
|
||||
const rawValue = process.env[name];
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { ChildProcess, SpawnOptions } from "node:child_process";
|
||||
import { SpawnOptions } from "node:child_process";
|
||||
|
||||
export type PlatformProcessKillerDeps = {
|
||||
platform: NodeJS.Platform;
|
||||
spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess;
|
||||
spawnProcess: (command: string, args: string[], options: SpawnOptions) => SpawnedKillerProcess;
|
||||
killProcess: (pid: number, signal: NodeJS.Signals) => void;
|
||||
log: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
type SpawnedKillerProcess = {
|
||||
on: (event: "error", listener: (error: Error) => void) => unknown;
|
||||
};
|
||||
|
||||
export function killProcessTree(pid: number, signal: NodeJS.Signals, deps: PlatformProcessKillerDeps): boolean {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
||||
deps.log(`Invalid pid for process tree termination: ${pid}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChildProcess, spawn, SpawnOptions } from "node:child_process";
|
||||
import { spawn, SpawnOptions } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
@@ -17,7 +17,7 @@ type NodecgProcessManagerConfig = {
|
||||
};
|
||||
|
||||
type NodecgProcessManagerDeps = {
|
||||
spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess;
|
||||
spawnProcess: (command: string, args: string[], options: SpawnOptions) => NodecgChildProcess;
|
||||
pathExists: (candidatePath: string) => boolean;
|
||||
fetchUrl: typeof fetch;
|
||||
platform: NodeJS.Platform;
|
||||
@@ -31,6 +31,22 @@ type NodecgProcessManagerDeps = {
|
||||
hasReadWriteAccess: (candidatePath: string) => boolean;
|
||||
};
|
||||
|
||||
type NodecgChildProcess = {
|
||||
pid?: number;
|
||||
killed: boolean;
|
||||
exitCode: number | null;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
stdout?: ProcessOutputStream | null;
|
||||
stderr?: ProcessOutputStream | null;
|
||||
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
|
||||
on(event: "error", listener: (error: Error) => void): unknown;
|
||||
once(event: "exit", listener: () => void): unknown;
|
||||
};
|
||||
|
||||
type ProcessOutputStream = {
|
||||
on(event: "data", listener: (chunk: unknown) => void): unknown;
|
||||
};
|
||||
|
||||
export type NodecgProcessManager = {
|
||||
startNodecgProcess: () => Promise<void>;
|
||||
waitForNodecgReady: (startTime: number) => Promise<void>;
|
||||
@@ -50,7 +66,7 @@ export function createNodecgProcessManager({
|
||||
}: NodecgProcessManagerConfig): NodecgProcessManager {
|
||||
const resolvedDeps = resolveDeps(deps);
|
||||
|
||||
let nodecgProcess: ChildProcess | null = null;
|
||||
let nodecgProcess: NodecgChildProcess | null = null;
|
||||
let nodecgState: NodecgProcessState = "idle";
|
||||
let startNodecgPromise: Promise<void> | null = null;
|
||||
let stopNodecgPromise: Promise<void> | null = null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
|
||||
import { getDefaultUpdateConfigPath } from "../app/paths";
|
||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||
import { isRecord, readNonEmptyString } from "../utils/unknown-values";
|
||||
import { UpdateFileConfig, validateHttpUrl } from "./update-schema";
|
||||
|
||||
const DEFAULT_UPDATE_ASSET_PATTERN = "Scoreko-setup-.*\\.exe$";
|
||||
@@ -17,8 +18,17 @@ type UpdateConfigOptions = {
|
||||
allowInsecureHttp: boolean;
|
||||
};
|
||||
|
||||
type UpdateRuntimeConfig = Pick<
|
||||
AppRuntimeConfig,
|
||||
| "updateApiUrl"
|
||||
| "updateAssetPattern"
|
||||
| "updateConfigPathOverride"
|
||||
| "updateReleasePageUrl"
|
||||
| "updatesEnabled"
|
||||
>;
|
||||
|
||||
export function loadUpdateSettings(
|
||||
appConfig: AppRuntimeConfig,
|
||||
appConfig: UpdateRuntimeConfig,
|
||||
rootPath: string,
|
||||
log: (...args: unknown[]) => void,
|
||||
options: UpdateConfigOptions = { allowInsecureHttp: true },
|
||||
@@ -32,12 +42,12 @@ export function loadUpdateSettings(
|
||||
...(apiUrl ? { apiUrl } : {}),
|
||||
...(releasePageUrl ? { releasePageUrl } : {}),
|
||||
assetPattern:
|
||||
appConfig.updateAssetPattern || readOptionalString(fileConfig.assetPattern) || DEFAULT_UPDATE_ASSET_PATTERN,
|
||||
appConfig.updateAssetPattern || readNonEmptyString(fileConfig.assetPattern) || DEFAULT_UPDATE_ASSET_PATTERN,
|
||||
};
|
||||
}
|
||||
|
||||
export function readUpdateFileConfig(
|
||||
appConfig: AppRuntimeConfig,
|
||||
appConfig: Pick<AppRuntimeConfig, "updateConfigPathOverride">,
|
||||
rootPath: string,
|
||||
log: (...args: unknown[]) => void,
|
||||
): UpdateFileConfig {
|
||||
@@ -70,18 +80,10 @@ function normalizeUpdateFileConfig(value: unknown): UpdateFileConfig {
|
||||
}
|
||||
|
||||
function readOptionalHttpUrl(value: unknown, options: UpdateConfigOptions): string | undefined {
|
||||
const rawValue = readOptionalString(value);
|
||||
const rawValue = readNonEmptyString(value);
|
||||
if (!rawValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return validateHttpUrl(rawValue, options) ?? undefined;
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BrowserWindow, dialog } from "electron";
|
||||
import type { MessageBoxOptions } from "electron";
|
||||
import type { MessageBoxOptions, MessageBoxReturnValue } from "electron";
|
||||
|
||||
import { ReleaseUpdate } from "./update-schema";
|
||||
|
||||
@@ -41,6 +41,9 @@ export async function askToInstallUpdate(update: ReleaseUpdate, parentWindow: Br
|
||||
return result.response === 0;
|
||||
}
|
||||
|
||||
function showMessageBox(parentWindow: BrowserWindow | null, options: MessageBoxOptions) {
|
||||
function showMessageBox(
|
||||
parentWindow: BrowserWindow | null,
|
||||
options: MessageBoxOptions,
|
||||
): Promise<MessageBoxReturnValue> {
|
||||
return parentWindow ? dialog.showMessageBox(parentWindow, options) : dialog.showMessageBox(options);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isRecord, readNonEmptyString } from "../utils/unknown-values";
|
||||
|
||||
export type GiteaReleaseAsset = {
|
||||
name: string;
|
||||
browserDownloadUrl: string;
|
||||
@@ -47,11 +49,14 @@ export function parseGiteaRelease(value: unknown): GiteaRelease | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = readNonEmptyString(value.name);
|
||||
const pageUrl = readOptionalUrlString(value.html_url);
|
||||
|
||||
return {
|
||||
tagName,
|
||||
assets,
|
||||
...(readOptionalString(value.name) ? { title: readOptionalString(value.name) } : {}),
|
||||
...(readOptionalUrlString(value.html_url) ? { pageUrl: readOptionalUrlString(value.html_url) } : {}),
|
||||
...(title ? { title } : {}),
|
||||
...(pageUrl ? { pageUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,16 +182,12 @@ function normalizeVersion(version: string): number[] {
|
||||
}
|
||||
|
||||
function readRequiredString(value: unknown): string | null {
|
||||
const text = readOptionalString(value);
|
||||
const text = readNonEmptyString(value);
|
||||
return text && text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readOptionalUrlString(value: unknown): string | undefined {
|
||||
const rawValue = readOptionalString(value);
|
||||
const rawValue = readNonEmptyString(value);
|
||||
if (!rawValue) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -194,10 +195,6 @@ function readOptionalUrlString(value: unknown): string | undefined {
|
||||
return validateHttpUrl(rawValue, { allowInsecureHttp: true }) ?? undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isPresent<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function readNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
@@ -85,7 +85,7 @@ test("waitForNodeCGReady resolves when endpoint returns 404", async () => {
|
||||
deps: {
|
||||
platform: "linux",
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => {
|
||||
handler();
|
||||
@@ -118,7 +118,7 @@ test("stopNodeCG sends SIGTERM and then SIGKILL if the process does not exit", a
|
||||
deps: {
|
||||
platform: "linux",
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: (pid, signal) => {
|
||||
killSignals.push({ pid, signal });
|
||||
@@ -163,7 +163,7 @@ test("stopNodeCG reuses the same promise when invoked in parallel", async () =>
|
||||
log: () => undefined,
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: () => undefined,
|
||||
setTimer: () => 0,
|
||||
@@ -206,7 +206,7 @@ test("startNodeCG reuses the same promise while startup is in progress", async (
|
||||
}),
|
||||
spawnProcess: () => {
|
||||
spawnCalls += 1;
|
||||
return child as unknown as import("node:child_process").ChildProcess;
|
||||
return child;
|
||||
},
|
||||
stdoutWrite: () => undefined,
|
||||
stderrWrite: () => undefined,
|
||||
@@ -241,7 +241,7 @@ test("stopNodeCG normalizes negative timeout to zero", async () => {
|
||||
log: () => undefined,
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: () => undefined,
|
||||
setTimer: (handler, timeoutMs) => {
|
||||
@@ -306,7 +306,7 @@ test("startNodeCG spawns Electron directly on Windows", async () => {
|
||||
capturedCommand = command;
|
||||
capturedArgs = args;
|
||||
capturedOptions.push(options);
|
||||
return child as unknown as import("node:child_process").ChildProcess;
|
||||
return child;
|
||||
},
|
||||
stdoutWrite: () => undefined,
|
||||
stderrWrite: () => undefined,
|
||||
@@ -333,7 +333,7 @@ test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness"
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
platform: "linux",
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => {
|
||||
child.emit("exit", 1, null);
|
||||
throw new Error("still starting");
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
getEnv,
|
||||
getOptionalEnv,
|
||||
parseEnvBool,
|
||||
parseEnvInt,
|
||||
parseEnvIntInRange,
|
||||
parseEnvPort,
|
||||
parseOptionalHttpUrl,
|
||||
@@ -56,18 +55,6 @@ test("getEnv returns the value when present", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("parseEnvInt returns fallback for invalid values", () => {
|
||||
withEnv("TEST_ENV_INT", "abc", () => {
|
||||
assert.equal(parseEnvInt("TEST_ENV_INT", 100), 100);
|
||||
});
|
||||
});
|
||||
|
||||
test("parseEnvInt parses valid integers", () => {
|
||||
withEnv("TEST_ENV_INT", "4500", () => {
|
||||
assert.equal(parseEnvInt("TEST_ENV_INT", 100), 4500);
|
||||
});
|
||||
});
|
||||
|
||||
test("parseEnvIntInRange hard-fails for out-of-range values", () => {
|
||||
withEnv("TEST_ENV_INT_RANGE", "999", () => {
|
||||
assert.throws(() => parseEnvIntInRange("TEST_ENV_INT_RANGE", 100, 0, 100), /must be an integer/);
|
||||
|
||||
@@ -80,7 +80,7 @@ test("readUpdateFileConfig normalizes malformed config into an empty file config
|
||||
});
|
||||
|
||||
test("readUpdateFileConfig logs invalid JSON and returns an empty file config", () => {
|
||||
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-settings-"));
|
||||
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-config-"));
|
||||
const staticPath = path.join(rootPath, "static");
|
||||
fs.mkdirSync(staticPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(staticPath, "updates.json"), "{ invalid", "utf8");
|
||||
@@ -95,7 +95,7 @@ test("readUpdateFileConfig logs invalid JSON and returns an empty file config",
|
||||
});
|
||||
|
||||
function makeTempRoot(config: unknown): string {
|
||||
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-settings-"));
|
||||
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-config-"));
|
||||
const staticPath = path.join(rootPath, "static");
|
||||
|
||||
fs.mkdirSync(staticPath, { recursive: true });
|
||||
Reference in New Issue
Block a user