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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ node_modules
dist
entry-asar/*.js*
entry-asar/*.ts
entry-asar/esm/*.mjs*
*.app
test/fixtures/apps
coverage
Expand Down
34 changes: 34 additions & 0 deletions entry-asar/esm/has-asar.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { app } from 'electron';
import { createRequire } from 'node:module';
import path from 'node:path';

const require = createRequire(import.meta.url);

if (process.arch === 'arm64') {
await setPaths('arm64');
} else {
await setPaths('x64');
}

async function setPaths(platform: string) {
// This should return the full path, ending in something like
// Notion.app/Contents/Resources/app.asar
const appPath = app.getAppPath();
const asarFile = `app-${platform}.asar`;

// Maybe we'll handle this in Electron one day
if (path.basename(appPath) === 'app.asar') {
const platformAppPath = path.join(path.dirname(appPath), asarFile);

// This is an undocumented API. It exists.
app.setAppPath(platformAppPath);
}

process._archPath = require.resolve(`../${asarFile}`);
try {
await import(process._archPath);
} catch (err) {
console.error(err);
process.exit(1);
}
}
34 changes: 34 additions & 0 deletions entry-asar/esm/no-asar.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { app } from 'electron';
import { createRequire } from 'node:module';
import path from 'node:path';

const require = createRequire(import.meta.url);

if (process.arch === 'arm64') {
await setPaths('arm64');
} else {
await setPaths('x64');
}

async function setPaths(platform: string) {
// This should return the full path, ending in something like
// Notion.app/Contents/Resources/app
const appPath = app.getAppPath();
const appFolder = `app-${platform}`;

// Maybe we'll handle this in Electron one day
if (path.basename(appPath) === 'app') {
const platformAppPath = path.join(path.dirname(appPath), appFolder);

// This is an undocumented private API. It exists.
app.setAppPath(platformAppPath);
}

process._archPath = require.resolve(`../${appFolder}`);
try {
await import(process._archPath);
} catch (err) {
console.error(err);
process.exit(1);
}
}
19 changes: 19 additions & 0 deletions entry-asar/esm/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2022",
"lib": ["es2022"],
"sourceMap": true,
"strict": true,
"rootDir": ".",
"outDir": ".",
"types": ["node"],
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"declaration": false,
"ignoreDeprecations": "6.0"
},
"include": ["*.mts", "../ambient.d.ts"],
"exclude": []
}
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,18 @@
"files": [
"dist/*",
"entry-asar/*",
"entry-asar/esm/*",
"!entry-asar/**/*.ts",
"!entry-asar/**/*.mts",
"!entry-asar/**/tsconfig.json",
"README.md"
],
"author": "Samuel Attard",
"publishConfig": {
"provenance": true
},
"scripts": {
"build": "tsc -p tsconfig.json && tsc -p tsconfig.entry-asar.json",
"build": "tsc -p tsconfig.json && tsc -p tsconfig.entry-asar.json && tsc -p entry-asar/esm/tsconfig.json",
"build:docs": "typedoc",
"lint": "oxfmt --check . && oxlint",
"lint:fix": "oxfmt --write . && oxlint --fix",
Expand All @@ -44,6 +47,7 @@
"@types/node": "~22.10.7",
"@types/plist": "^3.0.4",
"cross-zip": "^4.0.0",
"electron43": "npm:electron@^43.0.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"oxfmt": "^0.44.0",
Expand Down
39 changes: 39 additions & 0 deletions src/asar-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export enum AsarMode {
HAS_ASAR,
}

export enum EntrypointModule {
ESM = 'esm',
CJS = 'cjs',
}

export type MergeASARsOptions = {
x64AsarPath: string;
arm64AsarPath: string;
Expand All @@ -36,6 +41,40 @@ export const detectAsarMode = async (appPath: string) => {
return AsarMode.HAS_ASAR;
};

/**
* Determine an app entrypoint's module format from its package.json using
* Node's own resolution rules. Anything we cannot positively identify as ESM
* is treated as CJS, so we never ship an ESM shim on a guess.
*/
export const detectEntrypointModule = (packageJson: unknown): EntrypointModule => {
if (typeof packageJson !== 'object' || packageJson === null) {
return EntrypointModule.CJS;
}
const pj = packageJson as { main?: unknown; type?: unknown };
const main = typeof pj.main === 'string' && pj.main.length > 0 ? pj.main : 'index.js';
if (main.endsWith('.mjs')) return EntrypointModule.ESM;
if (main.endsWith('.cjs')) return EntrypointModule.CJS;
// .js / .json / .node / extensionless -> governed by the package "type" field
return pj.type === 'module' ? EntrypointModule.ESM : EntrypointModule.CJS;
};

/**
* Given the detected module format of each architecture's entrypoint, decide
* which shim to ship. Refuses to build a universal app when the two
* architectures disagree, since a single shim cannot satisfy both.
*/
export const resolveShimModule = (
x64: EntrypointModule,
arm64: EntrypointModule,
): EntrypointModule => {
if (x64 !== arm64) {
throw new Error(
`Can't create universal binary: the x64 app entrypoint is ${x64.toUpperCase()} but the arm64 app entrypoint is ${arm64.toUpperCase()}. Both architectures must use the same module system (CommonJS or ESM).`,
);
}
return x64;
};

export const generateAsarIntegrity = (asarPath: string) => {
return {
algorithm: 'SHA256' as const,
Expand Down
72 changes: 61 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@
import * as asar from '@electron/asar';
import plist from 'plist';

import { AsarMode, detectAsarMode, isUniversalMachO, mergeASARs } from './asar-utils.js';
import {
AsarMode,
detectAsarMode,
detectEntrypointModule,
EntrypointModule,
isUniversalMachO,
mergeASARs,
resolveShimModule,
} from './asar-utils.js';
import {
AppFile,
AppFileType,
Expand Down Expand Up @@ -270,17 +278,37 @@

const entryAsar = path.resolve(tmpDir, 'entry-asar');
await fs.promises.mkdir(entryAsar, { recursive: true });
await fs.promises.cp(
path.resolve(import.meta.dirname, '..', 'entry-asar', 'no-asar.js'),
path.resolve(entryAsar, 'index.js'),
);

let pj = JSON.parse(
await fs.promises.readFile(
path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json'),
'utf8',
),
);
pj.main = 'index.js';
const arm64Pj = JSON.parse(
await fs.promises.readFile(
path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app', 'package.json'),
'utf8',
),
);
const shimModule = resolveShimModule(
detectEntrypointModule(pj),
detectEntrypointModule(arm64Pj),
);

if (shimModule === EntrypointModule.ESM) {
await fs.promises.cp(
path.resolve(import.meta.dirname, '..', 'entry-asar', 'esm', 'no-asar.mjs'),
path.resolve(entryAsar, 'index.mjs'),
);
pj.main = 'index.mjs';
} else {
await fs.promises.cp(
path.resolve(import.meta.dirname, '..', 'entry-asar', 'no-asar.js'),
path.resolve(entryAsar, 'index.js'),
);
pj.main = 'index.js';
}
await fs.promises.writeFile(
path.resolve(entryAsar, 'package.json'),
JSON.stringify(pj) + '\n',
Expand Down Expand Up @@ -353,19 +381,41 @@

const entryAsar = path.resolve(tmpDir, 'entry-asar');
await fs.promises.mkdir(entryAsar, { recursive: true });
await fs.promises.cp(
path.resolve(import.meta.dirname, '..', 'entry-asar', 'has-asar.js'),
path.resolve(entryAsar, 'index.js'),
);

let pj = JSON.parse(
(
await asar.extractFile(
path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app.asar'),
'package.json',
)

Check warning on line 390 in src/index.ts

View workflow job for this annotation

GitHub Actions / Test (22.12.x)

typescript-eslint(await-thenable)

Unexpected `await` of a non-Promise (non-"Thenable") value.
).toString('utf8'),
);
pj.main = 'index.js';
const arm64Pj = JSON.parse(
asar
.extractFile(
path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'),
'package.json',
)
.toString('utf8'),
);
const shimModule = resolveShimModule(
detectEntrypointModule(pj),
detectEntrypointModule(arm64Pj),
);

if (shimModule === EntrypointModule.ESM) {
await fs.promises.cp(
path.resolve(import.meta.dirname, '..', 'entry-asar', 'esm', 'has-asar.mjs'),
path.resolve(entryAsar, 'index.mjs'),
);
pj.main = 'index.mjs';
} else {
await fs.promises.cp(
path.resolve(import.meta.dirname, '..', 'entry-asar', 'has-asar.js'),
path.resolve(entryAsar, 'index.js'),
);
pj.main = 'index.js';
}
await fs.promises.writeFile(
path.resolve(entryAsar, 'package.json'),
JSON.stringify(pj) + '\n',
Expand All @@ -385,10 +435,10 @@
const x64PlistPath = path.resolve(opts.x64AppPath, plistFile.relativePath);
const arm64PlistPath = path.resolve(opts.arm64AppPath, plistFile.relativePath);

const { ElectronAsarIntegrity: x64Integrity, ...x64Plist } = plist.parse(

Check warning on line 438 in src/index.ts

View workflow job for this annotation

GitHub Actions / Test (22.12.x)

eslint(no-unused-vars)

Variable 'x64Integrity' is declared but never used. Unused variables should start with a '_'.
await fs.promises.readFile(x64PlistPath, 'utf8'),
) as any;
const { ElectronAsarIntegrity: arm64Integrity, ...arm64Plist } = plist.parse(

Check warning on line 441 in src/index.ts

View workflow job for this annotation

GitHub Actions / Test (22.12.x)

eslint(no-unused-vars)

Variable 'arm64Integrity' is declared but never used. Unused variables should start with a '_'.
await fs.promises.readFile(arm64PlistPath, 'utf8'),
) as any;
if (JSON.stringify(x64Plist) !== JSON.stringify(arm64Plist)) {
Expand Down Expand Up @@ -420,7 +470,7 @@
d('moving final universal app to target destination');
await fs.promises.mkdir(path.dirname(opts.outAppPath), { recursive: true });
await fsMove(tmpApp, opts.outAppPath);
} catch (err) {

Check warning on line 473 in src/index.ts

View workflow job for this annotation

GitHub Actions / Test (22.12.x)

eslint(no-useless-catch)

Unnecessary catch clause
throw err;
} finally {
await fs.promises.rm(tmpDir, { recursive: true, force: true });
Expand Down
85 changes: 85 additions & 0 deletions test/detect-entrypoint.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';

import { detectEntrypointModule, EntrypointModule, resolveShimModule } from '../dist/asar-utils.js';

describe('detectEntrypointModule', () => {
it('detects ESM when main has a .mjs extension', () => {
expect(detectEntrypointModule({ main: 'index.mjs' })).toBe(EntrypointModule.ESM);
});

it('detects ESM for a nested .mjs main', () => {
expect(detectEntrypointModule({ main: 'src/app.mjs' })).toBe(EntrypointModule.ESM);
});

it('detects CJS when main has a .cjs extension', () => {
expect(detectEntrypointModule({ main: 'index.cjs' })).toBe(EntrypointModule.CJS);
});

it('lets the .cjs extension win over a "module" type', () => {
expect(detectEntrypointModule({ main: 'index.cjs', type: 'module' })).toBe(
EntrypointModule.CJS,
);
});

it('lets the .mjs extension win over a "commonjs" type', () => {
expect(detectEntrypointModule({ main: 'index.mjs', type: 'commonjs' })).toBe(
EntrypointModule.ESM,
);
});

it('detects ESM for a .js main when type is "module"', () => {
expect(detectEntrypointModule({ main: 'index.js', type: 'module' })).toBe(EntrypointModule.ESM);
});

it('detects CJS for a .js main when type is "commonjs"', () => {
expect(detectEntrypointModule({ main: 'index.js', type: 'commonjs' })).toBe(
EntrypointModule.CJS,
);
});

it('defaults a .js main with no type to CJS', () => {
expect(detectEntrypointModule({ main: 'index.js' })).toBe(EntrypointModule.CJS);
});

it('detects ESM when type is "module" and there is no main', () => {
expect(detectEntrypointModule({ type: 'module' })).toBe(EntrypointModule.ESM);
});

it('defaults an empty package.json to CJS', () => {
expect(detectEntrypointModule({})).toBe(EntrypointModule.CJS);
});

it('treats null as CJS', () => {
expect(detectEntrypointModule(null)).toBe(EntrypointModule.CJS);
});

it('treats a non-object as CJS', () => {
expect(detectEntrypointModule(42)).toBe(EntrypointModule.CJS);
});
});

describe('resolveShimModule', () => {
it('returns CJS when both arches are CJS', () => {
expect(resolveShimModule(EntrypointModule.CJS, EntrypointModule.CJS)).toBe(
EntrypointModule.CJS,
);
});

it('returns ESM when both arches are ESM', () => {
expect(resolveShimModule(EntrypointModule.ESM, EntrypointModule.ESM)).toBe(
EntrypointModule.ESM,
);
});

it('throws when x64 is ESM but arm64 is CJS', () => {
expect(() => resolveShimModule(EntrypointModule.ESM, EntrypointModule.CJS)).toThrow(
/ESM[\s\S]*CJS/,
);
});

it('throws when x64 is CJS but arm64 is ESM', () => {
expect(() => resolveShimModule(EntrypointModule.CJS, EntrypointModule.ESM)).toThrow(
/CJS[\s\S]*ESM/,
);
});
});
Loading
Loading