mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
253 lines
8.6 KiB
JavaScript
253 lines
8.6 KiB
JavaScript
/**
|
|
* Verifies that a packaged Electron `app.asar` ships a self-consistent set of
|
|
* runtime node_modules: every package inside the archive has its own
|
|
* non-optional dependencies shipped alongside it.
|
|
*
|
|
* electron-builder's pnpm dependency collector reads `pnpm list --json`, which
|
|
* deduplicates repeated packages and reports empty `dependencies` for all but
|
|
* one occurrence. That makes it silently drop deeply-nested transitive
|
|
* dependencies (for example `ms` under `debug`) from the packaged archive, which
|
|
* crashes the app on launch ("Cannot find module 'ms'", issue #1103).
|
|
*
|
|
* These helpers audit every package that is actually shipped inside the asar and
|
|
* report any non-optional dependency of a shipped package that is missing,
|
|
* resolving each one the way Node would inside the packaged tree. IO is injected
|
|
* so the core logic stays pure and unit-testable without a real archive.
|
|
*/
|
|
|
|
import path from 'node:path';
|
|
|
|
const PACKAGE_MANIFEST = 'package.json';
|
|
const NODE_MODULES_SEGMENT = '/node_modules/';
|
|
const EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT = '/electron-backend/native';
|
|
|
|
/**
|
|
* Embedded MPV's native payload is profile-specific and is written only by
|
|
* afterPack. Any copy retained in app.asar predates that mutation and can leak
|
|
* x64 helpers, runtimes, manifests, or notices into marker-only/system builds.
|
|
*/
|
|
export function collectEmbeddedMpvNativeArchiveEntries(
|
|
asarEntries,
|
|
pathSep = path.sep
|
|
) {
|
|
const toPosix = (value) =>
|
|
pathSep === '\\' ? value.replaceAll('\\', '/') : value;
|
|
|
|
return asarEntries
|
|
.map(toPosix)
|
|
.map((entry) => (entry.startsWith('/') ? entry : `/${entry}`))
|
|
.filter(
|
|
(entry) =>
|
|
entry === EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT ||
|
|
entry.startsWith(`${EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT}/`)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A genuine installed package lives directly under a node_modules directory as
|
|
* `<name>` or `@scope/<name>`. Nested folders that merely carry their own
|
|
* package.json (for example `fast-uri/benchmark` or `<pkg>/test`) are not
|
|
* packages and must not be treated as dependency sources.
|
|
*/
|
|
function isPackageRootDir(dir) {
|
|
const boundary = dir.lastIndexOf(NODE_MODULES_SEGMENT);
|
|
if (boundary === -1) {
|
|
return false;
|
|
}
|
|
|
|
const relative = dir.slice(boundary + NODE_MODULES_SEGMENT.length);
|
|
const segments = relative.split('/');
|
|
|
|
return segments[0].startsWith('@')
|
|
? segments.length === 2
|
|
: segments.length === 1;
|
|
}
|
|
|
|
/**
|
|
* Collects every genuine installed package directory in the archive (those that
|
|
* sit directly under a node_modules as `<name>` or `@scope/<name>`). Entry paths
|
|
* are leading-slash posix.
|
|
*/
|
|
export function collectAsarPackageDirs(asarEntries) {
|
|
const packageDirs = new Set();
|
|
|
|
for (const entry of asarEntries) {
|
|
if (!entry.endsWith(`/${PACKAGE_MANIFEST}`)) {
|
|
continue;
|
|
}
|
|
|
|
const dir = entry.slice(0, -(PACKAGE_MANIFEST.length + 1));
|
|
if (isPackageRootDir(dir)) {
|
|
packageDirs.add(dir);
|
|
}
|
|
}
|
|
|
|
return packageDirs;
|
|
}
|
|
|
|
/**
|
|
* Mirrors Node's upward node_modules resolution across the packaged tree, which
|
|
* is what electron-builder's hoisting produces. Walks every ancestor directory
|
|
* (not just node_modules boundaries) so a dependency hoisted to the archive
|
|
* root still resolves for a package that lives under an app subdirectory such
|
|
* as `/electron-backend/node_modules/foo`. Returns the resolved package
|
|
* directory or null when the dependency is absent.
|
|
*/
|
|
export function resolvePackagedDependency(
|
|
fromDir,
|
|
dependencyName,
|
|
packageDirs
|
|
) {
|
|
let current = fromDir;
|
|
|
|
while (true) {
|
|
// Node never looks inside `.../node_modules/node_modules`.
|
|
if (!current.endsWith(NODE_MODULES_SEGMENT.slice(0, -1))) {
|
|
const candidate = `${current}/node_modules/${dependencyName}`;
|
|
if (packageDirs.has(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
if (current === '') {
|
|
return null;
|
|
}
|
|
|
|
current = current.slice(0, current.lastIndexOf('/'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks that every package physically shipped inside the asar has its own
|
|
* non-optional dependencies shipped too (node-resolvable from its location).
|
|
* Returns the list of missing dependencies.
|
|
*
|
|
* The app root manifest ('') is intentionally skipped: it declares frontend-only
|
|
* packages (Angular, zone.js, ...) that are compiled into the web bundle rather
|
|
* than shipped as runtime node_modules, so requiring them here would be a false
|
|
* positive. Auditing what is actually bundled still catches the real regression
|
|
* (a shipped `debug` whose transitive `ms` was dropped — issue #1103).
|
|
*
|
|
* @param {Set<string>} packageDirs directories that contain a package.json
|
|
* @param {(packageDir: string) => object | null} readManifest manifest reader
|
|
* @returns {{ dependency: string, requiredBy: string }[]}
|
|
*/
|
|
export function findMissingPackagedDependencies(packageDirs, readManifest) {
|
|
const missing = [];
|
|
|
|
for (const packageDir of packageDirs) {
|
|
if (packageDir === '') {
|
|
continue;
|
|
}
|
|
|
|
const manifest = readManifest(packageDir);
|
|
if (!manifest) {
|
|
continue;
|
|
}
|
|
|
|
const optionalDependencies = new Set(
|
|
Object.keys(manifest.optionalDependencies ?? {})
|
|
);
|
|
// Packages sometimes list a host-provided peer (e.g. `electron`) in
|
|
// `dependencies` as well so package managers install it during
|
|
// development; at runtime the host supplies it, so its absence from
|
|
// the archive is not a packaging defect.
|
|
const peerDependencies = new Set(
|
|
Object.keys(manifest.peerDependencies ?? {})
|
|
);
|
|
|
|
for (const dependencyName of Object.keys(manifest.dependencies ?? {})) {
|
|
if (
|
|
optionalDependencies.has(dependencyName) ||
|
|
peerDependencies.has(dependencyName)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
!resolvePackagedDependency(
|
|
packageDir,
|
|
dependencyName,
|
|
packageDirs
|
|
)
|
|
) {
|
|
missing.push({
|
|
dependency: dependencyName,
|
|
requiredBy: packageDir,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return missing;
|
|
}
|
|
|
|
/**
|
|
* Inspects a real `app.asar` for missing runtime dependencies. `listPackage` and
|
|
* `extractFile` are injected (normally from `@electron/asar`).
|
|
*
|
|
* `@electron/asar` builds listing entries and resolves lookup paths with the
|
|
* HOST separator: on Windows `listPackage` returns entries like
|
|
* `\node_modules\debug\package.json` and `extractFile` splits its path on
|
|
* `path.sep`. The pure helpers above are posix-only, so listings are normalized
|
|
* to posix and lookup paths converted back to the host separator (`pathSep` is
|
|
* injectable for tests). Without this the guard silently audited nothing on
|
|
* Windows.
|
|
*
|
|
* Returns `{ missing, packageCount, manifestReadFailures }` so callers can
|
|
* reject a vacuous pass: a packaged app always ships node_modules, so
|
|
* `packageCount === 0` or read failures indicate the guard itself is broken,
|
|
* not a healthy archive.
|
|
*/
|
|
export function inspectPackagedDependencyClosure(
|
|
asarPath,
|
|
{ listPackage, extractFile, pathSep = path.sep }
|
|
) {
|
|
const toPosix = (value) =>
|
|
pathSep === '\\' ? value.replaceAll('\\', '/') : value;
|
|
const toHostPath = (value) =>
|
|
pathSep === '\\' ? value.replaceAll('/', '\\') : value;
|
|
|
|
const packageDirs = collectAsarPackageDirs(
|
|
listPackage(asarPath).map(toPosix)
|
|
);
|
|
const manifestCache = new Map();
|
|
const manifestReadFailures = [];
|
|
|
|
const readManifest = (packageDir) => {
|
|
if (manifestCache.has(packageDir)) {
|
|
return manifestCache.get(packageDir);
|
|
}
|
|
|
|
const relativePath = toHostPath(
|
|
(packageDir === '' ? '' : `${packageDir.slice(1)}/`) +
|
|
PACKAGE_MANIFEST
|
|
);
|
|
|
|
let manifest = null;
|
|
|
|
try {
|
|
manifest = JSON.parse(
|
|
extractFile(asarPath, relativePath).toString('utf8')
|
|
);
|
|
} catch (error) {
|
|
manifestReadFailures.push({
|
|
packageDir,
|
|
message: error.message,
|
|
});
|
|
manifest = null;
|
|
}
|
|
|
|
manifestCache.set(packageDir, manifest);
|
|
return manifest;
|
|
};
|
|
|
|
const missing = findMissingPackagedDependencies(packageDirs, readManifest);
|
|
|
|
return {
|
|
missing,
|
|
packageCount: packageDirs.size,
|
|
manifestReadFailures,
|
|
};
|
|
}
|