fix(pack): lazy-load sharp and verify unpacked natives

Avoid crashing Electron at startup when sharp is corrupt, and fail pack if asarUnpack natives look truncated.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-07 07:47:36 +08:00
parent 4456eb0277
commit 8d5a68c71e
8 changed files with 370 additions and 7 deletions
+192
View File
@@ -0,0 +1,192 @@
/**
* After electron-builder: fail the pack if sharp / @img look missing or truncated
* in release unpacked dirs. Catches AV quarantine and incomplete asarUnpack before shipping.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
/**
* @param {string} startDir
* @returns {string | null}
*/
function findAsarUnpackedNodeModules(startDir) {
const direct = path.join(startDir, 'resources', 'app.asar.unpacked', 'node_modules');
if (fs.existsSync(direct)) return direct;
// macOS: *.app/Contents/Resources/app.asar.unpacked/node_modules
if (!fs.existsSync(startDir)) return null;
for (const name of fs.readdirSync(startDir)) {
if (!name.endsWith('.app')) continue;
const macNm = path.join(
startDir,
name,
'Contents',
'Resources',
'app.asar.unpacked',
'node_modules',
);
if (fs.existsSync(macNm)) return macNm;
}
return null;
}
/**
* @param {string} filePath
* @param {number} minBytes
*/
function assertJsLooksIntact(filePath, minBytes) {
if (!fs.existsSync(filePath)) {
throw new Error(`[verify-packaged-sharp] missing: ${filePath}`);
}
const st = fs.statSync(filePath);
if (st.size < minBytes) {
throw new Error(
`[verify-packaged-sharp] truncated (${st.size} B < ${minBytes}): ${filePath}`,
);
}
const head = fs.readFileSync(filePath, { encoding: 'utf8', flag: 'r' }).slice(0, 120);
const trimmed = head.trimStart();
const ok =
trimmed.startsWith("'use strict'") ||
trimmed.startsWith('"use strict"') ||
head.includes('require(') ||
head.includes('module.exports');
if (!ok) {
throw new Error(
`[verify-packaged-sharp] sharp entry does not look like JS (corrupt?): ${filePath}`,
);
}
}
/**
* @param {string} dir
*/
function assertHasNativeBinary(dir) {
if (!fs.existsSync(dir)) {
throw new Error(`[verify-packaged-sharp] missing native package dir: ${dir}`);
}
const stack = [dir];
while (stack.length) {
const cur = stack.pop();
if (!cur) break;
for (const name of fs.readdirSync(cur)) {
const p = path.join(cur, name);
const st = fs.statSync(p);
if (st.isDirectory()) {
stack.push(p);
continue;
}
if (
name.endsWith('.node') ||
name.endsWith('.dll') ||
name.endsWith('.dylib') ||
/\.so(\.|$)/u.test(name)
) {
if (st.size < 50_000) {
throw new Error(
`[verify-packaged-sharp] native binary too small (${st.size} B): ${p}`,
);
}
return;
}
}
}
throw new Error(`[verify-packaged-sharp] no .node/.dll/.so under ${dir}`);
}
/**
* @param {{ label: string; unpackedRoot: string; imgDirs: string[] }} probe
*/
export function verifyUnpackedSharp(probe) {
const unpackedNm =
findAsarUnpackedNodeModules(probe.unpackedRoot) ??
path.join(probe.unpackedRoot, 'resources', 'app.asar.unpacked', 'node_modules');
const sharpIndex = path.join(unpackedNm, 'sharp', 'lib', 'index.js');
// Real sharp/lib/index.js is typically several KB; empty/AV-quarantined files fail here.
assertJsLooksIntact(sharpIndex, 32);
for (const img of probe.imgDirs) {
assertHasNativeBinary(path.join(unpackedNm, '@img', img));
}
}
/** @type {{ label: string; unpackedRoot: string; imgDirs: string[] }[]} */
const PROBES = [
{
label: 'win-unpacked',
unpackedRoot: path.join(root, 'release', 'win-unpacked'),
imgDirs: ['sharp-win32-x64'],
},
{
label: 'mac-arm64',
unpackedRoot: path.join(root, 'release', 'mac-arm64'),
imgDirs: ['sharp-darwin-arm64'],
},
{
label: 'mac',
unpackedRoot: path.join(root, 'release', 'mac'),
imgDirs: ['sharp-darwin-x64', 'sharp-darwin-arm64'],
},
{
label: 'linux-unpacked',
unpackedRoot: path.join(root, 'release', 'linux-unpacked'),
imgDirs: ['sharp-linux-x64', 'sharp-linux-arm64'],
},
];
/**
* Verify every existing unpacked release dir. At least one must exist.
* For multi-arch probes, require at least one listed @img package that is present.
*/
export function verifyPackagedSharpRelease(releaseRoot = root) {
const probes = PROBES.map((p) => ({
...p,
unpackedRoot: path.join(
releaseRoot,
path.relative(root, p.unpackedRoot),
),
}));
const existing = probes.filter((p) => fs.existsSync(p.unpackedRoot));
if (existing.length === 0) {
throw new Error(
'[verify-packaged-sharp] no release unpacked dir found — run electron-builder first',
);
}
for (const probe of existing) {
const nm = findAsarUnpackedNodeModules(probe.unpackedRoot);
if (!nm) {
throw new Error(
`[verify-packaged-sharp] app.asar.unpacked/node_modules missing under ${probe.unpackedRoot}`,
);
}
const presentImg = probe.imgDirs.filter((d) =>
fs.existsSync(path.join(nm, '@img', d)),
);
if (presentImg.length === 0) {
throw new Error(
`[verify-packaged-sharp] none of @img/{${probe.imgDirs.join(',')}} under ${nm}`,
);
}
verifyUnpackedSharp({ ...probe, imgDirs: presentImg });
console.log(`[verify-packaged-sharp] OK: ${probe.label}`);
}
}
function main() {
verifyPackagedSharpRelease();
}
const entry = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : '';
if (import.meta.url === entry) {
try {
main();
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
}