diff --git a/CHANGELOG.md b/CHANGELOG.md index 9068b17..1f948f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,23 +12,31 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- **Packaging:** the app archive (`app.asar`) holds only the app again: the built `dist` folder, + `package.json`, the production dependencies and `src/shared`. In 5.1.0 it also held the project's + webpack build cache, documentation images, sources and tests (about 300 MB unpacked), which made + every download 57 to 82 MB larger than 5.0.0. The release check now fails a build whose `app.asar` + holds anything else. + ## [5.1.0] - 2026-10-31 -Faster, more predictable Windows startup, a zip instead of the self-extracting portable exe, and -smaller downloads on every platform. +Faster, more predictable Windows startup and a zip instead of the self-extracting portable exe. + +### Known issues + +- Every download is 57 to 82 MB larger than 5.0.0 (the Windows x64 installer is 735 MB instead of + 671 MB) because the build packed the project's webpack build cache, documentation images, sources + and tests into `app.asar` next to the app. The app never loads these files. Fixed for the next + release (see Unreleased). ### Security - `minimatch` 10 → 11.0.3 for GHSA-g3hv-75wg-m249 (denial of service via globparts amplification). -- Electron 41 → 45 (45.1.0). Electron 41 was outside upstream security support and affected by eight - advisories, including GHSA-jr5w-w6x2-84p9 (HID/USB permission checks attributed to the wrong - origin; SameSite cookies not applied to windows opened from links). ### Changed -- `better-sqlite3` 12 → 13. 12.x no longer compiles against Electron 45; 13.x ships N-API prebuilds, - so it is no longer rebuilt per Electron version. -- macOS 13 (Ventura) is now the minimum supported macOS version (Electron 44 dropped macOS 12). - **Windows:** the self-extracting portable exe (`StratoSortStack-5.0.0-win-x64.exe`), which unpacked the whole app into `%TEMP%` on every start, is replaced by `StratoSortStack-5.1.0-win-x64.zip`. Extract it once and run `StratoSort Stack.exe` from the @@ -41,8 +49,8 @@ smaller downloads on every platform. (`STRATOSORT_ENABLE_EMBEDDING_WORKER`) uses the same record and supported-backend filter. - The Windows x64 build no longer ships node-llama-cpp's arm64 prebuilt (about 11 MB). - Each build ships only its own SQLite (`better-sqlite3`) native binary instead of the ones for - every platform: about 15 MB smaller on macOS, about 12 MB on Linux, and one 2 MB file less on - Windows. + every platform: about 15 MB less on macOS, about 12 MB on Linux, and one 2 MB file less on + Windows. In 5.1.0 these savings are outweighed by the known issue above. - On Windows and macOS the app no longer forces Chromium's `enable-gpu-rasterization` and `enable-zero-copy` switches, so Chromium's per-driver GPU blocklist applies. Linux keeps forcing them, as before. @@ -70,6 +78,15 @@ smaller downloads on every platform. First public release of StratoSort Stack: source published on GitHub under the PolyForm Noncommercial License 1.0.0, with signed installers on GitHub Releases. +The Security entry and the first two Changed entries shipped in 5.0.0 but were missing from its +release notes; the 5.1.0 notes listed them by mistake. + +### Security + +- Electron 41 → 45 (45.1.0). Electron 41 was outside upstream security support and affected by eight + advisories, including GHSA-jr5w-w6x2-84p9 (HID/USB permission checks attributed to the wrong + origin; SameSite cookies not applied to windows opened from links). + ### Added - Atlas map, timeline, knowledge stats card, Downloads triage trust pack, MCP server kit, and @@ -77,6 +94,9 @@ Noncommercial License 1.0.0, with signed installers on GitHub Releases. ### Changed +- `better-sqlite3` 12 → 13. 12.x no longer compiles against Electron 45; 13.x ships N-API prebuilds, + so it is no longer rebuilt per Electron version. +- macOS 13 (Ventura) is now the minimum supported macOS version (Electron 44 dropped macOS 12). - **License:** StratoSort Stack is now released under the PolyForm Noncommercial License 1.0.0. Personal and other noncommercial use is free with every feature; commercial use needs a paid key (see [docs/COMMERCIAL.md](docs/COMMERCIAL.md)). diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 077f5f4..956c3ca 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -409,14 +409,14 @@ supported-GPU floor moves with it. `verify-packaged-artifacts.js` requires all f package code: nothing reads it unless it builds from source, and nothing builds from source inside Electron. -| Pattern | Why it is unused at runtime | Estimated saving | -| :------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------ | -| `node-llama-cpp/llama/gitRelease.bundle` | Read only by `utils/gitReleaseBundles.js`, which only the source download/clone path uses. `getLlama` defaults `build` to `"never"` when `process.versions.electron` is set and the app never overrides it, so `canBuild` is false and the clone path is unreachable. | 36 MB, 1 file | -| `node-llama-cpp/llama/{addon,cmake,patches,profiles,toolchains,xpack}`, `llama/CMakeLists.txt` | Inputs to `compileLlamaCpp` (cmake-js), behind the same `canBuild` gate. | 0.3 MB, 57 files | -| `node-llama-cpp/templates` | Used only by the `init` CLI command. | 0.2 MB, 3 files | -| `@fugood/whisper.node/{src,whisper.cpp}`, `CMakeLists.txt` | C/C++ sources for building the addon. `lib/` only `require`s the prebuilt variant packages (or `build/Release`). | 5.4 MB, about 117 files | -| `better-sqlite3/{deps,src}` | SQLite amalgamation and addon sources for node-gyp. `lib/binding.js` loads `prebuilds/-.node` or `build/Release`. | 10.4 MB, 33 files | -| `better-sqlite3/prebuilds/` other than this build's own (`win.files`, `mac.files`, `linux.files`) | `getPrebuildPath()` loads only `-.node`, or `linuxmusl-.node` on musl Linux. Each platform's rule keeps `win32-${arch}`, `darwin-${arch}` or `linux-${arch}` plus `linuxmusl-${arch}`; electron-builder expands `${arch}` per packed arch. | 15 MB, 7 files (Windows, macOS); 12 MB, 6 files (Linux) | +| Pattern | Why it is unused at runtime | Estimated saving | +| :--------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------ | +| `node-llama-cpp/llama/gitRelease.bundle` | Read only by `utils/gitReleaseBundles.js`, which only the source download/clone path uses. `getLlama` defaults `build` to `"never"` when `process.versions.electron` is set and the app never overrides it, so `canBuild` is false and the clone path is unreachable. | 36 MB, 1 file | +| `node-llama-cpp/llama/{addon,cmake,patches,profiles,toolchains,xpack}`, `llama/CMakeLists.txt` | Inputs to `compileLlamaCpp` (cmake-js), behind the same `canBuild` gate. | 0.3 MB, 57 files | +| `node-llama-cpp/templates` | Used only by the `init` CLI command. | 0.2 MB, 3 files | +| `@fugood/whisper.node/{src,whisper.cpp}`, `CMakeLists.txt` | C/C++ sources for building the addon. `lib/` only `require`s the prebuilt variant packages (or `build/Release`). | 5.4 MB, about 117 files | +| `better-sqlite3/{deps,src}` | SQLite amalgamation and addon sources for node-gyp. `lib/binding.js` loads `prebuilds/-.node` or `build/Release`. | 10.4 MB, 33 files | +| `better-sqlite3/prebuilds/` other than this build's own | `getPrebuildPath()` loads only `-.node`, or `linuxmusl-.node` on musl Linux. The rule keeps `${platform}-${arch}` and `${platform}musl-${arch}`: electron-builder expands `${platform}` to the build machine's `process.platform` (each release leg builds on its own OS) and `${arch}` per packed arch, and only Linux has `musl` prebuilds. | 15 MB, 7 files (Windows, macOS); 12 MB, 6 files (Linux) | Together that is roughly 65 MB and 220 files less to write and scan on first launch (estimates from the on-disk sizes in `node_modules`; the download shrinks less, by an estimated 40 to 50 MB, because @@ -424,14 +424,22 @@ the installer compresses the source files well). Update the download size in [INSTALL_GUIDE.md](INSTALL_GUIDE.md) after the next packaged build. `verify-packaged-artifacts.js` checks that `llama/binariesGithubRelease.json` (read by node-llama-cpp on import) still ships and that `llama/gitRelease.bundle` does not. `@node-llama-cpp/win-arm64` (11 MB) declares -`cpu: ["arm64", "x64"]`, so npm installs it on x64 runners too. `win.files` excludes the other -arch's llama prebuilts with `!**/node_modules/@node-llama-cpp/win-!(${arch}|${arch}-*)/**`: -electron-builder expands `${arch}` per packed arch, so the x64 build drops `win-arm64` and the arm64 -build (which inherits `win.files` through `extends`) drops any `win-x64*` package. -`verify-packaged-artifacts.js` fails if a package for the other arch still holds files. It also -requires each build's own `better-sqlite3` prebuild (without it `lib/binding.js` falls back to -`build/Release`, which is not packaged, and SQLite cannot load) and fails if a prebuild for another -platform or arch was packaged. +`cpu: ["arm64", "x64"]`, so npm installs it on x64 runners too. The rule +`!**/node_modules/@node-llama-cpp/win-!(${arch}|${arch}-*)/**` excludes the other arch's llama +prebuilts: electron-builder expands `${arch}` per packed arch, so the x64 build drops `win-arm64` +and the arm64 build (`electron-builder.win-arm64.json` extends the base config) drops any `win-x64*` +package. npm does not install `win-*` packages on macOS or Linux. `verify-packaged-artifacts.js` +fails if a package for the other arch still holds files. It also requires each build's own +`better-sqlite3` prebuild (without it `lib/binding.js` falls back to `build/Release`, which is not +packaged, and SQLite cannot load) and fails if a prebuild for another platform or arch was packaged. + +Keep every `files` rule in the top-level list. electron-builder turns the top-level strings into one +file set, but a string list under `win`, `mac` or `linux` becomes a second matcher that holds only +exclusions, and electron-builder starts such a matcher with `**/*`. 5.1.0 shipped that way: its +`app.asar` held the whole project folder (webpack cache, docs, sources, tests; about 300 MB more), +so every download grew by 57 to 82 MB instead of shrinking. `verify-packaged-artifacts.js` now fails +when `app.asar` holds anything besides `dist`, `node_modules`, `package.json` and `src/shared`, and +`test/electron-builder-config.test.js` runs electron-builder's own matchers for each release leg. ## Signing And Notarization Requirements diff --git a/docs/commercial-use-inventory.json b/docs/commercial-use-inventory.json index 91cdd8a..72c810e 100644 --- a/docs/commercial-use-inventory.json +++ b/docs/commercial-use-inventory.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-10-31T15:05:19.740Z", + "generatedAt": "2026-11-01T00:30:59.499Z", "projectLicense": { "riskLevel": "allowed", "reason": "Project license is PolyForm Noncommercial 1.0.0 for personal/noncommercial use. Commercial rights are sold separately; third-party dependency obligations remain governed by their own licenses.", @@ -23818,7 +23818,9 @@ "!**/node_modules/node-llama-cpp/templates/**", "!**/node_modules/@fugood/whisper.node/CMakeLists.txt", "!**/node_modules/@fugood/whisper.node/{src,whisper.cpp}/**", - "!**/node_modules/better-sqlite3/{deps,src}/**" + "!**/node_modules/better-sqlite3/{deps,src}/**", + "!**/node_modules/better-sqlite3/prebuilds/!(${platform}-${arch}|${platform}musl-${arch}).node", + "!**/node_modules/@node-llama-cpp/win-!(${arch}|${arch}-*)/**" ], "extraResources": [ { diff --git a/electron-builder.json b/electron-builder.json index dc28417..5d7ba7d 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -67,7 +67,9 @@ "!**/node_modules/node-llama-cpp/templates/**", "!**/node_modules/@fugood/whisper.node/CMakeLists.txt", "!**/node_modules/@fugood/whisper.node/{src,whisper.cpp}/**", - "!**/node_modules/better-sqlite3/{deps,src}/**" + "!**/node_modules/better-sqlite3/{deps,src}/**", + "!**/node_modules/better-sqlite3/prebuilds/!(${platform}-${arch}|${platform}musl-${arch}).node", + "!**/node_modules/@node-llama-cpp/win-!(${arch}|${arch}-*)/**" ], "extraMetadata": { "main": "dist/main.js" @@ -88,11 +90,7 @@ "win": { "icon": "assets/icons/win/icon.ico", "target": ["nsis", "zip"], - "compression": "normal", - "files": [ - "!**/node_modules/better-sqlite3/prebuilds/!(win32-${arch}).node", - "!**/node_modules/@node-llama-cpp/win-!(${arch}|${arch}-*)/**" - ] + "compression": "normal" }, "nsis": { "oneClick": false, @@ -111,9 +109,6 @@ "artifactName": "StratoSortStack-Setup-${version}.exe" }, "mac": { - "files": [ - "!**/node_modules/better-sqlite3/prebuilds/!(darwin-${arch}).node" - ], "icon": "assets/icons/mac/icon.icns", "category": "public.app-category.productivity", "hardenedRuntime": true, @@ -143,9 +138,6 @@ ] }, "linux": { - "files": [ - "!**/node_modules/better-sqlite3/prebuilds/!(linux-${arch}|linuxmusl-${arch}).node" - ], "icon": "assets/icons/png/512x512.png", "maintainer": "Kennedy Applied Sciences, Inc. ", "target": ["AppImage", "deb"], diff --git a/scripts/verify-packaged-artifacts.js b/scripts/verify-packaged-artifacts.js index eeb6714..2e7d100 100644 --- a/scripts/verify-packaged-artifacts.js +++ b/scripts/verify-packaged-artifacts.js @@ -106,8 +106,8 @@ function getRequiredLlamaPrebuiltPackages(platform, arch) { } /** - * node-llama-cpp prebuilt packages for the OTHER Windows arch, which `win.files` - * excludes through `win-!(${arch}|${arch}-*)`. @node-llama-cpp/win-arm64 + * node-llama-cpp prebuilt packages for the OTHER Windows arch, which the top-level + * "files" rule `win-!(${arch}|${arch}-*)` excludes. @node-llama-cpp/win-arm64 * declares `cpu: ["arm64", "x64"]`, so npm installs it on x64 runners too. */ function getForeignArchLlamaPrebuiltPackages(platform, arch) { @@ -232,6 +232,68 @@ function ensureExecutable(targetPath, label) { } } +/** + * What app.asar may hold at its top level: the webpack output, package.json, + * the production dependencies and src/shared (the top-level "files" in + * electron-builder.json). Anything else means a "files" matcher fell back to + * `**\/*` and packed the project folder, as 5.1.0 did (.webpack-cache, docs, + * src/main, test: about 300 MB). + */ +const ALLOWED_ASAR_ENTRIES = { + dist: null, + node_modules: null, + 'package.json': null, + src: ['shared'] +}; + +/** + * Reads the JSON header of an asar archive: a uint32 size pickle (4, header + * size) followed by the header pickle (payload size, string length, JSON). + */ +function readAsarHeader(asarPath) { + const fd = fs.openSync(asarPath, 'r'); + try { + const sizePickle = Buffer.alloc(8); + fs.readSync(fd, sizePickle, 0, 8, 0); + const headerPickle = Buffer.alloc(sizePickle.readUInt32LE(4)); + fs.readSync(fd, headerPickle, 0, headerPickle.length, 8); + const jsonLength = headerPickle.readUInt32LE(4); + return JSON.parse(headerPickle.toString('utf8', 8, 8 + jsonLength)); + } finally { + fs.closeSync(fd); + } +} + +function verifyAsarContents(asarPath, label) { + ensureExists(asarPath, label); + let header; + try { + header = readAsarHeader(asarPath); + } catch (err) { + throw new Error(`${label} has no readable asar header: ${asarPath}`, { cause: err }); + } + const unexpected = []; + Object.entries(header.files || {}).forEach(([name, entry]) => { + if (!Object.prototype.hasOwnProperty.call(ALLOWED_ASAR_ENTRIES, name)) { + unexpected.push(name); + return; + } + const allowedChildren = ALLOWED_ASAR_ENTRIES[name]; + if (allowedChildren && entry.files) { + Object.keys(entry.files) + .filter((child) => !allowedChildren.includes(child)) + .forEach((child) => unexpected.push(`${name}/${child}`)); + } + }); + if (unexpected.length > 0) { + throw new Error( + `${label} contains files that are not part of the app: ${unexpected.sort().join(', ')}. ` + + 'Keep every "files" rule in the top-level list of electron-builder.json; a string list ' + + 'under win/mac/linux becomes its own matcher, which packs the whole project folder.' + ); + } +} + function verifyRequiredFiles(basePath, fileNames, labelPrefix) { fileNames.forEach((fileName) => { ensureExists(path.join(basePath, fileName), `${labelPrefix} ${fileName}`); @@ -311,7 +373,7 @@ function verifyLlamaPrebuiltPackages(unpackedNodeModulesRoot, platform, arch) { if (containsAnyFile(packageDir)) { throw new Error( `node-llama-cpp prebuilt package ${packageName} for another arch was packaged: ` + - `${packageDir}. Check the @node-llama-cpp exclusion in electron-builder.json "win.files".` + `${packageDir}. Check the @node-llama-cpp exclusion in electron-builder.json "files".` ); } }); @@ -364,7 +426,7 @@ function verifyWindowsArtifacts(buildRoot, arch, version = readPackageVersion()) ensureExists(path.join(appRoot, 'StratoSort Stack.exe'), 'Windows executable'); verifyRequiredFiles(appRoot, REQUIRED_ELECTRON_RUNTIME_FILES, 'Windows runtime file'); ensureExists(resources, 'Windows resources directory'); - ensureExists(path.join(resources, 'app.asar'), 'Windows app.asar'); + verifyAsarContents(path.join(resources, 'app.asar'), 'Windows app.asar'); ensureExists( path.join(resources, 'assets', 'runtime', 'llama-server.exe'), 'Windows runtime binary' @@ -406,7 +468,7 @@ function verifyMacArtifacts(buildRoot, arch) { getRequiredMacElectronRuntimeFiles(arch), 'macOS runtime file' ); - ensureExists(path.join(resources, 'app.asar'), 'macOS app.asar'); + verifyAsarContents(path.join(resources, 'app.asar'), 'macOS app.asar'); ensureExists(path.join(resources, 'assets', 'runtime', 'llama-server'), 'macOS runtime binary'); ensureExecutable( path.join(resources, 'assets', 'runtime', 'ffmpeg', 'ffmpeg'), @@ -430,7 +492,7 @@ function verifyLinuxArtifacts(buildRoot, arch) { ensureExists(appRoot, 'Linux unpacked root'); verifyRequiredFiles(appRoot, REQUIRED_ELECTRON_RUNTIME_FILES, 'Linux runtime file'); ensureExists(resources, 'Linux resources directory'); - ensureExists(path.join(resources, 'app.asar'), 'Linux app.asar'); + verifyAsarContents(path.join(resources, 'app.asar'), 'Linux app.asar'); ensureExists(path.join(resources, 'assets', 'runtime', 'llama-server'), 'Linux vision runtime'); ensureExecutable( path.join(resources, 'assets', 'runtime', 'ffmpeg', 'ffmpeg'), @@ -479,6 +541,7 @@ if (require.main === module) { } module.exports = { + ALLOWED_ASAR_ENTRIES, getRequiredLlamaPrebuiltPackages, getForeignArchLlamaPrebuiltPackages, getBetterSqlitePrebuilds, @@ -497,6 +560,8 @@ module.exports = { verifyLinuxArtifacts, verifyMacArtifacts, verifyNativeModuleFolders, + readAsarHeader, + verifyAsarContents, verifyRequiredFiles, verifyWhisperVariantFolders, verifyWindowsArtifacts diff --git a/test/electron-builder-config.test.js b/test/electron-builder-config.test.js index 7ea89c8..aa5dde3 100644 --- a/test/electron-builder-config.test.js +++ b/test/electron-builder-config.test.js @@ -68,34 +68,144 @@ describe('electron-builder.json Windows compression', () => { }); }); -// Runs app-builder-lib's own node_modules filter (the one NodeModuleCopyHelper -// applies while copying dependencies) against the committed `files` patterns. -describe('electron-builder.json node_modules exclusions', () => { - const { getNodeModuleFileMatcher, FileMatcher } = require('app-builder-lib/out/fileMatcher'); - - function isPackaged(platformKey, moduleRelativePath, arch = 'x64') { - const packager = { config: builderConfig, debugLogger: { isEnabled: false } }; - // app-builder-lib expands ${arch} in `files` patterns per packed arch. - const macroExpander = (pattern) => pattern.replace(/\$\{arch\}/g, arch); - const nodeModuleMatcher = getNodeModuleFileMatcher( - '/project', - '/out/app', - macroExpander, - builderConfig[platformKey] || {}, - packager - ); - const [, scope, name] = moduleRelativePath.match(/^node_modules\/(@[^/]+\/)?([^/]+)/); - const depDir = `/project/node_modules/${scope || ''}${name}`; - const filter = new FileMatcher( - depDir, - '/out/app', - macroExpander, - nodeModuleMatcher.patterns - ).createFilter(); - const stat = { isDirectory: () => false, moduleFullFilePath: moduleRelativePath }; - return filter(`/project/${moduleRelativePath}`, stat); - } +// electron-builder does not read `files` as written: before packaging it +// normalizes the top-level strings into a `{ filter: [...] }` file set +// (doMergeConfigs) and builds its app and node_modules filters from that plus +// the platform's own options. These helpers run the same steps on the +// committed configs. A string `files` list under win/mac/linux turned into a +// second, exclude-only matcher, which electron-builder starts with `**/*`, so +// 5.1.0 packed the whole project folder into app.asar. +const { doMergeConfigs } = require('app-builder-lib/out/util/config/config'); +const { + getMainFileMatchers, + getNodeModuleFileMatcher, + FileMatcher +} = require('app-builder-lib/out/fileMatcher'); + +const NODE_PLATFORM = { win: 'win32', mac: 'darwin', linux: 'linux' }; + +function loadNormalizedConfig(configFile) { + const read = (name) => JSON.parse(JSON.stringify(require(`../${name.replace(/^\.\//, '')}`))); + const config = read(configFile); + const parent = config.extends; + delete config.extends; + return doMergeConfigs(parent ? [read(parent), config] : [config]); +} + +// Each release leg builds on its own OS, so ${platform} (process.platform at +// build time) is the target's Node platform name. +function macroExpanderFor(platformKey, arch) { + return (pattern) => + pattern + .replace(/\$\{arch\}/g, arch) + .replace(/\$\{platform\}/g, NODE_PLATFORM[platformKey]) + .replace(/\$\{os\}/g, platformKey); +} + +function packagerFor(config) { + return { + config, + projectDir: '/project', + buildResourcesDir: `/project/${config.directories.buildResources}`, + isPrepackedAppAsar: false, + debugLogger: { isEnabled: false } + }; +} + +// Mirrors the copy walk: a file is packaged when some matcher accepts it and +// every folder above it. +function isAppFilePackaged(configFile, platformKey, arch, relativePath) { + const config = loadNormalizedConfig(configFile); + const matchers = getMainFileMatchers( + '/project', + '/out/app', + macroExpanderFor(platformKey, arch), + config[platformKey] || {}, + { info: packagerFor(config) }, + `/project/${config.directories.output}`, + false + ); + const segments = relativePath.split('/'); + return matchers.some((matcher) => { + const filter = matcher.createFilter(); + return segments.every((_, index) => { + const isDirectory = index < segments.length - 1; + const moduleFullFilePath = segments.slice(0, index + 1).join('/'); + return filter(`/project/${moduleFullFilePath}`, { + isDirectory: () => isDirectory, + moduleFullFilePath + }); + }); + }); +} +function isModulePackaged(platformKey, moduleRelativePath, arch = 'x64', configFile) { + const config = loadNormalizedConfig(configFile || 'electron-builder.json'); + const macroExpander = macroExpanderFor(platformKey, arch); + const nodeModuleMatcher = getNodeModuleFileMatcher( + '/project', + '/out/app', + macroExpander, + config[platformKey] || {}, + packagerFor(config) + ); + const [, scope, name] = moduleRelativePath.match(/^node_modules\/(@[^/]+\/)?([^/]+)/); + const depDir = `/project/node_modules/${scope || ''}${name}`; + const filter = new FileMatcher( + depDir, + '/out/app', + macroExpander, + nodeModuleMatcher.patterns + ).createFilter(); + const stat = { isDirectory: () => false, moduleFullFilePath: moduleRelativePath }; + return filter(`/project/${moduleRelativePath}`, stat); +} + +const RELEASE_LEGS = [ + ['electron-builder.json', 'win', 'x64'], + ['electron-builder.win-arm64.json', 'win', 'arm64'], + ['electron-builder.json', 'mac', 'x64'], + ['electron-builder.json', 'mac', 'arm64'], + ['electron-builder.json', 'linux', 'x64'], + ['electron-builder.json', 'linux', 'arm64'] +]; + +describe('electron-builder app files', () => { + test.each(RELEASE_LEGS)( + '%s (%s %s) packages the built app and none of the project tree', + (configFile, platformKey, arch) => { + const packaged = (relativePath) => + isAppFilePackaged(configFile, platformKey, arch, relativePath); + ['dist/main.js', 'dist/index.html', 'package.json', 'src/shared/logger.js'].forEach( + (relativePath) => + expect([relativePath, packaged(relativePath)]).toEqual([relativePath, true]) + ); + [ + '.webpack-cache/default-production/0.pack', + 'docs/assets/screenshot.png', + 'docs/walkthrough-assets/step-1.png', + 'src/main/simple-main.js', + 'src/renderer/index.js', + 'test/electron-builder-config.test.js', + 'scripts/verify-packaged-artifacts.js', + 'CLAUDE.md', + '.env', + 'webpack.config.js' + ].forEach((relativePath) => + expect([relativePath, packaged(relativePath)]).toEqual([relativePath, false]) + ); + } + ); + + test('no platform block carries its own files list', () => { + // A string list there becomes the exclude-only matcher described above. + ['win', 'mac', 'linux'].forEach((platformKey) => { + expect([platformKey, builderConfig[platformKey].files]).toEqual([platformKey, undefined]); + }); + }); +}); + +describe('electron-builder.json node_modules exclusions', () => { test.each([ 'node_modules/node-llama-cpp/llama/gitRelease.bundle', 'node_modules/node-llama-cpp/llama/CMakeLists.txt', @@ -111,7 +221,7 @@ describe('electron-builder.json node_modules exclusions', () => { 'node_modules/better-sqlite3/prebuilds/darwin-arm64.node', 'node_modules/better-sqlite3/prebuilds/linuxmusl-x64.node' ])('Windows build leaves out build-only %s', (moduleRelativePath) => { - expect(isPackaged('win', moduleRelativePath)).toBe(false); + expect(isModulePackaged('win', moduleRelativePath)).toBe(false); }); test.each([ @@ -125,9 +235,10 @@ describe('electron-builder.json node_modules exclusions', () => { 'node_modules/better-sqlite3/lib/binding.js', 'node_modules/better-sqlite3/prebuilds/win32-x64.node' ])('Windows build keeps runtime file %s', (moduleRelativePath) => { - expect(isPackaged('win', moduleRelativePath)).toBe(true); + expect(isModulePackaged('win', moduleRelativePath)).toBe(true); }); + const WIN_ARM64_CONFIG = 'electron-builder.win-arm64.json'; test.each([ ['x64', 'node_modules/@node-llama-cpp/win-arm64/bins/win-arm64/llama-addon.node', false], ['x64', 'node_modules/@node-llama-cpp/win-arm64/package.json', false], @@ -141,7 +252,8 @@ describe('electron-builder.json node_modules exclusions', () => { ['arm64', 'node_modules/@node-llama-cpp/win-x64-cuda/bins/win-x64-cuda/ggml-cuda.dll', false], ['arm64', 'node_modules/node-llama-cpp/dist/bindings/getLlama.js', true] ])('Windows %s build packages %s: %s', (arch, moduleRelativePath, expected) => { - expect(isPackaged('win', moduleRelativePath, arch)).toBe(expected); + const configFile = arch === 'arm64' ? WIN_ARM64_CONFIG : undefined; + expect(isModulePackaged('win', moduleRelativePath, arch, configFile)).toBe(expected); }); // lib/binding.js loads prebuilds/-.node (linuxmusl- on @@ -165,10 +277,18 @@ describe('electron-builder.json node_modules exclusions', () => { ['linux', 'x64', ['linux-x64', 'linuxmusl-x64']], ['linux', 'arm64', ['linux-arm64', 'linuxmusl-arm64']] ])('%s %s build keeps only its own better-sqlite3 prebuilds', (platformKey, arch, expected) => { + const configFile = platformKey === 'win' && arch === 'arm64' ? WIN_ARM64_CONFIG : undefined; const kept = SQLITE_PREBUILDS.filter((name) => - isPackaged(platformKey, `node_modules/better-sqlite3/prebuilds/${name}.node`, arch) + isModulePackaged( + platformKey, + `node_modules/better-sqlite3/prebuilds/${name}.node`, + arch, + configFile + ) ); expect(kept).toEqual(expected); - expect(isPackaged(platformKey, 'node_modules/better-sqlite3/lib/binding.js', arch)).toBe(true); + expect( + isModulePackaged(platformKey, 'node_modules/better-sqlite3/lib/binding.js', arch, configFile) + ).toBe(true); }); }); diff --git a/test/verifyPackagedArtifacts.test.js b/test/verifyPackagedArtifacts.test.js index f140a7e..ae4c761 100644 --- a/test/verifyPackagedArtifacts.test.js +++ b/test/verifyPackagedArtifacts.test.js @@ -13,6 +13,7 @@ const { getRequiredLlamaPrebuiltPackages, getRequiredWhisperVariantPackages, getWindowsZipArtifactName, + readAsarHeader, verifyBetterSqlitePrebuilds, verifyMacArtifacts, verifyWindowsArtifacts @@ -25,6 +26,30 @@ function writeFile(targetPath) { fs.writeFileSync(targetPath, 'test'); } +// The app.asar layout electron-builder produces from the top-level "files". +const APP_ASAR_ENTRIES = { + dist: { files: { 'main.js': { size: 1, offset: '0' } } }, + node_modules: { files: {} }, + 'package.json': { size: 2, offset: '1' }, + src: { files: { shared: { files: {} } } } +}; + +// Writes an asar header (no file data): a uint32 size pickle followed by the +// header pickle holding the JSON string, padded to 4 bytes. +function writeAsar(targetPath, entries = APP_ASAR_ENTRIES) { + const json = Buffer.from(JSON.stringify({ files: entries })); + const padded = Math.ceil(json.length / 4) * 4; + const headerPickle = Buffer.alloc(8 + padded); + headerPickle.writeUInt32LE(4 + padded, 0); + headerPickle.writeUInt32LE(json.length, 4); + json.copy(headerPickle, 8); + const sizePickle = Buffer.alloc(8); + sizePickle.writeUInt32LE(4, 0); + sizePickle.writeUInt32LE(headerPickle.length, 4); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, Buffer.concat([sizePickle, headerPickle])); +} + function writeExecutable(targetPath) { writeFile(targetPath); if (process.platform !== 'win32') { @@ -75,7 +100,7 @@ function createWindowsBuildRoot({ arch = 'x64', skipWhisperVariants = false } = REQUIRED_ELECTRON_RUNTIME_FILES.forEach((fileName) => { writeFile(path.join(appRoot, fileName)); }); - writeFile(path.join(resources, 'app.asar')); + writeAsar(path.join(resources, 'app.asar')); writeFile(path.join(resources, 'assets', 'runtime', 'llama-server.exe')); if (arch !== 'arm64') { writeExecutable(path.join(resources, 'assets', 'runtime', 'ffmpeg', 'ffmpeg.exe')); @@ -117,7 +142,7 @@ function createMacBuildRoot({ if (includeSnapshotBlob) { writeFile(path.join(frameworkResources, 'snapshot_blob.bin')); } - writeFile(path.join(resources, 'app.asar')); + writeAsar(path.join(resources, 'app.asar')); writeFile(path.join(resources, 'assets', 'runtime', 'llama-server')); writeExecutable(path.join(resources, 'assets', 'runtime', 'ffmpeg', 'ffmpeg')); @@ -156,6 +181,66 @@ describe('verify-packaged-artifacts', () => { ); }); + test('rejects Windows builds whose app.asar packed the project folder', () => { + // 5.1.0's app.asar held the webpack cache, docs, src/main and test. + const buildRoot = createWindowsBuildRoot(); + tempDirs.push(buildRoot); + writeAsar(path.join(buildRoot, 'win-unpacked', 'resources', 'app.asar'), { + ...APP_ASAR_ENTRIES, + '.webpack-cache': { files: {} }, + docs: { files: {} }, + src: { files: { main: { files: {} }, shared: { files: {} } } }, + test: { files: {} } + }); + + expect(() => verifyWindowsArtifacts(buildRoot, 'x64', VERSION)).toThrow( + /Windows app\.asar contains files that are not part of the app: \.webpack-cache, docs, src\/main, test\./ + ); + }); + + test('rejects macOS builds whose app.asar packed the project folder', () => { + const buildRoot = createMacBuildRoot({ arch: 'arm64' }); + tempDirs.push(buildRoot); + writeAsar( + path.join( + buildRoot, + 'mac-arm64', + 'StratoSort Stack.app', + 'Contents', + 'Resources', + 'app.asar' + ), + { ...APP_ASAR_ENTRIES, 'CLAUDE.md': { size: 1, offset: '3' } } + ); + + expect(() => verifyMacArtifacts(buildRoot, 'arm64')).toThrow( + /macOS app\.asar contains files that are not part of the app: CLAUDE\.md\./ + ); + }); + + test('rejects an app.asar without a readable header', () => { + const buildRoot = createWindowsBuildRoot(); + tempDirs.push(buildRoot); + writeFile(path.join(buildRoot, 'win-unpacked', 'resources', 'app.asar')); + + expect(() => verifyWindowsArtifacts(buildRoot, 'x64', VERSION)).toThrow( + /Windows app\.asar has no readable asar header/ + ); + }); + + test('readAsarHeader returns the archive listing', () => { + const buildRoot = createWindowsBuildRoot(); + tempDirs.push(buildRoot); + + const header = readAsarHeader(path.join(buildRoot, 'win-unpacked', 'resources', 'app.asar')); + expect(Object.keys(header.files).sort()).toEqual([ + 'dist', + 'node_modules', + 'package.json', + 'src' + ]); + }); + test('accepts macOS x64 builds with the per-arch V8 context snapshot filename', () => { const buildRoot = createMacBuildRoot({ arch: 'x64' }); tempDirs.push(buildRoot);