fix(packaging): preserve electron package metadata

Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4gray committed 2026-05-10 20:10:01 +02:00
1 parent 8bd48f5dc4
commit 4fb3347ecc
7 files changed
+403 -28

No files matched your search

+1
View File
@@ -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
+26 -2
View File
@@ -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"
},
@@ -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"
}
@@ -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'
@@ -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.`
);
}
}
+8 -1
View File
@@ -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}"
@@ -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(', ')}`