Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
matrix:
node-version:
- 22.12.x
runs-on: macos-latest
runs-on: macos-latest-xlarge
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down
15 changes: 1 addition & 14 deletions src/asar-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as asar from '@electron/asar';
import { minimatch } from 'minimatch';

import { d } from './debug.js';
import { MACHO_MAGIC, MACHO_UNIVERSAL_MAGIC } from './file-utils.js';

const LIPO = 'lipo';

Expand All @@ -24,20 +25,6 @@ export type MergeASARsOptions = {
singleArchFiles?: string;
};

// See: https://github.com/apple-opensource-mirror/llvmCore/blob/0c60489d96c87140db9a6a14c6e82b15f5e5d252/include/llvm/Object/MachOFormat.h#L108-L112
const MACHO_MAGIC = new Set([
// 32-bit Mach-O
0xfeedface, 0xcefaedfe,

// 64-bit Mach-O
0xfeedfacf, 0xcffaedfe,
]);

const MACHO_UNIVERSAL_MAGIC = new Set([
// universal
0xcafebabe, 0xbebafeca,
]);

export const detectAsarMode = async (appPath: string) => {
d('checking asar mode of', appPath);
const asarPath = path.resolve(appPath, 'Contents', 'Resources', 'app.asar');
Expand Down
51 changes: 35 additions & 16 deletions src/file-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,38 @@ 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 { minimatch } from 'minimatch';

const MACHO_PREFIX = 'Mach-O ';
// See: https://github.com/apple-opensource-mirror/llvmCore/blob/0c60489d96c87140db9a6a14c6e82b15f5e5d252/include/llvm/Object/MachOFormat.h#L108-L112
export const MACHO_MAGIC = new Set([
// 32-bit Mach-O
0xfeedface, 0xcefaedfe,

// 64-bit Mach-O
0xfeedfacf, 0xcffaedfe,
]);

export const MACHO_UNIVERSAL_MAGIC = new Set([
// universal
0xcafebabe, 0xbebafeca,
]);

// Java .class files share the 0xCAFEBABE magic with Mach-O fat binaries. For
// Mach-O, bytes 4-7 encode nfat_arch (a small integer); for Java they encode
// (minor_version << 16 | major_version) where major_version >= 45. Any value
// below 30 is safely Mach-O.
const FAT_ARCH_DISAMBIGUATION_THRESHOLD = 30;

export const isMachO = (header: Buffer): boolean => {
if (header.length < 4) return false;
const magic = header.readUInt32LE(0);
if (MACHO_MAGIC.has(magic)) return true;
if (MACHO_UNIVERSAL_MAGIC.has(magic)) {
if (header.length < 8) return true;
return header.readUInt32BE(4) < FAT_ARCH_DISAMBIGUATION_THRESHOLD;
}
return false;
};

const UNPACKED_ASAR_PATH = path.join('Contents', 'Resources', 'app.asar.unpacked');

Expand Down Expand Up @@ -52,7 +80,7 @@ export const getAllAppFiles = async (
appPath: string,
opts: GetAllAppFilesOpts,
): Promise<AppFile[]> => {
const unpackedPath = path.join('Contents', 'Resources', 'app.asar.unpacked');
appPath = await fs.promises.realpath(appPath);

const files: AppFile[] = [];

Expand All @@ -69,21 +97,11 @@ export const getAllAppFiles = async (

let fileType = AppFileType.PLAIN;

var fileOutput = '';
try {
fileOutput = await spawn('file', ['--brief', '--no-pad', p]);
} catch (e) {
if (e instanceof ExitCodeError) {
/* silently accept error codes from "file" */
} else {
throw e;
}
}
if (p.endsWith('.asar')) {
fileType = AppFileType.APP_CODE;
} else if (isSingleArchFile(relativePath, opts)) {
fileType = AppFileType.SINGLE_ARCH;
} else if (fileOutput.startsWith(MACHO_PREFIX)) {
} else if (isMachO(await readMachOHeader(p))) {
fileType = AppFileType.MACHO;
} else if (p.endsWith('.bin')) {
fileType = AppFileType.SNAPSHOT;
Expand All @@ -110,8 +128,9 @@ export const getAllAppFiles = async (

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) {
// no need to read the entire file, we only need the first 8 bytes to
// identify the Mach-O magic (and disambiguate fat binaries from Java .class)
await stream.pipeline(fs.createReadStream(path, { start: 0, end: 7 }), async function* (source) {
for await (const chunk of source) {
chunks.push(chunk);
}
Expand Down
12 changes: 10 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,17 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
d('building universal app in', tmpDir);

try {
d('copying x64 app as starter template');
const tmpApp = path.resolve(tmpDir, 'Tmp.app');
await spawn('cp', ['-R', opts.x64AppPath, tmpApp]);
try {
// On APFS (standard on modern macOS), -c does a copy-on-write clone
// that's near-instant even for multi-hundred-MB apps.
d('copying x64 app as starter template via APFS clone (cp -cR)');
await spawn('cp', ['-cR', opts.x64AppPath, tmpApp]);
} catch {
// -c fails on non-APFS volumes; fall back to a regular copy.
d('APFS clone unsupported, falling back to regular cp -R');
await spawn('cp', ['-R', opts.x64AppPath, tmpApp]);
}

const uniqueToX64: string[] = [];
const uniqueToArm64: string[] = [];
Expand Down
98 changes: 53 additions & 45 deletions test/globalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';

import { appsDir, asarsDir, fixtureDir, templateApp } from './util.js';
import { appsDir, asarsDir, downloadElectronZip, fixtureDir, templateApp } from './util.js';

// generates binaries from hello-world.c
// hello-world-universal, hello-world-x86_64, hello-world-arm64
Expand Down Expand Up @@ -30,53 +30,61 @@ export default async () => {
// 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 },
);
});
// @electron/get is not safe for concurrent downloads of the same artifact —
// warm the cache serially before the parallel templateApp calls (here and in
// the concurrent test suite) so they all hit the cached zip.
await downloadElectronZip('arm64');
await downloadElectronZip('x64');

// 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 Promise.all([
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 },
);
}),

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 },
);
});
// contains `extra-file.txt`
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('Arm64NoAsar.app', 'arm64', async (appPath) => {
await fs.promises.cp(
path.resolve(asarsDir, 'app'),
path.resolve(appPath, 'Contents', 'Resources', 'app'),
{ recursive: true, verbatimSymlinks: true },
);
});
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 },
);
}),

// 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 },
);
});
templateApp('Arm64NoAsar.app', 'arm64', async (appPath) => {
await fs.promises.cp(
path.resolve(asarsDir, 'app'),
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 },
);
});
// contains `extra-file.txt`
templateApp('Arm64NoAsarExtraFile.app', 'arm64', async (appPath) => {
await fs.promises.cp(
path.resolve(asarsDir, 'app2'),
path.resolve(appPath, 'Contents', 'Resources', 'app'),
{ recursive: true, verbatimSymlinks: true },
);
}),

templateApp('X64NoAsar.app', 'x64', async (appPath) => {
await fs.promises.cp(
path.resolve(asarsDir, 'app'),
path.resolve(appPath, 'Contents', 'Resources', 'app'),
{ recursive: true, verbatimSymlinks: true },
);
}),
]);
};
Loading