Merge branch 'main' into esm-asar-entrypoints

This commit is contained in:
2025-12-12 14:42:02 +01:00
35 changed files with 6036 additions and 3429 deletions

View File

@@ -1,35 +0,0 @@
version: 2.1
orbs:
cfa: continuousauth/npm@2.1.0
node: electronjs/node@2.3.0
workflows:
test_and_release:
# Run the test jobs first, then the release only when all the test jobs are successful
jobs:
- node/test:
executor: node/macos
name: test-mac-<< matrix.node-version >>
override-ci-command: yarn install --frozen-lockfile --ignore-engines
test-steps:
- node/install-rosetta
- run: yarn build
- run: yarn lint
- run: yarn test
use-test-steps: true
matrix:
alias: test
parameters:
node-version:
- 20.5.0
- 18.17.0
- 16.20.1
- cfa/release:
requires:
- test
filters:
branches:
only:
- main
context: cfa-release

View File

@@ -21,7 +21,7 @@ jobs:
creds: ${{ secrets.ECOSYSTEM_ISSUE_TRIAGE_GH_APP_CREDS }} creds: ${{ secrets.ECOSYSTEM_ISSUE_TRIAGE_GH_APP_CREDS }}
org: electron org: electron
- name: Add to Project - name: Add to Project
uses: dsanders11/project-actions/add-item@eb760c48894b5702398529cbb8f6e98378e315d0 # v1.3.0 uses: dsanders11/project-actions/add-item@2134fe7cc71c58b7ae259c82a8e63c6058255678 # v1.7.0
with: with:
field: Opened field: Opened
field-value: ${{ github.event.pull_request.created_at || github.event.issue.created_at }} field-value: ${{ github.event.pull_request.created_at || github.event.issue.created_at }}

36
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Publish documentation
on:
push:
tags:
- v[0-9]+.[0-9]+.[0-9]+*
permissions:
id-token: write
contents: read
jobs:
docs:
runs-on: ubuntu-latest
environment: docs-publish
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- name: Install dependencies
run: yarn --immutable
- name: Build API documentation
run: yarn build:docs
- name: Azure login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ secrets.AZURE_OIDC_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_OIDC_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_OIDC_SUBSCRIPTION_ID }}
- name: Upload to Azure Blob Storage
uses: azure/cli@9f7ce6f37c31b777ec6c6b6d1dfe7db79f497956 # v2.2.0
with:
inlineScript: |
az storage blob upload-batch --account-name ${{ secrets.AZURE_ECOSYSTEM_PACKAGES_STORAGE_ACCOUNT_NAME }} -d '$web/${{ github.event.repository.name }}/${{ github.ref_name }}' -s ./docs --overwrite --auth-mode login

37
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Release
on:
push:
branches:
- main
jobs:
test:
uses: ./.github/workflows/test.yml
release:
name: Release
runs-on: ubuntu-latest
needs: test
environment: npm-trusted-publisher
permissions:
id-token: write # for publishing releases
steps:
- name: Checkout
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- name: Install
run: yarn install --immutable
- name: Get GitHub App Token
id: secret-service
uses: electron/secret-service-action@3476425e8b30555aac15b1b7096938e254b0e155 # v1.0.0
- name: Run Semantic Release
uses: electron/semantic-trusted-release@5eceb399ac8de8863205cf6e34109bce473ba566 # v1.0.1
with:
github-token: ${{ fromJSON(steps.secret-service.outputs.secrets).GITHUB_TOKEN }}

View File

@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: semantic-pull-request - name: semantic-pull-request
uses: amannn/action-semantic-pull-request@cfb60706e18bc85e8aec535e3c577abe8f70378e # v5.5.2 uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:

38
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Test
on:
pull_request:
branches:
- main
schedule:
- cron: '0 22 * * 3'
workflow_call:
permissions:
contents: read
jobs:
test:
name: Test
strategy:
fail-fast: false
matrix:
node-version:
- 22.12.x
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: "${{ matrix.node-version }}"
cache: 'yarn'
- name: Install
run: yarn install --immutable
- name: Build
run: yarn build
- name: Lint
run: yarn lint
- name: Test
run: yarn test

3
.gitignore vendored
View File

@@ -8,4 +8,5 @@ entry-asar/esm/*.d.?ts
test/fixtures/apps test/fixtures/apps
coverage coverage
docs docs
.vscode .vscode
.yarn/install-state.gz

View File

@@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
yarn lint-staged yarn lint-staged

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.12

View File

@@ -2,7 +2,7 @@
"plugins": [ "plugins": [
"@semantic-release/commit-analyzer", "@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator", "@semantic-release/release-notes-generator",
"@continuous-auth/semantic-release-npm", "@semantic-release/npm",
"@semantic-release/github" "@semantic-release/github"
], ],
"branches": [ "main" ] "branches": [ "main" ]

942
.yarn/releases/yarn-4.10.3.cjs vendored Executable file

File diff suppressed because one or more lines are too long

10
.yarnrc.yml Normal file
View File

@@ -0,0 +1,10 @@
enableScripts: false
nodeLinker: node-modules
npmMinimalAgeGate: 10080
npmPreapprovedPackages:
- "@electron/*"
yarnPath: .yarn/releases/yarn-4.10.3.cjs

View File

@@ -2,8 +2,9 @@
> Create universal macOS Electron applications > Create universal macOS Electron applications
[![CircleCI](https://circleci.com/gh/electron/universal/tree/main.svg?style=shield)](https://circleci.com/gh/electron/universal) [![Test](https://github.com/electron/universal/actions/workflows/test.yml/badge.svg)](https://github.com/electron/universal/actions/workflows/test.yml)
[![NPM package](https://img.shields.io/npm/v/@electron/universal)](https://npm.im/@electron/universal) [![NPM package](https://img.shields.io/npm/v/@electron/universal)](https://npm.im/@electron/universal)
[![API docs](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fregistry.npmjs.org%2F%40electron%2Funiversal%2Flatest&query=%24.version&logo=typescript&logoColor=white&label=API%20Docs)](https://packages.electronjs.org/universal)
## Usage ## Usage
@@ -22,6 +23,8 @@ await makeUniversalApp({
}); });
``` ```
For full API usage, see the [API documentation](https://packages.electronjs.org/universal).
## Advanced configuration ## Advanced configuration
The basic usage patterns will work for most apps out of the box. Additional configuration The basic usage patterns will work for most apps out of the box. Additional configuration
@@ -118,8 +121,4 @@ Note that if you are using `mergeASARs`, you may need to add architecture-specif
binary resources to the `singleArchFiles` pattern. binary resources to the `singleArchFiles` pattern.
See [Merging ASARs usage](#merging-asar-archives-to-reduce-app-size) for an example. See [Merging ASARs usage](#merging-asar-archives-to-reduce-app-size) for an example.
#### How do I build my app for Apple silicon in the first place?
Check out the [Electron Apple silicon blog post](https://www.electronjs.org/blog/apple-silicon).
[`minimatch`]: https://github.com/isaacs/minimatch?tab=readme-ov-file#features [`minimatch`]: https://github.com/isaacs/minimatch?tab=readme-ov-file#features

View File

@@ -1,14 +0,0 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'.': [
'ts-jest',
{
tsconfig: 'tsconfig.jest.json'
}
]
},
globalSetup: './jest.setup.ts'
};

View File

@@ -1,73 +0,0 @@
import { downloadArtifact } from '@electron/get';
import * as zip from 'cross-zip';
import * as fs from 'fs-extra';
import * as path from 'path';
const asarsDir = path.resolve(__dirname, 'test', 'fixtures', 'asars');
const appsDir = path.resolve(__dirname, 'test', 'fixtures', 'apps');
const templateApp = async (
name: string,
arch: string,
modify: (appPath: string) => Promise<void>,
) => {
const electronZip = await downloadArtifact({
artifactName: 'electron',
version: '27.0.0',
platform: 'darwin',
arch,
});
const appPath = path.resolve(appsDir, name);
zip.unzipSync(electronZip, appsDir);
await fs.rename(path.resolve(appsDir, 'Electron.app'), appPath);
await fs.remove(path.resolve(appPath, 'Contents', 'Resources', 'default_app.asar'));
await modify(appPath);
};
export default async () => {
await fs.remove(appsDir);
await fs.mkdirp(appsDir);
await templateApp('Arm64Asar.app', 'arm64', async (appPath) => {
await fs.copy(
path.resolve(asarsDir, 'app.asar'),
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
);
});
// contains `extra-file.txt`
await templateApp('Arm64AsarExtraFile.app', 'arm64', async (appPath) => {
await fs.copy(
path.resolve(asarsDir, 'app2.asar'),
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
);
});
await templateApp('X64Asar.app', 'x64', async (appPath) => {
await fs.copy(
path.resolve(asarsDir, 'app.asar'),
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
);
});
await templateApp('Arm64NoAsar.app', 'arm64', async (appPath) => {
await fs.copy(
path.resolve(asarsDir, 'app'),
path.resolve(appPath, 'Contents', 'Resources', 'app'),
);
});
// contains `extra-file.txt`
await templateApp('Arm64NoAsarExtraFile.app', 'arm64', async (appPath) => {
await fs.copy(
path.resolve(asarsDir, 'app2'),
path.resolve(appPath, 'Contents', 'Resources', 'app'),
);
});
await templateApp('X64NoAsar.app', 'x64', async (appPath) => {
await fs.copy(
path.resolve(asarsDir, 'app'),
path.resolve(appPath, 'Contents', 'Resources', 'app'),
);
});
};

View File

@@ -2,8 +2,8 @@
"name": "@electron/universal", "name": "@electron/universal",
"version": "0.0.0-development", "version": "0.0.0-development",
"description": "Utility for creating Universal macOS applications from two x64 and arm64 Electron applications", "description": "Utility for creating Universal macOS applications from two x64 and arm64 Electron applications",
"main": "dist/cjs/index.js", "type": "module",
"module": "dist/esm/index.js", "exports": "./dist/index.js",
"license": "MIT", "license": "MIT",
"keywords": [ "keywords": [
"electron", "electron",
@@ -12,10 +12,10 @@
], ],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/electron/universal.git" "url": "git+https://github.com/electron/universal.git"
}, },
"engines": { "engines": {
"node": ">=16.4" "node": ">=22.12.0"
}, },
"files": [ "files": [
"dist/*", "dist/*",
@@ -25,39 +25,39 @@
"README.md" "README.md"
], ],
"author": "Samuel Attard", "author": "Samuel Attard",
"publishConfig": {
"provenance": true
},
"scripts": { "scripts": {
"build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && tsc -p entry-asar/esm/tsconfig.json && tsc -p entry-asar/cjs/tsconfig.json", "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && tsc -p entry-asar/esm/tsconfig.json && tsc -p entry-asar/cjs/tsconfig.json",
"build:docs": "npx typedoc", "build:docs": "npx typedoc",
"lint": "prettier --check \"{src,entry-asar,test}/**/*.ts\" \"*.ts\"", "lint": "prettier --check \"{src,entry-asar,test}/**/*.ts\" \"*.ts\"",
"prettier:write": "prettier --write \"{src,entry-asar,test}/**/*.ts\" \"*.ts\"", "prettier:write": "prettier --write \"{src,entry-asar,test}/**/*.ts\" \"*.ts\"",
"prepublishOnly": "npm run build", "prepack": "npm run build",
"test": "jest", "test": "vitest run",
"prepare": "husky install" "prepare": "husky"
}, },
"devDependencies": { "devDependencies": {
"@electron/get": "^3.0.0", "@electron/get": "^4.0.0",
"@tsconfig/node22": "^22.0.1",
"@types/cross-zip": "^4.0.1", "@types/cross-zip": "^4.0.1",
"@types/debug": "^4.1.10", "@types/debug": "^4.1.10",
"@types/fs-extra": "^11.0.3",
"@types/jest": "^29.5.7",
"@types/minimatch": "^5.1.2", "@types/minimatch": "^5.1.2",
"@types/node": "^20.8.10", "@types/node": "~22.10.7",
"@types/plist": "^3.0.4", "@types/plist": "^3.0.4",
"cross-zip": "^4.0.0", "cross-zip": "^4.0.0",
"husky": "^8.0.3", "husky": "^9.1.7",
"jest": "^29.7.0", "lint-staged": "^16.1.0",
"lint-staged": "^15.0.2", "prettier": "^3.5.3",
"prettier": "^3.0.3",
"ts-jest": "^29.1.1",
"typedoc": "~0.25.13", "typedoc": "~0.25.13",
"typescript": "^5.2.2" "typescript": "^5.8.3",
"vitest": "^3.1.3"
}, },
"dependencies": { "dependencies": {
"@electron/asar": "^3.2.7", "@electron/asar": "^4.0.0",
"@malept/cross-spawn-promise": "^2.0.0", "@malept/cross-spawn-promise": "^2.0.0",
"debug": "^4.3.1", "debug": "^4.3.1",
"dir-compare": "^4.2.0", "dir-compare": "^4.2.0",
"fs-extra": "^11.1.1",
"minimatch": "^9.0.3", "minimatch": "^9.0.3",
"plist": "^3.1.0" "plist": "^3.1.0"
}, },
@@ -66,7 +66,5 @@
"prettier --write" "prettier --write"
] ]
}, },
"resolutions": { "packageManager": "yarn@4.10.3+sha512.c38cafb5c7bb273f3926d04e55e1d8c9dfa7d9c3ea1f36a4868fa028b9e5f72298f0b7f401ad5eb921749eb012eb1c3bb74bf7503df3ee43fd600d14a018266f"
"jackspeak": "2.1.1"
}
} }

