mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-06 05:32:06 +00:00
Refactor NodeCG runtime preparation and update handling
- Updated paths and configurations in doctor.mjs and prepare-nodecg-runtime.mjs to use new build-config.mjs imports. - Enhanced runtime installation checks and permissions validation. - Introduced new update configuration management in update-config.ts, including loading and validating update settings. - Implemented update service for managing update checks and downloads in update-service.ts. - Replaced update-utils.ts with update-schema.ts for better structure and clarity in update handling. - Added comprehensive tests for update download and settings management. - Ensured secure handling of download URLs and improved error handling in update processes.
This commit is contained in:
@@ -0,0 +1,121 @@
|
|||||||
|
# Phase 4 Summary
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Executed only the filesystem, updater, and packaging/build-config cleanup requested for this phase.
|
||||||
|
|
||||||
|
Documentation used as source of truth:
|
||||||
|
|
||||||
|
- `docs/refactor/ARCHITECTURE_AUDIT.md`
|
||||||
|
- `docs/refactor/ARCHITECTURE_RULES.md`
|
||||||
|
- `docs/refactor/TARGET_ARCHITECTURE.md`
|
||||||
|
- `docs/refactor/MIGRATION_PLAN.md`
|
||||||
|
- `docs/refactor/SESSION_HANDOFF.md`
|
||||||
|
|
||||||
|
## Filesystem And Paths
|
||||||
|
|
||||||
|
- Added pure path helpers in `src/main/app/paths.ts` for:
|
||||||
|
- managed NodeCG runtime storage under Electron `userData`
|
||||||
|
- default update config location
|
||||||
|
- update download temp directory
|
||||||
|
- safe child-path resolution that rejects traversal and absolute-path escape
|
||||||
|
- Updated runtime provisioning to use the managed-runtime path helper instead of rebuilding that storage path locally.
|
||||||
|
- Added tests for update storage paths and path traversal rejection.
|
||||||
|
|
||||||
|
## Updater
|
||||||
|
|
||||||
|
- Reorganized updater modules toward the target architecture:
|
||||||
|
- `src/main/updates/update-service.ts`
|
||||||
|
- `src/main/updates/update-config.ts`
|
||||||
|
- `src/main/updates/update-schema.ts`
|
||||||
|
- `src/main/updates/update-download.ts`
|
||||||
|
- Removed the older updater module names:
|
||||||
|
- `update-manager.ts`
|
||||||
|
- `update-settings.ts`
|
||||||
|
- `update-utils.ts`
|
||||||
|
- Added runtime validation for remote Gitea release metadata before building update state.
|
||||||
|
- Added URL policy handling so packaged builds reject insecure HTTP update URLs and installer downloads.
|
||||||
|
- Kept local development able to use HTTP update endpoints explicitly through the dev policy.
|
||||||
|
- Changed installer download behavior to:
|
||||||
|
- validate URL protocol before fetch
|
||||||
|
- sanitize installer file names
|
||||||
|
- constrain output to the safe temp download directory
|
||||||
|
- write to a staging file first
|
||||||
|
- finalize with atomic rename
|
||||||
|
- clean staging files on failure
|
||||||
|
- Kept dialogs and install handoff separate from schema parsing and download streaming.
|
||||||
|
|
||||||
|
## Packaging And Build Config
|
||||||
|
|
||||||
|
- Added `scripts/build-config.mjs` as the shared build-layout source for scripts.
|
||||||
|
- Consolidated repeated script constants for:
|
||||||
|
- Electron package root
|
||||||
|
- parent Scoreko bundle root
|
||||||
|
- packaged NodeCG runtime root
|
||||||
|
- bundle name
|
||||||
|
- generated bundle entries
|
||||||
|
- prepared runtime entries
|
||||||
|
- npm/electron cache locations
|
||||||
|
- local binary path resolution
|
||||||
|
- Updated packaging-related scripts to use the shared config:
|
||||||
|
- `scripts/build-scoreko-bundle.mjs`
|
||||||
|
- `scripts/prepare-nodecg-runtime.mjs`
|
||||||
|
- `scripts/rebuild-nodecg-native.mjs`
|
||||||
|
- `scripts/doctor.mjs`
|
||||||
|
- Improved the missing parent-project error in `build-scoreko-bundle.mjs` so CI/local failures report the expected layout and missing markers.
|
||||||
|
|
||||||
|
## Intentionally Not Changed
|
||||||
|
|
||||||
|
- No UX changes.
|
||||||
|
- No custom renderer.
|
||||||
|
- No preload.
|
||||||
|
- No IPC.
|
||||||
|
- No Electron window behavior changes.
|
||||||
|
- No NodeCG runtime model changes.
|
||||||
|
- No user-owned runtime directory deletion changes.
|
||||||
|
- No broad build framework introduced.
|
||||||
|
- No `any` added.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Commands run successfully:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm.cmd run typecheck
|
||||||
|
npm.cmd test
|
||||||
|
npm.cmd run lint
|
||||||
|
npm.cmd run doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
Current test result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
65 tests passing
|
||||||
|
```
|
||||||
|
|
||||||
|
Packaging verification:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm.cmd run pack
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- Passed with escalated filesystem permission, generating `release/win-unpacked`.
|
||||||
|
- A later non-escalated rerun was blocked by the sandbox while writing generated bundle output in the parent Scoreko project (`shared/dist`). That rerun failed before packaging because of sandbox filesystem permissions, not because of a build error.
|
||||||
|
- A final escalated rerun could not be started because the approval system rejected the escalation. Typecheck, tests, lint, and doctor were run successfully around the packaging verification.
|
||||||
|
|
||||||
|
Sanity searches:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rg -n "\bany\b|update-manager|update-settings|update-utils|ActualizaciÃ|estÃ|versiÃ|nodeIntegration:\s*true|webSecurity:\s*false|ipcMain|ipcRenderer|contextBridge|preload" src scripts docs/refactor
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- No `any` was introduced in production or test source.
|
||||||
|
- No legacy updater module references remain in `src`.
|
||||||
|
- No touched Spanish update text is mojibaked.
|
||||||
|
- No production IPC or preload surface exists.
|
||||||
|
- No unsafe Electron window settings were introduced.
|
||||||
|
- Remaining IPC/preload matches are documentation and the regression test that guards the zero-surface policy.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const electronRoot = process.cwd();
|
||||||
|
export const bundleRoot = path.resolve(electronRoot, "..");
|
||||||
|
export const nodecgRuntimeRoot = path.join(electronRoot, "lib", "nodecg");
|
||||||
|
export const nodecgRuntimeNodeModules = path.join(nodecgRuntimeRoot, "node_modules");
|
||||||
|
export const bundleName = process.env.NODECG_BUNDLE_NAME?.trim() || "scoreko-dev";
|
||||||
|
export const runtimeBundleRoot = path.join(nodecgRuntimeRoot, "bundles", bundleName);
|
||||||
|
export const runtimeNpmCache = process.env.npm_config_cache ?? path.join(electronRoot, ".npm-runtime-cache");
|
||||||
|
export const electronCache = process.env.ELECTRON_CACHE ?? path.join(electronRoot, ".electron-cache");
|
||||||
|
|
||||||
|
export const bundleRootMarkers = ["package.json", "pnpm-lock.yaml"];
|
||||||
|
export const generatedBundleEntries = ["extension", "node_modules/.vite", "shared/dist", "dashboard", "graphics"];
|
||||||
|
export const preparedBundleEntries = [
|
||||||
|
"assets",
|
||||||
|
"dashboard",
|
||||||
|
"extension",
|
||||||
|
"graphics",
|
||||||
|
"nodecg",
|
||||||
|
"schemas",
|
||||||
|
"shared",
|
||||||
|
"configschema.json",
|
||||||
|
"LICENSE",
|
||||||
|
"package.json",
|
||||||
|
"README.md",
|
||||||
|
];
|
||||||
|
export const requiredPreparedBundleEntries = [
|
||||||
|
"dashboard",
|
||||||
|
"extension",
|
||||||
|
"graphics",
|
||||||
|
"nodecg",
|
||||||
|
"schemas",
|
||||||
|
"shared",
|
||||||
|
"package.json",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getNpmCommand() {
|
||||||
|
return process.platform === "win32" ? "npm.cmd" : "npm";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalBinPath(commandName) {
|
||||||
|
const extension = process.platform === "win32" ? ".CMD" : "";
|
||||||
|
return path.join(bundleRoot, "node_modules", ".bin", `${commandName}${extension}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPathInside(rootPath, relativePath) {
|
||||||
|
const resolvedRoot = path.resolve(rootPath);
|
||||||
|
const targetPath = path.resolve(resolvedRoot, relativePath);
|
||||||
|
const pathFromRoot = path.relative(resolvedRoot, targetPath);
|
||||||
|
|
||||||
|
if (!pathFromRoot || pathFromRoot.startsWith("..") || path.isAbsolute(pathFromRoot)) {
|
||||||
|
throw new Error(`Refusing to access path outside ${resolvedRoot}: ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetPath;
|
||||||
|
}
|
||||||
@@ -3,14 +3,29 @@ import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
const electronRoot = process.cwd();
|
import {
|
||||||
const bundleRoot = path.resolve(electronRoot, "..");
|
bundleRoot,
|
||||||
const packageJsonPath = path.join(bundleRoot, "package.json");
|
bundleRootMarkers,
|
||||||
const pnpmLockPath = path.join(bundleRoot, "pnpm-lock.yaml");
|
electronRoot,
|
||||||
|
generatedBundleEntries,
|
||||||
|
getLocalBinPath,
|
||||||
|
getPathInside,
|
||||||
|
} from "./build-config.mjs";
|
||||||
|
|
||||||
const nodeModulesPath = path.join(bundleRoot, "node_modules");
|
const nodeModulesPath = path.join(bundleRoot, "node_modules");
|
||||||
|
|
||||||
if (!existsSync(packageJsonPath) || !existsSync(pnpmLockPath)) {
|
const missingMarkers = bundleRootMarkers
|
||||||
console.error(`Scoreko bundle root was not found at: ${bundleRoot}`);
|
.map((entry) => path.join(bundleRoot, entry))
|
||||||
|
.filter((candidatePath) => !existsSync(candidatePath));
|
||||||
|
|
||||||
|
if (missingMarkers.length > 0) {
|
||||||
|
console.error(
|
||||||
|
[
|
||||||
|
`Scoreko bundle root was not found at: ${bundleRoot}`,
|
||||||
|
"This Electron package expects to live inside the Scoreko repository with the bundle project as its parent.",
|
||||||
|
...missingMarkers.map((candidatePath) => `Missing: ${candidatePath}`),
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +40,6 @@ if (!existsSync(nodeModulesPath)) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const generatedBundleEntries = ["extension", "node_modules/.vite", "shared/dist", "dashboard", "graphics"];
|
|
||||||
const childEnv = {
|
const childEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
COREPACK_HOME: process.env.COREPACK_HOME ?? path.join(electronRoot, ".corepack"),
|
COREPACK_HOME: process.env.COREPACK_HOME ?? path.join(electronRoot, ".corepack"),
|
||||||
@@ -33,12 +47,7 @@ const childEnv = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function removeGeneratedOutput(relativePath) {
|
function removeGeneratedOutput(relativePath) {
|
||||||
const targetPath = path.resolve(bundleRoot, relativePath);
|
const targetPath = getPathInside(bundleRoot, relativePath);
|
||||||
|
|
||||||
if (!targetPath.startsWith(`${bundleRoot}${path.sep}`)) {
|
|
||||||
throw new Error(`Refusing to remove path outside the bundle root: ${targetPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
rmSync(targetPath, { recursive: true, force: true });
|
rmSync(targetPath, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,11 +69,6 @@ function runCommand(command, args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function runLocalBin(commandName, args) {
|
|
||||||
const extension = process.platform === "win32" ? ".CMD" : "";
|
|
||||||
runCommand(path.join(bundleRoot, "node_modules", ".bin", `${commandName}${extension}`), args);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of generatedBundleEntries) {
|
for (const entry of generatedBundleEntries) {
|
||||||
removeGeneratedOutput(entry);
|
removeGeneratedOutput(entry);
|
||||||
}
|
}
|
||||||
@@ -73,5 +77,5 @@ for (const entry of ["shared/dist", "dashboard", "graphics", "extension"]) {
|
|||||||
mkdirSync(path.join(bundleRoot, entry), { recursive: true });
|
mkdirSync(path.join(bundleRoot, entry), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
runLocalBin("vite", ["build", "--configLoader", "runner"]);
|
runCommand(getLocalBinPath("vite"), ["build", "--configLoader", "runner"]);
|
||||||
runLocalBin("tsc", ["-b", "tsconfig.extension.json"]);
|
runCommand(getLocalBinPath("tsc"), ["-b", "tsconfig.extension.json"]);
|
||||||
|
|||||||
+7
-9
@@ -3,8 +3,7 @@ import fs from "node:fs";
|
|||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const cwd = process.cwd();
|
import { bundleName, nodecgRuntimeRoot } from "./build-config.mjs";
|
||||||
const nodecgRootPath = path.resolve(cwd, "lib", "nodecg");
|
|
||||||
|
|
||||||
const checks = [];
|
const checks = [];
|
||||||
|
|
||||||
@@ -36,20 +35,19 @@ function parseIntInRange(name, fallback, min, max) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function checkNodecgInstall() {
|
function checkNodecgInstall() {
|
||||||
const indexPath = path.join(nodecgRootPath, "index.js");
|
const indexPath = path.join(nodecgRuntimeRoot, "index.js");
|
||||||
const bootstrapPath = path.join(nodecgRootPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
|
const bootstrapPath = path.join(nodecgRuntimeRoot, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
|
||||||
const manifestPath = path.join(nodecgRootPath, ".scoreko-runtime.json");
|
const manifestPath = path.join(nodecgRuntimeRoot, ".scoreko-runtime.json");
|
||||||
const bundleName = (process.env.NODECG_BUNDLE_NAME ?? "scoreko-dev").trim();
|
const bundlePath = path.join(nodecgRuntimeRoot, "bundles", bundleName);
|
||||||
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName);
|
|
||||||
|
|
||||||
addCheck(fs.existsSync(nodecgRootPath), "Packaged NodeCG runtime", nodecgRootPath);
|
addCheck(fs.existsSync(nodecgRuntimeRoot), "Packaged NodeCG runtime", nodecgRuntimeRoot);
|
||||||
addCheck(fs.existsSync(indexPath), "Runtime index.js", indexPath);
|
addCheck(fs.existsSync(indexPath), "Runtime index.js", indexPath);
|
||||||
addCheck(fs.existsSync(bootstrapPath), "NodeCG bootstrap", bootstrapPath);
|
addCheck(fs.existsSync(bootstrapPath), "NodeCG bootstrap", bootstrapPath);
|
||||||
addCheck(fs.existsSync(manifestPath), "Runtime manifest", manifestPath);
|
addCheck(fs.existsSync(manifestPath), "Runtime manifest", manifestPath);
|
||||||
addCheck(fs.existsSync(bundlePath), `Packaged bundle '${bundleName}'`, bundlePath);
|
addCheck(fs.existsSync(bundlePath), `Packaged bundle '${bundleName}'`, bundlePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.accessSync(nodecgRootPath, fs.constants.R_OK | fs.constants.W_OK);
|
fs.accessSync(nodecgRuntimeRoot, fs.constants.R_OK | fs.constants.W_OK);
|
||||||
addCheck(true, "lib/nodecg permissions", "Read/write OK for local development");
|
addCheck(true, "lib/nodecg permissions", "Read/write OK for local development");
|
||||||
} catch {
|
} catch {
|
||||||
addCheck(false, "lib/nodecg permissions", "No read/write permissions in lib/nodecg");
|
addCheck(false, "lib/nodecg permissions", "No read/write permissions in lib/nodecg");
|
||||||
|
|||||||
@@ -3,28 +3,17 @@ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } fr
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
const electronRoot = process.cwd();
|
import {
|
||||||
const bundleRoot = path.resolve(electronRoot, "..");
|
bundleName,
|
||||||
const runtimeRoot = path.join(electronRoot, "lib", "nodecg");
|
bundleRoot,
|
||||||
const runtimeNodeModules = path.join(runtimeRoot, "node_modules");
|
getNpmCommand,
|
||||||
const bundleName = process.env.NODECG_BUNDLE_NAME?.trim() || "scoreko-dev";
|
nodecgRuntimeNodeModules,
|
||||||
const runtimeBundleRoot = path.join(runtimeRoot, "bundles", bundleName);
|
nodecgRuntimeRoot,
|
||||||
|
preparedBundleEntries,
|
||||||
const bundleEntries = [
|
requiredPreparedBundleEntries,
|
||||||
"assets",
|
runtimeBundleRoot,
|
||||||
"dashboard",
|
runtimeNpmCache,
|
||||||
"extension",
|
} from "./build-config.mjs";
|
||||||
"graphics",
|
|
||||||
"nodecg",
|
|
||||||
"schemas",
|
|
||||||
"shared",
|
|
||||||
"configschema.json",
|
|
||||||
"LICENSE",
|
|
||||||
"package.json",
|
|
||||||
"README.md",
|
|
||||||
];
|
|
||||||
|
|
||||||
const requiredBundleEntries = ["dashboard", "extension", "graphics", "nodecg", "schemas", "shared", "package.json"];
|
|
||||||
|
|
||||||
function readJson(filePath) {
|
function readJson(filePath) {
|
||||||
return JSON.parse(readFileSync(filePath, "utf8"));
|
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||||
@@ -51,7 +40,7 @@ function run(command, args, cwd) {
|
|||||||
shell: process.platform === "win32",
|
shell: process.platform === "win32",
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
npm_config_cache: process.env.npm_config_cache ?? path.join(electronRoot, ".npm-runtime-cache"),
|
npm_config_cache: runtimeNpmCache,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +70,7 @@ function getInstalledNodecgVersion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assertBundleBuildExists() {
|
function assertBundleBuildExists() {
|
||||||
for (const entry of requiredBundleEntries) {
|
for (const entry of requiredPreparedBundleEntries) {
|
||||||
const source = path.join(bundleRoot, entry);
|
const source = path.join(bundleRoot, entry);
|
||||||
if (!existsSync(source)) {
|
if (!existsSync(source)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -103,7 +92,7 @@ function createRuntimePackageJson() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
path.join(runtimeRoot, "package.json"),
|
path.join(nodecgRuntimeRoot, "package.json"),
|
||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
{
|
{
|
||||||
private: true,
|
private: true,
|
||||||
@@ -121,23 +110,23 @@ function createRuntimePackageJson() {
|
|||||||
)}\n`,
|
)}\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
writeFileSync(path.join(runtimeRoot, "index.js"), 'require("nodecg");\n');
|
writeFileSync(path.join(nodecgRuntimeRoot, "index.js"), 'require("nodecg");\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyBundle() {
|
function copyBundle() {
|
||||||
mkdirSync(runtimeBundleRoot, { recursive: true });
|
mkdirSync(runtimeBundleRoot, { recursive: true });
|
||||||
|
|
||||||
for (const entry of bundleEntries) {
|
for (const entry of preparedBundleEntries) {
|
||||||
copyIfExists(path.join(bundleRoot, entry), path.join(runtimeBundleRoot, entry));
|
copyIfExists(path.join(bundleRoot, entry), path.join(runtimeBundleRoot, entry));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeManifest() {
|
function writeManifest() {
|
||||||
const bundlePackageJson = readJson(path.join(bundleRoot, "package.json"));
|
const bundlePackageJson = readJson(path.join(bundleRoot, "package.json"));
|
||||||
const runtimePackageJson = readJson(path.join(runtimeRoot, "package.json"));
|
const runtimePackageJson = readJson(path.join(nodecgRuntimeRoot, "package.json"));
|
||||||
|
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
path.join(runtimeRoot, ".scoreko-runtime.json"),
|
path.join(nodecgRuntimeRoot, ".scoreko-runtime.json"),
|
||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
{
|
{
|
||||||
bundleName,
|
bundleName,
|
||||||
@@ -157,23 +146,22 @@ function installRuntimeDependencies() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
run(getNpmCommand(), ["install", "--omit=dev", "--no-audit", "--no-fund"], nodecgRuntimeRoot);
|
||||||
run(npmCommand, ["install", "--omit=dev", "--no-audit", "--no-fund"], runtimeRoot);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
assertBundleBuildExists();
|
assertBundleBuildExists();
|
||||||
|
|
||||||
rmSync(runtimeRoot, { recursive: true, force: true });
|
rmSync(nodecgRuntimeRoot, { recursive: true, force: true });
|
||||||
mkdirSync(runtimeNodeModules, { recursive: true });
|
mkdirSync(nodecgRuntimeNodeModules, { recursive: true });
|
||||||
mkdirSync(path.join(runtimeRoot, "bundles"), { recursive: true });
|
mkdirSync(path.join(nodecgRuntimeRoot, "bundles"), { recursive: true });
|
||||||
|
|
||||||
createRuntimePackageJson();
|
createRuntimePackageJson();
|
||||||
copyBundle();
|
copyBundle();
|
||||||
installRuntimeDependencies();
|
installRuntimeDependencies();
|
||||||
writeManifest();
|
writeManifest();
|
||||||
|
|
||||||
console.log(`[prepare-runtime] NodeCG runtime ready at ${runtimeRoot}`);
|
console.log(`[prepare-runtime] NodeCG runtime ready at ${nodecgRuntimeRoot}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,18 +2,17 @@ import { existsSync, readFileSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
const root = process.cwd();
|
import { electronCache, electronRoot, getNpmCommand, nodecgRuntimeRoot, runtimeNpmCache } from "./build-config.mjs";
|
||||||
const nodecgDir = path.join(root, "lib", "nodecg");
|
|
||||||
const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
const packageJson = JSON.parse(readFileSync(path.join(electronRoot, "package.json"), "utf8"));
|
||||||
const electronVersion = packageJson.devDependencies?.electron ?? packageJson.dependencies?.electron;
|
const electronVersion = packageJson.devDependencies?.electron ?? packageJson.dependencies?.electron;
|
||||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
||||||
|
|
||||||
if (!electronVersion) {
|
if (!electronVersion) {
|
||||||
console.error("Could not determine Electron version from package.json.");
|
console.error("Could not determine Electron version from package.json.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existsSync(path.join(nodecgDir, "package.json"))) {
|
if (!existsSync(path.join(nodecgRuntimeRoot, "package.json"))) {
|
||||||
console.error("No packaged NodeCG runtime found. Run npm run prepare:runtime first.");
|
console.error("No packaged NodeCG runtime found. Run npm run prepare:runtime first.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -30,8 +29,8 @@ function run(command, args, cwd) {
|
|||||||
npm_config_runtime: "electron",
|
npm_config_runtime: "electron",
|
||||||
npm_config_target: electronVersion,
|
npm_config_target: electronVersion,
|
||||||
npm_config_disturl: "https://electronjs.org/headers",
|
npm_config_disturl: "https://electronjs.org/headers",
|
||||||
npm_config_cache: process.env.npm_config_cache ?? path.join(root, ".npm-runtime-cache"),
|
npm_config_cache: runtimeNpmCache,
|
||||||
ELECTRON_CACHE: process.env.ELECTRON_CACHE ?? path.join(root, ".electron-cache"),
|
ELECTRON_CACHE: electronCache,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,13 +44,13 @@ function run(command, args, cwd) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n[rebuild-native] Rebuilding better-sqlite3 for Electron ${electronVersion} in: ${nodecgDir}`);
|
console.log(`\n[rebuild-native] Rebuilding better-sqlite3 for Electron ${electronVersion} in: ${nodecgRuntimeRoot}`);
|
||||||
await run(npmCommand, [
|
await run(getNpmCommand(), [
|
||||||
"rebuild",
|
"rebuild",
|
||||||
"better-sqlite3",
|
"better-sqlite3",
|
||||||
"--runtime=electron",
|
"--runtime=electron",
|
||||||
`--target=${electronVersion}`,
|
`--target=${electronVersion}`,
|
||||||
"--dist-url=https://electronjs.org/headers",
|
"--dist-url=https://electronjs.org/headers",
|
||||||
], nodecgDir);
|
], nodecgRuntimeRoot);
|
||||||
|
|
||||||
console.log("\n[rebuild-native] Done.");
|
console.log("\n[rebuild-native] Done.");
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getRuntimeConfig } from "../config/runtime-config";
|
|||||||
import { showFatalError, log } from "../errors/error-presenter";
|
import { showFatalError, log } from "../errors/error-presenter";
|
||||||
import { createNodecgProcessManager } from "../nodecg/process-manager";
|
import { createNodecgProcessManager } from "../nodecg/process-manager";
|
||||||
import { prepareUserNodecgRuntime } from "../nodecg/runtime-provisioner";
|
import { prepareUserNodecgRuntime } from "../nodecg/runtime-provisioner";
|
||||||
import { scheduleUpdateCheck } from "../updates/update-manager";
|
import { scheduleUpdateCheck } from "../updates/update-service";
|
||||||
import { createLoadingWindow, createMainWindow } from "../windows/window-service";
|
import { createLoadingWindow, createMainWindow } from "../windows/window-service";
|
||||||
import { createApplicationController } from "./application-controller";
|
import { createApplicationController } from "./application-controller";
|
||||||
import { getApplicationPaths } from "./paths";
|
import { getApplicationPaths } from "./paths";
|
||||||
|
|||||||
@@ -19,10 +19,36 @@ export function getUserDataPath(appDataPath: string, userDataDirectoryName: stri
|
|||||||
return path.join(appDataPath, userDataDirectoryName);
|
return path.join(appDataPath, userDataDirectoryName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getManagedNodecgRuntimePath(userDataPath: string): string {
|
||||||
|
return path.join(userDataPath, "nodecg");
|
||||||
|
}
|
||||||
|
|
||||||
export function getSourceNodecgRuntimePath(rootPath: string): string {
|
export function getSourceNodecgRuntimePath(rootPath: string): string {
|
||||||
return path.resolve(rootPath, "lib", "nodecg");
|
return path.resolve(rootPath, "lib", "nodecg");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultUpdateConfigPath(rootPath: string): string {
|
||||||
|
return path.join(rootPath, "static", "updates.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUpdateDownloadDirectory(tempDirectory: string): string {
|
||||||
|
return path.join(tempDirectory, "scoreko-updates");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSafeChildPath(parentDirectory: string, fileName: string): string {
|
||||||
|
const resolvedParent = path.resolve(parentDirectory);
|
||||||
|
const resolvedChild = path.resolve(resolvedParent, fileName);
|
||||||
|
const relativePath = path.relative(resolvedParent, resolvedChild);
|
||||||
|
const isInsideParent =
|
||||||
|
relativePath.length > 0 && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
|
||||||
|
|
||||||
|
if (!isInsideParent) {
|
||||||
|
throw new Error(`Refusing to build a path outside ${resolvedParent}: ${fileName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedChild;
|
||||||
|
}
|
||||||
|
|
||||||
export function getNodecgBaseUrl(nodecgPort: string): string {
|
export function getNodecgBaseUrl(nodecgPort: string): string {
|
||||||
return `http://127.0.0.1:${nodecgPort}`;
|
return `http://127.0.0.1:${nodecgPort}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { getManagedNodecgRuntimePath } from "../app/paths";
|
||||||
|
|
||||||
type RuntimeProvisionerConfig = {
|
type RuntimeProvisionerConfig = {
|
||||||
sourceRuntimePath: string;
|
sourceRuntimePath: string;
|
||||||
userDataPath: string;
|
userDataPath: string;
|
||||||
@@ -55,7 +57,7 @@ export function prepareUserNodecgRuntime({
|
|||||||
deps,
|
deps,
|
||||||
}: RuntimeProvisionerConfig): PreparedNodecgRuntime {
|
}: RuntimeProvisionerConfig): PreparedNodecgRuntime {
|
||||||
const resolvedDeps = resolveDeps(deps);
|
const resolvedDeps = resolveDeps(deps);
|
||||||
const targetRuntimePath = path.join(userDataPath, "nodecg");
|
const targetRuntimePath = getManagedNodecgRuntimePath(userDataPath);
|
||||||
|
|
||||||
validateSourceRuntime(sourceRuntimePath, bundleName, resolvedDeps.existsSync);
|
validateSourceRuntime(sourceRuntimePath, bundleName, resolvedDeps.existsSync);
|
||||||
resolvedDeps.mkdirSync(targetRuntimePath, { recursive: true });
|
resolvedDeps.mkdirSync(targetRuntimePath, { recursive: true });
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
|
import { getDefaultUpdateConfigPath } from "../app/paths";
|
||||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||||
import { UpdateFileConfig } from "./update-utils";
|
import { UpdateFileConfig, validateHttpUrl } from "./update-schema";
|
||||||
|
|
||||||
const DEFAULT_UPDATE_ASSET_PATTERN = "Scoreko-setup-.*\\.exe$";
|
const DEFAULT_UPDATE_ASSET_PATTERN = "Scoreko-setup-.*\\.exe$";
|
||||||
|
|
||||||
@@ -13,17 +13,24 @@ export type UpdateSettings = {
|
|||||||
assetPattern: string;
|
assetPattern: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UpdateConfigOptions = {
|
||||||
|
allowInsecureHttp: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function loadUpdateSettings(
|
export function loadUpdateSettings(
|
||||||
appConfig: AppRuntimeConfig,
|
appConfig: AppRuntimeConfig,
|
||||||
rootPath: string,
|
rootPath: string,
|
||||||
log: (...args: unknown[]) => void,
|
log: (...args: unknown[]) => void,
|
||||||
|
options: UpdateConfigOptions = { allowInsecureHttp: true },
|
||||||
): UpdateSettings {
|
): UpdateSettings {
|
||||||
const fileConfig = readUpdateFileConfig(appConfig, rootPath, log);
|
const fileConfig = readUpdateFileConfig(appConfig, rootPath, log);
|
||||||
|
const apiUrl = readOptionalHttpUrl(appConfig.updateApiUrl ?? fileConfig.apiUrl, options);
|
||||||
|
const releasePageUrl = readOptionalHttpUrl(appConfig.updateReleasePageUrl ?? fileConfig.releasePageUrl, options);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: appConfig.updatesEnabled && (Boolean(fileConfig.enabled) || Boolean(appConfig.updateApiUrl)),
|
enabled: appConfig.updatesEnabled && (Boolean(fileConfig.enabled) || Boolean(appConfig.updateApiUrl)) && Boolean(apiUrl),
|
||||||
apiUrl: appConfig.updateApiUrl ?? readOptionalString(fileConfig.apiUrl),
|
...(apiUrl ? { apiUrl } : {}),
|
||||||
releasePageUrl: appConfig.updateReleasePageUrl ?? readOptionalString(fileConfig.releasePageUrl),
|
...(releasePageUrl ? { releasePageUrl } : {}),
|
||||||
assetPattern:
|
assetPattern:
|
||||||
appConfig.updateAssetPattern || readOptionalString(fileConfig.assetPattern) || DEFAULT_UPDATE_ASSET_PATTERN,
|
appConfig.updateAssetPattern || readOptionalString(fileConfig.assetPattern) || DEFAULT_UPDATE_ASSET_PATTERN,
|
||||||
};
|
};
|
||||||
@@ -34,7 +41,7 @@ export function readUpdateFileConfig(
|
|||||||
rootPath: string,
|
rootPath: string,
|
||||||
log: (...args: unknown[]) => void,
|
log: (...args: unknown[]) => void,
|
||||||
): UpdateFileConfig {
|
): UpdateFileConfig {
|
||||||
const configPath = appConfig.updateConfigPathOverride ?? path.join(rootPath, "static", "updates.json");
|
const configPath = appConfig.updateConfigPathOverride ?? getDefaultUpdateConfigPath(rootPath);
|
||||||
|
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
return {};
|
return {};
|
||||||
@@ -62,6 +69,15 @@ function normalizeUpdateFileConfig(value: unknown): UpdateFileConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalHttpUrl(value: unknown, options: UpdateConfigOptions): string | undefined {
|
||||||
|
const rawValue = readOptionalString(value);
|
||||||
|
if (!rawValue) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateHttpUrl(rawValue, options) ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function readOptionalString(value: unknown): string | undefined {
|
function readOptionalString(value: unknown): string | undefined {
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BrowserWindow, dialog } from "electron";
|
import { BrowserWindow, dialog } from "electron";
|
||||||
import type { MessageBoxOptions } from "electron";
|
import type { MessageBoxOptions } from "electron";
|
||||||
|
|
||||||
import { ReleaseUpdate } from "./update-utils";
|
import { ReleaseUpdate } from "./update-schema";
|
||||||
|
|
||||||
export type DownloadUpdateChoice = "download" | "open-release" | "dismiss";
|
export type DownloadUpdateChoice = "download" | "open-release" | "dismiss";
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,103 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import { Writable } from "node:stream";
|
||||||
import { Readable } from "node:stream";
|
|
||||||
|
|
||||||
import { ReleaseUpdate, sanitizeFileName } from "./update-utils";
|
import { getSafeChildPath, getUpdateDownloadDirectory } from "../app/paths";
|
||||||
|
import { ReleaseUpdate, sanitizeFileName, validateHttpUrl } from "./update-schema";
|
||||||
|
|
||||||
type UpdateDownloadConfig = {
|
type UpdateDownloadConfig = {
|
||||||
tempDirectory: string;
|
tempDirectory: string;
|
||||||
|
allowInsecureHttp: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function downloadInstaller(update: ReleaseUpdate, config: UpdateDownloadConfig): Promise<string> {
|
export async function downloadInstaller(update: ReleaseUpdate, config: UpdateDownloadConfig): Promise<string> {
|
||||||
|
const downloadUrl = validateHttpUrl(update.installer.downloadUrl, {
|
||||||
|
allowInsecureHttp: config.allowInsecureHttp,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!downloadUrl) {
|
||||||
|
throw new Error("Update installer URL is invalid or uses an unsupported protocol.");
|
||||||
|
}
|
||||||
|
|
||||||
const safeFileName = sanitizeFileName(update.installer.name);
|
const safeFileName = sanitizeFileName(update.installer.name);
|
||||||
const downloadDirectory = path.join(config.tempDirectory, "scoreko-updates");
|
const downloadDirectory = getUpdateDownloadDirectory(config.tempDirectory);
|
||||||
const targetPath = path.join(downloadDirectory, safeFileName);
|
const targetPath = getSafeChildPath(downloadDirectory, safeFileName);
|
||||||
|
const stagingPath = getSafeChildPath(downloadDirectory, `${safeFileName}.${process.pid}.${Date.now()}.download`);
|
||||||
|
|
||||||
fs.mkdirSync(downloadDirectory, { recursive: true });
|
fs.mkdirSync(downloadDirectory, { recursive: true });
|
||||||
|
fs.rmSync(stagingPath, { force: true });
|
||||||
|
|
||||||
const response = await fetch(update.installer.downloadUrl);
|
const response = await fetch(downloadUrl);
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
throw new Error(`Could not download update installer. HTTP ${response.status}.`);
|
throw new Error(`Could not download update installer. HTTP ${response.status}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
try {
|
||||||
const fileStream = fs.createWriteStream(targetPath);
|
await writeResponseBodyToFile(response.body, stagingPath);
|
||||||
const responseStream = Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]);
|
fs.renameSync(stagingPath, targetPath);
|
||||||
|
} catch (error) {
|
||||||
responseStream.on("error", reject);
|
fs.rmSync(stagingPath, { force: true });
|
||||||
fileStream.on("error", reject);
|
throw error;
|
||||||
fileStream.on("finish", resolve);
|
}
|
||||||
responseStream.pipe(fileStream);
|
|
||||||
});
|
|
||||||
|
|
||||||
return targetPath;
|
return targetPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeResponseBodyToFile(body: ReadableStream<Uint8Array>, filePath: string): Promise<void> {
|
||||||
|
const reader = body.getReader();
|
||||||
|
const fileStream = fs.createWriteStream(filePath, { flags: "wx" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeChunk(fileStream, chunk.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
await endStream(fileStream);
|
||||||
|
} catch (error) {
|
||||||
|
fileStream.destroy();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeChunk(stream: Writable, chunk: Uint8Array): Promise<void> {
|
||||||
|
if (stream.write(chunk)) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const cleanup = (): void => {
|
||||||
|
stream.off("drain", onDrain);
|
||||||
|
stream.off("error", onError);
|
||||||
|
};
|
||||||
|
const onDrain = (): void => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const onError = (error: Error): void => {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
stream.once("drain", onDrain);
|
||||||
|
stream.once("error", onError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function endStream(stream: Writable): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
stream.end((error?: Error | null) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
export type GiteaReleaseAsset = {
|
||||||
|
name: string;
|
||||||
|
browserDownloadUrl: string;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GiteaRelease = {
|
||||||
|
tagName: string;
|
||||||
|
title?: string;
|
||||||
|
pageUrl?: string;
|
||||||
|
assets: GiteaReleaseAsset[];
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UrlPolicy = {
|
||||||
|
allowInsecureHttp: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseGiteaRelease(value: unknown): GiteaRelease | null {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagName = readRequiredString(value.tag_name);
|
||||||
|
const assets = Array.isArray(value.assets) ? value.assets.map(parseGiteaReleaseAsset).filter(isPresent) : null;
|
||||||
|
|
||||||
|
if (!tagName || !assets) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagName,
|
||||||
|
assets,
|
||||||
|
...(readOptionalString(value.name) ? { title: readOptionalString(value.name) } : {}),
|
||||||
|
...(readOptionalUrlString(value.html_url) ? { pageUrl: readOptionalUrlString(value.html_url) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 selectInstallerAsset(
|
||||||
|
release: GiteaRelease,
|
||||||
|
assetPattern: string,
|
||||||
|
policy: UrlPolicy = { allowInsecureHttp: true },
|
||||||
|
): InstallerAsset | null {
|
||||||
|
const matcher = new RegExp(assetPattern, "i");
|
||||||
|
|
||||||
|
for (const asset of release.assets) {
|
||||||
|
if (!matcher.test(asset.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadUrl = validateHttpUrl(asset.browserDownloadUrl, policy);
|
||||||
|
if (!downloadUrl) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: asset.name,
|
||||||
|
downloadUrl,
|
||||||
|
...(typeof asset.size === "number" ? { size: asset.size } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReleaseUpdate(
|
||||||
|
release: GiteaRelease,
|
||||||
|
currentVersion: string,
|
||||||
|
assetPattern: string,
|
||||||
|
policy: UrlPolicy = { allowInsecureHttp: true },
|
||||||
|
): ReleaseUpdate | null {
|
||||||
|
const version = release.tagName.replace(/^v/i, "");
|
||||||
|
if (!version || !isVersionNewer(version, currentVersion)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installer = selectInstallerAsset(release, assetPattern, policy);
|
||||||
|
if (!installer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageUrl = release.pageUrl ? validateHttpUrl(release.pageUrl, policy) ?? undefined : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
title: release.title ?? `Scoreko ${version}`,
|
||||||
|
...(pageUrl ? { pageUrl } : {}),
|
||||||
|
installer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeFileName(fileName: string): string {
|
||||||
|
const sanitized = fileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim();
|
||||||
|
return sanitized.length > 0 ? sanitized : "scoreko-update-installer";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateHttpUrl(value: string, policy: UrlPolicy): string | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
|
||||||
|
if (url.protocol === "https:" || (policy.allowInsecureHttp && url.protocol === "http:")) {
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGiteaReleaseAsset(value: unknown): GiteaReleaseAsset | null {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = readRequiredString(value.name);
|
||||||
|
const browserDownloadUrl = readRequiredString(value.browser_download_url);
|
||||||
|
|
||||||
|
if (!name || !browserDownloadUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
browserDownloadUrl,
|
||||||
|
...(typeof value.size === "number" && value.size >= 0 ? { size: value.size } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequiredString(value: unknown): string | null {
|
||||||
|
const text = readOptionalString(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);
|
||||||
|
if (!rawValue) {
|
||||||
|
return 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;
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@ import { app, BrowserWindow, shell } from "electron";
|
|||||||
|
|
||||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||||
import { askToDownloadUpdate, askToInstallUpdate } from "./update-dialogs";
|
import { askToDownloadUpdate, askToInstallUpdate } from "./update-dialogs";
|
||||||
|
import { loadUpdateSettings, UpdateSettings } from "./update-config";
|
||||||
import { downloadInstaller } from "./update-download";
|
import { downloadInstaller } from "./update-download";
|
||||||
import { loadUpdateSettings, UpdateSettings } from "./update-settings";
|
import { buildReleaseUpdate, GiteaRelease, parseGiteaRelease } from "./update-schema";
|
||||||
import { buildReleaseUpdate, GiteaRelease } from "./update-utils";
|
|
||||||
|
|
||||||
type UpdateManagerConfig = {
|
type UpdateServiceConfig = {
|
||||||
appConfig: AppRuntimeConfig;
|
appConfig: AppRuntimeConfig;
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
getParentWindow: () => BrowserWindow | null;
|
getParentWindow: () => BrowserWindow | null;
|
||||||
@@ -14,14 +14,19 @@ type UpdateManagerConfig = {
|
|||||||
log: (...args: unknown[]) => void;
|
log: (...args: unknown[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UpdateProtocolPolicy = {
|
||||||
|
allowInsecureHttp: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function scheduleUpdateCheck({
|
export function scheduleUpdateCheck({
|
||||||
appConfig,
|
appConfig,
|
||||||
rootPath,
|
rootPath,
|
||||||
getParentWindow,
|
getParentWindow,
|
||||||
beforeInstall,
|
beforeInstall,
|
||||||
log,
|
log,
|
||||||
}: UpdateManagerConfig): void {
|
}: UpdateServiceConfig): void {
|
||||||
const settings = loadUpdateSettings(appConfig, rootPath, log);
|
const protocolPolicy = getUpdateProtocolPolicy();
|
||||||
|
const settings = loadUpdateSettings(appConfig, rootPath, log, protocolPolicy);
|
||||||
|
|
||||||
if (!settings.enabled || !settings.apiUrl) {
|
if (!settings.enabled || !settings.apiUrl) {
|
||||||
log("Update checks disabled or not configured.");
|
log("Update checks disabled or not configured.");
|
||||||
@@ -29,7 +34,7 @@ export function scheduleUpdateCheck({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void checkForUpdates({ settings, getParentWindow, beforeInstall, log });
|
void checkForUpdates({ settings, getParentWindow, beforeInstall, log, protocolPolicy });
|
||||||
}, appConfig.updateCheckDelayMs);
|
}, appConfig.updateCheckDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,11 +43,13 @@ async function checkForUpdates({
|
|||||||
getParentWindow,
|
getParentWindow,
|
||||||
beforeInstall,
|
beforeInstall,
|
||||||
log,
|
log,
|
||||||
|
protocolPolicy,
|
||||||
}: {
|
}: {
|
||||||
settings: UpdateSettings;
|
settings: UpdateSettings;
|
||||||
getParentWindow: () => BrowserWindow | null;
|
getParentWindow: () => BrowserWindow | null;
|
||||||
beforeInstall: () => Promise<void>;
|
beforeInstall: () => Promise<void>;
|
||||||
log: (...args: unknown[]) => void;
|
log: (...args: unknown[]) => void;
|
||||||
|
protocolPolicy: UpdateProtocolPolicy;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (!settings.apiUrl) {
|
if (!settings.apiUrl) {
|
||||||
@@ -50,7 +57,7 @@ async function checkForUpdates({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const release = await fetchLatestRelease(settings.apiUrl);
|
const release = await fetchLatestRelease(settings.apiUrl);
|
||||||
const update = buildReleaseUpdate(release, app.getVersion(), settings.assetPattern);
|
const update = buildReleaseUpdate(release, app.getVersion(), settings.assetPattern, protocolPolicy);
|
||||||
|
|
||||||
if (!update) {
|
if (!update) {
|
||||||
log("No Scoreko update available.");
|
log("No Scoreko update available.");
|
||||||
@@ -69,7 +76,10 @@ async function checkForUpdates({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const installerPath = await downloadInstaller(update, { tempDirectory: app.getPath("temp") });
|
const installerPath = await downloadInstaller(update, {
|
||||||
|
tempDirectory: app.getPath("temp"),
|
||||||
|
allowInsecureHttp: protocolPolicy.allowInsecureHttp,
|
||||||
|
});
|
||||||
const shouldInstall = await askToInstallUpdate(update, getParentWindow());
|
const shouldInstall = await askToInstallUpdate(update, getParentWindow());
|
||||||
if (!shouldInstall) {
|
if (!shouldInstall) {
|
||||||
await shell.showItemInFolder(installerPath);
|
await shell.showItemInFolder(installerPath);
|
||||||
@@ -99,7 +109,12 @@ async function fetchLatestRelease(apiUrl: string): Promise<GiteaRelease> {
|
|||||||
throw new Error(`Gitea update check failed with HTTP ${response.status}.`);
|
throw new Error(`Gitea update check failed with HTTP ${response.status}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await response.json()) as GiteaRelease;
|
const release = parseGiteaRelease(await response.json());
|
||||||
|
if (!release) {
|
||||||
|
throw new Error("Gitea update metadata is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return release;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openReleasePage(releasePageUrl: string | undefined): Promise<void> {
|
async function openReleasePage(releasePageUrl: string | undefined): Promise<void> {
|
||||||
@@ -107,3 +122,9 @@ async function openReleasePage(releasePageUrl: string | undefined): Promise<void
|
|||||||
await shell.openExternal(releasePageUrl);
|
await shell.openExternal(releasePageUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUpdateProtocolPolicy(): UpdateProtocolPolicy {
|
||||||
|
return {
|
||||||
|
allowInsecureHttp: !app.isPackaged,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,13 @@ import test from "node:test";
|
|||||||
import {
|
import {
|
||||||
getApplicationPaths,
|
getApplicationPaths,
|
||||||
getDashboardUrl,
|
getDashboardUrl,
|
||||||
|
getDefaultUpdateConfigPath,
|
||||||
|
getManagedNodecgRuntimePath,
|
||||||
getNodecgBaseUrl,
|
getNodecgBaseUrl,
|
||||||
getRootPath,
|
getRootPath,
|
||||||
|
getSafeChildPath,
|
||||||
getSourceNodecgRuntimePath,
|
getSourceNodecgRuntimePath,
|
||||||
|
getUpdateDownloadDirectory,
|
||||||
getUserDataPath,
|
getUserDataPath,
|
||||||
} from "../main/app/paths";
|
} from "../main/app/paths";
|
||||||
|
|
||||||
@@ -18,6 +22,9 @@ test("app path helpers build deterministic development paths and URLs", () => {
|
|||||||
assert.equal(rootPath, path.resolve(compiledMainDir, "../.."));
|
assert.equal(rootPath, path.resolve(compiledMainDir, "../.."));
|
||||||
assert.equal(getSourceNodecgRuntimePath(rootPath), path.resolve(rootPath, "lib", "nodecg"));
|
assert.equal(getSourceNodecgRuntimePath(rootPath), path.resolve(rootPath, "lib", "nodecg"));
|
||||||
assert.equal(getUserDataPath("/app-data", "scoreko"), path.join("/app-data", "scoreko"));
|
assert.equal(getUserDataPath("/app-data", "scoreko"), path.join("/app-data", "scoreko"));
|
||||||
|
assert.equal(getManagedNodecgRuntimePath("/app-data/scoreko"), path.join("/app-data/scoreko", "nodecg"));
|
||||||
|
assert.equal(getDefaultUpdateConfigPath(rootPath), path.join(rootPath, "static", "updates.json"));
|
||||||
|
assert.equal(getUpdateDownloadDirectory("/tmp"), path.join("/tmp", "scoreko-updates"));
|
||||||
assert.equal(getNodecgBaseUrl("9090"), "http://127.0.0.1:9090");
|
assert.equal(getNodecgBaseUrl("9090"), "http://127.0.0.1:9090");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
getDashboardUrl("9090", "scoreko-dev", "dashboard/main.html?standalone=true"),
|
getDashboardUrl("9090", "scoreko-dev", "dashboard/main.html?standalone=true"),
|
||||||
@@ -45,3 +52,8 @@ test("getApplicationPaths keeps packaged root under Electron resources", () => {
|
|||||||
assert.equal(paths.userDataPath, path.join("/users/test/AppData/Roaming", "scoreko"));
|
assert.equal(paths.userDataPath, path.join("/users/test/AppData/Roaming", "scoreko"));
|
||||||
assert.equal(paths.nodecgBaseUrl, "http://127.0.0.1:9090");
|
assert.equal(paths.nodecgBaseUrl, "http://127.0.0.1:9090");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("getSafeChildPath rejects path traversal", () => {
|
||||||
|
assert.equal(getSafeChildPath("/tmp/scoreko-updates", "setup.exe"), path.resolve("/tmp/scoreko-updates/setup.exe"));
|
||||||
|
assert.throws(() => getSafeChildPath("/tmp/scoreko-updates", "../setup.exe"), /outside/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { downloadInstaller } from "../main/updates/update-download";
|
||||||
|
|
||||||
|
test("downloadInstaller writes into the update temp directory and removes staging files", async () => {
|
||||||
|
const previousFetch = globalThis.fetch;
|
||||||
|
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-download-"));
|
||||||
|
|
||||||
|
globalThis.fetch = async () => new Response("installer-bytes");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const installerPath = await downloadInstaller(
|
||||||
|
{
|
||||||
|
version: "0.2.0",
|
||||||
|
title: "Scoreko 0.2.0",
|
||||||
|
installer: {
|
||||||
|
name: "Scoreko/setup:0.2.0.exe",
|
||||||
|
downloadUrl: "https://updates.local/Scoreko-setup-0.2.0.exe",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ tempDirectory, allowInsecureHttp: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadDirectory = path.join(tempDirectory, "scoreko-updates");
|
||||||
|
assert.equal(installerPath, path.join(downloadDirectory, "Scoreko_setup_0.2.0.exe"));
|
||||||
|
assert.equal(fs.readFileSync(installerPath, "utf8"), "installer-bytes");
|
||||||
|
assert.deepEqual(
|
||||||
|
fs.readdirSync(downloadDirectory).filter((entry) => entry.endsWith(".download")),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = previousFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("downloadInstaller rejects insecure production download URLs", async () => {
|
||||||
|
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-download-"));
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
downloadInstaller(
|
||||||
|
{
|
||||||
|
version: "0.2.0",
|
||||||
|
title: "Scoreko 0.2.0",
|
||||||
|
installer: {
|
||||||
|
name: "Scoreko-setup-0.2.0.exe",
|
||||||
|
downloadUrl: "http://updates.local/Scoreko-setup-0.2.0.exe",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ tempDirectory, allowInsecureHttp: false },
|
||||||
|
),
|
||||||
|
/unsupported protocol/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import path from "node:path";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { AppRuntimeConfig } from "../main/config/runtime-config";
|
import { AppRuntimeConfig } from "../main/config/runtime-config";
|
||||||
import { loadUpdateSettings, readUpdateFileConfig } from "../main/updates/update-settings";
|
import { loadUpdateSettings, readUpdateFileConfig } from "../main/updates/update-config";
|
||||||
|
|
||||||
const baseConfig: AppRuntimeConfig = {
|
const baseConfig: AppRuntimeConfig = {
|
||||||
title: "Scoreko",
|
title: "Scoreko",
|
||||||
@@ -34,6 +34,18 @@ test("loadUpdateSettings keeps updates disabled when the runtime config disables
|
|||||||
assert.equal(settings.apiUrl, "https://gitea.local/releases/latest");
|
assert.equal(settings.apiUrl, "https://gitea.local/releases/latest");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("loadUpdateSettings fails closed on insecure production update URLs", () => {
|
||||||
|
const rootPath = makeTempRoot({
|
||||||
|
enabled: true,
|
||||||
|
apiUrl: "http://gitea.local/releases/latest",
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = loadUpdateSettings(baseConfig, rootPath, () => undefined, { allowInsecureHttp: false });
|
||||||
|
|
||||||
|
assert.equal(settings.enabled, false);
|
||||||
|
assert.equal(settings.apiUrl, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
test("loadUpdateSettings lets runtime config override file settings", () => {
|
test("loadUpdateSettings lets runtime config override file settings", () => {
|
||||||
const rootPath = makeTempRoot({
|
const rootPath = makeTempRoot({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import test from "node:test";
|
|||||||
import {
|
import {
|
||||||
buildReleaseUpdate,
|
buildReleaseUpdate,
|
||||||
isVersionNewer,
|
isVersionNewer,
|
||||||
|
parseGiteaRelease,
|
||||||
sanitizeFileName,
|
sanitizeFileName,
|
||||||
selectInstallerAsset,
|
selectInstallerAsset,
|
||||||
} from "../main/updates/update-utils";
|
} from "../main/updates/update-schema";
|
||||||
|
|
||||||
test("isVersionNewer compares semantic versions with optional v prefix", () => {
|
test("isVersionNewer compares semantic versions with optional v prefix", () => {
|
||||||
assert.equal(isVersionNewer("v0.2.0", "0.1.9"), true);
|
assert.equal(isVersionNewer("v0.2.0", "0.1.9"), true);
|
||||||
@@ -17,9 +18,10 @@ test("isVersionNewer compares semantic versions with optional v prefix", () => {
|
|||||||
test("selectInstallerAsset picks the first matching exe asset", () => {
|
test("selectInstallerAsset picks the first matching exe asset", () => {
|
||||||
const asset = selectInstallerAsset(
|
const asset = selectInstallerAsset(
|
||||||
{
|
{
|
||||||
|
tagName: "v0.2.0",
|
||||||
assets: [
|
assets: [
|
||||||
{ name: "latest.yml", browser_download_url: "http://gitea/latest.yml" },
|
{ name: "latest.yml", browserDownloadUrl: "http://gitea/latest.yml" },
|
||||||
{ name: "Scoreko-setup-0.2.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.2.0.exe", size: 100 },
|
{ name: "Scoreko-setup-0.2.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe", size: 100 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"Scoreko-setup-.*\\.exe$",
|
"Scoreko-setup-.*\\.exe$",
|
||||||
@@ -35,8 +37,8 @@ test("selectInstallerAsset picks the first matching exe asset", () => {
|
|||||||
test("buildReleaseUpdate returns null when the release is not newer", () => {
|
test("buildReleaseUpdate returns null when the release is not newer", () => {
|
||||||
const update = buildReleaseUpdate(
|
const update = buildReleaseUpdate(
|
||||||
{
|
{
|
||||||
tag_name: "v0.1.0",
|
tagName: "v0.1.0",
|
||||||
assets: [{ name: "Scoreko-setup-0.1.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.1.0.exe" }],
|
assets: [{ name: "Scoreko-setup-0.1.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.1.0.exe" }],
|
||||||
},
|
},
|
||||||
"0.1.0",
|
"0.1.0",
|
||||||
"Scoreko-setup-.*\\.exe$",
|
"Scoreko-setup-.*\\.exe$",
|
||||||
@@ -48,10 +50,10 @@ test("buildReleaseUpdate returns null when the release is not newer", () => {
|
|||||||
test("buildReleaseUpdate builds update info for newer releases", () => {
|
test("buildReleaseUpdate builds update info for newer releases", () => {
|
||||||
const update = buildReleaseUpdate(
|
const update = buildReleaseUpdate(
|
||||||
{
|
{
|
||||||
tag_name: "v0.2.0",
|
tagName: "v0.2.0",
|
||||||
name: "Scoreko 0.2.0",
|
title: "Scoreko 0.2.0",
|
||||||
html_url: "http://gitea/releases/v0.2.0",
|
pageUrl: "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" }],
|
assets: [{ name: "Scoreko-setup-0.2.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe" }],
|
||||||
},
|
},
|
||||||
"0.1.0",
|
"0.1.0",
|
||||||
"Scoreko-setup-.*\\.exe$",
|
"Scoreko-setup-.*\\.exe$",
|
||||||
@@ -66,3 +68,22 @@ test("buildReleaseUpdate builds update info for newer releases", () => {
|
|||||||
test("sanitizeFileName removes Windows-unsafe characters", () => {
|
test("sanitizeFileName removes Windows-unsafe characters", () => {
|
||||||
assert.equal(sanitizeFileName('Scoreko:setup*"0.2.0.exe'), "Scoreko_setup__0.2.0.exe");
|
assert.equal(sanitizeFileName('Scoreko:setup*"0.2.0.exe'), "Scoreko_setup__0.2.0.exe");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parseGiteaRelease rejects malformed remote metadata", () => {
|
||||||
|
assert.equal(parseGiteaRelease({ name: "missing tag", assets: [] }), null);
|
||||||
|
assert.equal(parseGiteaRelease({ tag_name: "v0.2.0", assets: "wrong" }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildReleaseUpdate rejects insecure download URLs when policy forbids them", () => {
|
||||||
|
const update = buildReleaseUpdate(
|
||||||
|
{
|
||||||
|
tagName: "v0.2.0",
|
||||||
|
assets: [{ name: "Scoreko-setup-0.2.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe" }],
|
||||||
|
},
|
||||||
|
"0.1.0",
|
||||||
|
"Scoreko-setup-.*\\.exe$",
|
||||||
|
{ allowInsecureHttp: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(update, null);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user