From 6e8387fe692c8b97ee0efb8c2bc489e0942eeccc Mon Sep 17 00:00:00 2001 From: PiEgg Date: Mon, 5 Oct 2026 19:01:16 +0800 Subject: [PATCH] :bug: Fix(build): patch electron-builder keychain unlock for macOS 26.6 (#1448) --- .github/workflows/ci-check.yml | 65 ++++- .github/workflows/main.yml | 8 - package.json | 2 + patches/app-builder-lib@26.1.0.patch | 23 ++ pnpm-lock.yaml | 13 +- pnpm-workspace.yaml | 3 + scripts/verify-packaged-deps.js | 381 +++++++++++++++++++++++++++ 7 files changed, 480 insertions(+), 15 deletions(-) create mode 100644 patches/app-builder-lib@26.1.0.patch create mode 100644 scripts/verify-packaged-deps.js diff --git a/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml index 574388f..293a54e 100644 --- a/.github/workflows/ci-check.yml +++ b/.github/workflows/ci-check.yml @@ -42,7 +42,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 with: - version: 11.7.0 run_install: false - name: Locate pnpm store @@ -54,9 +53,9 @@ jobs: uses: actions/cache@v4 with: path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-24-pnpm-11.7.0-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml') }} + key: ${{ runner.os }}-node-24-pnpm-10.29.2-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml') }} restore-keys: | - ${{ runner.os }}-node-24-pnpm-11.7.0- + ${{ runner.os }}-node-24-pnpm-10.29.2- - name: Install dependencies run: pnpm install --frozen-lockfile @@ -88,3 +87,63 @@ jobs: - name: Run tests if: matrix.check == 'Tests' run: pnpm test + + packaged-deps: + name: Packaged Deps + if: github.event.deleted != true + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Locate pnpm store + id: pnpm-store + shell: bash + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-24-pnpm-10.29.2-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml') }} + restore-keys: | + ${{ runner.os }}-node-24-pnpm-10.29.2- + + - name: Cache Electron downloads + uses: actions/cache@v4 + with: + path: | + ~/.cache/electron + ~/.cache/electron-builder + key: ${{ runner.os }}-electron-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-electron- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + # Unpacked Linux app only: no installer, no signing, no publishing. It is enough to catch pnpm/electron-builder combinations that silently drop runtime deps from app.asar (#1400, electron-builder 26.13.1 ~ 26.17.0). + - name: Package Linux app + run: pnpm exec electron-builder --config electron-builder.config.ts --linux dir --publish never + env: + # scripts/notarize.js already returns early on non-darwin builds; skip registering afterSign at all to be explicit. + SKIP_NOTARIZE: 'true' + + - name: Verify packaged runtime deps + run: pnpm run verify:packaged-deps diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 15a84ad..60a65bc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -70,8 +70,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10.29.2 - name: Setup Node.js uses: actions/setup-node@v6 @@ -126,8 +124,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10.29.2 - name: Setup Node.js uses: actions/setup-node@v6 @@ -260,8 +256,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10.29.2 - name: Setup Node.js uses: actions/setup-node@v6 @@ -305,8 +299,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10.29.2 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/package.json b/package.json index 3580a72..b86fe9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "picgo", "version": "3.0.2", + "packageManager": "pnpm@10.29.2", "private": true, "main": "dist_electron/main/index.js", "description": "A powerful & simple image uploader for creators.", @@ -25,6 +26,7 @@ "postinstall": "electron-builder install-app-deps", "postuninstall": "electron-builder install-app-deps", "upload-dist": "node ./scripts/upload-dist.js", + "verify:packaged-deps": "node ./scripts/verify-packaged-deps.js", "lint:dpdm": "dpdm -T --tsconfig ./tsconfig.json --no-tree --no-warning --exit-code circular:1 src/background.ts", "lint:dpdm-fe": "dpdm -T --tsconfig ./tsconfig.json --no-tree --no-warning --exit-code circular:1 src/renderer/main.tsx", "check": "pnpm run tsc && pnpm run lint --fix", diff --git a/patches/app-builder-lib@26.1.0.patch b/patches/app-builder-lib@26.1.0.patch new file mode 100644 index 0000000..87b5e92 --- /dev/null +++ b/patches/app-builder-lib@26.1.0.patch @@ -0,0 +1,23 @@ +diff --git a/out/codeSign/macCodeSign.js b/out/codeSign/macCodeSign.js +index 843a507ed3814f06c6ca99494ef1cf7bfb22067e..b2b27b474478d125d816b3f8e19498050ad602e6 100644 +--- a/out/codeSign/macCodeSign.js ++++ b/out/codeSign/macCodeSign.js +@@ -157,15 +157,15 @@ async function createKeychain({ tmpDir, cscLink, cscKeyPassword, cscILink, cscIK + if (cscIKeyPassword != null) { + cscPasswords.push(cscIKeyPassword); + } +- return await importCerts(keychainFile, certPaths, cscPasswords); ++ return await importCerts(keychainFile, certPaths, cscPasswords, keychainPassword); + } +-async function importCerts(keychainFile, paths, keyPasswords) { ++async function importCerts(keychainFile, paths, keyPasswords, keychainPassword) { + for (let i = 0; i < paths.length; i++) { + const password = keyPasswords[i]; + await (0, builder_util_1.exec)("/usr/bin/security", ["import", paths[i], "-k", keychainFile, "-T", "/usr/bin/codesign", "-T", "/usr/bin/productbuild", "-P", password]); + // https://stackoverflow.com/questions/39868578/security-codesign-in-sierra-keychain-ignores-access-control-settings-and-ui-p + // https://github.com/electron-userland/electron-packager/issues/701#issuecomment-322315996 +- await (0, builder_util_1.exec)("/usr/bin/security", ["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]); ++ await (0, builder_util_1.exec)("/usr/bin/security", ["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]); + } + return { + keychainFile, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c25272..0e5de47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + app-builder-lib@26.1.0: + hash: 45818f0065bd4e018c505c5cf6c7feec2a8e8c095e28622ea88d15494c19fa30 + path: patches/app-builder-lib@26.1.0.patch + importers: .: @@ -10119,7 +10124,7 @@ snapshots: app-builder-bin@5.0.0-alpha.12: {} - app-builder-lib@26.1.0(dmg-builder@26.1.0)(electron-builder-squirrel-windows@26.1.0): + app-builder-lib@26.1.0(patch_hash=45818f0065bd4e018c505c5cf6c7feec2a8e8c095e28622ea88d15494c19fa30)(dmg-builder@26.1.0)(electron-builder-squirrel-windows@26.1.0): dependencies: '@develar/schema-utils': 2.6.5 '@electron/asar': 3.4.1 @@ -11073,7 +11078,7 @@ snapshots: dmg-builder@26.1.0(electron-builder-squirrel-windows@26.1.0): dependencies: - app-builder-lib: 26.1.0(dmg-builder@26.1.0)(electron-builder-squirrel-windows@26.1.0) + app-builder-lib: 26.1.0(patch_hash=45818f0065bd4e018c505c5cf6c7feec2a8e8c095e28622ea88d15494c19fa30)(dmg-builder@26.1.0)(electron-builder-squirrel-windows@26.1.0) builder-util: 26.1.0 fs-extra: 10.1.0 iconv-lite: 0.6.3 @@ -11168,7 +11173,7 @@ snapshots: electron-builder-squirrel-windows@26.1.0(dmg-builder@26.1.0): dependencies: - app-builder-lib: 26.1.0(dmg-builder@26.1.0)(electron-builder-squirrel-windows@26.1.0) + app-builder-lib: 26.1.0(patch_hash=45818f0065bd4e018c505c5cf6c7feec2a8e8c095e28622ea88d15494c19fa30)(dmg-builder@26.1.0)(electron-builder-squirrel-windows@26.1.0) builder-util: 26.1.0 electron-winstaller: 5.4.0 transitivePeerDependencies: @@ -11177,7 +11182,7 @@ snapshots: electron-builder@26.1.0(electron-builder-squirrel-windows@26.1.0): dependencies: - app-builder-lib: 26.1.0(dmg-builder@26.1.0)(electron-builder-squirrel-windows@26.1.0) + app-builder-lib: 26.1.0(patch_hash=45818f0065bd4e018c505c5cf6c7feec2a8e8c095e28622ea88d15494c19fa30)(dmg-builder@26.1.0)(electron-builder-squirrel-windows@26.1.0) builder-util: 26.1.0 builder-util-runtime: 9.5.0 chalk: 4.1.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c824c54..b8e3a64 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,6 +15,9 @@ onlyBuiltDependencies: - husky - vue-demi +patchedDependencies: + app-builder-lib@26.1.0: patches/app-builder-lib@26.1.0.patch + supportedArchitectures: cpu: [ x64, arm64 ] os: [ current ] diff --git a/scripts/verify-packaged-deps.js b/scripts/verify-packaged-deps.js new file mode 100644 index 0000000..3075d4a --- /dev/null +++ b/scripts/verify-packaged-deps.js @@ -0,0 +1,381 @@ +/** + * Verify that the app.asar produced by electron-builder contains every runtime dependency of the main/preload bundles. + * + * Background: several releases built and signed fine but crashed on launch because electron-builder silently dropped + * runtime deps from app.asar (pnpm >= 10.29.3 + electron-builder 26.1.0 dropped `array-timsort`; electron-builder + * 26.13.1 ~ 26.17.0 dropped `picgo` itself because the root package shares its name). This script makes such builds + * fail in CI instead of after release. + * + * Two layers: + * 1. Static closure check: collect every bare module specifier that the packaged main/preload bundles still + * `require`/`import` at runtime (renderer code is bundled by vite and is ignored), then walk each package's + * `dependencies` inside the asar using Node's node_modules lookup algorithm. Missing optional dependencies are + * ignored (they are usually platform specific), everything else is reported with its full chain. + * 2. Runtime smoke test: run the packaged Electron binary with ELECTRON_RUN_AS_NODE=1 and `require` every external + * from inside app.asar. Only MODULE_NOT_FOUND errors fail the check; other errors are reported as warnings since + * some modules may legitimately expect a full Electron environment. + * + * Usage: + * node scripts/verify-packaged-deps.js [--dist ] [--asar ]... [--skip-runtime] + * + * Without `--asar`, every app.asar found under `--dist` (default `dist`) is checked. + */ + +const fs = require('fs') +const path = require('path') +const { builtinModules } = require('module') +const { spawnSync } = require('child_process') +const ts = require('typescript') + +const ROOT_DIR = path.join(__dirname, '..') + +// Modules provided by the runtime itself rather than by node_modules. +const RUNTIME_PROVIDED_MODULES = new Set(['electron', ...builtinModules]) + +// Marker used to find the JSON result in the smoke test output, since required modules may print to stdout. +const RESULT_MARKER = '__PICGO_VERIFY_PACKAGED_DEPS_RESULT__' + +const RUNTIME_TIMEOUT_MS = 60 * 1000 + +function loadAsar () { + try { + return require('@electron/asar') + } catch { + // @electron/asar is a dependency of electron-builder (app-builder-lib); resolve it from there if it is not hoisted. + const appBuilderLibDir = path.dirname(require.resolve('app-builder-lib/package.json', { + paths: [path.dirname(require.resolve('electron-builder/package.json'))] + })) + return require(require.resolve('@electron/asar', { paths: [appBuilderLibDir] })) + } +} + +const asar = loadAsar() + +function parseArgs (argv) { + const options = { dist: path.join(ROOT_DIR, 'dist'), asarPaths: [], skipRuntime: false } + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--dist') { + options.dist = path.resolve(argv[++i]) + } else if (arg === '--asar') { + options.asarPaths.push(path.resolve(argv[++i])) + } else if (arg === '--skip-runtime') { + options.skipRuntime = true + } else if (arg === '-h' || arg === '--help') { + console.log('Usage: node scripts/verify-packaged-deps.js [--dist ] [--asar ]... [--skip-runtime]') + process.exit(0) + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + return options +} + +/** + * Find packaged apps under the electron-builder output dir, e.g. `dist/mac-arm64/PicGo.app/Contents/Resources/app.asar`, + * `dist/linux-unpacked/resources/app.asar` or `dist/win-unpacked/resources/app.asar`. + */ +function findAsarFiles (distDir) { + if (!fs.existsSync(distDir)) return [] + const results = [] + for (const entry of fs.readdirSync(distDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const outDir = path.join(distDir, entry.name) + const candidates = [path.join(outDir, 'resources', 'app.asar')] + for (const child of fs.readdirSync(outDir)) { + if (child.endsWith('.app')) candidates.push(path.join(outDir, child, 'Contents', 'Resources', 'app.asar')) + } + results.push(...candidates.filter(candidate => fs.existsSync(candidate))) + } + return results +} + +/** + * Locate the Electron executable that ships next to the asar, so the smoke test runs with the exact runtime users get. + */ +function findAppExecutable (asarPath) { + const resourcesDir = path.dirname(asarPath) + if (path.basename(path.dirname(resourcesDir)) === 'Contents') { + const macOSDir = path.join(path.dirname(resourcesDir), 'MacOS') + const binaries = fs.existsSync(macOSDir) ? fs.readdirSync(macOSDir) : [] + return binaries.length > 0 ? { platform: 'darwin', file: path.join(macOSDir, binaries[0]) } : null + } + const appDir = path.dirname(resourcesDir) + for (const file of fs.readdirSync(appDir)) { + const fullPath = path.join(appDir, file) + if (!fs.statSync(fullPath).isFile()) continue + if (file.toLowerCase() === 'picgo.exe') return { platform: 'win32', file: fullPath } + if (file === 'PicGo' || file === 'picgo') return { platform: 'linux', file: fullPath } + } + return null +} + +class AsarArchive { + constructor (asarPath) { + this.asarPath = asarPath + this.entries = asar.listPackage(asarPath, { isPack: false }).map(entry => entry.split(path.sep).join('/')) + } + + exists (filename) { + try { + asar.statFile(this.asarPath, filename.replace(/^\//, ''), true) + return true + } catch { + return false + } + } + + readText (filename) { + return asar.extractFile(this.asarPath, filename.replace(/^\//, ''), true).toString('utf8') + } + + readJson (filename) { + return JSON.parse(this.readText(filename)) + } +} + +function getPackageName (specifier) { + const parts = specifier.split('/') + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] +} + +function isExternalPackageSpecifier (specifier) { + if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(specifier)) return false + if (specifier.startsWith('node:')) return false + return !RUNTIME_PROVIDED_MODULES.has(getPackageName(specifier)) +} + +/** + * Collect module specifiers from require()/import()/import/export-from with string literal arguments. Parsing the AST + * (instead of grepping) avoids false positives from examples inside comments such as `require('yaku')`. + */ +function collectModuleSpecifiers (code, fileName) { + const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS) + const specifiers = new Set() + const visit = node => { + if (ts.isCallExpression(node) && node.arguments.length > 0 && ts.isStringLiteralLike(node.arguments[0])) { + const isRequire = ts.isIdentifier(node.expression) && node.expression.text === 'require' + const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword + if (isRequire || isDynamicImport) specifiers.add(node.arguments[0].text) + } else if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) { + specifiers.add(node.moduleSpecifier.text) + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + return specifiers +} + +/** + * Collect external packages required by the packaged main/preload code. Returns Map>. + */ +function collectExternalModules (archive) { + const externals = new Map() + const entryFiles = archive.entries.filter(entry => ( + /\.(c|m)?js$/.test(entry) && + !entry.split('/').includes('node_modules') && + !entry.startsWith('/dist_electron/renderer/') + )) + for (const file of entryFiles) { + for (const specifier of collectModuleSpecifiers(archive.readText(file), file)) { + if (!isExternalPackageSpecifier(specifier)) continue + if (!externals.has(specifier)) externals.set(specifier, new Set()) + externals.get(specifier).add(file.replace(/^\//, '')) + } + } + return { externals, entryFiles } +} + +/** + * Node's node_modules lookup: from `fromDir` walk up to the archive root, trying `/node_modules/`. + */ +function resolvePackageDir (archive, fromDir, packageName) { + let dir = fromDir + while (true) { + if (path.posix.basename(dir) !== 'node_modules') { + const candidate = path.posix.join(dir, 'node_modules', packageName) + if (archive.exists(`${candidate}/package.json`)) return candidate + } + if (dir === '/') return null + dir = path.posix.dirname(dir) + } +} + +function checkStaticClosure (archive, externals) { + const missing = [] + const visited = new Set() + // Each queue item carries the dependency chain that led to it, so a failure can be reported as `a -> b -> c`. + const queue = [] + + for (const [specifier, files] of externals) { + const packageName = getPackageName(specifier) + const fromFile = [...files][0] + const packageDir = resolvePackageDir(archive, path.posix.dirname(`/${fromFile}`), packageName) + if (!packageDir) { + missing.push({ chain: [`${fromFile} (require)`, packageName] }) + continue + } + queue.push({ packageDir, chain: [packageName] }) + } + + while (queue.length > 0) { + const { packageDir, chain } = queue.shift() + if (visited.has(packageDir)) continue + visited.add(packageDir) + + const pkg = archive.readJson(`${packageDir}/package.json`) + const optionalDependencies = pkg.optionalDependencies || {} + const dependencyNames = new Set([...Object.keys(pkg.dependencies || {}), ...Object.keys(optionalDependencies)]) + + for (const dependencyName of dependencyNames) { + const dependencyDir = resolvePackageDir(archive, packageDir, dependencyName) + if (dependencyDir) { + queue.push({ packageDir: dependencyDir, chain: [...chain, dependencyName] }) + } else if (!(dependencyName in optionalDependencies)) { + // Missing optional dependencies are expected: they are usually native binaries for other platforms/arches. + missing.push({ chain: [...chain, dependencyName] }) + } + } + } + + return { missing, checkedPackages: visited.size } +} + +/** + * Root `dependencies` that are not in the asar at all. Most of them are renderer deps bundled by vite, so this is only + * a diagnostic hint (it makes the "root package name collision" failure mode obvious) and never fails the check. + */ +function findMissingRootDependencies (archive) { + const pkg = archive.readJson('/package.json') + return Object.keys(pkg.dependencies || {}).filter(name => !resolvePackageDir(archive, '/', name)) +} + +// Executed inside the packaged Electron binary with ELECTRON_RUN_AS_NODE=1; argv: [asarPath, marker, ...specifiers]. +const RUNTIME_CHECK_SOURCE = ` +const path = require('path') +const { createRequire } = require('module') +const [asarPath, marker, ...specifiers] = process.argv.slice(1) +const requireFromApp = createRequire(path.join(asarPath, 'dist_electron', 'main', 'index.js')) +const results = [] +for (const specifier of specifiers) { + try { + requireFromApp(specifier) + results.push({ specifier, ok: true }) + } catch (error) { + results.push({ specifier, ok: false, code: error && error.code, message: String(error && error.message || error).split('\\n')[0] }) + } +} +process.stdout.write('\\n' + marker + JSON.stringify(results) + '\\n', () => process.exit(0)) +` + +function runRuntimeCheck (asarPath, specifiers) { + const executable = findAppExecutable(asarPath) + if (!executable) { + return { skipped: 'packaged Electron executable not found' } + } + if (executable.platform !== process.platform) { + return { skipped: `executable is for ${executable.platform}, current platform is ${process.platform}` } + } + + const result = spawnSync(executable.file, ['-e', RUNTIME_CHECK_SOURCE, asarPath, RESULT_MARKER, ...specifiers], { + cwd: path.dirname(asarPath), + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + encoding: 'utf8', + timeout: RUNTIME_TIMEOUT_MS, + maxBuffer: 64 * 1024 * 1024 + }) + + const markerLine = (result.stdout || '').split('\n').find(line => line.startsWith(RESULT_MARKER)) + if (!markerLine) { + const details = [ + result.error ? `spawn error: ${result.error.message}` : `exit code: ${result.status}, signal: ${result.signal}`, + (result.stderr || '').trim().split('\n').slice(-20).join('\n') + ].filter(Boolean).join('\n') + return { fatal: `failed to run ${executable.file} with ELECTRON_RUN_AS_NODE=1\n${details}` } + } + + return { results: JSON.parse(markerLine.slice(RESULT_MARKER.length)), executable: executable.file } +} + +function verifyAsar (asarPath, options) { + const relativeAsarPath = path.relative(ROOT_DIR, asarPath) || asarPath + console.log(`\n[verify-packaged-deps] Checking ${relativeAsarPath}`) + + const archive = new AsarArchive(asarPath) + const { externals, entryFiles } = collectExternalModules(archive) + if (entryFiles.length === 0) { + console.error(' [x] No main/preload JS files found in the asar') + return false + } + if (externals.size === 0) { + console.error(` [x] No external modules found in ${entryFiles.join(', ')}; refusing to report success on an empty check`) + return false + } + + const specifiers = [...externals.keys()].sort() + console.log(` Entry files: ${entryFiles.map(file => file.replace(/^\//, '')).join(', ')}`) + console.log(` External modules (${specifiers.length}): ${specifiers.join(', ')}`) + + let ok = true + + const { missing, checkedPackages } = checkStaticClosure(archive, externals) + if (missing.length > 0) { + ok = false + console.error(` [x] Static check: ${missing.length} missing dependenc${missing.length === 1 ? 'y' : 'ies'} in app.asar:`) + for (const { chain } of missing) console.error(` ${chain.join(' -> ')}`) + } else { + console.log(` [ok] Static check: ${checkedPackages} packages in the runtime dependency closure all resolve`) + } + + const missingRootDependencies = findMissingRootDependencies(archive) + if (missingRootDependencies.length > 0) { + console.warn(` [warn] Root package.json dependencies not present in app.asar (${missingRootDependencies.length}): ${missingRootDependencies.join(', ')}`) + } + + if (options.skipRuntime) { + console.log(' [skip] Runtime check: --skip-runtime') + return ok + } + + const runtime = runRuntimeCheck(asarPath, specifiers) + if (runtime.skipped) { + console.warn(` [skip] Runtime check: ${runtime.skipped}`) + } else if (runtime.fatal) { + ok = false + console.error(` [x] Runtime check: ${runtime.fatal}`) + } else { + const notFound = runtime.results.filter(result => !result.ok && result.code === 'MODULE_NOT_FOUND') + const otherErrors = runtime.results.filter(result => !result.ok && result.code !== 'MODULE_NOT_FOUND') + for (const result of otherErrors) { + // Not a packaging problem by itself: the module may depend on a full Electron environment when loaded. + console.warn(` [warn] Runtime check: require('${result.specifier}') threw ${result.code || 'an error'}: ${result.message}`) + } + if (notFound.length > 0) { + ok = false + console.error(` [x] Runtime check: ${notFound.length} module(s) failed with MODULE_NOT_FOUND:`) + for (const result of notFound) console.error(` require('${result.specifier}'): ${result.message}`) + } else { + console.log(` [ok] Runtime check: required ${runtime.results.length - otherErrors.length}/${runtime.results.length} externals with ${path.relative(ROOT_DIR, runtime.executable)} (ELECTRON_RUN_AS_NODE=1)`) + } + } + + return ok +} + +function main () { + const options = parseArgs(process.argv.slice(2)) + const asarPaths = options.asarPaths.length > 0 ? options.asarPaths : findAsarFiles(options.dist) + if (asarPaths.length === 0) { + console.error(`[verify-packaged-deps] No app.asar found under ${options.dist}. Run electron-builder with a \`dir\` target first.`) + process.exit(1) + } + + const failed = asarPaths.filter(asarPath => !verifyAsar(asarPath, options)) + if (failed.length > 0) { + console.error(`\n[verify-packaged-deps] FAILED: ${failed.length}/${asarPaths.length} packaged app(s) are missing runtime dependencies.`) + process.exit(1) + } + console.log(`\n[verify-packaged-deps] OK: ${asarPaths.length} packaged app(s) verified.`) +} + +main()