Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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",
"electron28": "npm:electron@^28.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