View File

@@ -1,11 +1,13 @@
import asar from '@electron/asar'; import { execFileSync } from 'node:child_process';
import { execFileSync } from 'child_process'; import crypto from 'node:crypto';
import crypto from 'crypto'; import fs from 'node:fs';
import fs from 'fs-extra'; import os from 'node:os';
import path from 'path'; import path from 'node:path';
import * as asar from '@electron/asar';
import { minimatch } from 'minimatch'; import { minimatch } from 'minimatch';
import os from 'os';
import { d } from './debug'; import { d } from './debug.js';
const LIPO = 'lipo'; const LIPO = 'lipo';
@@ -40,7 +42,7 @@ export const detectAsarMode = async (appPath: string) => {
d('checking asar mode of', appPath); d('checking asar mode of', appPath);
const asarPath = path.resolve(appPath, 'Contents', 'Resources', 'app.asar'); const asarPath = path.resolve(appPath, 'Contents', 'Resources', 'app.asar');
if (!(await fs.pathExists(asarPath))) { if (!fs.existsSync(asarPath)) {
d('determined no asar'); d('determined no asar');
return AsarMode.NO_ASAR; return AsarMode.NO_ASAR;
} }
@@ -84,8 +86,10 @@ export const mergeASARs = async ({
}: MergeASARsOptions): Promise<void> => { }: MergeASARsOptions): Promise<void> => {
d(`merging ${x64AsarPath} and ${arm64AsarPath}`); d(`merging ${x64AsarPath} and ${arm64AsarPath}`);
const x64Files = new Set(asar.listPackage(x64AsarPath).map(toRelativePath)); const x64Files = new Set(asar.listPackage(x64AsarPath, { isPack: false }).map(toRelativePath));
const arm64Files = new Set(asar.listPackage(arm64AsarPath).map(toRelativePath)); const arm64Files = new Set(
asar.listPackage(arm64AsarPath, { isPack: false }).map(toRelativePath),
);
// //
// Build set of unpacked directories and files // Build set of unpacked directories and files
@@ -146,14 +150,13 @@ export const mergeASARs = async ({
const x64Content = asar.extractFile(x64AsarPath, file); const x64Content = asar.extractFile(x64AsarPath, file);
const arm64Content = asar.extractFile(arm64AsarPath, file); const arm64Content = asar.extractFile(arm64AsarPath, file);
// Skip file if the same content
if (x64Content.compare(arm64Content) === 0) { if (x64Content.compare(arm64Content) === 0) {
continue; continue;
} }
if ( // Skip universal Mach-O files.
MACHO_UNIVERSAL_MAGIC.has(x64Content.readUInt32LE(0)) && if (isUniversalMachO(x64Content)) {
MACHO_UNIVERSAL_MAGIC.has(arm64Content.readUInt32LE(0))
) {
continue; continue;
} }
@@ -168,8 +171,8 @@ export const mergeASARs = async ({
// Extract both // Extract both
// //
const x64Dir = await fs.mkdtemp(path.join(os.tmpdir(), 'x64-')); const x64Dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'x64-'));
const arm64Dir = await fs.mkdtemp(path.join(os.tmpdir(), 'arm64-')); const arm64Dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'arm64-'));
try { try {
d(`extracting ${x64AsarPath} to ${x64Dir}`); d(`extracting ${x64AsarPath} to ${x64Dir}`);
@@ -184,18 +187,22 @@ export const mergeASARs = async ({
if (isDirectory(arm64AsarPath, file)) { if (isDirectory(arm64AsarPath, file)) {
d(`creating unique directory: ${file}`); d(`creating unique directory: ${file}`);
await fs.mkdirp(destination); await fs.promises.mkdir(destination, { recursive: true });
continue; continue;
} }
d(`xopying unique file: ${file}`); d(`copying unique file: ${file}`);
await fs.mkdirp(path.dirname(destination)); await fs.promises.mkdir(path.dirname(destination), { recursive: true });
await fs.copy(source, destination); await fs.promises.cp(source, destination, {
force: true,
recursive: true,
verbatimSymlinks: true,
});
} }
for (const binding of commonBindings) { for (const binding of commonBindings) {
const source = await fs.realpath(path.resolve(arm64Dir, binding)); const source = await fs.promises.realpath(path.resolve(arm64Dir, binding));
const destination = await fs.realpath(path.resolve(x64Dir, binding)); const destination = await fs.promises.realpath(path.resolve(x64Dir, binding));
d(`merging binding: ${binding}`); d(`merging binding: ${binding}`);
execFileSync(LIPO, [source, destination, '-create', '-output', destination]); execFileSync(LIPO, [source, destination, '-create', '-output', destination]);
@@ -218,6 +225,13 @@ export const mergeASARs = async ({
d('done merging'); d('done merging');
} finally { } finally {
await Promise.all([fs.remove(x64Dir), fs.remove(arm64Dir)]); await Promise.all([
fs.promises.rm(x64Dir, { recursive: true, force: true }),
fs.promises.rm(arm64Dir, { recursive: true, force: true }),
]);
} }
}; };
export const isUniversalMachO = (fileContent: Buffer) => {
return MACHO_UNIVERSAL_MAGIC.has(fileContent.readUInt32LE(0));
};

View File

@@ -1,15 +1,21 @@
import fs from 'node:fs';
import path from 'node:path';
import { promises as stream } from 'node:stream';
import { spawn, ExitCodeError } from '@malept/cross-spawn-promise'; import { spawn, ExitCodeError } from '@malept/cross-spawn-promise';
import * as fs from 'fs-extra'; import { minimatch } from 'minimatch';
import * as path from 'path';
const MACHO_PREFIX = 'Mach-O '; const MACHO_PREFIX = 'Mach-O ';
const UNPACKED_ASAR_PATH = path.join('Contents', 'Resources', 'app.asar.unpacked');
export enum AppFileType { export enum AppFileType {
MACHO, MACHO,
PLAIN, PLAIN,
INFO_PLIST, INFO_PLIST,
SNAPSHOT, SNAPSHOT,
APP_CODE, APP_CODE,
SINGLE_ARCH,
} }
export type AppFile = { export type AppFile = {
@@ -17,22 +23,50 @@ export type AppFile = {
type: AppFileType; type: AppFileType;
}; };
export type GetAllAppFilesOpts = {
singleArchFiles?: string;
};
const isSingleArchFile = (relativePath: string, opts: GetAllAppFilesOpts): boolean => {
if (opts.singleArchFiles === undefined) {
return false;
}
const unpackedPath = path.relative(UNPACKED_ASAR_PATH, relativePath);
// Outside of app.asar.unpacked
if (unpackedPath.startsWith('..')) {
return false;
}
return minimatch(unpackedPath, opts.singleArchFiles, {
matchBase: true,
});
};
/** /**
* *
* @param appPath Path to the application * @param appPath Path to the application
*/ */
export const getAllAppFiles = async (appPath: string): Promise<AppFile[]> => { export const getAllAppFiles = async (
appPath: string,
opts: GetAllAppFilesOpts,
): Promise<AppFile[]> => {
const unpackedPath = path.join('Contents', 'Resources', 'app.asar.unpacked');
const files: AppFile[] = []; const files: AppFile[] = [];
const visited = new Set<string>(); const visited = new Set<string>();
const traverse = async (p: string) => { const traverse = async (p: string) => {
p = await fs.realpath(p); p = await fs.promises.realpath(p);
if (visited.has(p)) return; if (visited.has(p)) return;
visited.add(p); visited.add(p);
const info = await fs.stat(p); const info = await fs.promises.stat(p);
if (info.isSymbolicLink()) return; if (info.isSymbolicLink()) return;
if (info.isFile()) { if (info.isFile()) {
const relativePath = path.relative(appPath, p);
let fileType = AppFileType.PLAIN; let fileType = AppFileType.PLAIN;
var fileOutput = ''; var fileOutput = '';
@@ -45,8 +79,10 @@ export const getAllAppFiles = async (appPath: string): Promise<AppFile[]> => {
throw e; throw e;
} }
} }
if (p.includes('app.asar')) { if (p.endsWith('.asar')) {
fileType = AppFileType.APP_CODE; fileType = AppFileType.APP_CODE;
} else if (isSingleArchFile(relativePath, opts)) {
fileType = AppFileType.SINGLE_ARCH;
} else if (fileOutput.startsWith(MACHO_PREFIX)) { } else if (fileOutput.startsWith(MACHO_PREFIX)) {
fileType = AppFileType.MACHO; fileType = AppFileType.MACHO;
} else if (p.endsWith('.bin')) { } else if (p.endsWith('.bin')) {
@@ -56,13 +92,13 @@ export const getAllAppFiles = async (appPath: string): Promise<AppFile[]> => {
} }
files.push({ files.push({
relativePath: path.relative(appPath, p), relativePath,
type: fileType, type: fileType,
}); });
} }
if (info.isDirectory()) { if (info.isDirectory()) {
for (const child of await fs.readdir(p)) { for (const child of await fs.promises.readdir(p)) {
await traverse(path.resolve(p, child)); await traverse(path.resolve(p, child));
} }
} }
@@ -71,3 +107,32 @@ export const getAllAppFiles = async (appPath: string): Promise<AppFile[]> => {
return files; return files;
}; };
export const readMachOHeader = async (path: string) => {
const chunks: Buffer[] = [];
// no need to read the entire file, we only need the first 4 bytes of the file to determine the header
await stream.pipeline(fs.createReadStream(path, { start: 0, end: 3 }), async function* (source) {
for await (const chunk of source) {
chunks.push(chunk);
}
});
return Buffer.concat(chunks);
};
export const fsMove = async (oldPath: string, newPath: string) => {
try {
await fs.promises.rename(oldPath, newPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
// Cross-device link, fallback to copy and delete
await fs.promises.cp(oldPath, newPath, {
force: true,
recursive: true,
verbatimSymlinks: true,
});
await fs.promises.rm(oldPath, { force: true, recursive: true });
} else {
throw err;
}
}
};

View File

@@ -1,16 +1,18 @@
import { spawn } from '@malept/cross-spawn-promise'; import fs from 'node:fs';
import * as asar from '@electron/asar'; import os from 'node:os';
import * as fs from 'fs-extra'; import path from 'node:path';
import { minimatch } from 'minimatch';
import * as os from 'os';
import * as path from 'path';
import * as plist from 'plist';
import * as dircompare from 'dir-compare';
import { AppFile, AppFileType, getAllAppFiles } from './file-utils'; import * as asar from '@electron/asar';
import { AsarMode, detectAsarMode, generateAsarIntegrity, mergeASARs } from './asar-utils'; import { spawn } from '@malept/cross-spawn-promise';
import { sha } from './sha'; import * as dircompare from 'dir-compare';
import { d } from './debug'; import { minimatch } from 'minimatch';
import plist from 'plist';
import { AsarMode, detectAsarMode, isUniversalMachO, mergeASARs } from './asar-utils.js';
import { AppFile, AppFileType, fsMove, getAllAppFiles, readMachOHeader } from './file-utils.js';
import { sha } from './sha.js';
import { d } from './debug.js';
import { computeIntegrityData } from './integrity.js';
/** /**
* Options to pass into the {@link makeUniversalApp} function. * Options to pass into the {@link makeUniversalApp} function.
@@ -73,7 +75,12 @@ export type MakeUniversalOpts = {
}; };
const dupedFiles = (files: AppFile[]) => const dupedFiles = (files: AppFile[]) =>
files.filter((f) => f.type !== AppFileType.SNAPSHOT && f.type !== AppFileType.APP_CODE); files.filter(
(f) =>
f.type !== AppFileType.SNAPSHOT &&
f.type !== AppFileType.APP_CODE &&
f.type !== AppFileType.SINGLE_ARCH,
);
export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> => { export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> => {
d('making a universal app with options', opts); d('making a universal app with options', opts);
@@ -87,7 +94,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
if (!opts.outAppPath || !path.isAbsolute(opts.outAppPath)) if (!opts.outAppPath || !path.isAbsolute(opts.outAppPath))
throw new Error('Expected opts.outAppPath to be an absolute path but it was not'); throw new Error('Expected opts.outAppPath to be an absolute path but it was not');
if (await fs.pathExists(opts.outAppPath)) { if (fs.existsSync(opts.outAppPath)) {
d('output path exists already'); d('output path exists already');
if (!opts.force) { if (!opts.force) {
throw new Error( throw new Error(
@@ -95,7 +102,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
); );
} else { } else {
d('overwriting existing application because force == true'); d('overwriting existing application because force == true');
await fs.remove(opts.outAppPath); await fs.promises.rm(opts.outAppPath, { recursive: true, force: true });
} }
} }
@@ -109,7 +116,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
'Both the x64 and arm64 versions of your application need to have been built with the same asar settings (enabled vs disabled)', 'Both the x64 and arm64 versions of your application need to have been built with the same asar settings (enabled vs disabled)',
); );
const tmpDir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-universal-')); const tmpDir = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'electron-universal-'));
d('building universal app in', tmpDir); d('building universal app in', tmpDir);
try { try {
@@ -119,8 +126,8 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
const uniqueToX64: string[] = []; const uniqueToX64: string[] = [];
const uniqueToArm64: string[] = []; const uniqueToArm64: string[] = [];
const x64Files = await getAllAppFiles(await fs.realpath(tmpApp)); const x64Files = await getAllAppFiles(await fs.promises.realpath(tmpApp), opts);
const arm64Files = await getAllAppFiles(await fs.realpath(opts.arm64AppPath)); const arm64Files = await getAllAppFiles(await fs.promises.realpath(opts.arm64AppPath), opts);
for (const file of dupedFiles(x64Files)) { for (const file of dupedFiles(x64Files)) {
if (!arm64Files.some((f) => f.relativePath === file.relativePath)) if (!arm64Files.some((f) => f.relativePath === file.relativePath))
@@ -141,7 +148,9 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
); );
} }
for (const file of x64Files.filter((f) => f.type === AppFileType.PLAIN)) { // Single Arch files are copied as is without processing.
const multiArchFiles = x64Files.filter((f) => f.type !== AppFileType.SINGLE_ARCH);
for (const file of multiArchFiles.filter((f) => f.type === AppFileType.PLAIN)) {
const x64Sha = await sha(path.resolve(opts.x64AppPath, file.relativePath)); const x64Sha = await sha(path.resolve(opts.x64AppPath, file.relativePath));
const arm64Sha = await sha(path.resolve(opts.arm64AppPath, file.relativePath)); const arm64Sha = await sha(path.resolve(opts.arm64AppPath, file.relativePath));
if (x64Sha !== arm64Sha) { if (x64Sha !== arm64Sha) {
@@ -157,9 +166,20 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
} }
} }
const knownMergedMachOFiles = new Set(); const knownMergedMachOFiles = new Set();
for (const machOFile of x64Files.filter((f) => f.type === AppFileType.MACHO)) { for (const machOFile of multiArchFiles.filter((f) => f.type === AppFileType.MACHO)) {
const first = await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)); const first = await fs.promises.realpath(path.resolve(tmpApp, machOFile.relativePath));
const second = await fs.realpath(path.resolve(opts.arm64AppPath, machOFile.relativePath)); const second = await fs.promises.realpath(
path.resolve(opts.arm64AppPath, machOFile.relativePath),
);
if (
isUniversalMachO(await readMachOHeader(first)) &&
isUniversalMachO(await readMachOHeader(second))
) {
d(machOFile.relativePath, `is already universal across builds, skipping lipo`);
knownMergedMachOFiles.add(machOFile.relativePath);
continue;
}
const x64Sha = await sha(path.resolve(opts.x64AppPath, machOFile.relativePath)); const x64Sha = await sha(path.resolve(opts.x64AppPath, machOFile.relativePath));
const arm64Sha = await sha(path.resolve(opts.arm64AppPath, machOFile.relativePath)); const arm64Sha = await sha(path.resolve(opts.arm64AppPath, machOFile.relativePath));
@@ -191,7 +211,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
second, second,
'-create', '-create',
'-output', '-output',
await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)), await fs.promises.realpath(path.resolve(tmpApp, machOFile.relativePath)),
]); ]);
knownMergedMachOFiles.add(machOFile.relativePath); knownMergedMachOFiles.add(machOFile.relativePath);
} }
@@ -222,39 +242,47 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
if (nonMergedDifferences.length > 0) { if (nonMergedDifferences.length > 0) {
d('x64 and arm64 app folders are different, creating dynamic entry ASAR'); d('x64 and arm64 app folders are different, creating dynamic entry ASAR');
await fs.move( await fsMove(
path.resolve(tmpApp, 'Contents', 'Resources', 'app'), path.resolve(tmpApp, 'Contents', 'Resources', 'app'),
path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64'),
); );
await fs.copy( await fs.promises.cp(
path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'),
path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64'),
{ force: true, recursive: true, verbatimSymlinks: true },
); );
const entryAsar = path.resolve(tmpDir, 'entry-asar'); const entryAsar = path.resolve(tmpDir, 'entry-asar');
await fs.mkdir(entryAsar); await fs.promises.mkdir(entryAsar, { recursive: true });
let pj = await fs.readJson( let pj = JSON.parse(
path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json'), await fs.promises.readFile(
path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json'),
'utf8',
),
); );
// Load a shim that redirects to the correct folder for the architecture. // Load a shim that redirects to the correct folder for the architecture.
// This needs to be a different file depending on if the app entrypoint is CommonJS or ESM. // This needs to be a different file depending on if the app entrypoint is CommonJS or ESM.
if (pj.type === 'module' || pj.main.endsWith('.mjs')) { if (pj.type === 'module' || pj.main.endsWith('.mjs')) {
await fs.copy( await fs.promises.cp(
path.resolve(__dirname, '..', '..', 'entry-asar', 'esm', 'no-asar.mjs'), path.resolve(import.meta.dirname, '..', '..', 'entry-asar', 'esm', 'no-asar.mjs'),
path.resolve(entryAsar, 'index.mjs'), path.resolve(entryAsar, 'index.mjs'),
); );
pj.main = 'index.mjs'; pj.main = 'index.mjs';
} else { } else {
await fs.copy( await fs.promises.cp(
path.resolve(__dirname, '..', '..', 'entry-asar', 'cjs', 'no-asar.js'), path.resolve(import.meta.dirname, '..', '..', 'entry-asar', 'cjs', 'no-asar.js'),
path.resolve(entryAsar, 'index.js'), path.resolve(entryAsar, 'index.js'),
); );
pj.main = 'index.js'; pj.main = 'index.js';
} }
await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); await fs.promises.writeFile(
path.resolve(entryAsar, 'package.json'),
JSON.stringify(pj) + '\n',
'utf8',
);
await asar.createPackage( await asar.createPackage(
entryAsar, entryAsar,
path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'),
@@ -264,9 +292,6 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
} }
} }
const generatedIntegrity: Record<string, { algorithm: 'SHA256'; hash: string }> = {};
let didSplitAsar = false;
/** /**
* If we have an ASAR we just need to check if the two "app.asar" files have the same hash, * If we have an ASAR we just need to check if the two "app.asar" files have the same hash,
* if they are, same as above, we can leave one there and call it a day. If they're different * if they are, same as above, we can leave one there and call it a day. If they're different
@@ -284,8 +309,6 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
outputAsarPath: output, outputAsarPath: output,
singleArchFiles: opts.singleArchFiles, singleArchFiles: opts.singleArchFiles,
}); });
generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity(output);
} else if (x64AsarMode === AsarMode.HAS_ASAR) { } else if (x64AsarMode === AsarMode.HAS_ASAR) {
d('checking if the x64 and arm64 asars are identical'); d('checking if the x64 and arm64 asars are identical');
const x64AsarSha = await sha(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar')); const x64AsarSha = await sha(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'));
@@ -294,22 +317,22 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
); );
if (x64AsarSha !== arm64AsarSha) { if (x64AsarSha !== arm64AsarSha) {
didSplitAsar = true;
d('x64 and arm64 asars are different'); d('x64 and arm64 asars are different');
const x64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar'); const x64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar');
await fs.move(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), x64AsarPath); await fsMove(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), x64AsarPath);
const x64Unpacked = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar.unpacked'); const x64Unpacked = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar.unpacked');
if (await fs.pathExists(x64Unpacked)) { if (fs.existsSync(x64Unpacked)) {
await fs.move( await fsMove(
x64Unpacked, x64Unpacked,
path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar.unpacked'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar.unpacked'),
); );
} }
const arm64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar'); const arm64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar');
await fs.copy( await fs.promises.cp(
path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'),
arm64AsarPath, arm64AsarPath,
{ force: true, recursive: true, verbatimSymlinks: true },
); );
const arm64Unpacked = path.resolve( const arm64Unpacked = path.resolve(
opts.arm64AppPath, opts.arm64AppPath,
@@ -317,15 +340,16 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
'Resources', 'Resources',
'app.asar.unpacked', 'app.asar.unpacked',
); );
if (await fs.pathExists(arm64Unpacked)) { if (fs.existsSync(arm64Unpacked)) {
await fs.copy( await fs.promises.cp(
arm64Unpacked, arm64Unpacked,
path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar.unpacked'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar.unpacked'),
{ force: true, recursive: true, verbatimSymlinks: true },
); );
} }
const entryAsar = path.resolve(tmpDir, 'entry-asar'); const entryAsar = path.resolve(tmpDir, 'entry-asar');
await fs.mkdir(entryAsar); await fs.promises.mkdir(entryAsar, { recursive: true });
let pj = JSON.parse( let pj = JSON.parse(
( (
await asar.extractFile( await asar.extractFile(
@@ -338,44 +362,43 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
// Load a shim that redirects to the correct `app.asar` for the architecture. // Load a shim that redirects to the correct `app.asar` for the architecture.
// This needs to be a different file depending on if the app entrypoint is CommonJS or ESM. // This needs to be a different file depending on if the app entrypoint is CommonJS or ESM.
if (pj.type === 'module' || pj.main.endsWith('.mjs')) { if (pj.type === 'module' || pj.main.endsWith('.mjs')) {
await fs.copy( await fs.promises.cp(
path.resolve(__dirname, '..', '..', 'entry-asar', 'esm', 'has-asar.mjs'), path.resolve(import.meta.dirname, '..', '..', 'entry-asar', 'esm', 'has-asar.mjs'),
path.resolve(entryAsar, 'index.mjs'), path.resolve(entryAsar, 'index.mjs'),
); );
pj.main = 'index.mjs'; pj.main = 'index.mjs';
} else { } else {
await fs.copy( await fs.promises.cp(
path.resolve(__dirname, '..', '..', 'entry-asar', 'cjs', 'has-asar.js'), path.resolve(import.meta.dirname, '..', '..', 'entry-asar', 'cjs', 'has-asar.js'),
path.resolve(entryAsar, 'index.js'), path.resolve(entryAsar, 'index.js'),
); );
pj.main = 'index.js'; pj.main = 'index.js';
} }
await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); await fs.promises.writeFile(
path.resolve(entryAsar, 'package.json'),
JSON.stringify(pj) + '\n',
'utf8',
);
const asarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'); const asarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar');
await asar.createPackage(entryAsar, asarPath); await asar.createPackage(entryAsar, asarPath);
generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity(asarPath);
generatedIntegrity['Resources/app-x64.asar'] = generateAsarIntegrity(x64AsarPath);
generatedIntegrity['Resources/app-arm64.asar'] = generateAsarIntegrity(arm64AsarPath);
} else { } else {
d('x64 and arm64 asars are the same'); d('x64 and arm64 asars are the same');
generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity(
path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'),
);
} }
} }
const plistFiles = x64Files.filter((f) => f.type === AppFileType.INFO_PLIST); const generatedIntegrity = await computeIntegrityData(path.join(tmpApp, 'Contents'), opts);
const plistFiles = multiArchFiles.filter((f) => f.type === AppFileType.INFO_PLIST);
for (const plistFile of plistFiles) { for (const plistFile of plistFiles) {
const x64PlistPath = path.resolve(opts.x64AppPath, plistFile.relativePath); const x64PlistPath = path.resolve(opts.x64AppPath, plistFile.relativePath);
const arm64PlistPath = path.resolve(opts.arm64AppPath, plistFile.relativePath); const arm64PlistPath = path.resolve(opts.arm64AppPath, plistFile.relativePath);
const { ElectronAsarIntegrity: x64Integrity, ...x64Plist } = plist.parse( const { ElectronAsarIntegrity: x64Integrity, ...x64Plist } = plist.parse(
await fs.readFile(x64PlistPath, 'utf8'), await fs.promises.readFile(x64PlistPath, 'utf8'),
) as any; ) as any;
const { ElectronAsarIntegrity: arm64Integrity, ...arm64Plist } = plist.parse( const { ElectronAsarIntegrity: arm64Integrity, ...arm64Plist } = plist.parse(
await fs.readFile(arm64PlistPath, 'utf8'), await fs.promises.readFile(arm64PlistPath, 'utf8'),
) as any; ) as any;
if (JSON.stringify(x64Plist) !== JSON.stringify(arm64Plist)) { if (JSON.stringify(x64Plist) !== JSON.stringify(arm64Plist)) {
throw new Error( throw new Error(
@@ -390,23 +413,26 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
? { ...x64Plist, ElectronAsarIntegrity: generatedIntegrity } ? { ...x64Plist, ElectronAsarIntegrity: generatedIntegrity }
: { ...x64Plist }; : { ...x64Plist };
await fs.writeFile(path.resolve(tmpApp, plistFile.relativePath), plist.build(mergedPlist)); await fs.promises.writeFile(
path.resolve(tmpApp, plistFile.relativePath),
plist.build(mergedPlist),
);
} }
for (const snapshotsFile of arm64Files.filter((f) => f.type === AppFileType.SNAPSHOT)) { for (const snapshotsFile of arm64Files.filter((f) => f.type === AppFileType.SNAPSHOT)) {
d('copying snapshot file', snapshotsFile.relativePath, 'to target application'); d('copying snapshot file', snapshotsFile.relativePath, 'to target application');
await fs.copy( await fs.promises.cp(
path.resolve(opts.arm64AppPath, snapshotsFile.relativePath), path.resolve(opts.arm64AppPath, snapshotsFile.relativePath),
path.resolve(tmpApp, snapshotsFile.relativePath), path.resolve(tmpApp, snapshotsFile.relativePath),
); );
} }
d('moving final universal app to target destination'); d('moving final universal app to target destination');
await fs.mkdirp(path.dirname(opts.outAppPath)); await fs.promises.mkdir(path.dirname(opts.outAppPath), { recursive: true });
await spawn('mv', [tmpApp, opts.outAppPath]); await spawn('mv', [tmpApp, opts.outAppPath]);
} catch (err) { } catch (err) {
throw err; throw err;
} finally { } finally {
await fs.remove(tmpDir); await fs.promises.rm(tmpDir, { recursive: true, force: true });
} }
}; };

58
src/integrity.ts Normal file
View File

@@ -0,0 +1,58 @@
import fs from 'node:fs';
import path from 'node:path';
import { AppFileType, getAllAppFiles } from './file-utils.js';
import { generateAsarIntegrity } from './asar-utils.js';
type IntegrityMap = {
[filepath: string]: string;
};
export interface HeaderHash {
algorithm: 'SHA256';
hash: string;
}
export interface AsarIntegrity {
[key: string]: HeaderHash;
}
export type ComputeIntegrityDataOpts = {
singleArchFiles?: string;
};
export async function computeIntegrityData(
contentsPath: string,
opts: ComputeIntegrityDataOpts,
): Promise<AsarIntegrity> {
const root = await fs.promises.realpath(contentsPath);
const resourcesRelativePath = 'Resources';
const resourcesPath = path.resolve(root, resourcesRelativePath);
const resources = await getAllAppFiles(resourcesPath, opts);
const resourceAsars = resources
.filter((file) => file.type === AppFileType.APP_CODE)
.reduce<IntegrityMap>(
(prev, file) => ({
...prev,
[path.join(resourcesRelativePath, file.relativePath)]: path.join(
resourcesPath,
file.relativePath,
),
}),
{},
);
// sort to produce constant result
const allAsars = Object.entries(resourceAsars).sort(([name1], [name2]) =>
name1.localeCompare(name2),
);
const hashes = await Promise.all(allAsars.map(async ([, from]) => generateAsarIntegrity(from)));
const asarIntegrity: AsarIntegrity = {};
for (let i = 0; i < allAsars.length; i++) {
const [asar] = allAsars[i];
asarIntegrity[asar] = hashes[i];
}
return asarIntegrity;
}

View File

@@ -1,8 +1,8 @@
import * as fs from 'fs-extra'; import fs from 'node:fs';
import * as crypto from 'crypto'; import crypto from 'node:crypto';
import { pipeline } from 'stream/promises'; import { pipeline } from 'node:stream/promises';
import { d } from './debug'; import { d } from './debug.js';
export const sha = async (filePath: string) => { export const sha = async (filePath: string) => {
d('hashing', filePath); d('hashing', filePath);

View File

@@ -0,0 +1,920 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`makeUniversalApp > asar mode > should correctly merge two identical asars 1`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
],
"hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
},
"size": 64,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
},
}
`;
exports[`makeUniversalApp > asar mode > should correctly merge two identical asars 2`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should create a shim if asars are different between architectures 1`] = `
{
"files": {
"extra-file.txt": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de",
],
"hash": "b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de",
},
"size": 15,
},
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
],
"hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
},
"size": 64,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
},
}
`;
exports[`makeUniversalApp > asar mode > should create a shim if asars are different between architectures 2`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
],
"hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
},
"size": 64,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
},
}
`;
exports[`makeUniversalApp > asar mode > should create a shim if asars are different between architectures 3`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"b7e5f58d3c0fddc1a57d1279a7f19a34a01784f4036920d4b60a1e33f6d1635b",
],
"hash": "b7e5f58d3c0fddc1a57d1279a7f19a34a01784f4036920d4b60a1e33f6d1635b",
},
"size": 1068,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
],
"hash": "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
},
"size": 33,
},
},
}
`;
exports[`makeUniversalApp > asar mode > should create a shim if asars are different between architectures 4`] = `
{
"Contents/Info.plist": {
"Resources/app-arm64.asar": {
"algorithm": "SHA256",
"hash": "71db54541357128943df64d54480a22d0cdf4c283f2044f48101fb1fc6e6fb2d",
},
"Resources/app-x64.asar": {
"algorithm": "SHA256",
"hash": "85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf",
},
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "b62aaaed07ff72dc33da1720d900e0443c060285ef374ce1bdaef1d4f28b5fe4",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should generate AsarIntegrity for all asars in the application 1`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8",
],
"hash": "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8",
},
"size": 66,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
"private": {
"files": {
"var": {
"files": {
"app": {
"files": {
"file.txt": {
"link": "private/var/file.txt",
},
},
},
"file.txt": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
],
"hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
},
"size": 11,
},
},
},
},
},
"var": {
"link": "private/var",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should generate AsarIntegrity for all asars in the application 2`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8",
],
"hash": "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8",
},
"size": 66,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
"private": {
"files": {
"var": {
"files": {
"app": {
"files": {
"file.txt": {
"link": "private/var/file.txt",
},
},
},
"file.txt": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
],
"hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
},
"size": 11,
},
},
},
},
},
"var": {
"link": "private/var",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should generate AsarIntegrity for all asars in the application 3`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "7e6af4d00f4cc737eff922e2b386128a269f80887b79a011022f1276bdbe7832",
},
"Resources/webbapp.asar": {
"algorithm": "SHA256",
"hash": "7e6af4d00f4cc737eff922e2b386128a269f80887b79a011022f1276bdbe7832",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should merge two different asars when \`mergeASARs\` is enabled 1`] = `
{
"files": {
"extra-file.txt": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de",
],
"hash": "b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de",
},
"size": 15,
},
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
],
"hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
},
"size": 64,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
},
}
`;
exports[`makeUniversalApp > asar mode > should merge two different asars when \`mergeASARs\` is enabled 2`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "71db54541357128943df64d54480a22d0cdf4c283f2044f48101fb1fc6e6fb2d",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should merge two different asars with native files when \`mergeASARs\` is enabled 1`] = `
{
"files": {
"hello-world-arm64": "<stripped>",
"hello-world-x64": "<stripped>",
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8",
],
"hash": "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8",
},
"size": 66,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
"private": {
"files": {
"var": {
"files": {
"app": {
"files": {
"file.txt": {
"link": "private/var/file.txt",
},
},
},
"file.txt": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
],
"hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
},
"size": 11,
},
},
},
},
},
"var": {
"link": "private/var",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should merge two different asars with native files when \`mergeASARs\` is enabled 2`] = `[]`;
exports[`makeUniversalApp > asar mode > should merge two different asars with native files when \`mergeASARs\` is enabled 3`] = `
[
"hello-world-arm64",
"hello-world-x64",
]
`;
exports[`makeUniversalApp > asar mode > should merge two different asars with native files when \`mergeASARs\` is enabled 4`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "<stripped>",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should not inject ElectronAsarIntegrity into \`infoPlistsToIgnore\` 1`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8",
],
"hash": "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8",
},
"size": 66,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
"private": {
"files": {
"var": {
"files": {
"app": {
"files": {
"file.txt": {
"link": "private/var/file.txt",
},
},
},
"file.txt": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
],
"hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
},
"size": 11,
},
},
},
},
},
"var": {
"link": "private/var",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should not inject ElectronAsarIntegrity into \`infoPlistsToIgnore\` 2`] = `
{
"Contents/Info.plist": undefined,
"Contents/Resources/SubApp-1.app/Contents/Info.plist": undefined,
}
`;
exports[`makeUniversalApp > force > packages successfully if \`out\` bundle already exists and \`force\` is \`true\` 1`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
],
"hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
},
"size": 64,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
},
}
`;
exports[`makeUniversalApp > force > packages successfully if \`out\` bundle already exists and \`force\` is \`true\` 2`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf",
},
},
}
`;
exports[`makeUniversalApp > no asar mode > different app dirs with different macho files (shim and lipo) 1`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e",
],
"hash": "f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e",
},
"size": 1063,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
],
"hash": "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
},
"size": 33,
},
},
}
`;
exports[`makeUniversalApp > no asar mode > different app dirs with different macho files (shim and lipo) 2`] = `
[
"private/var/i-aint-got-no-rhythm.bin",
]
`;
exports[`makeUniversalApp > no asar mode > different app dirs with different macho files (shim and lipo) 3`] = `
[
"hello-world",
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
"private/var/i-aint-got-no-rhythm.bin",
]
`;
exports[`makeUniversalApp > no asar mode > different app dirs with different macho files (shim and lipo) 4`] = `
[
"hello-world",
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
"private/var/hello-world.bin",
]
`;
exports[`makeUniversalApp > no asar mode > different app dirs with different macho files (shim and lipo) 5`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "<stripped>",
},
},
}
`;
exports[`makeUniversalApp > no asar mode > different app dirs with universal macho files (shim but don't lipo) 1`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e",
],
"hash": "f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e",
},
"size": 1063,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
],
"hash": "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
},
"size": 33,
},
},
}
`;
exports[`makeUniversalApp > no asar mode > different app dirs with universal macho files (shim but don't lipo) 2`] = `
[
"private/var/i-aint-got-no-rhythm.bin",
]
`;
exports[`makeUniversalApp > no asar mode > different app dirs with universal macho files (shim but don't lipo) 3`] = `
[
"hello-world",
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
"private/var/i-aint-got-no-rhythm.bin",
]
`;
exports[`makeUniversalApp > no asar mode > different app dirs with universal macho files (shim but don't lipo) 4`] = `
[
"hello-world",
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
"private/var/hello-world.bin",
]
`;
exports[`makeUniversalApp > no asar mode > different app dirs with universal macho files (shim but don't lipo) 5`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "<stripped>",
},
},
}
`;
exports[`makeUniversalApp > no asar mode > identical app dirs with different macho files (e.g. do not shim, but still lipo) 1`] = `
[
"hello-world",
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
]
`;
exports[`makeUniversalApp > no asar mode > identical app dirs with different macho files (e.g. do not shim, but still lipo) 2`] = `
{
"Contents/Info.plist": {},
}
`;
exports[`makeUniversalApp > no asar mode > identical app dirs with universal macho files (e.g., do not shim, just copy x64 dir) 1`] = `
[
"hello-world",
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
]
`;
exports[`makeUniversalApp > no asar mode > identical app dirs with universal macho files (e.g., do not shim, just copy x64 dir) 2`] = `
{
"Contents/Info.plist": {},
}
`;
exports[`makeUniversalApp > no asar mode > should correctly merge two identical app folders 1`] = `
[
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
]
`;
exports[`makeUniversalApp > no asar mode > should correctly merge two identical app folders 2`] = `
{
"Contents/Info.plist": {},
}
`;
exports[`makeUniversalApp > no asar mode > should shim two different app folders 1`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e",
],
"hash": "f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e",
},
"size": 1063,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
],
"hash": "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
},
"size": 33,
},
},
}
`;
exports[`makeUniversalApp > no asar mode > should shim two different app folders 2`] = `
[
"private/var/i-aint-got-no-rhythm.bin",
]
`;
exports[`makeUniversalApp > no asar mode > should shim two different app folders 3`] = `
[
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
"private/var/i-aint-got-no-rhythm.bin",
]
`;
exports[`makeUniversalApp > no asar mode > should shim two different app folders 4`] = `
[
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
"private/var/hello-world.bin",
]
`;
exports[`makeUniversalApp > no asar mode > should shim two different app folders 5`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "27433ee3e34b3b0dabb29d18d40646126e80c56dbce8c4bb2adef7278b5a46c0",
},
},
}
`;
exports[`makeUniversalApp > works for lipo binary resources 1`] = `
{
"files": {
"hello-world": "<stripped>",
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8",
],
"hash": "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8",
},
"size": 66,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
"private": {
"files": {
"var": {
"files": {
"app": {
"files": {
"file.txt": {
"link": "private/var/file.txt",
},
},
},
"file.txt": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
],
"hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
},
"size": 11,
},
},
},
},
},
"var": {
"link": "private/var",
},
},
}
`;
exports[`makeUniversalApp > works for lipo binary resources 2`] = `[]`;
exports[`makeUniversalApp > works for lipo binary resources 3`] = `
[
"hello-world",
]
`;
exports[`makeUniversalApp > works for lipo binary resources 4`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "<stripped>",
},
},
}
`;

View File

@@ -1,9 +1,11 @@
import * as path from 'path'; import * as path from 'node:path';
import { AsarMode, detectAsarMode, generateAsarIntegrity } from '../src/asar-utils'; import { describe, expect, it } from 'vitest';
const asarsPath = path.resolve(__dirname, 'fixtures', 'asars'); import { AsarMode, detectAsarMode, generateAsarIntegrity } from '../src/asar-utils.js';
const appsPath = path.resolve(__dirname, 'fixtures', 'apps');
const asarsPath = path.resolve(import.meta.dirname, 'fixtures', 'asars');
const appsPath = path.resolve(import.meta.dirname, 'fixtures', 'apps');
describe('asar-utils', () => { describe('asar-utils', () => {
describe('detectAsarMode', () => { describe('detectAsarMode', () => {

View File

@@ -1,8 +1,10 @@
import * as path from 'path'; import * as path from 'node:path';
import { AppFile, AppFileType, getAllAppFiles } from '../src/file-utils'; import { beforeAll, describe, expect, it } from 'vitest';
const appsPath = path.resolve(__dirname, 'fixtures', 'apps'); import { AppFile, AppFileType, getAllAppFiles } from '../src/file-utils.js';
const appsPath = path.resolve(import.meta.dirname, 'fixtures', 'apps');
describe('file-utils', () => { describe('file-utils', () => {
describe('getAllAppFiles', () => { describe('getAllAppFiles', () => {
@@ -10,8 +12,8 @@ describe('file-utils', () => {
let noAsarFiles: AppFile[]; let noAsarFiles: AppFile[];
beforeAll(async () => { beforeAll(async () => {
asarFiles = await getAllAppFiles(path.resolve(appsPath, 'Arm64Asar.app')); asarFiles = await getAllAppFiles(path.resolve(appsPath, 'Arm64Asar.app'), {});
noAsarFiles = await getAllAppFiles(path.resolve(appsPath, 'Arm64NoAsar.app')); noAsarFiles = await getAllAppFiles(path.resolve(appsPath, 'Arm64NoAsar.app'), {});
}); });
it('should correctly identify plist files', async () => { it('should correctly identify plist files', async () => {

6
test/fixtures/hello-world.c vendored Normal file
View File

@@ -0,0 +1,6 @@
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}

82
test/globalSetup.ts Normal file
View File

@@ -0,0 +1,82 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { appsDir, asarsDir, fixtureDir, templateApp } from './util.js';
// generates binaries from hello-world.c
// hello-world-universal, hello-world-x86_64, hello-world-arm64
const generateMachO = () => {
const src = path.resolve(fixtureDir, 'hello-world.c');
const outputFiles = ['x86_64', 'arm64'].map((arch) => {
const machO = path.resolve(appsDir, `hello-world-${arch === 'x86_64' ? 'x64' : arch}`);
execFileSync('clang', ['-arch', arch, '-o', machO, src]);
return machO;
});
execFileSync('lipo', [
...outputFiles,
'-create',
'-output',
path.resolve(appsDir, 'hello-world-universal'),
]);
};
export default async () => {
await fs.promises.rm(appsDir, { recursive: true, force: true });
await fs.promises.mkdir(appsDir, { recursive: true });
// generate mach-o binaries to be leveraged in lipo tests
generateMachO();
await templateApp('Arm64Asar.app', 'arm64', async (appPath) => {
await fs.promises.cp(
path.resolve(asarsDir, 'app.asar'),
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
{ recursive: true, verbatimSymlinks: true },
);
});
// contains `extra-file.txt`
await templateApp('Arm64AsarExtraFile.app', 'arm64', async (appPath) => {
await fs.promises.cp(
path.resolve(asarsDir, 'app2.asar'),
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
{ recursive: true, verbatimSymlinks: true },
);
});
await templateApp('X64Asar.app', 'x64', async (appPath) => {
await fs.promises.cp(
path.resolve(asarsDir, 'app.asar'),
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
{ recursive: true, verbatimSymlinks: true },
);
});
await templateApp('Arm64NoAsar.app', 'arm64', async (appPath) => {
await fs.promises.cp(
path.resolve(asarsDir, 'app'),
path.resolve(appPath, 'Contents', 'Resources', 'app'),
{ recursive: true, verbatimSymlinks: true },
);
});
// contains `extra-file.txt`
await templateApp('Arm64NoAsarExtraFile.app', 'arm64', async (appPath) => {
await fs.promises.cp(
path.resolve(asarsDir, 'app2'),
path.resolve(appPath, 'Contents', 'Resources', 'app'),
{ recursive: true, verbatimSymlinks: true },
);
});
await templateApp('X64NoAsar.app', 'x64', async (appPath) => {
await fs.promises.cp(
path.resolve(asarsDir, 'app'),
path.resolve(appPath, 'Contents', 'Resources', 'app'),
{ recursive: true, verbatimSymlinks: true },
);
});
};

View File

@@ -1,24 +1,27 @@
import { spawn } from '@malept/cross-spawn-promise'; import fs from 'node:fs';
import * as fs from 'fs-extra'; import path from 'node:path';
import * as path from 'path';
import { makeUniversalApp } from '../dist/cjs/index'; import { afterEach, describe, expect, it } from 'vitest';
const appsPath = path.resolve(__dirname, 'fixtures', 'apps'); import { makeUniversalApp } from '../dist/index.js';
const appsOutPath = path.resolve(__dirname, 'fixtures', 'apps', 'out'); import { fsMove } from '../src/file-utils.js';
import {
createStagingAppDir,
generateNativeApp,
templateApp,
VERIFY_APP_TIMEOUT,
verifyApp,
} from './util.js';
import { createPackage, createPackageWithOptions } from '@electron/asar';
async function ensureUniversal(app: string) { const appsPath = path.resolve(import.meta.dirname, 'fixtures', 'apps');
const exe = path.resolve(app, 'Contents', 'MacOS', 'Electron'); const appsOutPath = path.resolve(import.meta.dirname, 'fixtures', 'apps', 'out');
const result = await spawn(exe);
expect(result).toContain('arm64');
const result2 = await spawn('arch', ['-x86_64', exe]);
expect(result2).toContain('x64');
}
// See `jest.setup.ts` for app fixture setup process // See `globalSetup.ts` for app fixture setup process
describe('makeUniversalApp', () => { describe('makeUniversalApp', () => {
afterEach(async () => { afterEach(async () => {
await fs.emptyDir(appsOutPath); await fs.promises.rm(appsOutPath, { force: true, recursive: true });
await fs.promises.mkdir(appsOutPath, { recursive: true });
}); });
it('throws an error if asar is only detected in one arch', async () => { it('throws an error if asar is only detected in one arch', async () => {
@@ -34,12 +37,27 @@ describe('makeUniversalApp', () => {
); );
}); });
it.todo('works for lipo binary resources'); it('works for lipo binary resources', { timeout: VERIFY_APP_TIMEOUT }, async () => {
const x64AppPath = await generateNativeApp({
appNameWithExtension: 'LipoX64.app',
arch: 'x64',
createAsar: true,
});
const arm64AppPath = await generateNativeApp({
appNameWithExtension: 'LipoArm64.app',
arch: 'arm64',
createAsar: true,
});
const out = path.resolve(appsOutPath, 'Lipo.app');
await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath: out, mergeASARs: true });
await verifyApp(out, true);
});
describe('force', () => { describe('force', () => {
it('throws an error if `out` bundle already exists and `force` is `false`', async () => { it('throws an error if `out` bundle already exists and `force` is `false`', async () => {
const out = path.resolve(appsOutPath, 'Error.app'); const out = path.resolve(appsOutPath, 'Error.app');
await fs.mkdirp(out); await fs.promises.mkdir(out, { recursive: true });
await expect( await expect(
makeUniversalApp({ makeUniversalApp({
x64AppPath: path.resolve(appsPath, 'X64Asar.app'), x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
@@ -49,113 +67,409 @@ describe('makeUniversalApp', () => {
).rejects.toThrow(/The out path ".*" already exists and force is not set to true/); ).rejects.toThrow(/The out path ".*" already exists and force is not set to true/);
}); });
it('packages successfully if `out` bundle already exists and `force` is `true`', async () => { it(
const out = path.resolve(appsOutPath, 'Error.app'); 'packages successfully if `out` bundle already exists and `force` is `true`',
await fs.mkdirp(out); { timeout: VERIFY_APP_TIMEOUT },
await makeUniversalApp({ async () => {
x64AppPath: path.resolve(appsPath, 'X64Asar.app'), const out = path.resolve(appsOutPath, 'NoError.app');
arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'), await fs.promises.mkdir(out, { recursive: true });
outAppPath: out, await makeUniversalApp({
force: true, x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
}); arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'),
await ensureUniversal(out); outAppPath: out,
// Only a single asar as they were identical force: true,
expect( });
(await fs.readdir(path.resolve(out, 'Contents', 'Resources'))).filter((p) => await verifyApp(out);
p.endsWith('asar'), },
), );
).toEqual(['app.asar']);
}, 60000);
}); });
describe('asar mode', () => { describe('asar mode', () => {
it('should correctly merge two identical asars', async () => { it('should correctly merge two identical asars', { timeout: VERIFY_APP_TIMEOUT }, async () => {
const out = path.resolve(appsOutPath, 'MergedAsar.app'); const out = path.resolve(appsOutPath, 'MergedAsar.app');
await makeUniversalApp({ await makeUniversalApp({
x64AppPath: path.resolve(appsPath, 'X64Asar.app'), x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'), arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'),
outAppPath: out, outAppPath: out,
}); });
await ensureUniversal(out); await verifyApp(out);
// Only a single asar as they were identical });
expect(
(await fs.readdir(path.resolve(out, 'Contents', 'Resources'))).filter((p) =>
p.endsWith('asar'),
),
).toEqual(['app.asar']);
}, 60000);
it('should create a shim if asars are different between architectures', async () => { it(
const out = path.resolve(appsOutPath, 'ShimmedAsar.app'); 'should create a shim if asars are different between architectures',
await makeUniversalApp({ { timeout: VERIFY_APP_TIMEOUT },
x64AppPath: path.resolve(appsPath, 'X64Asar.app'), async () => {
arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'), const out = path.resolve(appsOutPath, 'ShimmedAsar.app');
outAppPath: out, await makeUniversalApp({
}); x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
await ensureUniversal(out); arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'),
// We have three asars including the arch-agnostic shim outAppPath: out,
expect( });
(await fs.readdir(path.resolve(out, 'Contents', 'Resources'))) await verifyApp(out);
.filter((p) => p.endsWith('asar')) },
.sort(), );
).toEqual(['app.asar', 'app-x64.asar', 'app-arm64.asar'].sort());
}, 60000);
it('should merge two different asars when `mergeASARs` is enabled', async () => { it(
const out = path.resolve(appsOutPath, 'MergedAsar.app'); 'should merge two different asars when `mergeASARs` is enabled',
await makeUniversalApp({ { timeout: VERIFY_APP_TIMEOUT },
x64AppPath: path.resolve(appsPath, 'X64Asar.app'), async () => {
arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'), const out = path.resolve(appsOutPath, 'MergedAsar.app');
outAppPath: out, await makeUniversalApp({
mergeASARs: true,
singleArchFiles: 'extra-file.txt',
});
await ensureUniversal(out);
// Only a single merged asar
expect(
(await fs.readdir(path.resolve(out, 'Contents', 'Resources'))).filter((p) =>
p.endsWith('asar'),
),
).toEqual(['app.asar']);
}, 60000);
it('throws an error if `mergeASARs` is enabled and `singleArchFiles` is missing a unique file', async () => {
const out = path.resolve(appsOutPath, 'Error.app');
await expect(
makeUniversalApp({
x64AppPath: path.resolve(appsPath, 'X64Asar.app'), x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'), arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'),
outAppPath: out, outAppPath: out,
mergeASARs: true, mergeASARs: true,
singleArchFiles: 'bad-rule', singleArchFiles: 'extra-file.txt',
}), });
).rejects.toThrow(/Detected unique file "extra-file\.txt"/); await verifyApp(out);
}, 60000); },
);
it.todo('should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`'); it(
'throws an error if `mergeASARs` is enabled and `singleArchFiles` is missing a unique file',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const out = path.resolve(appsOutPath, 'Error.app');
await expect(
makeUniversalApp({
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'),
outAppPath: out,
mergeASARs: true,
singleArchFiles: 'bad-rule',
}),
).rejects.toThrow(/Detected unique file "extra-file\.txt"/);
},
);
it(
'should merge two different asars with native files when `mergeASARs` is enabled',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const x64AppPath = await generateNativeApp({
appNameWithExtension: 'SingleArchFiles-x64.app',
arch: 'x64',
createAsar: true,
singleArchBindings: true,
});
const arm64AppPath = await generateNativeApp({
appNameWithExtension: 'SingleArchFiles-arm64.app',
arch: 'arm64',
createAsar: true,
singleArchBindings: true,
});
const out = path.resolve(appsOutPath, 'SingleArchFiles.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath: out,
mergeASARs: true,
singleArchFiles: 'hello-world-*',
});
await verifyApp(out, true);
},
);
it(
'throws an error if `mergeASARs` is enabled and `singleArchFiles` is missing a unique native file',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const x64AppPath = await generateNativeApp({
appNameWithExtension: 'SingleArchFiles-2-x64.app',
arch: 'x64',
createAsar: true,
singleArchBindings: true,
});
const arm64AppPath = await generateNativeApp({
appNameWithExtension: 'SingleArchFiles-2-arm64.app',
arch: 'arm64',
createAsar: true,
singleArchBindings: true,
});
const out = path.resolve(appsOutPath, 'SingleArchFiles-2.app');
await expect(
makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath: out,
mergeASARs: true,
singleArchFiles: 'bad-rule',
}),
).rejects.toThrow(
/the number of mach-o files is not the same between the arm64 and x64 builds/,
);
},
);
it(
'should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const arm64AppPath = await templateApp('Arm64-1.app', 'arm64', async (appPath) => {
const { testPath } = await createStagingAppDir('Arm64-1');
await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar'));
await templateApp('SubApp-1.app', 'arm64', async (subArm64AppPath) => {
await fsMove(
subArm64AppPath,
path.resolve(appPath, 'Contents', 'Resources', path.basename(subArm64AppPath)),
);
});
});
const x64AppPath = await templateApp('X64-1.app', 'x64', async (appPath) => {
const { testPath } = await createStagingAppDir('X64-1');
await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar'));
await templateApp('SubApp-1.app', 'x64', async (subArm64AppPath) => {
await fsMove(
subArm64AppPath,
path.resolve(appPath, 'Contents', 'Resources', path.basename(subArm64AppPath)),
);
});
});
const outAppPath = path.resolve(appsOutPath, 'UnmodifiedPlist.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath,
mergeASARs: true,
infoPlistsToIgnore: 'SubApp-1.app/Contents/Info.plist',
});
await verifyApp(outAppPath);
},
);
// TODO: Investigate if this should even be allowed.
// Current logic detects all unpacked files as APP_CODE, which doesn't seem correct since it could also be a macho file requiring lipo
// https://github.com/electron/universal/blob/d90d573ccf69a5b14b91aa818c8b97e0e6840399/src/file-utils.ts#L48-L49
it.skip(
'should shim asars with different unpacked dirs',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const arm64AppPath = await templateApp('UnpackedArm64.app', 'arm64', async (appPath) => {
const { testPath } = await createStagingAppDir('UnpackedAppArm64');
await createPackageWithOptions(
testPath,
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
{
unpackDir: 'var',
unpack: '*.txt',
},
);
});
const x64AppPath = await templateApp('UnpackedX64.app', 'x64', async (appPath) => {
const { testPath } = await createStagingAppDir('UnpackedAppX64');
await createPackageWithOptions(
testPath,
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
{},
);
});
const outAppPath = path.resolve(appsOutPath, 'UnpackedDir.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath,
});
await verifyApp(outAppPath);
},
);
it(
'should generate AsarIntegrity for all asars in the application',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const { testPath } = await createStagingAppDir('app-2');
const testAsarPath = path.resolve(appsOutPath, 'app-2.asar');
await createPackage(testPath, testAsarPath);
const arm64AppPath = await templateApp('Arm64-2.app', 'arm64', async (appPath) => {
await fs.promises.copyFile(
testAsarPath,
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
);
await fs.promises.copyFile(
testAsarPath,
path.resolve(appPath, 'Contents', 'Resources', 'webapp.asar'),
);
});
const x64AppPath = await templateApp('X64-2.app', 'x64', async (appPath) => {
await fs.promises.copyFile(
testAsarPath,
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
);
await fs.promises.copyFile(
testAsarPath,
path.resolve(appPath, 'Contents', 'Resources', 'webbapp.asar'),
);
});
const outAppPath = path.resolve(appsOutPath, 'MultipleAsars.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath,
mergeASARs: true,
});
await verifyApp(outAppPath);
},
);
}); });
describe('no asar mode', () => { describe('no asar mode', () => {
it('should correctly merge two identical app folders', async () => { it(
const out = path.resolve(appsOutPath, 'MergedNoAsar.app'); 'should correctly merge two identical app folders',
await makeUniversalApp({ { timeout: VERIFY_APP_TIMEOUT },
x64AppPath: path.resolve(appsPath, 'X64NoAsar.app'), async () => {
arm64AppPath: path.resolve(appsPath, 'Arm64NoAsar.app'), const out = path.resolve(appsOutPath, 'MergedNoAsar.app');
outAppPath: out, await makeUniversalApp({
x64AppPath: path.resolve(appsPath, 'X64NoAsar.app'),
arm64AppPath: path.resolve(appsPath, 'Arm64NoAsar.app'),
outAppPath: out,
});
await verifyApp(out);
},
);
it('should shim two different app folders', { timeout: VERIFY_APP_TIMEOUT }, async () => {
const arm64AppPath = await templateApp('ShimArm64.app', 'arm64', async (appPath) => {
const { testPath } = await createStagingAppDir('shimArm64', {
'i-aint-got-no-rhythm.bin': 'boomshakalaka',
});
await fs.promises.cp(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app'), {
recursive: true,
verbatimSymlinks: true,
});
}); });
await ensureUniversal(out);
// Only a single app folder as they were identical
expect(
(await fs.readdir(path.resolve(out, 'Contents', 'Resources'))).filter((p) =>
p.startsWith('app'),
),
).toEqual(['app']);
}, 60000);
it.todo('should shim two different app folders'); const x64AppPath = await templateApp('ShimX64.app', 'x64', async (appPath) => {
const { testPath } = await createStagingAppDir('shimX64', {
'hello-world.bin': 'Hello World',
});
await fs.promises.cp(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app'), {
recursive: true,
verbatimSymlinks: true,
});
});
const outAppPath = path.resolve(appsOutPath, 'ShimNoAsar.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath,
});
await verifyApp(outAppPath);
});
it(
'different app dirs with different macho files (shim and lipo)',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const x64AppPath = await generateNativeApp({
appNameWithExtension: 'DifferentMachoAppX64-1.app',
arch: 'x64',
createAsar: false,
additionalFiles: {
'hello-world.bin': 'Hello World',
},
});
const arm64AppPath = await generateNativeApp({
appNameWithExtension: 'DifferentMachoAppArm64-1.app',
arch: 'arm64',
createAsar: false,
additionalFiles: {
'i-aint-got-no-rhythm.bin': 'boomshakalaka',
},
});
const outAppPath = path.resolve(appsOutPath, 'DifferentMachoApp1.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath,
});
await verifyApp(outAppPath, true);
},
);
it(
"different app dirs with universal macho files (shim but don't lipo)",
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const x64AppPath = await generateNativeApp({
appNameWithExtension: 'DifferentButUniversalMachoAppX64-2.app',
arch: 'x64',
createAsar: false,
nativeModuleArch: 'universal',
additionalFiles: {
'hello-world.bin': 'Hello World',
},
});
const arm64AppPath = await generateNativeApp({
appNameWithExtension: 'DifferentButUniversalMachoAppArm64-2.app',
arch: 'arm64',
createAsar: false,
nativeModuleArch: 'universal',
additionalFiles: {
'i-aint-got-no-rhythm.bin': 'boomshakalaka',
},
});
const outAppPath = path.resolve(appsOutPath, 'DifferentButUniversalMachoApp.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath,
});
await verifyApp(outAppPath, true);
},
);
it(
'identical app dirs with different macho files (e.g. do not shim, but still lipo)',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const x64AppPath = await generateNativeApp({
appNameWithExtension: 'DifferentMachoAppX64-2.app',
arch: 'x64',
createAsar: false,
});
const arm64AppPath = await generateNativeApp({
appNameWithExtension: 'DifferentMachoAppArm64-2.app',
arch: 'arm64',
createAsar: false,
});
const out = path.resolve(appsOutPath, 'DifferentMachoApp2.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath: out,
});
await verifyApp(out, true);
},
);
it(
'identical app dirs with universal macho files (e.g., do not shim, just copy x64 dir)',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const x64AppPath = await generateNativeApp({
appNameWithExtension: 'UniversalMachoAppX64.app',
arch: 'x64',
createAsar: false,
nativeModuleArch: 'universal',
});
const arm64AppPath = await generateNativeApp({
appNameWithExtension: 'UniversalMachoAppArm64.app',
arch: 'arm64',
createAsar: false,
nativeModuleArch: 'universal',
});
const out = path.resolve(appsOutPath, 'UniversalMachoApp.app');
await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath: out });
await verifyApp(out, true);
},
);
}); });
// TODO: Add tests for
// * different app dirs with different macho files
// * identical app dirs with universal macho files
}); });

View File

@@ -1,10 +1,12 @@
import * as path from 'path'; import path from 'node:path';
import { sha } from '../src/sha'; import { describe, expect, it } from 'vitest';
import { sha } from '../src/sha.js';
describe('sha', () => { describe('sha', () => {
it('should correctly hash a file', async () => { it('should correctly hash a file', async () => {
expect(await sha(path.resolve(__dirname, 'fixtures', 'tohash'))).toEqual( expect(await sha(path.resolve(import.meta.dirname, 'fixtures', 'tohash'))).toEqual(
'12998c017066eb0d2a70b94e6ed3192985855ce390f321bbdb832022888bd251', '12998c017066eb0d2a70b94e6ed3192985855ce390f321bbdb832022888bd251',
); );
}); });

276
test/util.ts Normal file
View File

@@ -0,0 +1,276 @@
import fs from 'node:fs';
import path from 'node:path';
import { createPackageWithOptions, getRawHeader } from '@electron/asar';
import { downloadArtifact } from '@electron/get';
import { spawn } from '@malept/cross-spawn-promise';
import * as zip from 'cross-zip';
import plist from 'plist';
import * as fileUtils from '../dist/file-utils.js';
// We do a LOT of verifications in `verifyApp` 😅
// exec universal binary -> verify ALL asars -> verify ALL app dirs -> verify ALL asar integrity entries
// plus some tests create fixtures at runtime
export const VERIFY_APP_TIMEOUT = 80 * 1000;
export const fixtureDir = path.resolve(import.meta.dirname, 'fixtures');
export const asarsDir = path.resolve(fixtureDir, 'asars');
export const appsDir = path.resolve(fixtureDir, 'apps');
export const appsOutPath = path.resolve(appsDir, 'out');
export const verifyApp = async (appPath: string, containsRuntimeGeneratedMacho = false) => {
const { expect } = await import('vitest');
await ensureUniversal(appPath);
const resourcesDir = path.resolve(appPath, 'Contents', 'Resources');
const resourcesDirContents = await fs.promises.readdir(resourcesDir);
// sort for consistent result
const asars = resourcesDirContents.filter((p) => p.endsWith('.asar')).sort();
for await (const asar of asars) {
// verify header
const asarFs = getRawHeader(path.resolve(resourcesDir, asar));
expect(
removeUnstableProperties(
asarFs.header,
containsRuntimeGeneratedMacho
? ['hello-world', 'hello-world-arm64', 'hello-world-x64']
: [],
),
).toMatchSnapshot();
}
// check all app and unpacked dirs (incl. shimmed)
const dirsToSnapshot = [
'app',
'app.asar.unpacked',
'app-x64',
'app-x64.asar.unpacked',
'app-arm64',
'app-arm64.asar.unpacked',
];
const appDirs = resourcesDirContents
.filter((p) => dirsToSnapshot.includes(path.basename(p)))
.sort();
for await (const dir of appDirs) {
await verifyFileTree(path.resolve(resourcesDir, dir));
}
const allFiles = await fileUtils.getAllAppFiles(appPath, {});
const infoPlists = allFiles
.filter(
(appFile) =>
appFile.type === fileUtils.AppFileType.INFO_PLIST &&
// These are test app fixtures, no need to snapshot within `TestApp.app/Contents/Frameworks`
!appFile.relativePath.includes(path.join('Contents', 'Frameworks')),
)
.map((af) => af.relativePath)
.sort();
const integrityMap: Record<string, string> = {};
const integrity = await Promise.all(
infoPlists.map((ip) => extractAsarIntegrity(path.resolve(appPath, ip))),
);
for (let i = 0; i < integrity.length; i++) {
const relativePath = infoPlists[i];
const asarIntegrity = integrity[i];
// note: `infoPlistsToIgnore` will not have integrity in sub-app plists
integrityMap[relativePath] = asarIntegrity
? removeUnstableProperties(asarIntegrity, containsRuntimeGeneratedMacho ? ['hash'] : [])
: undefined;
}
expect(integrityMap).toMatchSnapshot();
};
const extractAsarIntegrity = async (infoPlist: string) => {
const { ElectronAsarIntegrity: integrity, ...otherData } = plist.parse(
await fs.promises.readFile(infoPlist, 'utf-8'),
) as any;
return integrity;
};
export const verifyFileTree = async (dirPath: string) => {
const { expect } = await import('vitest');
const dirFiles = await fileUtils.getAllAppFiles(dirPath, {});
const files = dirFiles.map((file) => {
const it = path.join(dirPath, file.relativePath);
const name = toSystemIndependentPath(file.relativePath);
if (it.endsWith('.txt') || it.endsWith('.json')) {
return { name, content: fs.readFileSync(it, 'utf-8') };
}
return name;
});
expect(files).toMatchSnapshot();
};
export const ensureUniversal = async (app: string) => {
const { expect } = await import('vitest');
const exe = path.resolve(app, 'Contents', 'MacOS', 'Electron');
const result = await spawn(exe);
expect(result).toContain('arm64');
const result2 = await spawn('arch', ['-x86_64', exe]);
expect(result2).toContain('x64');
};
export const toSystemIndependentPath = (s: string): string => {
return path.sep === '/' ? s : s.replace(/\\/g, '/');
};
export const removeUnstableProperties = (data: any, stripKeys: string[]) => {
const removeKeysRecursively: (obj: any, keysToRemove: string[]) => any = (obj, keysToRemove) => {
if (!obj || typeof obj !== 'object') {
return obj;
}
// if the value is an array, map over it
if (Array.isArray(obj)) {
return obj.map((item: any) => removeKeysRecursively(item, keysToRemove));
}
return Object.keys(obj).reduce<any>((acc, key) => {
// if the value of the current key is another object, make a recursive call to remove the key from the nested object
if (!keysToRemove.includes(key)) {
acc[key] = removeKeysRecursively(obj[key], keysToRemove);
} else {
acc[key] = '<stripped>';
}
return acc;
}, {});
};
const filteredData = removeKeysRecursively(data, stripKeys);
return JSON.parse(
JSON.stringify(filteredData, (name, value) => {
if (name === 'offset') {
return undefined;
}
return value;
}),
);
};
/**
* Creates an app directory at runtime for usage:
* - `testPath` can be used with `asar.createPackage`. Just set the output `.asar` path to `Test.app/Contents/Resources/<asar_name>.asar`
* - `testPath` can be utilized for logic paths involving `AsarMode.NO_ASAR` and copied directly to `Test.app/Contents/Resources`
*
* Directory structure:
* testName
* ├── private
* │ └── var
* │ ├── app
* │ │ └── file.txt -> ../file.txt
* │ └── file.txt
* └── var -> private/var
* ├── index.js
* ├── package.json
*/
export const createStagingAppDir = async (
testName: string | undefined,
additionalFiles: Record<string, string> = {},
) => {
const outDir = (testName || 'app') + Math.floor(Math.random() * 100); // tests run in parallel, randomize dir suffix to prevent naming collisions
const testPath = path.join(appsDir, outDir);
await fs.promises.rm(testPath, { recursive: true, force: true });
await fs.promises.cp(path.join(asarsDir, 'app'), testPath, {
recursive: true,
verbatimSymlinks: true,
});
const privateVarPath = path.join(testPath, 'private', 'var');
const varPath = path.join(testPath, 'var');
await fs.promises.mkdir(privateVarPath, { recursive: true });
await fs.promises.symlink(path.relative(testPath, privateVarPath), varPath);
const files = {
'file.txt': 'hello world',
...additionalFiles,
};
for await (const [filename, fileData] of Object.entries(files)) {
const originFilePath = path.join(varPath, filename);
await fs.promises.writeFile(originFilePath, fileData);
}
const appPath = path.join(varPath, 'app');
await fs.promises.mkdir(appPath, { recursive: true });
await fs.promises.symlink('../file.txt', path.join(appPath, 'file.txt'));
return {
testPath,
varPath,
appPath,
};
};
export const templateApp = async (
name: string,
arch: string,
modify: (appPath: string) => Promise<void>,
) => {
const electronZip = await downloadArtifact({
artifactName: 'electron',
version: '27.0.0',
platform: 'darwin',
arch,
});
const appPath = path.resolve(appsDir, name);
zip.unzipSync(electronZip, appsDir);
await fs.promises.rename(path.resolve(appsDir, 'Electron.app'), appPath);
await fs.promises.rm(path.resolve(appPath, 'Contents', 'Resources', 'default_app.asar'), {
recursive: true,
force: true,
});
await modify(appPath);
return appPath;
};
export const generateNativeApp = async (options: {
appNameWithExtension: string;
arch: string;
createAsar: boolean;
nativeModuleArch?: string;
additionalFiles?: Record<string, string>;
singleArchBindings?: boolean;
}) => {
const {
appNameWithExtension,
arch,
createAsar,
nativeModuleArch = arch,
additionalFiles,
singleArchBindings,
} = options;
const appPath = await templateApp(appNameWithExtension, arch, async (appPath) => {
const resources = path.join(appPath, 'Contents', 'Resources');
const resourcesApp = path.resolve(resources, 'app');
if (!fs.existsSync(resourcesApp)) {
await fs.promises.mkdir(resourcesApp, { recursive: true });
}
const { testPath } = await createStagingAppDir(
path.basename(appNameWithExtension, '.app'),
additionalFiles,
);
let targetBinding: string;
if (singleArchBindings) {
targetBinding = path.join(testPath, `hello-world-${nativeModuleArch}`);
} else {
targetBinding = path.join(testPath, 'hello-world');
}
await fs.promises.cp(path.join(appsDir, `hello-world-${nativeModuleArch}`), targetBinding, {
recursive: true,
verbatimSymlinks: true,
});
if (createAsar) {
await createPackageWithOptions(testPath, path.resolve(resources, 'app.asar'), {
unpack: '**/hello-world*',
});
} else {
await fs.promises.cp(testPath, resourcesApp, { recursive: true, verbatimSymlinks: true });
}
});
return appPath;
};

View File

@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"include": ["src"]
}

View File

@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "esnext",
"outDir": "dist/esm"
},
"include": ["src"]
}

View File

@@ -1,11 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "esnext",
"outDir": "dist/esm",
"types": [
"jest"
]
},
"include": ["src"]
}

View File

@@ -1,22 +1,15 @@
{ {
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"module": "commonjs",
"target": "es2017",
"lib": [
"es2017"
],
"sourceMap": true, "sourceMap": true,
"strict": true, "outDir": "dist",
"outDir": "dist/cjs",
"types": [ "types": [
"node", "node",
], ],
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"esModuleInterop": true,
"declaration": true "declaration": true
}, },
"include": [ "include": [
"src" "src"
], ]
} }

7
vitest.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globalSetup: './test/globalSetup.ts',
},
});

5974
yarn.lock

File diff suppressed because it is too large Load Diff