5 Commits

Author SHA1 Message Date
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
14 changed files with 593 additions and 36 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 }}

View File

@@ -22,7 +22,7 @@ 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: 20.x
cache: 'yarn' cache: 'yarn'

View File

@@ -26,7 +26,7 @@ jobs:
- 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'

View File

@@ -1,10 +1,34 @@
import { execFileSync } from 'child_process';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import * as path from 'path'; import * as path from 'path';
import { appsDir, asarsDir, templateApp } from './test/util'; import { appsDir, asarsDir, fixtureDir, templateApp } from './test/util';
// 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 () => { export default async () => {
await fs.remove(appsDir); await fs.remove(appsDir);
await fs.mkdirp(appsDir); await fs.mkdirp(appsDir);
// generate mach-o binaries to be leveraged in lipo tests
generateMachO();
await templateApp('Arm64Asar.app', 'arm64', async (appPath) => { await templateApp('Arm64Asar.app', 'arm64', async (appPath) => {
await fs.copy( await fs.copy(
path.resolve(asarsDir, 'app.asar'), path.resolve(asarsDir, 'app.asar'),

View File

@@ -148,14 +148,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;
} }
@@ -223,3 +222,7 @@ export const mergeASARs = async ({
await Promise.all([fs.remove(x64Dir), fs.remove(arm64Dir)]); await Promise.all([fs.remove(x64Dir), fs.remove(arm64Dir)]);
} }
}; };
export const isUniversalMachO = (fileContent: Buffer) => {
return MACHO_UNIVERSAL_MAGIC.has(fileContent.readUInt32LE(0));
};

View File

@@ -1,6 +1,7 @@
import { spawn, ExitCodeError } from '@malept/cross-spawn-promise'; import { spawn, ExitCodeError } from '@malept/cross-spawn-promise';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import * as path from 'path'; import * as path from 'path';
import { promises as stream } from 'node:stream';
const MACHO_PREFIX = 'Mach-O '; const MACHO_PREFIX = 'Mach-O ';
@@ -71,3 +72,14 @@ 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);
};

View File

