8 Commits

Author SHA1 Message Date
David Sanders
421713cf80 feat!: bump engines to Node.js >=22.12.0 (#139)
Some checks failed
Publish documentation / docs (push) Failing after 1m9s
BREAKING CHANGE: Requires Node.js v22.12.0 LTS or higher. ESM-only.
2025-07-03 15:30:07 -07:00
David Sanders
175672e430 test: update snapshot to remove skipped test (#138) 2025-05-28 15:40:37 -07:00
David Sanders
1695dc9eac ci: timeout release job after 1 hour (#136) 2025-05-09 16:28:07 -07:00
Mike Maietta
64be29d2f7 fix: Skip lipo if native module is already universal. Add native module fixtures for lipo tests (#126)
* fix: when native modules are already universal, don't lipo. adds `node-mac-permissions` fixture from https://github.com/codebytere/node-mac-permissions and resolves 3 `it.todo` test cases

* add test `different app dirs with different macho files (shim and lipo)`

* add additional test

* PR feedback

* gotta close `fd`

* use `stream` to read first 4 bytes. copy native fixture before packing into asar to leverage `unpack: "**/*.node"` properly.

* convert params to object

* rename `createTestApp` to `createStagingAppDir` and add jsdoc to the function

* compiler error from merge conflict

* update snapshots

* update snapshots

* only check x64Content since it's the tmp app

* compile macho binaries at runtime using hellow-world.c for fixtures in lipo tests

* Update jest.setup.ts

Co-authored-by: Erik Moura <erikian@erikian.dev>

* Update jest.setup.ts

Co-authored-by: Erik Moura <erikian@erikian.dev>

* remove unstable properties for specific keys

* force redo

* update snapshots

* stripping only hello-world from snapshot and only hash from macho-specific asar integrity

* optimize logic :)

---------

Co-authored-by: Erik Moura <erikian@erikian.dev>
2025-05-02 13:10:36 -03:00
dependabot[bot]
ec7c971959 build(deps): bump actions/setup-node from 4.3.0 to 4.4.0 (#134)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.3.0 to 4.4.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](cdca7365b2...49933ea528)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-01 10:07:01 -07:00
dependabot[bot]
5b957e6858 build(deps): bump dsanders11/project-actions from 1.5.1 to 1.7.0 (#132)
Bumps [dsanders11/project-actions](https://github.com/dsanders11/project-actions) from 1.5.1 to 1.7.0.
- [Release notes](https://github.com/dsanders11/project-actions/releases)
- [Changelog](https://github.com/dsanders11/project-actions/blob/main/.releaserc.json)
- [Commits](9c80cd31f5...2134fe7cc7)

---
updated-dependencies:
- dependency-name: dsanders11/project-actions
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 13:27:42 -05:00
dependabot[bot]
4276c7cf38 build(deps): bump actions/setup-node from 4.2.0 to 4.3.0 (#131)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.2.0 to 4.3.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](1d0ff469b7...cdca7365b2)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 11:41:59 -05:00
Erik Moura
977baa4d42 test: add explicit imports for jest functions (#128)
* test: add explicit imports for `jest` functions

* add workaround for non-`.spec.ts` file
2025-03-31 23:17:05 -07:00
29 changed files with 1819 additions and 2769 deletions

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@9c80cd31f58599941c64f74636bea95ba5d46090 # v1.5.1 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 }}

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

@@ -0,0 +1,35 @@
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: '.nvmrc'
- name: Install dependencies
run: yarn --frozen-lockfile
- 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@089eac9d8cc39f5d003e94f8b65efc51076c9cbd # v2.1.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

View File

@@ -22,13 +22,14 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: 20.x node-version-file: '.nvmrc'
cache: 'yarn' cache: 'yarn'
- name: Install - name: Install
run: yarn install --frozen-lockfile run: yarn install --frozen-lockfile
- uses: continuousauth/action@4e8a2573eeb706f6d7300d6a9f3ca6322740b72d # v1.0.5 - uses: continuousauth/action@4e8a2573eeb706f6d7300d6a9f3ca6322740b72d # v1.0.5
timeout-minutes: 60
with: with:
project-id: ${{ secrets.CFA_PROJECT_ID }} project-id: ${{ secrets.CFA_PROJECT_ID }}
secret: ${{ secrets.CFA_SECRET }} secret: ${{ secrets.CFA_SECRET }}

View File

@@ -18,24 +18,18 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
node-version: node-version:
- '20.5' - 22.12.x
- '18.17'
- '16.20'
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: "${{ matrix.node-version }}" node-version: "${{ matrix.node-version }}"
cache: 'yarn' cache: 'yarn'
- name: Install (Node.js v18+) - name: Install
if : ${{ matrix.node-version != '16.20' }}
run: yarn install --frozen-lockfile run: yarn install --frozen-lockfile
- name: Install (Node.js < v18)
if : ${{ matrix.node-version == '16.20' }}
run: yarn install --frozen-lockfile --ignore-engines
- name: Build - name: Build
run: yarn build run: yarn build
- name: Lint - name: Lint

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

@@ -1,16 +0,0 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.ts?$': [
'ts-jest',
{
tsconfig: 'tsconfig.jest.json',
},
],
},
testMatch: ['<rootDir>/test/**/*.spec.ts'],
globalSetup: './jest.setup.ts',
testTimeout: 10000,
};

View File

@@ -1,51 +0,0 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import { appsDir, asarsDir, templateApp } from './test/util';
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",
@@ -15,7 +15,7 @@
"url": "git+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/*",
@@ -28,38 +28,36 @@
"provenance": true "provenance": true
}, },
"scripts": { "scripts": {
"build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && tsc -p tsconfig.entry-asar.json", "build": "tsc -p tsconfig.json && tsc -p tsconfig.entry-asar.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", "prepublishOnly": "npm run build",
"test": "jest", "pretest": "npm run build",
"prepare": "husky install" "test": "vitest run",
"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.2.10", "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.3.1", "@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"
}, },
@@ -67,8 +65,5 @@
"*.ts": [ "*.ts": [
"prettier --write" "prettier --write"
] ]
},
"resolutions": {
"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;
} }
@@ -148,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;
} }
@@ -170,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}`);
@@ -186,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]);
@@ -220,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,6 +1,8 @@
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 * as path from 'path';
const MACHO_PREFIX = 'Mach-O '; const MACHO_PREFIX = 'Mach-O ';
@@ -26,11 +28,11 @@ export const getAllAppFiles = async (appPath: string): Promise<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()) {
let fileType = AppFileType.PLAIN; let fileType = AppFileType.PLAIN;
@@ -62,7 +64,7 @@ export const getAllAppFiles = async (appPath: string): Promise<AppFile[]> => {
} }
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 +73,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,17 +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, 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 { computeIntegrityData } from './integrity'; import * as 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.
@@ -88,7 +89,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(
@@ -96,7 +97,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 });
} }
} }
@@ -110,7 +111,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 {
@@ -120,8 +121,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));
const arm64Files = await getAllAppFiles(await fs.realpath(opts.arm64AppPath)); const arm64Files = await getAllAppFiles(await fs.promises.realpath(opts.arm64AppPath));
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))
@@ -159,8 +160,19 @@ 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 x64Files.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));
@@ -192,7 +204,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);
} }
@@ -223,26 +235,34 @@ 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 });
await fs.copy( await fs.promises.cp(
path.resolve(__dirname, '..', '..', 'entry-asar', 'no-asar.js'), path.resolve(import.meta.dirname, '..', 'entry-asar', 'no-asar.js'),
path.resolve(entryAsar, 'index.js'), path.resolve(entryAsar, 'index.js'),
); );
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',
),
); );
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'),
@@ -279,19 +299,20 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
if (x64AsarSha !== arm64AsarSha) { if (x64AsarSha !== arm64AsarSha) {
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,
@@ -299,17 +320,18 @@ 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 });
await fs.copy( await fs.promises.cp(
path.resolve(__dirname, '..', '..', 'entry-asar', 'has-asar.js'), path.resolve(import.meta.dirname, '..', 'entry-asar', 'has-asar.js'),
path.resolve(entryAsar, 'index.js'), path.resolve(entryAsar, 'index.js'),
); );
let pj = JSON.parse( let pj = JSON.parse(
@@ -321,7 +343,11 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
).toString('utf8'), ).toString('utf8'),
); );
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);
} else { } else {
@@ -337,10 +363,10 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
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(
@@ -355,23 +381,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 });
} }
}; };

View File

@@ -1,8 +1,8 @@
import * as fs from 'fs-extra'; import fs from 'node:fs';
import path from 'path'; import path from 'node:path';
import { AppFileType, getAllAppFiles } from './file-utils';
import { sha } from './sha'; import { AppFileType, getAllAppFiles } from './file-utils.js';
import { generateAsarIntegrity } from './asar-utils'; import { generateAsarIntegrity } from './asar-utils.js';
type IntegrityMap = { type IntegrityMap = {
[filepath: string]: string; [filepath: string]: string;
@@ -18,7 +18,7 @@ export interface AsarIntegrity {
} }
export async function computeIntegrityData(contentsPath: string): Promise<AsarIntegrity> { export async function computeIntegrityData(contentsPath: string): Promise<AsarIntegrity> {
const root = await fs.realpath(contentsPath); const root = await fs.promises.realpath(contentsPath);
const resourcesRelativePath = 'Resources'; const resourcesRelativePath = 'Resources';
const resourcesPath = path.resolve(root, resourcesRelativePath); const resourcesPath = path.resolve(root, resourcesRelativePath);

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

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`makeUniversalApp asar mode should correctly merge two identical asars 1`] = ` exports[`makeUniversalApp > asar mode > should correctly merge two identical asars 1`] = `
{ {
"files": { "files": {
"index.js": { "index.js": {
@@ -29,7 +29,7 @@ exports[`makeUniversalApp asar mode should correctly merge two identical asars 1
} }
`; `;
exports[`makeUniversalApp asar mode should correctly merge two identical asars 2`] = ` exports[`makeUniversalApp > asar mode > should correctly merge two identical asars 2`] = `
{ {
"Contents/Info.plist": { "Contents/Info.plist": {
"Resources/app.asar": { "Resources/app.asar": {
@@ -40,7 +40,7 @@ exports[`makeUniversalApp asar mode should correctly merge two identical asars 2
} }
`; `;
exports[`makeUniversalApp asar mode should create a shim if asars are different between architectures 1`] = ` exports[`makeUniversalApp > asar mode > should create a shim if asars are different between architectures 1`] = `
{ {
"files": { "files": {
"extra-file.txt": { "extra-file.txt": {
@@ -80,7 +80,7 @@ exports[`makeUniversalApp asar mode should create a shim if asars are different
} }
`; `;
exports[`makeUniversalApp asar mode should create a shim if asars are different between architectures 2`] = ` exports[`makeUniversalApp > asar mode > should create a shim if asars are different between architectures 2`] = `
{ {
"files": { "files": {
"index.js": { "index.js": {
@@ -109,7 +109,7 @@ exports[`makeUniversalApp asar mode should create a shim if asars are different
} }
`; `;
exports[`makeUniversalApp asar mode should create a shim if asars are different between architectures 3`] = ` exports[`makeUniversalApp > asar mode > should create a shim if asars are different between architectures 3`] = `
{ {
"files": { "files": {
"index.js": { "index.js": {
@@ -138,7 +138,7 @@ exports[`makeUniversalApp asar mode should create a shim if asars are different
} }
`; `;
exports[`makeUniversalApp asar mode should create a shim if asars are different between architectures 4`] = ` exports[`makeUniversalApp > asar mode > should create a shim if asars are different between architectures 4`] = `
{ {
"Contents/Info.plist": { "Contents/Info.plist": {
"Resources/app-arm64.asar": { "Resources/app-arm64.asar": {
@@ -157,7 +157,7 @@ exports[`makeUniversalApp asar mode should create a shim if asars are different
} }
`; `;
exports[`makeUniversalApp asar mode should generate AsarIntegrity for all asars in the application 1`] = ` exports[`makeUniversalApp > asar mode > should generate AsarIntegrity for all asars in the application 1`] = `
{ {
"files": { "files": {
"index.js": { "index.js": {
@@ -215,7 +215,7 @@ exports[`makeUniversalApp asar mode should generate AsarIntegrity for all asars
} }
`; `;
exports[`makeUniversalApp asar mode should generate AsarIntegrity for all asars in the application 2`] = ` exports[`makeUniversalApp > asar mode > should generate AsarIntegrity for all asars in the application 2`] = `
{ {
"files": { "files": {
"index.js": { "index.js": {
@@ -273,7 +273,7 @@ exports[`makeUniversalApp asar mode should generate AsarIntegrity for all asars
} }
`; `;
exports[`makeUniversalApp asar mode should generate AsarIntegrity for all asars in the application 3`] = ` exports[`makeUniversalApp > asar mode > should generate AsarIntegrity for all asars in the application 3`] = `
{ {
"Contents/Info.plist": { "Contents/Info.plist": {
"Resources/app.asar": { "Resources/app.asar": {
@@ -288,7 +288,7 @@ exports[`makeUniversalApp asar mode should generate AsarIntegrity for all asars
} }
`; `;
exports[`makeUniversalApp asar mode should merge two different asars when \`mergeASARs\` is enabled 1`] = ` exports[`makeUniversalApp > asar mode > should merge two different asars when \`mergeASARs\` is enabled 1`] = `
{ {
"files": { "files": {
"extra-file.txt": { "extra-file.txt": {
@@ -328,7 +328,7 @@ exports[`makeUniversalApp asar mode should merge two different asars when \`merg
} }
`; `;
exports[`makeUniversalApp asar mode should merge two different asars when \`mergeASARs\` is enabled 2`] = ` exports[`makeUniversalApp > asar mode > should merge two different asars when \`mergeASARs\` is enabled 2`] = `
{ {
"Contents/Info.plist": { "Contents/Info.plist": {
"Resources/app.asar": { "Resources/app.asar": {
@@ -339,7 +339,7 @@ exports[`makeUniversalApp asar mode should merge two different asars when \`merg
} }
`; `;
exports[`makeUniversalApp asar mode should not inject ElectronAsarIntegrity into \`infoPlistsToIgnore\` 1`] = ` exports[`makeUniversalApp > asar mode > should not inject ElectronAsarIntegrity into \`infoPlistsToIgnore\` 1`] = `
{ {
"files": { "files": {
"index.js": { "index.js": {
@@ -397,190 +397,14 @@ exports[`makeUniversalApp asar mode should not inject ElectronAsarIntegrity into
} }
`; `;
exports[`makeUniversalApp asar mode should not inject ElectronAsarIntegrity into \`infoPlistsToIgnore\` 2`] = ` exports[`makeUniversalApp > asar mode > should not inject ElectronAsarIntegrity into \`infoPlistsToIgnore\` 2`] = `
{ {
"Contents/Info.plist": undefined, "Contents/Info.plist": undefined,
"Contents/Resources/SubApp-1.app/Contents/Info.plist": undefined, "Contents/Resources/SubApp-1.app/Contents/Info.plist": undefined,
} }
`; `;
exports[`makeUniversalApp asar mode should shim asars with different unpacked dirs 1`] = ` exports[`makeUniversalApp > force > packages successfully if \`out\` bundle already exists and \`force\` is \`true\` 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",
"unpacked": true,
},
},
},
"file.txt": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
],
"hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
},
"size": 11,
"unpacked": true,
},
},
},
},
},
"var": {
"link": "private/var",
"unpacked": true,
},
},
}
`;
exports[`makeUniversalApp asar mode should shim asars with different unpacked dirs 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 shim asars with different unpacked dirs 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 shim asars with different unpacked dirs 4`] = `
[
{
"content": "hello world",
"name": "private/var/file.txt",
},
]
`;
exports[`makeUniversalApp asar mode should shim asars with different unpacked dirs 5`] = `
{
"Contents/Info.plist": {
"Resources/app-arm64.asar": {
"algorithm": "SHA256",
"hash": "d06a628e759f54def7ff8785a077b3a3d756882cb84ee99e9725966226e1f195",
},
"Resources/app-x64.asar": {
"algorithm": "SHA256",
"hash": "7e6af4d00f4cc737eff922e2b386128a269f80887b79a011022f1276bdbe7832",
},
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "b62aaaed07ff72dc33da1720d900e0443c060285ef374ce1bdaef1d4f28b5fe4",
},
},
}
`;
exports[`makeUniversalApp force packages successfully if \`out\` bundle already exists and \`force\` is \`true\` 1`] = `
{ {
"files": { "files": {
"index.js": { "index.js": {
@@ -609,7 +433,7 @@ exports[`makeUniversalApp force packages successfully if \`out\` bundle already
} }
`; `;
exports[`makeUniversalApp force packages successfully if \`out\` bundle already exists and \`force\` is \`true\` 2`] = ` exports[`makeUniversalApp > force > packages successfully if \`out\` bundle already exists and \`force\` is \`true\` 2`] = `
{ {
"Contents/Info.plist": { "Contents/Info.plist": {
"Resources/app.asar": { "Resources/app.asar": {
@@ -620,26 +444,7 @@ exports[`makeUniversalApp force packages successfully if \`out\` bundle already
} }
`; `;
exports[`makeUniversalApp no asar mode should correctly merge two identical app folders 1`] = ` exports[`makeUniversalApp > no asar mode > different app dirs with different macho files (shim and lipo) 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": { "files": {
"index.js": { "index.js": {
@@ -668,13 +473,248 @@ exports[`makeUniversalApp no asar mode should shim two different app folders 1`]
} }
`; `;
exports[`makeUniversalApp no asar mode should shim two different app folders 2`] = ` exports[`makeUniversalApp > no asar mode > different app dirs with different macho files (shim and lipo) 2`] = `
[ [
"private/var/i-aint-got-no-rhythm.bin", "private/var/i-aint-got-no-rhythm.bin",
] ]
`; `;
exports[`makeUniversalApp no asar mode should shim two different app folders 3`] = ` 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", "index.js",
{ {
@@ -692,7 +732,7 @@ exports[`makeUniversalApp no asar mode should shim two different app folders 3`]
] ]
`; `;
exports[`makeUniversalApp no asar mode should shim two different app folders 4`] = ` exports[`makeUniversalApp > no asar mode > should shim two different app folders 4`] = `
[ [
"index.js", "index.js",
{ {
@@ -710,7 +750,7 @@ exports[`makeUniversalApp no asar mode should shim two different app folders 4`]
] ]
`; `;
exports[`makeUniversalApp no asar mode should shim two different app folders 5`] = ` exports[`makeUniversalApp > no asar mode > should shim two different app folders 5`] = `
{ {
"Contents/Info.plist": { "Contents/Info.plist": {
"Resources/app.asar": { "Resources/app.asar": {
@@ -720,3 +760,81 @@ exports[`makeUniversalApp no asar mode should shim two different app folders 5`]
}, },
} }
`; `;
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', () => {

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,17 +1,27 @@
import * as fs from 'fs-extra'; import fs from 'node:fs';
import * as path from 'path'; import path from 'node:path';
import { makeUniversalApp } from '../dist/cjs/index'; import { afterEach, describe, expect, it } from 'vitest';
import { createTestApp, templateApp, VERIFY_APP_TIMEOUT, verifyApp } from './util';
import { makeUniversalApp } from '../dist/index.js';
import { fsMove } from '../src/file-utils.js';
import {
createStagingAppDir,
generateNativeApp,
templateApp,
VERIFY_APP_TIMEOUT,
verifyApp,
} from './util.js';
import { createPackage, createPackageWithOptions } from '@electron/asar'; import { createPackage, createPackageWithOptions } from '@electron/asar';
const appsPath = path.resolve(__dirname, 'fixtures', 'apps'); const appsPath = path.resolve(import.meta.dirname, 'fixtures', 'apps');
const appsOutPath = path.resolve(__dirname, 'fixtures', 'apps', 'out'); const appsOutPath = path.resolve(import.meta.dirname, 'fixtures', 'apps', 'out');
// 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 () => {
@@ -27,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'),
@@ -44,9 +69,10 @@ describe('makeUniversalApp', () => {
it( it(
'packages successfully if `out` bundle already exists and `force` is `true`', 'packages successfully if `out` bundle already exists and `force` is `true`',
{ timeout: VERIFY_APP_TIMEOUT },
async () => { async () => {
const out = path.resolve(appsOutPath, 'Error.app'); const out = path.resolve(appsOutPath, 'NoError.app');
await fs.mkdirp(out); await fs.promises.mkdir(out, { recursive: true });
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'),
@@ -55,27 +81,23 @@ describe('makeUniversalApp', () => {
}); });
await verifyApp(out); await verifyApp(out);
}, },
VERIFY_APP_TIMEOUT,
); );
}); });
describe('asar mode', () => { describe('asar mode', () => {
it( it('should correctly merge two identical asars', { timeout: VERIFY_APP_TIMEOUT }, async () => {
'should correctly merge two identical asars', const out = path.resolve(appsOutPath, 'MergedAsar.app');
async () => { await makeUniversalApp({
const out = path.resolve(appsOutPath, 'MergedAsar.app'); x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
await makeUniversalApp({ arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'),
x64AppPath: path.resolve(appsPath, 'X64Asar.app'), outAppPath: out,
arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'), });
outAppPath: out, await verifyApp(out);
}); });
await verifyApp(out);
},
VERIFY_APP_TIMEOUT,
);
it( it(
'should create a shim if asars are different between architectures', 'should create a shim if asars are different between architectures',
{ timeout: VERIFY_APP_TIMEOUT },
async () => { async () => {
const out = path.resolve(appsOutPath, 'ShimmedAsar.app'); const out = path.resolve(appsOutPath, 'ShimmedAsar.app');
await makeUniversalApp({ await makeUniversalApp({
@@ -85,11 +107,11 @@ describe('makeUniversalApp', () => {
}); });
await verifyApp(out); await verifyApp(out);
}, },
VERIFY_APP_TIMEOUT,
); );
it( it(
'should merge two different asars when `mergeASARs` is enabled', 'should merge two different asars when `mergeASARs` is enabled',
{ timeout: VERIFY_APP_TIMEOUT },
async () => { async () => {
const out = path.resolve(appsOutPath, 'MergedAsar.app'); const out = path.resolve(appsOutPath, 'MergedAsar.app');
await makeUniversalApp({ await makeUniversalApp({
@@ -101,11 +123,11 @@ describe('makeUniversalApp', () => {
}); });
await verifyApp(out); await verifyApp(out);
}, },
VERIFY_APP_TIMEOUT,
); );
it( it(
'throws an error if `mergeASARs` is enabled and `singleArchFiles` is missing a unique file', 'throws an error if `mergeASARs` is enabled and `singleArchFiles` is missing a unique file',
{ timeout: VERIFY_APP_TIMEOUT },
async () => { async () => {
const out = path.resolve(appsOutPath, 'Error.app'); const out = path.resolve(appsOutPath, 'Error.app');
await expect( await expect(
@@ -118,27 +140,27 @@ describe('makeUniversalApp', () => {
}), }),
).rejects.toThrow(/Detected unique file "extra-file\.txt"/); ).rejects.toThrow(/Detected unique file "extra-file\.txt"/);
}, },
VERIFY_APP_TIMEOUT,
); );
it( it(
'should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`', 'should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`',
{ timeout: VERIFY_APP_TIMEOUT },
async () => { async () => {
const arm64AppPath = await templateApp('Arm64-1.app', 'arm64', async (appPath) => { const arm64AppPath = await templateApp('Arm64-1.app', 'arm64', async (appPath) => {
const { testPath } = await createTestApp('Arm64-1'); const { testPath } = await createStagingAppDir('Arm64-1');
await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar')); await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar'));
await templateApp('SubApp-1.app', 'arm64', async (subArm64AppPath) => { await templateApp('SubApp-1.app', 'arm64', async (subArm64AppPath) => {
await fs.move( await fsMove(
subArm64AppPath, subArm64AppPath,
path.resolve(appPath, 'Contents', 'Resources', path.basename(subArm64AppPath)), path.resolve(appPath, 'Contents', 'Resources', path.basename(subArm64AppPath)),
); );
}); });
}); });
const x64AppPath = await templateApp('X64-1.app', 'x64', async (appPath) => { const x64AppPath = await templateApp('X64-1.app', 'x64', async (appPath) => {
const { testPath } = await createTestApp('X64-1'); const { testPath } = await createStagingAppDir('X64-1');
await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar')); await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar'));
await templateApp('SubApp-1.app', 'x64', async (subArm64AppPath) => { await templateApp('SubApp-1.app', 'x64', async (subArm64AppPath) => {
await fs.move( await fsMove(
subArm64AppPath, subArm64AppPath,
path.resolve(appPath, 'Contents', 'Resources', path.basename(subArm64AppPath)), path.resolve(appPath, 'Contents', 'Resources', path.basename(subArm64AppPath)),
); );
@@ -154,7 +176,6 @@ describe('makeUniversalApp', () => {
}); });
await verifyApp(outAppPath); await verifyApp(outAppPath);
}, },
VERIFY_APP_TIMEOUT,
); );
// TODO: Investigate if this should even be allowed. // TODO: Investigate if this should even be allowed.
@@ -162,9 +183,10 @@ describe('makeUniversalApp', () => {
// https://github.com/electron/universal/blob/d90d573ccf69a5b14b91aa818c8b97e0e6840399/src/file-utils.ts#L48-L49 // https://github.com/electron/universal/blob/d90d573ccf69a5b14b91aa818c8b97e0e6840399/src/file-utils.ts#L48-L49
it.skip( it.skip(
'should shim asars with different unpacked dirs', 'should shim asars with different unpacked dirs',
{ timeout: VERIFY_APP_TIMEOUT },
async () => { async () => {
const arm64AppPath = await templateApp('UnpackedArm64.app', 'arm64', async (appPath) => { const arm64AppPath = await templateApp('UnpackedArm64.app', 'arm64', async (appPath) => {
const { testPath } = await createTestApp('UnpackedAppArm64'); const { testPath } = await createStagingAppDir('UnpackedAppArm64');
await createPackageWithOptions( await createPackageWithOptions(
testPath, testPath,
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
@@ -176,7 +198,7 @@ describe('makeUniversalApp', () => {
}); });
const x64AppPath = await templateApp('UnpackedX64.app', 'x64', async (appPath) => { const x64AppPath = await templateApp('UnpackedX64.app', 'x64', async (appPath) => {
const { testPath } = await createTestApp('UnpackedAppX64'); const { testPath } = await createStagingAppDir('UnpackedAppX64');
await createPackageWithOptions( await createPackageWithOptions(
testPath, testPath,
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
@@ -192,32 +214,32 @@ describe('makeUniversalApp', () => {
}); });
await verifyApp(outAppPath); await verifyApp(outAppPath);
}, },
VERIFY_APP_TIMEOUT,
); );
it( it(
'should generate AsarIntegrity for all asars in the application', 'should generate AsarIntegrity for all asars in the application',
{ timeout: VERIFY_APP_TIMEOUT },
async () => { async () => {
const { testPath } = await createTestApp('app-2'); const { testPath } = await createStagingAppDir('app-2');
const testAsarPath = path.resolve(appsOutPath, 'app-2.asar'); const testAsarPath = path.resolve(appsOutPath, 'app-2.asar');
await createPackage(testPath, testAsarPath); await createPackage(testPath, testAsarPath);
const arm64AppPath = await templateApp('Arm64-2.app', 'arm64', async (appPath) => { const arm64AppPath = await templateApp('Arm64-2.app', 'arm64', async (appPath) => {
await fs.copyFile( await fs.promises.copyFile(
testAsarPath, testAsarPath,
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
); );
await fs.copyFile( await fs.promises.copyFile(
testAsarPath, testAsarPath,
path.resolve(appPath, 'Contents', 'Resources', 'webapp.asar'), path.resolve(appPath, 'Contents', 'Resources', 'webapp.asar'),
); );
}); });
const x64AppPath = await templateApp('X64-2.app', 'x64', async (appPath) => { const x64AppPath = await templateApp('X64-2.app', 'x64', async (appPath) => {
await fs.copyFile( await fs.promises.copyFile(
testAsarPath, testAsarPath,
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
); );
await fs.copyFile( await fs.promises.copyFile(
testAsarPath, testAsarPath,
path.resolve(appPath, 'Contents', 'Resources', 'webbapp.asar'), path.resolve(appPath, 'Contents', 'Resources', 'webbapp.asar'),
); );
@@ -231,13 +253,13 @@ describe('makeUniversalApp', () => {
}); });
await verifyApp(outAppPath); await verifyApp(outAppPath);
}, },
VERIFY_APP_TIMEOUT,
); );
}); });
describe('no asar mode', () => { describe('no asar mode', () => {
it( it(
'should correctly merge two identical app folders', 'should correctly merge two identical app folders',
{ timeout: VERIFY_APP_TIMEOUT },
async () => { async () => {
const out = path.resolve(appsOutPath, 'MergedNoAsar.app'); const out = path.resolve(appsOutPath, 'MergedNoAsar.app');
await makeUniversalApp({ await makeUniversalApp({
@@ -247,37 +269,148 @@ describe('makeUniversalApp', () => {
}); });
await verifyApp(out); await verifyApp(out);
}, },
VERIFY_APP_TIMEOUT,
); );
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,
});
});
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( it(
'should shim two different app folders', 'different app dirs with different macho files (shim and lipo)',
{ timeout: VERIFY_APP_TIMEOUT },
async () => { async () => {
const arm64AppPath = await templateApp('ShimArm64.app', 'arm64', async (appPath) => { const x64AppPath = await generateNativeApp({
const { testPath } = await createTestApp('shimArm64', { 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', 'i-aint-got-no-rhythm.bin': 'boomshakalaka',
}); },
await fs.copy(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app'));
}); });
const x64AppPath = await templateApp('ShimX64.app', 'x64', async (appPath) => { const outAppPath = path.resolve(appsOutPath, 'DifferentMachoApp1.app');
const { testPath } = await createTestApp('shimX64', { 'hello-world.bin': 'Hello World' });
await fs.copy(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app'));
});
const outAppPath = path.resolve(appsOutPath, 'ShimNoAsar.app');
await makeUniversalApp({ await makeUniversalApp({
x64AppPath, x64AppPath,
arm64AppPath, arm64AppPath,
outAppPath, outAppPath,
}); });
await verifyApp(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);
}, },
VERIFY_APP_TIMEOUT,
); );
}); });
// 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',
); );
}); });

View File

@@ -1,33 +1,40 @@
import fs from 'node:fs';
import path from 'node:path';
import { createPackageWithOptions, getRawHeader } from '@electron/asar';
import { downloadArtifact } from '@electron/get'; import { downloadArtifact } from '@electron/get';
import { spawn } from '@malept/cross-spawn-promise'; import { spawn } from '@malept/cross-spawn-promise';
import * as zip from 'cross-zip'; import * as zip from 'cross-zip';
import * as fs from 'fs-extra';
import * as path from 'path';
import plist from 'plist'; import plist from 'plist';
import * as fileUtils from '../dist/cjs/file-utils';
import { getRawHeader } from '@electron/asar'; import * as fileUtils from '../dist/file-utils.js';
// We do a LOT of verifications in `verifyApp` 😅 // We do a LOT of verifications in `verifyApp` 😅
// exec universal binary -> verify ALL asars -> verify ALL app dirs -> verify ALL asar integrity entries // exec universal binary -> verify ALL asars -> verify ALL app dirs -> verify ALL asar integrity entries
// plus some tests create fixtures at runtime // plus some tests create fixtures at runtime
export const VERIFY_APP_TIMEOUT = 80 * 1000; export const VERIFY_APP_TIMEOUT = 80 * 1000;
export const asarsDir = path.resolve(__dirname, 'fixtures', 'asars'); export const fixtureDir = path.resolve(import.meta.dirname, 'fixtures');
export const appsDir = path.resolve(__dirname, 'fixtures', 'apps'); export const asarsDir = path.resolve(fixtureDir, 'asars');
export const appsDir = path.resolve(fixtureDir, 'apps');
export const appsOutPath = path.resolve(appsDir, 'out'); export const appsOutPath = path.resolve(appsDir, 'out');
export const verifyApp = async (appPath: string) => { export const verifyApp = async (appPath: string, containsRuntimeGeneratedMacho = false) => {
const { expect } = await import('vitest');
await ensureUniversal(appPath); await ensureUniversal(appPath);
const resourcesDir = path.resolve(appPath, 'Contents', 'Resources'); const resourcesDir = path.resolve(appPath, 'Contents', 'Resources');
const resourcesDirContents = await fs.readdir(resourcesDir); const resourcesDirContents = await fs.promises.readdir(resourcesDir);
// sort for consistent result // sort for consistent result
const asars = resourcesDirContents.filter((p) => p.endsWith('.asar')).sort(); const asars = resourcesDirContents.filter((p) => p.endsWith('.asar')).sort();
for await (const asar of asars) { for await (const asar of asars) {
// verify header // verify header
const asarFs = getRawHeader(path.resolve(resourcesDir, asar)); const asarFs = getRawHeader(path.resolve(resourcesDir, asar));
expect(removeUnstableProperties(asarFs.header)).toMatchSnapshot(); expect(
removeUnstableProperties(asarFs.header, containsRuntimeGeneratedMacho ? ['hello-world'] : []),
).toMatchSnapshot();
} }
// check all app and unpacked dirs (incl. shimmed) // check all app and unpacked dirs (incl. shimmed)
@@ -64,20 +71,24 @@ export const verifyApp = async (appPath: string) => {
for (let i = 0; i < integrity.length; i++) { for (let i = 0; i < integrity.length; i++) {
const relativePath = infoPlists[i]; const relativePath = infoPlists[i];
const asarIntegrity = integrity[i]; const asarIntegrity = integrity[i];
integrityMap[relativePath] = asarIntegrity; // note: `infoPlistsToIgnore` will not have integrity in sub-app plists
integrityMap[relativePath] = asarIntegrity
? removeUnstableProperties(asarIntegrity, containsRuntimeGeneratedMacho ? ['hash'] : [])
: undefined;
} }
expect(integrityMap).toMatchSnapshot(); expect(integrityMap).toMatchSnapshot();
}; };
// note: `infoPlistsToIgnore` will not have integrity in sub-app plists
const extractAsarIntegrity = async (infoPlist: string) => { const extractAsarIntegrity = async (infoPlist: string) => {
const { ElectronAsarIntegrity: integrity, ...otherData } = plist.parse( const { ElectronAsarIntegrity: integrity, ...otherData } = plist.parse(
await fs.readFile(infoPlist, 'utf-8'), await fs.promises.readFile(infoPlist, 'utf-8'),
) as any; ) as any;
return integrity; return integrity;
}; };
export const verifyFileTree = async (dirPath: string) => { export const verifyFileTree = async (dirPath: string) => {
const { expect } = await import('vitest');
const dirFiles = await fileUtils.getAllAppFiles(dirPath); const dirFiles = await fileUtils.getAllAppFiles(dirPath);
const files = dirFiles.map((file) => { const files = dirFiles.map((file) => {
const it = path.join(dirPath, file.relativePath); const it = path.join(dirPath, file.relativePath);
@@ -91,6 +102,8 @@ export const verifyFileTree = async (dirPath: string) => {
}; };
export const ensureUniversal = async (app: string) => { export const ensureUniversal = async (app: string) => {
const { expect } = await import('vitest');
const exe = path.resolve(app, 'Contents', 'MacOS', 'Electron'); const exe = path.resolve(app, 'Contents', 'MacOS', 'Electron');
const result = await spawn(exe); const result = await spawn(exe);
expect(result).toContain('arm64'); expect(result).toContain('arm64');
@@ -102,9 +115,29 @@ export const toSystemIndependentPath = (s: string): string => {
return path.sep === '/' ? s : s.replace(/\\/g, '/'); return path.sep === '/' ? s : s.replace(/\\/g, '/');
}; };
export const removeUnstableProperties = (data: any) => { 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( return JSON.parse(
JSON.stringify(data, (name, value) => { JSON.stringify(filteredData, (name, value) => {
if (name === 'offset') { if (name === 'offset') {
return undefined; return undefined;
} }
@@ -114,6 +147,10 @@ export const removeUnstableProperties = (data: any) => {
}; };
/** /**
* 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: * Directory structure:
* testName * testName
* ├── private * ├── private
@@ -125,21 +162,24 @@ export const removeUnstableProperties = (data: any) => {
* ├── index.js * ├── index.js
* ├── package.json * ├── package.json
*/ */
export const createTestApp = async ( export const createStagingAppDir = async (
testName: string | undefined, testName: string | undefined,
additionalFiles: Record<string, string> = {}, 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 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); const testPath = path.join(appsDir, outDir);
await fs.remove(testPath); await fs.promises.rm(testPath, { recursive: true, force: true });
await fs.copy(path.join(asarsDir, 'app'), testPath); await fs.promises.cp(path.join(asarsDir, 'app'), testPath, {
recursive: true,
verbatimSymlinks: true,
});
const privateVarPath = path.join(testPath, 'private', 'var'); const privateVarPath = path.join(testPath, 'private', 'var');
const varPath = path.join(testPath, 'var'); const varPath = path.join(testPath, 'var');
await fs.mkdir(privateVarPath, { recursive: true }); await fs.promises.mkdir(privateVarPath, { recursive: true });
await fs.symlink(path.relative(testPath, privateVarPath), varPath); await fs.promises.symlink(path.relative(testPath, privateVarPath), varPath);
const files = { const files = {
'file.txt': 'hello world', 'file.txt': 'hello world',
@@ -147,11 +187,11 @@ export const createTestApp = async (
}; };
for await (const [filename, fileData] of Object.entries(files)) { for await (const [filename, fileData] of Object.entries(files)) {
const originFilePath = path.join(varPath, filename); const originFilePath = path.join(varPath, filename);
await fs.writeFile(originFilePath, fileData); await fs.promises.writeFile(originFilePath, fileData);
} }
const appPath = path.join(varPath, 'app'); const appPath = path.join(varPath, 'app');
await fs.mkdirp(appPath); await fs.promises.mkdir(appPath, { recursive: true });
await fs.symlink('../file.txt', path.join(appPath, 'file.txt')); await fs.promises.symlink('../file.txt', path.join(appPath, 'file.txt'));
return { return {
testPath, testPath,
@@ -173,9 +213,52 @@ export const templateApp = async (
}); });
const appPath = path.resolve(appsDir, name); const appPath = path.resolve(appsDir, name);
zip.unzipSync(electronZip, appsDir); zip.unzipSync(electronZip, appsDir);
await fs.rename(path.resolve(appsDir, 'Electron.app'), appPath); await fs.promises.rename(path.resolve(appsDir, 'Electron.app'), appPath);
await fs.remove(path.resolve(appPath, 'Contents', 'Resources', 'default_app.asar')); await fs.promises.rm(path.resolve(appPath, 'Contents', 'Resources', 'default_app.asar'), {
recursive: true,
force: true,
});
await modify(appPath); await modify(appPath);
return appPath; return appPath;
}; };
export const generateNativeApp = async (options: {
appNameWithExtension: string;
arch: string;
createAsar: boolean;
nativeModuleArch?: string;
additionalFiles?: Record<string, string>;
}) => {
const {
appNameWithExtension,
arch,
createAsar,
nativeModuleArch = arch,
additionalFiles,
} = 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,
);
await fs.promises.cp(
path.join(appsDir, `hello-world-${nativeModuleArch}`),
path.join(testPath, 'hello-world'),
{ 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,7 +1,20 @@
{ {
"extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"module": "commonjs",
"target": "es2017",
"lib": [
"es2017"
],
"sourceMap": true,
"strict": true,
"outDir": "entry-asar", "outDir": "entry-asar",
"types": [
"node",
],
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"esModuleInterop": true,
"declaration": false
}, },
"include": [ "include": [
"entry-asar" "entry-asar"

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,23 +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"
"entry-asar"
] ]
} }

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',
},
});

3083
yarn.lock

File diff suppressed because it is too large Load Diff