From 4fb3347ecceb119cd918ed14bad569cfc7b78d80 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 10 May 2026 20:10:01 +0200 Subject: [PATCH] fix(packaging): preserve electron package metadata Entire-Checkpoint: f957cd9849e0 --- .gitignore | 1 + apps/electron-backend/project.json | 28 +++- .../src/app/options/maker.options.json | 3 +- .../electron-package-identity.test.mjs | 100 +++++++++++ .../generate-electron-builder-metadata.mjs | 156 ++++++++++++++++++ tools/packaging/project.json | 9 +- .../verify-electron-package-layout.mjs | 134 ++++++++++++--- 7 files changed, 403 insertions(+), 28 deletions(-) create mode 100644 tools/packaging/generate-electron-builder-metadata.mjs diff --git a/.gitignore b/.gitignore index 845bb4888..698ce0d19 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,7 @@ Thumbs.db # Native Electron addons apps/electron-backend/native/build/ +apps/electron-backend/src/app/options/electron-builder.metadata.generated.json vendor/embedded-mpv/darwin-*/include/ vendor/embedded-mpv/darwin-*/lib/ vendor/embedded-mpv/darwin-*/runtime-manifest.json diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index d96fb3678..80bda92be 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -125,7 +125,10 @@ } }, "package": { - "dependsOn": ["electron-backend:build"], + "dependsOn": [ + "electron-backend:build", + "electron-backend:generate-builder-metadata" + ], "executor": "nx-electron:package", "options": { "name": "electron-backend", @@ -136,7 +139,10 @@ } }, "make": { - "dependsOn": ["electron-backend:build"], + "dependsOn": [ + "electron-backend:build", + "electron-backend:generate-builder-metadata" + ], "executor": "nx-electron:make", "options": { "name": "electron-backend", @@ -145,6 +151,24 @@ "outputPath": "dist/executables" } }, + "generate-builder-metadata": { + "dependsOn": ["electron-backend:build"], + "executor": "nx:run-commands", + "cache": true, + "inputs": [ + "{workspaceRoot}/package.json", + "{workspaceRoot}/electron-builder.json", + "{workspaceRoot}/tools/packaging/generate-electron-builder-metadata.mjs" + ], + "outputs": [ + "{workspaceRoot}/apps/electron-backend/src/app/options/electron-builder.metadata.generated.json", + "{workspaceRoot}/dist/apps/electron-backend/package.json" + ], + "options": { + "command": "node tools/packaging/generate-electron-builder-metadata.mjs", + "cwd": "{workspaceRoot}" + } + }, "lint": { "command": "eslint apps/electron-backend/**/*.ts" }, diff --git a/apps/electron-backend/src/app/options/maker.options.json b/apps/electron-backend/src/app/options/maker.options.json index 54de4c14a..af345c308 100644 --- a/apps/electron-backend/src/app/options/maker.options.json +++ b/apps/electron-backend/src/app/options/maker.options.json @@ -1,3 +1,4 @@ { - "$schema": "../../../../../node_modules/nx-electron/src/validation/maker.schema.json" + "$schema": "../../../../../node_modules/nx-electron/src/validation/maker.schema.json", + "extends": "apps/electron-backend/src/app/options/electron-builder.metadata.generated.json" } diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index 533044cc3..411271368 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -7,12 +7,39 @@ import { fileURLToPath } from 'node:url'; const currentDir = dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); +const packageMetadata = JSON.parse( + fs.readFileSync(join(currentDir, '..', '..', 'package.json'), 'utf8') +); const electronBuilderConfig = JSON.parse( fs.readFileSync( join(currentDir, '..', '..', 'electron-builder.json'), 'utf8' ) ); +const electronProjectConfig = JSON.parse( + fs.readFileSync( + join(currentDir, '..', '..', 'apps', 'electron-backend', 'project.json'), + 'utf8' + ) +); +const makerOptions = JSON.parse( + fs.readFileSync( + join( + currentDir, + '..', + '..', + 'apps', + 'electron-backend', + 'src', + 'app', + 'options', + 'maker.options.json' + ), + 'utf8' + ) +); +const generatedMetadataConfigPath = + 'apps/electron-backend/src/app/options/electron-builder.metadata.generated.json'; test('Linux package identity does not expose the internal Electron backend project name', () => { assert.equal(electronBuilderConfig.productName, 'IPTVnator'); @@ -25,6 +52,79 @@ test('Linux package identity does not expose the internal Electron backend proje ); }); +test('generated Electron package metadata mirrors the root package identity', async () => { + const { + buildElectronBuilderMetadata, + buildElectronPackageMetadata, + } = await import( + './generate-electron-builder-metadata.mjs' + ); + const generatedElectronPackage = buildElectronPackageMetadata( + packageMetadata, + electronBuilderConfig, + { + name: 'electron-backend', + version: '0.0.1', + dependencies: { + 'better-sqlite3': '12.5.0', + }, + } + ); + + assert.deepEqual( + buildElectronBuilderMetadata(packageMetadata, electronBuilderConfig) + .extraMetadata, + { + name: packageMetadata.name, + productName: electronBuilderConfig.productName, + version: packageMetadata.version, + description: packageMetadata.description, + author: packageMetadata.author, + homepage: packageMetadata.homepage, + license: packageMetadata.license, + main: electronBuilderConfig.extraMetadata.main, + } + ); + assert.deepEqual(generatedElectronPackage, { + name: packageMetadata.name, + productName: electronBuilderConfig.productName, + version: packageMetadata.version, + description: packageMetadata.description, + author: packageMetadata.author, + homepage: packageMetadata.homepage, + license: packageMetadata.license, + main: electronBuilderConfig.extraMetadata.main, + dependencies: { + 'better-sqlite3': '12.5.0', + }, + }); +}); + +test('nx-electron packaging prepares metadata before make/package', () => { + assert.equal(makerOptions.extends, generatedMetadataConfigPath); + assert.deepEqual( + electronProjectConfig.targets['generate-builder-metadata'].dependsOn, + ['electron-backend:build'] + ); + assert.deepEqual( + electronProjectConfig.targets['generate-builder-metadata'].outputs, + [ + `{workspaceRoot}/${generatedMetadataConfigPath}`, + '{workspaceRoot}/dist/apps/electron-backend/package.json', + ] + ); + assert.ok( + electronProjectConfig.targets.make.dependsOn.includes( + 'electron-backend:generate-builder-metadata' + ) + ); + assert.ok( + electronProjectConfig.targets.package.dependsOn.includes( + 'electron-backend:generate-builder-metadata' + ) + ); +}); + test('nx-electron packaging does not copy duplicate root package metadata', () => { const nxElectronExecutorPath = require.resolve( 'nx-electron/src/executors/package/executor.js' diff --git a/tools/packaging/generate-electron-builder-metadata.mjs b/tools/packaging/generate-electron-builder-metadata.mjs new file mode 100644 index 000000000..cbebb455b --- /dev/null +++ b/tools/packaging/generate-electron-builder-metadata.mjs @@ -0,0 +1,156 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const generatedMetadataConfigPath = path.join( + 'apps', + 'electron-backend', + 'src', + 'app', + 'options', + 'electron-builder.metadata.generated.json' +); +export const electronBackendPackageJsonPath = path.join( + 'dist', + 'apps', + 'electron-backend', + 'package.json' +); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function removeUndefinedValues(value) { + return Object.fromEntries( + Object.entries(value).filter(([, entryValue]) => entryValue !== undefined) + ); +} + +function requirePackageField(packageMetadata, fieldName) { + const value = packageMetadata[fieldName]; + + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Root package.json must define a non-empty ${fieldName}.`); + } + + return value; +} + +export function buildElectronBuilderMetadata( + packageMetadata, + electronBuilderConfig = {} +) { + const extraMetadata = removeUndefinedValues({ + name: requirePackageField(packageMetadata, 'name'), + productName: + electronBuilderConfig.productName ?? + electronBuilderConfig.extraMetadata?.productName, + version: requirePackageField(packageMetadata, 'version'), + description: packageMetadata.description, + author: packageMetadata.author, + homepage: packageMetadata.homepage, + license: packageMetadata.license, + main: + electronBuilderConfig.extraMetadata?.main ?? + 'electron-backend/main.js', + }); + + return { + extends: 'electron-builder.json', + extraMetadata, + }; +} + +export function buildElectronPackageMetadata( + packageMetadata, + electronBuilderConfig = {}, + currentElectronPackageMetadata = {} +) { + return removeUndefinedValues({ + ...currentElectronPackageMetadata, + ...buildElectronBuilderMetadata(packageMetadata, electronBuilderConfig) + .extraMetadata, + }); +} + +export function writeElectronPackageMetadata({ + workspaceRoot = process.cwd(), + packageMetadata, + electronBuilderConfig, + packageJsonPath = electronBackendPackageJsonPath, +} = {}) { + const absolutePackageJsonPath = path.join(workspaceRoot, packageJsonPath); + + if (!fs.existsSync(absolutePackageJsonPath)) { + return { + outputPath: absolutePackageJsonPath, + updated: false, + }; + } + + const currentElectronPackageMetadata = readJson(absolutePackageJsonPath); + const nextElectronPackageMetadata = buildElectronPackageMetadata( + packageMetadata, + electronBuilderConfig, + currentElectronPackageMetadata + ); + + fs.writeFileSync( + absolutePackageJsonPath, + `${JSON.stringify(nextElectronPackageMetadata, null, 2)}\n` + ); + + return { + outputPath: absolutePackageJsonPath, + updated: true, + }; +} + +export function writeElectronBuilderMetadata({ + workspaceRoot = process.cwd(), + outputPath = generatedMetadataConfigPath, +} = {}) { + const packageMetadata = readJson(path.join(workspaceRoot, 'package.json')); + const electronBuilderConfig = readJson( + path.join(workspaceRoot, 'electron-builder.json') + ); + const metadataConfig = buildElectronBuilderMetadata( + packageMetadata, + electronBuilderConfig + ); + const absoluteOutputPath = path.join(workspaceRoot, outputPath); + const packageMetadataResult = writeElectronPackageMetadata({ + workspaceRoot, + packageMetadata, + electronBuilderConfig, + }); + + fs.mkdirSync(path.dirname(absoluteOutputPath), { recursive: true }); + fs.writeFileSync( + absoluteOutputPath, + `${JSON.stringify(metadataConfig, null, 4)}\n` + ); + + return { + outputPath: absoluteOutputPath, + metadataConfig, + packageMetadataResult, + }; +} + +const currentFilePath = fileURLToPath(import.meta.url); + +if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) { + const { outputPath, packageMetadataResult } = writeElectronBuilderMetadata(); + console.log(`Wrote Electron builder metadata: ${outputPath}`); + if (packageMetadataResult.updated) { + console.log( + `Wrote Electron package metadata: ${packageMetadataResult.outputPath}` + ); + } else { + console.log( + `Skipped Electron package metadata because ${packageMetadataResult.outputPath} does not exist yet.` + ); + } +} diff --git a/tools/packaging/project.json b/tools/packaging/project.json index b833acebb..b12d400c3 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -7,7 +7,14 @@ "test": { "executor": "nx:run-commands", "cache": true, - "inputs": ["default", "{workspaceRoot}/electron-builder.json"], + "inputs": [ + "default", + "{workspaceRoot}/package.json", + "{workspaceRoot}/electron-builder.json", + "{workspaceRoot}/apps/electron-backend/project.json", + "{workspaceRoot}/apps/electron-backend/src/app/options/maker.options.json", + "{workspaceRoot}/tools/packaging/generate-electron-builder-metadata.mjs" + ], "options": { "command": "node --test tools/packaging/electron-package-identity.test.mjs", "cwd": "{workspaceRoot}" diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs index 18dd64777..f3652a474 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -3,6 +3,8 @@ import { createRequire } from 'module'; import path from 'path'; const require = createRequire(import.meta.url); +const electronBuilderRequire = createRequire(require.resolve('electron-builder')); +const { extractFile } = electronBuilderRequire('@electron/asar'); const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-macos.cjs'); const args = process.argv.slice(2); const normalizedArgs = args[0] === '--' ? args.slice(1) : args; @@ -16,15 +18,17 @@ if (!platform) { } const workspaceRoot = process.cwd(); -const executablesRoot = path.join(workspaceRoot, 'dist', 'executables'); +const packageOutputRoots = [ + path.join(workspaceRoot, 'dist', 'executables'), + path.join(workspaceRoot, 'dist', 'packages'), +]; const packageJsonPath = path.join(workspaceRoot, 'package.json'); const electronBuilderConfigPath = path.join( workspaceRoot, 'electron-builder.json' ); -const builderEffectiveConfigPath = path.join( - executablesRoot, - 'builder-effective-config.yaml' +const builderEffectiveConfigPaths = packageOutputRoots.map((outputRoot) => + path.join(outputRoot, 'builder-effective-config.yaml') ); const flatpakMetainfoPath = path.join( workspaceRoot, @@ -47,6 +51,20 @@ const workerRelativeDir = path.join( 'workers' ); const workerFiles = ['epg-parser.worker.js', 'database.worker.js']; +const packagedPackageMetadata = { + name: packageMetadata.name, + productName: + electronBuilderConfig.productName ?? + electronBuilderConfig.extraMetadata?.productName, + version: packageMetadata.version, + description: packageMetadata.description, + author: packageMetadata.author, + homepage: packageMetadata.homepage, + license: packageMetadata.license, + main: + electronBuilderConfig.extraMetadata?.main ?? + 'electron-backend/main.js', +}; const nativeModuleRelativeDirs = [ path.join('app.asar.unpacked', 'node_modules'), path.join('app.asar.unpacked', 'electron-backend', 'node_modules'), @@ -79,11 +97,11 @@ function isTruthy(value) { } function getMacResourceDirs() { - const candidates = [ + const candidates = packageOutputRoots.flatMap((outputRoot) => [ { arch: 'x64', directory: path.join( - executablesRoot, + outputRoot, 'mac', 'IPTVnator.app', 'Contents', @@ -93,14 +111,14 @@ function getMacResourceDirs() { { arch: 'arm64', directory: path.join( - executablesRoot, + outputRoot, 'mac-arm64', 'IPTVnator.app', 'Contents', 'Resources' ), }, - ]; + ]); return candidates .filter((candidate) => !arch || candidate.arch === arch) @@ -109,20 +127,20 @@ function getMacResourceDirs() { } function getUnpackedResourceDirs(prefix) { - if (!directoryExists(executablesRoot)) { - return []; - } - - return fs - .readdirSync(executablesRoot, { withFileTypes: true }) - .filter( - (entry) => - entry.isDirectory() && - entry.name.startsWith(prefix) && - entry.name.endsWith('-unpacked') - ) - .map((entry) => path.join(executablesRoot, entry.name, 'resources')) - .filter(directoryExists); + return packageOutputRoots + .filter(directoryExists) + .flatMap((outputRoot) => + fs + .readdirSync(outputRoot, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + entry.name.startsWith(prefix) && + entry.name.endsWith('-unpacked') + ) + .map((entry) => path.join(outputRoot, entry.name, 'resources')) + .filter(directoryExists) + ); } function getResourceDirs() { @@ -140,7 +158,15 @@ function getResourceDirs() { } function sanitizeExecutableName(value) { - return value.replace(/[<>:"/\\|?*\u0000-\u001f]/g, ''); + const invalidCharacters = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']); + + return [...value] + .filter( + (character) => + character.charCodeAt(0) >= 32 && + !invalidCharacters.has(character) + ) + .join(''); } function getLinuxExecutableName() { @@ -345,7 +371,9 @@ function parseEffectiveSnapConfig(yamlContent) { } function loadSnapConfigInspection() { - if (fileExists(builderEffectiveConfigPath)) { + const builderEffectiveConfigPath = builderEffectiveConfigPaths.find(fileExists); + + if (builderEffectiveConfigPath && fileExists(builderEffectiveConfigPath)) { const effectiveConfigContent = fs.readFileSync( builderEffectiveConfigPath, 'utf8' @@ -368,6 +396,62 @@ function loadSnapConfigInspection() { }; } +function formatJsonValue(value) { + return JSON.stringify(value); +} + +function packageMetadataMatches(actualValue, expectedValue) { + return formatJsonValue(actualValue) === formatJsonValue(expectedValue); +} + +function readAsarPackageMetadata(asarPath) { + return JSON.parse(extractFile(asarPath, 'package.json').toString('utf8')); +} + +function verifyPackagedPackageMetadata(resourceDir, errors) { + const asarPath = path.join(resourceDir, 'app.asar'); + + if (!fileExists(asarPath)) { + errors.push(`Missing packaged app archive: ${asarPath}`); + return; + } + + let appPackageMetadata; + + try { + appPackageMetadata = readAsarPackageMetadata(asarPath); + } catch (error) { + errors.push( + `Unable to read package.json from ${asarPath}: ${error.message}` + ); + return; + } + + const mismatches = Object.entries(packagedPackageMetadata) + .filter(([, expectedValue]) => expectedValue !== undefined) + .filter( + ([fieldName, expectedValue]) => + !packageMetadataMatches( + appPackageMetadata[fieldName], + expectedValue + ) + ) + .map(([fieldName, expectedValue]) => { + const actualValue = appPackageMetadata[fieldName]; + + return `- ${fieldName}: expected ${formatJsonValue(expectedValue)}, received ${formatJsonValue(actualValue)}`; + }); + + if (mismatches.length > 0) { + errors.push( + [ + `Packaged app package.json metadata does not match root package identity in ${asarPath}.`, + ...mismatches, + ].join('\n') + ); + } +} + function verifyLinuxLauncher(resourceDir, errors) { const appDir = path.dirname(resourceDir); const launcherPath = path.join(appDir, linuxExecutableName); @@ -462,6 +546,8 @@ function verifyResourceDir(resourceDir) { const errors = []; + verifyPackagedPackageMetadata(resourceDir, errors); + if (missingWorkers.length > 0) { errors.push( `Missing worker artifacts in ${resourceDir}: ${missingWorkers.join(', ')}`