fix: fully respect singleArchFiles option (#152)
Some checks failed
Publish documentation / docs (push) Failing after 13s

Files listed under `singleArchFiles` are allowed to be unique for
different platforms so `dupedFiles` should not return them.

Fix: #151
This commit is contained in:
Fedor Indutny
2025-10-29 09:52:59 -07:00
committed by GitHub
parent 2e087ef6c8
commit 0939980564
7 changed files with 221 additions and 22 deletions

View File

@@ -3,15 +3,19 @@ import path from 'node:path';
import { promises as stream } from 'node:stream'; import { promises as stream } from 'node:stream';
import { spawn, ExitCodeError } from '@malept/cross-spawn-promise'; import { spawn, ExitCodeError } from '@malept/cross-spawn-promise';
import { minimatch } from 'minimatch';
const MACHO_PREFIX = 'Mach-O '; const MACHO_PREFIX = 'Mach-O ';
const UNPACKED_ASAR_PATH = path.join('Contents', 'Resources', 'app.asar.unpacked');
export enum AppFileType { export enum AppFileType {
MACHO, MACHO,
PLAIN, PLAIN,
INFO_PLIST, INFO_PLIST,
SNAPSHOT, SNAPSHOT,
APP_CODE, APP_CODE,
SINGLE_ARCH,
} }
export type AppFile = { export type AppFile = {
@@ -19,11 +23,37 @@ export type AppFile = {
type: AppFileType; type: AppFileType;
}; };
export type GetAllAppFilesOpts = {
singleArchFiles?: string;
};
const isSingleArchFile = (relativePath: string, opts: GetAllAppFilesOpts): boolean => {
if (opts.singleArchFiles === undefined) {
return false;
}
const unpackedPath = path.relative(UNPACKED_ASAR_PATH, relativePath);
// Outside of app.asar.unpacked
if (unpackedPath.startsWith('..')) {
return false;
}
return minimatch(unpackedPath, opts.singleArchFiles, {
matchBase: true,
});
};
/** /**
* *
* @param appPath Path to the application * @param appPath Path to the application
*/ */
export const getAllAppFiles = async (appPath: string): Promise<AppFile[]> => { export const getAllAppFiles = async (
appPath: string,
opts: GetAllAppFilesOpts,
): Promise<AppFile[]> => {
const unpackedPath = path.join('Contents', 'Resources', 'app.asar.unpacked');
const files: AppFile[] = []; const files: AppFile[] = [];
const visited = new Set<string>(); const visited = new Set<string>();
@@ -35,6 +65,8 @@ export const getAllAppFiles = async (appPath: string): Promise<AppFile[]> => {
const info = await fs.promises.stat(p); const info = await fs.promises.stat(p);
if (info.isSymbolicLink()) return; if (info.isSymbolicLink()) return;
if (info.isFile()) { if (info.isFile()) {
const relativePath = path.relative(appPath, p);
let fileType = AppFileType.PLAIN; let fileType = AppFileType.PLAIN;
var fileOutput = ''; var fileOutput = '';
@@ -49,6 +81,8 @@ export const getAllAppFiles = async (appPath: string): Promise<AppFile[]> => {
} }
if (p.endsWith('.asar')) { if (p.endsWith('.asar')) {
fileType = AppFileType.APP_CODE; fileType = AppFileType.APP_CODE;
} else if (isSingleArchFile(relativePath, opts)) {
fileType = AppFileType.SINGLE_ARCH;
} else if (fileOutput.startsWith(MACHO_PREFIX)) { } else if (fileOutput.startsWith(MACHO_PREFIX)) {
fileType = AppFileType.MACHO; fileType = AppFileType.MACHO;
} else if (p.endsWith('.bin')) { } else if (p.endsWith('.bin')) {
@@ -58,7 +92,7 @@ export const getAllAppFiles = async (appPath: string): Promise<AppFile[]> => {
} }
files.push({ files.push({
relativePath: path.relative(appPath, p), relativePath,
type: fileType, type: fileType,
}); });
} }

View File

@@ -75,7 +75,12 @@ export type MakeUniversalOpts = {
}; };
const dupedFiles = (files: AppFile[]) => const dupedFiles = (files: AppFile[]) =>
files.filter((f) => f.type !== AppFileType.SNAPSHOT && f.type !== AppFileType.APP_CODE); files.filter(
(f) =>
f.type !== AppFileType.SNAPSHOT &&
f.type !== AppFileType.APP_CODE &&
f.type !== AppFileType.SINGLE_ARCH,
);
export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> => { export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> => {
d('making a universal app with options', opts); d('making a universal app with options', opts);
@@ -121,8 +126,8 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
const uniqueToX64: string[] = []; const uniqueToX64: string[] = [];
const uniqueToArm64: string[] = []; const uniqueToArm64: string[] = [];
const x64Files = await getAllAppFiles(await fs.promises.realpath(tmpApp)); const x64Files = await getAllAppFiles(await fs.promises.realpath(tmpApp), opts);
const arm64Files = await getAllAppFiles(await fs.promises.realpath(opts.arm64AppPath)); const arm64Files = await getAllAppFiles(await fs.promises.realpath(opts.arm64AppPath), opts);
for (const file of dupedFiles(x64Files)) { for (const file of dupedFiles(x64Files)) {
if (!arm64Files.some((f) => f.relativePath === file.relativePath)) if (!arm64Files.some((f) => f.relativePath === file.relativePath))
@@ -143,7 +148,9 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
); );
} }
for (const file of x64Files.filter((f) => f.type === AppFileType.PLAIN)) { // Single Arch files are copied as is without processing.
const multiArchFiles = x64Files.filter((f) => f.type !== AppFileType.SINGLE_ARCH);
for (const file of multiArchFiles.filter((f) => f.type === AppFileType.PLAIN)) {
const x64Sha = await sha(path.resolve(opts.x64AppPath, file.relativePath)); const x64Sha = await sha(path.resolve(opts.x64AppPath, file.relativePath));
const arm64Sha = await sha(path.resolve(opts.arm64AppPath, file.relativePath)); const arm64Sha = await sha(path.resolve(opts.arm64AppPath, file.relativePath));
if (x64Sha !== arm64Sha) { if (x64Sha !== arm64Sha) {
@@ -159,7 +166,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
} }
} }
const knownMergedMachOFiles = new Set(); const knownMergedMachOFiles = new Set();
for (const machOFile of x64Files.filter((f) => f.type === AppFileType.MACHO)) { for (const machOFile of multiArchFiles.filter((f) => f.type === AppFileType.MACHO)) {
const first = await fs.promises.realpath(path.resolve(tmpApp, machOFile.relativePath)); const first = await fs.promises.realpath(path.resolve(tmpApp, machOFile.relativePath));
const second = await fs.promises.realpath( const second = await fs.promises.realpath(
path.resolve(opts.arm64AppPath, machOFile.relativePath), path.resolve(opts.arm64AppPath, machOFile.relativePath),
@@ -355,9 +362,9 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
} }
} }
const generatedIntegrity = await computeIntegrityData(path.join(tmpApp, 'Contents')); const generatedIntegrity = await computeIntegrityData(path.join(tmpApp, 'Contents'), opts);
const plistFiles = x64Files.filter((f) => f.type === AppFileType.INFO_PLIST); const plistFiles = multiArchFiles.filter((f) => f.type === AppFileType.INFO_PLIST);
for (const plistFile of plistFiles) { for (const plistFile of plistFiles) {
const x64PlistPath = path.resolve(opts.x64AppPath, plistFile.relativePath); const x64PlistPath = path.resolve(opts.x64AppPath, plistFile.relativePath);
const arm64PlistPath = path.resolve(opts.arm64AppPath, plistFile.relativePath); const arm64PlistPath = path.resolve(opts.arm64AppPath, plistFile.relativePath);

View File

@@ -17,13 +17,20 @@ export interface AsarIntegrity {
[key: string]: HeaderHash; [key: string]: HeaderHash;
} }
export async function computeIntegrityData(contentsPath: string): Promise<AsarIntegrity> { export type ComputeIntegrityDataOpts = {
singleArchFiles?: string;
};
export async function computeIntegrityData(
contentsPath: string,
opts: ComputeIntegrityDataOpts,
): Promise<AsarIntegrity> {
const root = await fs.promises.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);
const resources = await getAllAppFiles(resourcesPath); const resources = await getAllAppFiles(resourcesPath, opts);
const resourceAsars = resources const resourceAsars = resources
.filter((file) => file.type === AppFileType.APP_CODE) .filter((file) => file.type === AppFileType.APP_CODE)
.reduce<IntegrityMap>( .reduce<IntegrityMap>(

View File

@@ -339,6 +339,86 @@ exports[`makeUniversalApp > asar mode > should merge two different asars when \`
} }
`; `;
exports[`makeUniversalApp > asar mode > should merge two different asars with native files when \`mergeASARs\` is enabled 1`] = `
{
"files": {
"hello-world-arm64": "<stripped>",
"hello-world-x64": "<stripped>",
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8",
],
"hash": "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8",
},
"size": 66,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
"private": {
"files": {
"var": {
"files": {
"app": {
"files": {
"file.txt": {
"link": "private/var/file.txt",
},
},
},
"file.txt": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
],
"hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
},
"size": 11,
},
},
},
},
},
"var": {
"link": "private/var",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should merge two different asars with native files when \`mergeASARs\` is enabled 2`] = `[]`;
exports[`makeUniversalApp > asar mode > should merge two different asars with native files when \`mergeASARs\` is enabled 3`] = `
[
"hello-world-arm64",
"hello-world-x64",
]
`;
exports[`makeUniversalApp > asar mode > should merge two different asars with native files when \`mergeASARs\` is enabled 4`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "<stripped>",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should not inject ElectronAsarIntegrity into \`infoPlistsToIgnore\` 1`] = ` exports[`makeUniversalApp > asar mode > should not inject ElectronAsarIntegrity into \`infoPlistsToIgnore\` 1`] = `
{ {
"files": { "files": {

View File

@@ -12,8 +12,8 @@ describe('file-utils', () => {
let noAsarFiles: AppFile[]; let noAsarFiles: AppFile[];
beforeAll(async () => { beforeAll(async () => {
asarFiles = await getAllAppFiles(path.resolve(appsPath, 'Arm64Asar.app')); asarFiles = await getAllAppFiles(path.resolve(appsPath, 'Arm64Asar.app'), {});
noAsarFiles = await getAllAppFiles(path.resolve(appsPath, 'Arm64NoAsar.app')); noAsarFiles = await getAllAppFiles(path.resolve(appsPath, 'Arm64NoAsar.app'), {});
}); });
it('should correctly identify plist files', async () => { it('should correctly identify plist files', async () => {

View File

@@ -142,6 +142,65 @@ describe('makeUniversalApp', () => {
}, },
); );
it(
'should merge two different asars with native files when `mergeASARs` is enabled',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const x64AppPath = await generateNativeApp({
appNameWithExtension: 'SingleArchFiles-x64.app',
arch: 'x64',
createAsar: true,
singleArchBindings: true,
});
const arm64AppPath = await generateNativeApp({
appNameWithExtension: 'SingleArchFiles-arm64.app',
arch: 'arm64',
createAsar: true,
singleArchBindings: true,
});
const out = path.resolve(appsOutPath, 'SingleArchFiles.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath: out,
mergeASARs: true,
singleArchFiles: 'hello-world-*',
});
await verifyApp(out, true);
},
);
it(
'throws an error if `mergeASARs` is enabled and `singleArchFiles` is missing a unique native file',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const x64AppPath = await generateNativeApp({
appNameWithExtension: 'SingleArchFiles-2-x64.app',
arch: 'x64',
createAsar: true,
singleArchBindings: true,
});
const arm64AppPath = await generateNativeApp({
appNameWithExtension: 'SingleArchFiles-2-arm64.app',
arch: 'arm64',
createAsar: true,
singleArchBindings: true,
});
const out = path.resolve(appsOutPath, 'SingleArchFiles-2.app');
await expect(
makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath: out,
mergeASARs: true,
singleArchFiles: 'bad-rule',
}),
).rejects.toThrow(
/the number of mach-o files is not the same between the arm64 and x64 builds/,
);
},
);
it( it(
'should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`', 'should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`',
{ timeout: VERIFY_APP_TIMEOUT }, { timeout: VERIFY_APP_TIMEOUT },

View File

@@ -33,7 +33,12 @@ export const verifyApp = async (appPath: string, containsRuntimeGeneratedMacho =
// verify header // verify header
const asarFs = getRawHeader(path.resolve(resourcesDir, asar)); const asarFs = getRawHeader(path.resolve(resourcesDir, asar));
expect( expect(
removeUnstableProperties(asarFs.header, containsRuntimeGeneratedMacho ? ['hello-world'] : []), removeUnstableProperties(
asarFs.header,
containsRuntimeGeneratedMacho
? ['hello-world', 'hello-world-arm64', 'hello-world-x64']
: [],
),
).toMatchSnapshot(); ).toMatchSnapshot();
} }
@@ -53,7 +58,7 @@ export const verifyApp = async (appPath: string, containsRuntimeGeneratedMacho =
await verifyFileTree(path.resolve(resourcesDir, dir)); await verifyFileTree(path.resolve(resourcesDir, dir));
} }
const allFiles = await fileUtils.getAllAppFiles(appPath); const allFiles = await fileUtils.getAllAppFiles(appPath, {});
const infoPlists = allFiles const infoPlists = allFiles
.filter( .filter(
(appFile) => (appFile) =>
@@ -89,7 +94,7 @@ const extractAsarIntegrity = async (infoPlist: string) => {
export const verifyFileTree = async (dirPath: string) => { export const verifyFileTree = async (dirPath: string) => {
const { expect } = await import('vitest'); 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);
const name = toSystemIndependentPath(file.relativePath); const name = toSystemIndependentPath(file.relativePath);
@@ -229,6 +234,7 @@ export const generateNativeApp = async (options: {
createAsar: boolean; createAsar: boolean;
nativeModuleArch?: string; nativeModuleArch?: string;
additionalFiles?: Record<string, string>; additionalFiles?: Record<string, string>;
singleArchBindings?: boolean;
}) => { }) => {
const { const {
appNameWithExtension, appNameWithExtension,
@@ -236,6 +242,7 @@ export const generateNativeApp = async (options: {
createAsar, createAsar,
nativeModuleArch = arch, nativeModuleArch = arch,
additionalFiles, additionalFiles,
singleArchBindings,
} = options; } = options;
const appPath = await templateApp(appNameWithExtension, arch, async (appPath) => { const appPath = await templateApp(appNameWithExtension, arch, async (appPath) => {
const resources = path.join(appPath, 'Contents', 'Resources'); const resources = path.join(appPath, 'Contents', 'Resources');
@@ -247,14 +254,19 @@ export const generateNativeApp = async (options: {
path.basename(appNameWithExtension, '.app'), path.basename(appNameWithExtension, '.app'),
additionalFiles, additionalFiles,
); );
await fs.promises.cp( let targetBinding: string;
path.join(appsDir, `hello-world-${nativeModuleArch}`), if (singleArchBindings) {
path.join(testPath, 'hello-world'), targetBinding = path.join(testPath, `hello-world-${nativeModuleArch}`);
{ recursive: true, verbatimSymlinks: true }, } else {
); targetBinding = path.join(testPath, 'hello-world');
}
await fs.promises.cp(path.join(appsDir, `hello-world-${nativeModuleArch}`), targetBinding, {
recursive: true,
verbatimSymlinks: true,
});
if (createAsar) { if (createAsar) {
await createPackageWithOptions(testPath, path.resolve(resources, 'app.asar'), { await createPackageWithOptions(testPath, path.resolve(resources, 'app.asar'), {
unpack: '**/hello-world', unpack: '**/hello-world*',
}); });
} else { } else {
await fs.promises.cp(testPath, resourcesApp, { recursive: true, verbatimSymlinks: true }); await fs.promises.cp(testPath, resourcesApp, { recursive: true, verbatimSymlinks: true });