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:
@@ -71,3 +71,4 @@ run('npm', ['run', 'build']);
|
||||
ensureReleaseNativeDeps(projectRoot, 'linux');
|
||||
run('electron-builder', ['--linux']);
|
||||
normalizeLinuxReleaseNames();
|
||||
run('node', [path.join(projectRoot, 'scripts', 'verify-packaged-sharp.mjs')]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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 { verifyUnpackedSharp } from './verify-packaged-sharp.mjs';
|
||||
|
||||
void test('verifyUnpackedSharp: accepts intact layout', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-sharp-verify-'));
|
||||
try {
|
||||
const nm = path.join(root, 'resources', 'app.asar.unpacked', 'node_modules');
|
||||
const sharpLib = path.join(nm, 'sharp', 'lib');
|
||||
fs.mkdirSync(sharpLib, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(sharpLib, 'index.js'),
|
||||
"'use strict';\nmodule.exports = require('./constructor');\n",
|
||||
'utf8',
|
||||
);
|
||||
const imgDir = path.join(nm, '@img', 'sharp-win32-x64');
|
||||
fs.mkdirSync(imgDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(imgDir, 'sharp.node'), Buffer.alloc(60_000, 1));
|
||||
|
||||
verifyUnpackedSharp({
|
||||
label: 'fixture',
|
||||
unpackedRoot: root,
|
||||
imgDirs: ['sharp-win32-x64'],
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test('verifyUnpackedSharp: rejects truncated sharp index', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-sharp-verify-'));
|
||||
try {
|
||||
const nm = path.join(root, 'resources', 'app.asar.unpacked', 'node_modules');
|
||||
const sharpLib = path.join(nm, 'sharp', 'lib');
|
||||
fs.mkdirSync(sharpLib, { recursive: true });
|
||||
fs.writeFileSync(path.join(sharpLib, 'index.js'), 'x', 'utf8');
|
||||
const imgDir = path.join(nm, '@img', 'sharp-win32-x64');
|
||||
fs.mkdirSync(imgDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(imgDir, 'sharp.node'), Buffer.alloc(60_000, 1));
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyUnpackedSharp({
|
||||
label: 'fixture',
|
||||
unpackedRoot: root,
|
||||
imgDirs: ['sharp-win32-x64'],
|
||||
}),
|
||||
/truncated|corrupt/i,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test('verifyUnpackedSharp: accepts mac .app layout', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-sharp-verify-mac-'));
|
||||
try {
|
||||
const nm = path.join(
|
||||
root,
|
||||
'TTRPGPlayer.app',
|
||||
'Contents',
|
||||
'Resources',
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
);
|
||||
const sharpLib = path.join(nm, 'sharp', 'lib');
|
||||
fs.mkdirSync(sharpLib, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(sharpLib, 'index.js'),
|
||||
"'use strict';\nmodule.exports = {};\n",
|
||||
'utf8',
|
||||
);
|
||||
const imgDir = path.join(nm, '@img', 'sharp-darwin-arm64');
|
||||
fs.mkdirSync(imgDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(imgDir, 'sharp.node'), Buffer.alloc(60_000, 1));
|
||||
|
||||
verifyUnpackedSharp({
|
||||
label: 'mac-fixture',
|
||||
unpackedRoot: root,
|
||||
imgDirs: ['sharp-darwin-arm64'],
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user