@@ -1,14 +1,14 @@
import { spawn } from '@malept/cross-spawn-promise';
import * as asar from '@electron/asar'; import * as asar from '@electron/asar';
import { spawn } from '@malept/cross-spawn-promise';
import * as dircompare from 'dir-compare';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import { minimatch } from 'minimatch'; import { minimatch } from 'minimatch';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import * as plist from 'plist'; import * as plist from 'plist';
import * as dircompare from 'dir-compare';
import { AppFile, AppFileType, getAllAppFiles } from './file-utils'; import { AsarMode, detectAsarMode, isUniversalMachO, mergeASARs } from './asar-utils';
import { AsarMode, detectAsarMode, mergeASARs } from './asar-utils'; import { AppFile, AppFileType, getAllAppFiles, readMachOHeader } from './file-utils';
import { sha } from './sha'; import { sha } from './sha';
import { d } from './debug'; import { d } from './debug';
import { computeIntegrityData } from './integrity'; import { computeIntegrityData } from './integrity';
@@ -162,6 +162,15 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
const first = await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)); const first = await fs.realpath(path.resolve(tmpApp, machOFile.relativePath));
const second = await fs.realpath(path.resolve(opts.arm64AppPath, machOFile.relativePath)); const second = await fs.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));
if (x64Sha === arm64Sha) { if (x64Sha === arm64Sha) {

View File

@@ -620,6 +620,222 @@ exports[`makeUniversalApp force packages successfully if \`out\` bundle already
} }
`; `;
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`] = ` exports[`makeUniversalApp no asar mode should correctly merge two identical app folders 1`] = `
[ [
"index.js", "index.js",
@@ -720,3 +936,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,6 +1,7 @@
import * as path from 'path'; import * as path from 'path';
import { AsarMode, detectAsarMode, generateAsarIntegrity } from '../src/asar-utils'; import { AsarMode, detectAsarMode, generateAsarIntegrity } from '../src/asar-utils';
import { describe, expect, it } from '@jest/globals';
const asarsPath = path.resolve(__dirname, 'fixtures', 'asars'); const asarsPath = path.resolve(__dirname, 'fixtures', 'asars');
const appsPath = path.resolve(__dirname, 'fixtures', 'apps'); const appsPath = path.resolve(__dirname, 'fixtures', 'apps');

View File

@@ -1,6 +1,7 @@
import * as path from 'path'; import * as path from 'path';
import { AppFile, AppFileType, getAllAppFiles } from '../src/file-utils'; import { AppFile, AppFileType, getAllAppFiles } from '../src/file-utils';
import { beforeAll, describe, expect, it } from '@jest/globals';
const appsPath = path.resolve(__dirname, 'fixtures', 'apps'); const appsPath = path.resolve(__dirname, 'fixtures', 'apps');

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;
}

View File

@@ -2,8 +2,15 @@ import * as fs from 'fs-extra';
import * as path from 'path'; import * as path from 'path';
import { makeUniversalApp } from '../dist/cjs/index'; import { makeUniversalApp } from '../dist/cjs/index';
import { createTestApp, templateApp, VERIFY_APP_TIMEOUT, verifyApp } from './util'; import {
createStagingAppDir,
generateNativeApp,
templateApp,
VERIFY_APP_TIMEOUT,
verifyApp,
} from './util';
import { createPackage, createPackageWithOptions } from '@electron/asar'; import { createPackage, createPackageWithOptions } from '@electron/asar';
import { afterEach, describe, expect, it } from '@jest/globals';
const appsPath = path.resolve(__dirname, 'fixtures', 'apps'); const appsPath = path.resolve(__dirname, 'fixtures', 'apps');
const appsOutPath = path.resolve(__dirname, 'fixtures', 'apps', 'out'); const appsOutPath = path.resolve(__dirname, 'fixtures', 'apps', 'out');
@@ -27,7 +34,26 @@ describe('makeUniversalApp', () => {
); );
}); });
it.todo('works for lipo binary resources'); it(
'works for lipo binary resources',
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);
},
VERIFY_APP_TIMEOUT,
);
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 () => {
@@ -45,7 +71,7 @@ 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`',
async () => { async () => {
const out = path.resolve(appsOutPath, 'Error.app'); const out = path.resolve(appsOutPath, 'NoError.app');
await fs.mkdirp(out); await fs.mkdirp(out);
await makeUniversalApp({ await makeUniversalApp({
x64AppPath: path.resolve(appsPath, 'X64Asar.app'), x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
@@ -125,7 +151,7 @@ describe('makeUniversalApp', () => {
'should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`', 'should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`',
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 fs.move(
@@ -135,7 +161,7 @@ describe('makeUniversalApp', () => {
}); });
}); });
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 fs.move(
@@ -164,7 +190,7 @@ describe('makeUniversalApp', () => {
'should shim asars with different unpacked dirs', 'should shim asars with different unpacked dirs',
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 +202,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'),
@@ -198,7 +224,7 @@ describe('makeUniversalApp', () => {
it( it(
'should generate AsarIntegrity for all asars in the application', 'should generate AsarIntegrity for all asars in the application',
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);
@@ -254,14 +280,16 @@ describe('makeUniversalApp', () => {
'should shim two different app folders', 'should shim two different app folders',
async () => { async () => {
const arm64AppPath = await templateApp('ShimArm64.app', 'arm64', async (appPath) => { const arm64AppPath = await templateApp('ShimArm64.app', 'arm64', async (appPath) => {
const { testPath } = await createTestApp('shimArm64', { const { testPath } = await createStagingAppDir('shimArm64', {
'i-aint-got-no-rhythm.bin': 'boomshakalaka', 'i-aint-got-no-rhythm.bin': 'boomshakalaka',
}); });
await fs.copy(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app')); await fs.copy(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app'));
}); });
const x64AppPath = await templateApp('ShimX64.app', 'x64', async (appPath) => { const x64AppPath = await templateApp('ShimX64.app', 'x64', async (appPath) => {
const { testPath } = await createTestApp('shimX64', { 'hello-world.bin': 'Hello World' }); const { testPath } = await createStagingAppDir('shimX64', {
'hello-world.bin': 'Hello World',
});
await fs.copy(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app')); await fs.copy(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app'));
}); });
@@ -275,9 +303,117 @@ describe('makeUniversalApp', () => {
}, },
VERIFY_APP_TIMEOUT, VERIFY_APP_TIMEOUT,
); );
});
// TODO: Add tests for it(
// * different app dirs with different macho files 'different app dirs with different macho files (shim and lipo)',
// * identical app dirs with universal macho files 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);
},
VERIFY_APP_TIMEOUT,
);
it(
"different app dirs with universal macho files (shim but don't lipo)",
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);
},
VERIFY_APP_TIMEOUT,
);
it(
'identical app dirs with different macho files (e.g. do not shim, but still lipo)',
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);
},
VERIFY_APP_TIMEOUT,
);
it(
'identical app dirs with universal macho files (e.g., do not shim, just copy x64 dir)',
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,
);
});
}); });

View File

@@ -1,6 +1,7 @@
import * as path from 'path'; import * as path from 'path';
import { sha } from '../src/sha'; import { sha } from '../src/sha';
import { describe, expect, it } from '@jest/globals';
describe('sha', () => { describe('sha', () => {
it('should correctly hash a file', async () => { it('should correctly hash a file', async () => {

View File

@@ -5,18 +5,21 @@ import * as fs from 'fs-extra';
import * as path from 'path'; import * as path from 'path';
import plist from 'plist'; import plist from 'plist';
import * as fileUtils from '../dist/cjs/file-utils'; import * as fileUtils from '../dist/cjs/file-utils';
import { getRawHeader } from '@electron/asar'; import { createPackageWithOptions, getRawHeader } from '@electron/asar';
declare const expect: typeof import('@jest/globals').expect;
// 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(__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) => {
await ensureUniversal(appPath); await ensureUniversal(appPath);
const resourcesDir = path.resolve(appPath, 'Contents', 'Resources'); const resourcesDir = path.resolve(appPath, 'Contents', 'Resources');
@@ -27,7 +30,9 @@ export const verifyApp = async (appPath: string) => {
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,12 +69,14 @@ 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.readFile(infoPlist, 'utf-8'),
@@ -102,9 +109,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 +141,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,7 +156,7 @@ 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> = {},
) => { ) => {
@@ -179,3 +210,42 @@ export const templateApp = async (
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.mkdir(resourcesApp);
}
const { testPath } = await createStagingAppDir(
path.basename(appNameWithExtension, '.app'),
additionalFiles,
);
await fs.copy(
path.join(appsDir, `hello-world-${nativeModuleArch}`),
path.join(testPath, 'hello-world'),
);
if (createAsar) {
await createPackageWithOptions(testPath, path.resolve(resources, 'app.asar'), {
unpack: '**/hello-world',
});
} else {
await fs.copy(testPath, resourcesApp);
}
});
return appPath;
};