Compare commits
27 Commits
v1.4.3
...
b3059564b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3059564b7 | ||
|
|
a7d68c490d | ||
|
|
f7d15b8d34 | ||
|
|
03e27e5a1d | ||
|
|
1c55526cdb | ||
|
|
dfe5236357 | ||
|
|
e9a5812213 | ||
|
|
ed1efe60a0 | ||
|
|
89c99b438a | ||
|
|
e6d2697cbc | ||
|
|
c71eed1a06 | ||
|
|
5f8afd1b05 | ||
|
|
9236bd5853 | ||
|
|
a837738a09 | ||
|
|
44baf3b723 | ||
|
|
0e3b472c9a | ||
|
|
da359016d5 | ||
|
|
5d7bdf3ff3 | ||
|
|
16ce6ffbea | ||
|
|
df604873fa | ||
|
|
57201b124c | ||
|
|
20b1b02c11 | ||
|
|
b6f0c88db4 | ||
|
|
bb304ce10b | ||
|
|
8e2842b4a3 | ||
|
|
02119d5a83 | ||
|
|
1948f1caa9 |
@@ -1,8 +1,8 @@
|
|||||||
version: 2.1
|
version: 2.1
|
||||||
|
|
||||||
orbs:
|
orbs:
|
||||||
cfa: continuousauth/npm@1.0.2
|
cfa: continuousauth/npm@2.1.0
|
||||||
node: electronjs/node@1.4.1
|
node: electronjs/node@2.3.0
|
||||||
|
|
||||||
workflows:
|
workflows:
|
||||||
test_and_release:
|
test_and_release:
|
||||||
@@ -13,6 +13,7 @@ workflows:
|
|||||||
name: test-mac-<< matrix.node-version >>
|
name: test-mac-<< matrix.node-version >>
|
||||||
override-ci-command: yarn install --frozen-lockfile --ignore-engines
|
override-ci-command: yarn install --frozen-lockfile --ignore-engines
|
||||||
test-steps:
|
test-steps:
|
||||||
|
- node/install-rosetta
|
||||||
- run: yarn build
|
- run: yarn build
|
||||||
- run: yarn lint
|
- run: yarn lint
|
||||||
- run: yarn test
|
- run: yarn test
|
||||||
@@ -24,9 +25,6 @@ workflows:
|
|||||||
- 20.5.0
|
- 20.5.0
|
||||||
- 18.17.0
|
- 18.17.0
|
||||||
- 16.20.1
|
- 16.20.1
|
||||||
- 14.21.3
|
|
||||||
- 12.22.12
|
|
||||||
- 10.24.1
|
|
||||||
- cfa/release:
|
- cfa/release:
|
||||||
requires:
|
requires:
|
||||||
- test
|
- test
|
||||||
|
|||||||
6
.github/dependabot.yml
vendored
Normal file
6
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
2
.github/workflows/add-to-project.yml
vendored
2
.github/workflows/add-to-project.yml
vendored
@@ -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@3a81985616963f32fae17d1d1b406c631f3201a1 # v1.1.0
|
uses: dsanders11/project-actions/add-item@eb760c48894b5702398529cbb8f6e98378e315d0 # v1.3.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 }}
|
||||||
|
|||||||
2
.github/workflows/semantic.yml
vendored
2
.github/workflows/semantic.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: semantic-pull-request
|
- name: semantic-pull-request
|
||||||
uses: amannn/action-semantic-pull-request@c3cd5d1ea3580753008872425915e343e351ab54 # v5.2.0
|
uses: amannn/action-semantic-pull-request@cfb60706e18bc85e8aec535e3c577abe8f70378e # v5.5.2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,3 +1,11 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
*.app
|
entry-asar/cjs/*.js*
|
||||||
|
entry-asar/cjs/*.d.ts
|
||||||
|
entry-asar/esm/*.?js*
|
||||||
|
entry-asar/esm/*.d.?ts
|
||||||
|
*.app
|
||||||
|
test/fixtures/apps
|
||||||
|
coverage
|
||||||
|
docs
|
||||||
|
.vscode
|
||||||
@@ -3,5 +3,13 @@
|
|||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"parser": "typescript"
|
"parser": "typescript",
|
||||||
}
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.json", "*.jsonc", "*.json5"],
|
||||||
|
"options": {
|
||||||
|
"parser": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
104
README.md
104
README.md
@@ -5,9 +5,13 @@
|
|||||||
[](https://circleci.com/gh/electron/universal)
|
[](https://circleci.com/gh/electron/universal)
|
||||||
[](https://npm.im/@electron/universal)
|
[](https://npm.im/@electron/universal)
|
||||||
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
This package takes an x64 app and an arm64 app and glues them together into a
|
||||||
|
[Universal macOS binary](https://developer.apple.com/documentation/apple-silicon/building-a-universal-macos-binary).
|
||||||
|
|
||||||
|
Note that parameters need to be **absolute** paths.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { makeUniversalApp } from '@electron/universal';
|
import { makeUniversalApp } from '@electron/universal';
|
||||||
|
|
||||||
@@ -18,20 +22,104 @@ await makeUniversalApp({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Advanced configuration
|
||||||
|
|
||||||
|
The basic usage patterns will work for most apps out of the box. Additional configuration
|
||||||
|
options are available for advanced usecases.
|
||||||
|
|
||||||
|
### Merging ASAR archives to reduce app size
|
||||||
|
|
||||||
|
**Added in [v1.2.0](https://github.com/electron/universal/commit/38ab1c3559e25382957d608e49e624dc72a4409c)**
|
||||||
|
|
||||||
|
If you are using ASAR archives to store your Electron app's JavaScript code, you can use the
|
||||||
|
`mergeASARs` option to merge your x64 and arm64 ASAR files to reduce the bundle size of
|
||||||
|
the output Universal app.
|
||||||
|
|
||||||
|
If some files are present in only the x64 app but not the arm64 version (or vice-versa),
|
||||||
|
you can exclude them from the merging process by specifying a `minimatch` pattern
|
||||||
|
in `singleArchFiles`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { makeUniversalApp } from '@electron/universal';
|
||||||
|
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: 'path/to/App_x64.app',
|
||||||
|
arm64AppPath: 'path/to/App_arm64.app',
|
||||||
|
outAppPath: 'path/to/App_universal.app',
|
||||||
|
mergeASARs: true,
|
||||||
|
singleArchFiles: 'node_modules/some-native-module/lib/binding/Release/**', // if you have files in your asar that are unique to x64 or arm64 apps
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
If `@electron/universal` detects an architecture-unique file that isn't covered by the
|
||||||
|
`singleArchFiles` rule, an error will be thrown.
|
||||||
|
|
||||||
|
### Skip lipo for certain binaries in your Universal app
|
||||||
|
|
||||||
|
**Added in [1.3.0](https://github.com/electron/universal/commit/01dfb8a9636965fe154192b07934670dd42509f3)**
|
||||||
|
|
||||||
|
If your Electron app contains binary resources that are already merged with the
|
||||||
|
`lipo` tool, providing a [`minimatch`] pattern to matching files in the `x64ArchFiles`
|
||||||
|
parameter will prevent `@electron/universal` from attempting to merge them a second time.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { makeUniversalApp } from '@electron/universal';
|
||||||
|
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: 'path/to/App_x64.app',
|
||||||
|
arm64AppPath: 'path/to/App_arm64.app',
|
||||||
|
outAppPath: 'path/to/App_universal.app',
|
||||||
|
mergeASARs: true,
|
||||||
|
x64ArchFiles: '*/electron-helper', // `electron-helper` is a binary merged using `lipo`
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
If `@electron/universal` detects a lipo'd file that isn't covered by the `x64ArchFiles` rule,
|
||||||
|
an error will be thrown.
|
||||||
|
|
||||||
|
### Including already codesigned app bundles into your Universal app
|
||||||
|
|
||||||
|
**Added in [v1.4.0](https://github.com/electron/universal/commit/b02ce7697fd2a3c2c79e1f6ab6bf7052125865cc)**
|
||||||
|
|
||||||
|
By default, the merging process will generate an `ElectronAsarIntegrity` key for
|
||||||
|
any `Info.plist` files in your Electron app.
|
||||||
|
|
||||||
|
If your Electron app bundles another `.app` that is already signed, you need to use
|
||||||
|
the `infoPlistsToIgnore` option to avoid modifying that app's plist.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { makeUniversalApp } from '@electron/universal';
|
||||||
|
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: 'path/to/App_x64.app',
|
||||||
|
arm64AppPath: 'path/to/App_arm64.app',
|
||||||
|
outAppPath: 'path/to/App_universal.app',
|
||||||
|
infoPlistsToIgnore: 'my-internal.app/Contents/Info.plist'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
#### The app is twice as big now, why?
|
#### The app is twice as big now, why?
|
||||||
|
|
||||||
Well, a Universal app isn't anything magical. It is literally the x64 app and
|
A Universal app is just the x64 app and the arm64 app glued together into a single application.
|
||||||
the arm64 app glued together into a single application. It's twice as big
|
It's twice as big because it contains two apps in one.
|
||||||
because it contains two apps in one.
|
|
||||||
|
Merging your ASAR bundles can yield significant app size reductions depending on how large
|
||||||
|
your `app.asar` file is.
|
||||||
|
|
||||||
#### What about native modules?
|
#### What about native modules?
|
||||||
|
|
||||||
The way `@electron/universal` works today means you don't need to worry about
|
Out of the box, you don't need to worry about building universal versions of your
|
||||||
things like building universal versions of your native modules. As long as
|
native modules. As long as your x64 and arm64 apps work in isolation, the Universal
|
||||||
your x64 and arm64 apps work in isolation the Universal app will work as well.
|
app will work as well.
|
||||||
|
|
||||||
|
Note that if you are using `mergeASARs`, you may need to add architecture-specific
|
||||||
|
binary resources to the `singleArchFiles` pattern.
|
||||||
|
See [Merging ASARs usage](#merging-asar-archives-to-reduce-app-size) for an example.
|
||||||
|
|
||||||
#### How do I build my app for Apple silicon in the first place?
|
#### How do I build my app for Apple silicon in the first place?
|
||||||
|
|
||||||
Check out the [Electron Apple silicon blog post](https://www.electronjs.org/blog/apple-silicon)
|
Check out the [Electron Apple silicon blog post](https://www.electronjs.org/blog/apple-silicon).
|
||||||
|
|
||||||
|
[`minimatch`]: https://github.com/isaacs/minimatch?tab=readme-ov-file#features
|
||||||
|
|||||||
19
entry-asar/ambient.d.ts
vendored
Normal file
19
entry-asar/ambient.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
declare namespace NodeJS {
|
||||||
|
interface Process extends EventEmitter {
|
||||||
|
// This is an undocumented private API. It exists.
|
||||||
|
_archPath: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'electron' {
|
||||||
|
const app: Electron.App;
|
||||||
|
|
||||||
|
namespace Electron {
|
||||||
|
interface App {
|
||||||
|
getAppPath: () => string;
|
||||||
|
setAppPath: (p: string) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { app };
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
const { app } = require('electron');
|
import { app } from 'electron';
|
||||||
const path = require('path');
|
import path from 'path';
|
||||||
|
|
||||||
if (process.arch === 'arm64') {
|
if (process.arch === 'arm64') {
|
||||||
setPaths('arm64');
|
setPaths('arm64');
|
||||||
@@ -7,7 +7,7 @@ if (process.arch === 'arm64') {
|
|||||||
setPaths('x64');
|
setPaths('x64');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPaths(platform) {
|
function setPaths(platform: string) {
|
||||||
// This should return the full path, ending in something like
|
// This should return the full path, ending in something like
|
||||||
// Notion.app/Contents/Resources/app.asar
|
// Notion.app/Contents/Resources/app.asar
|
||||||
const appPath = app.getAppPath();
|
const appPath = app.getAppPath();
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
if (process.arch === 'arm64') {
|
if (process.arch === 'arm64') {
|
||||||
setPaths('arm64');
|
setPaths('arm64');
|
||||||
} else {
|
} else {
|
||||||
setPaths('x64');
|
setPaths('x64');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPaths(platform) {
|
function setPaths(platform: string) {
|
||||||
// This should return the full path, ending in something like
|
// This should return the full path, ending in something like
|
||||||
// Notion.app/Contents/Resources/app
|
// Notion.app/Contents/Resources/app
|
||||||
const appPath = app.getAppPath();
|
const appPath = app.getAppPath();
|
||||||
11
entry-asar/cjs/tsconfig.json
Normal file
11
entry-asar/cjs/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": ".",
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
".",
|
||||||
|
"../ambient.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": []
|
||||||
|
}
|
||||||
28
entry-asar/esm/has-asar.mts
Normal file
28
entry-asar/esm/has-asar.mts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
process._archPath = require.resolve(`../${asarFile}`);
|
||||||
|
await import(process._archPath);
|
||||||
|
}
|
||||||
29
entry-asar/esm/no-asar.mts
Normal file
29
entry-asar/esm/no-asar.mts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
process._archPath = require.resolve(`../${appFolder}`);
|
||||||
|
|
||||||
|
await import(process._archPath);
|
||||||
|
}
|
||||||
13
entry-asar/esm/tsconfig.json
Normal file
13
entry-asar/esm/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.esm.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"target":"ESNext",
|
||||||
|
"outDir": ".",
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
".",
|
||||||
|
"../ambient.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": []
|
||||||
|
}
|
||||||
14
jest.config.js
Normal file
14
jest.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {
|
||||||
|
'.': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
tsconfig: 'tsconfig.jest.json'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
globalSetup: './jest.setup.ts'
|
||||||
|
};
|
||||||
73
jest.setup.ts
Normal file
73
jest.setup.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { downloadArtifact } from '@electron/get';
|
||||||
|
import * as zip from 'cross-zip';
|
||||||
|
import * as fs from 'fs-extra';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const asarsDir = path.resolve(__dirname, 'test', 'fixtures', 'asars');
|
||||||
|
const appsDir = path.resolve(__dirname, 'test', 'fixtures', 'apps');
|
||||||
|
|
||||||
|
const templateApp = async (
|
||||||
|
name: string,
|
||||||
|
arch: string,
|
||||||
|
modify: (appPath: string) => Promise<void>,
|
||||||
|
) => {
|
||||||
|
const electronZip = await downloadArtifact({
|
||||||
|
artifactName: 'electron',
|
||||||
|
version: '27.0.0',
|
||||||
|
platform: 'darwin',
|
||||||
|
arch,
|
||||||
|
});
|
||||||
|
const appPath = path.resolve(appsDir, name);
|
||||||
|
zip.unzipSync(electronZip, appsDir);
|
||||||
|
await fs.rename(path.resolve(appsDir, 'Electron.app'), appPath);
|
||||||
|
await fs.remove(path.resolve(appPath, 'Contents', 'Resources', 'default_app.asar'));
|
||||||
|
await modify(appPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async () => {
|
||||||
|
await fs.remove(appsDir);
|
||||||
|
await fs.mkdirp(appsDir);
|
||||||
|
await templateApp('Arm64Asar.app', 'arm64', async (appPath) => {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(asarsDir, 'app.asar'),
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// contains `extra-file.txt`
|
||||||
|
await templateApp('Arm64AsarExtraFile.app', 'arm64', async (appPath) => {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(asarsDir, 'app2.asar'),
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await templateApp('X64Asar.app', 'x64', async (appPath) => {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(asarsDir, 'app.asar'),
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await templateApp('Arm64NoAsar.app', 'arm64', async (appPath) => {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(asarsDir, 'app'),
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// contains `extra-file.txt`
|
||||||
|
await templateApp('Arm64NoAsarExtraFile.app', 'arm64', async (appPath) => {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(asarsDir, 'app2'),
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await templateApp('X64NoAsar.app', 'x64', async (appPath) => {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(asarsDir, 'app'),
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
56
package.json
56
package.json
@@ -15,46 +15,58 @@
|
|||||||
"url": "https://github.com/electron/universal.git"
|
"url": "https://github.com/electron/universal.git"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=16.4"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/*",
|
"dist/*",
|
||||||
"entry-asar/*",
|
"entry-asar/*",
|
||||||
|
"!entry-asar/**/*.{ts,mts}",
|
||||||
|
"!entry-asar/**/tsconfig.json",
|
||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"author": "Samuel Attard",
|
"author": "Samuel Attard",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && tsc -p tsconfig.esm.json",
|
"build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && tsc -p entry-asar/esm/tsconfig.json && tsc -p entry-asar/cjs/tsconfig.json",
|
||||||
"lint": "prettier --check \"{src,entry-asar}/**/*.{js,ts}\"",
|
"build:docs": "npx typedoc",
|
||||||
"prettier:write": "prettier --write \"{src,entry-asar}/**/*.{js,ts}\"",
|
"lint": "prettier --check \"{src,entry-asar,test}/**/*.ts\" \"*.ts\"",
|
||||||
|
"prettier:write": "prettier --write \"{src,entry-asar,test}/**/*.ts\" \"*.ts\"",
|
||||||
"prepublishOnly": "npm run build",
|
"prepublishOnly": "npm run build",
|
||||||
"test": "exit 0",
|
"test": "jest",
|
||||||
"prepare": "husky install"
|
"prepare": "husky install"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@continuous-auth/semantic-release-npm": "^3.0.0",
|
"@electron/get": "^3.0.0",
|
||||||
"@types/debug": "^4.1.5",
|
"@types/cross-zip": "^4.0.1",
|
||||||
"@types/fs-extra": "^9.0.4",
|
"@types/debug": "^4.1.10",
|
||||||
"@types/minimatch": "^3.0.5",
|
"@types/fs-extra": "^11.0.3",
|
||||||
"@types/node": "^14.14.7",
|
"@types/jest": "^29.5.7",
|
||||||
"@types/plist": "^3.0.2",
|
"@types/minimatch": "^5.1.2",
|
||||||
"husky": "^8.0.0",
|
"@types/node": "^20.8.10",
|
||||||
"lint-staged": "^10.5.1",
|
"@types/plist": "^3.0.4",
|
||||||
"prettier": "^2.1.2",
|
"cross-zip": "^4.0.0",
|
||||||
"typescript": "^4.0.5"
|
"husky": "^8.0.3",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"lint-staged": "^15.0.2",
|
||||||
|
"prettier": "^3.0.3",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"typedoc": "~0.25.13",
|
||||||
|
"typescript": "^5.2.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron/asar": "^3.2.1",
|
"@electron/asar": "^3.2.7",
|
||||||
"@malept/cross-spawn-promise": "^1.1.0",
|
"@malept/cross-spawn-promise": "^2.0.0",
|
||||||
"debug": "^4.3.1",
|
"debug": "^4.3.1",
|
||||||
"dir-compare": "^3.0.0",
|
"dir-compare": "^4.2.0",
|
||||||
"fs-extra": "^9.0.1",
|
"fs-extra": "^11.1.1",
|
||||||
"minimatch": "^3.0.4",
|
"minimatch": "^9.0.3",
|
||||||
"plist": "^3.0.4"
|
"plist": "^3.1.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.ts": [
|
"*.ts": [
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"jackspeak": "2.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as asar from '@electron/asar';
|
import asar from '@electron/asar';
|
||||||
import { execFileSync } from 'child_process';
|
import { execFileSync } from 'child_process';
|
||||||
import * as crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import * as fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import * as path from 'path';
|
import path from 'path';
|
||||||
import * as minimatch from 'minimatch';
|
import { minimatch } from 'minimatch';
|
||||||
import * as os from 'os';
|
import os from 'os';
|
||||||
import { d } from './debug';
|
import { d } from './debug';
|
||||||
|
|
||||||
const LIPO = 'lipo';
|
const LIPO = 'lipo';
|
||||||
@@ -25,18 +25,15 @@ export type MergeASARsOptions = {
|
|||||||
// See: https://github.com/apple-opensource-mirror/llvmCore/blob/0c60489d96c87140db9a6a14c6e82b15f5e5d252/include/llvm/Object/MachOFormat.h#L108-L112
|
// See: https://github.com/apple-opensource-mirror/llvmCore/blob/0c60489d96c87140db9a6a14c6e82b15f5e5d252/include/llvm/Object/MachOFormat.h#L108-L112
|
||||||
const MACHO_MAGIC = new Set([
|
const MACHO_MAGIC = new Set([
|
||||||
// 32-bit Mach-O
|
// 32-bit Mach-O
|
||||||
0xfeedface,
|
0xfeedface, 0xcefaedfe,
|
||||||
0xcefaedfe,
|
|
||||||
|
|
||||||
// 64-bit Mach-O
|
// 64-bit Mach-O
|
||||||
0xfeedfacf,
|
0xfeedfacf, 0xcffaedfe,
|
||||||
0xcffaedfe,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const MACHO_UNIVERSAL_MAGIC = new Set([
|
const MACHO_UNIVERSAL_MAGIC = new Set([
|
||||||
// universal
|
// universal
|
||||||
0xcafebabe,
|
0xcafebabe, 0xbebafeca,
|
||||||
0xbebafeca,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const detectAsarMode = async (appPath: string) => {
|
export const detectAsarMode = async (appPath: string) => {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import * as debug from 'debug';
|
import debug from 'debug';
|
||||||
|
|
||||||
export const d = debug('electron-universal');
|
export const d = debug('electron-universal');
|
||||||
|
|||||||
104
src/index.ts
104
src/index.ts
@@ -1,8 +1,7 @@
|
|||||||
import { spawn } from '@malept/cross-spawn-promise';
|
import { spawn } from '@malept/cross-spawn-promise';
|
||||||
import * as asar from '@electron/asar';
|
import * as asar from '@electron/asar';
|
||||||
import * as crypto from 'crypto';
|
|
||||||
import * as fs from 'fs-extra';
|
import * as fs from 'fs-extra';
|
||||||
import * as 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';
|
||||||
@@ -13,39 +12,62 @@ import { AsarMode, detectAsarMode, generateAsarIntegrity, mergeASARs } from './a
|
|||||||
import { sha } from './sha';
|
import { sha } from './sha';
|
||||||
import { d } from './debug';
|
import { d } from './debug';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options to pass into the {@link makeUniversalApp} function.
|
||||||
|
*
|
||||||
|
* Requires absolute paths for input x64 and arm64 apps and an absolute path to the
|
||||||
|
* output universal app.
|
||||||
|
*/
|
||||||
export type MakeUniversalOpts = {
|
export type MakeUniversalOpts = {
|
||||||
/**
|
/**
|
||||||
* Absolute file system path to the x64 version of your application. E.g. /Foo/bar/MyApp_x64.app
|
* Absolute file system path to the x64 version of your application (e.g. `/Foo/bar/MyApp_x64.app`).
|
||||||
*/
|
*/
|
||||||
x64AppPath: string;
|
x64AppPath: string;
|
||||||
/**
|
/**
|
||||||
* Absolute file system path to the arm64 version of your application. E.g. /Foo/bar/MyApp_arm64.app
|
* Absolute file system path to the arm64 version of your application (e.g. `/Foo/bar/MyApp_arm64.app`).
|
||||||
*/
|
*/
|
||||||
arm64AppPath: string;
|
arm64AppPath: string;
|
||||||
/**
|
/**
|
||||||
* Absolute file system path you want the universal app to be written to. E.g. /Foo/var/MyApp_universal.app
|
* Absolute file system path you want the universal app to be written to (e.g. `/Foo/var/MyApp_universal.app`).
|
||||||
*
|
*
|
||||||
* If this file exists it will be overwritten ONLY if "force" is set to true
|
* If this file exists on disk already, it will be overwritten ONLY if {@link MakeUniversalOpts.force} is set to `true`.
|
||||||
*/
|
*/
|
||||||
outAppPath: string;
|
outAppPath: string;
|
||||||
/**
|
/**
|
||||||
* Forcefully overwrite any existing files that are in the way of generating the universal application
|
* Forcefully overwrite any existing files that are in the way of generating the universal application.
|
||||||
|
*
|
||||||
|
* @defaultValue `false`
|
||||||
*/
|
*/
|
||||||
force: boolean;
|
force?: boolean;
|
||||||
/**
|
/**
|
||||||
* Merge x64 and arm64 ASARs into one.
|
* Merge x64 and arm64 ASARs into one.
|
||||||
|
*
|
||||||
|
* @defaultValue `false`
|
||||||
*/
|
*/
|
||||||
mergeASARs?: boolean;
|
mergeASARs?: boolean;
|
||||||
/**
|
/**
|
||||||
* Minimatch pattern of paths that are allowed to be present in one of the ASAR files, but not in the other.
|
* If {@link MakeUniversalOpts.mergeASARs} is enabled, this property provides a
|
||||||
|
* {@link https://github.com/isaacs/minimatch?tab=readme-ov-file#features | minimatch}
|
||||||
|
* pattern of paths that are allowed to be present in one of the ASAR files, but not in the other.
|
||||||
|
*
|
||||||
*/
|
*/
|
||||||
singleArchFiles?: string;
|
singleArchFiles?: string;
|
||||||
/**
|
/**
|
||||||
* Minimatch pattern of binaries that are expected to be the same x64 binary in both of the ASAR files.
|
* A {@link https://github.com/isaacs/minimatch?tab=readme-ov-file#features | minimatch}
|
||||||
|
* pattern of binaries that are expected to be the same x64 binary in both
|
||||||
|
*
|
||||||
|
* Use this if your application contains binaries that have already been merged into a universal file
|
||||||
|
* using the `lipo` tool.
|
||||||
|
*
|
||||||
|
* @see Apple's {@link https://developer.apple.com/documentation/apple-silicon/building-a-universal-macos-binary | Building a universal macOS binary} documentation
|
||||||
|
*
|
||||||
*/
|
*/
|
||||||
x64ArchFiles?: string;
|
x64ArchFiles?: string;
|
||||||
/**
|
/**
|
||||||
* Minimatch pattern of paths that should not receive an injected ElectronAsarIntegrity value
|
* A {@link https://github.com/isaacs/minimatch?tab=readme-ov-file#features | minimatch} pattern of `Info.plist`
|
||||||
|
* paths that should not receive an injected `ElectronAsarIntegrity` value.
|
||||||
|
*
|
||||||
|
* Use this if your application contains another bundle that's already signed.
|
||||||
*/
|
*/
|
||||||
infoPlistsToIgnore?: string;
|
infoPlistsToIgnore?: string;
|
||||||
};
|
};
|
||||||
@@ -134,7 +156,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const knownMergedMachOFiles = new Set();
|
||||||
for (const machOFile of x64Files.filter((f) => f.type === AppFileType.MACHO)) {
|
for (const machOFile of x64Files.filter((f) => f.type === AppFileType.MACHO)) {
|
||||||
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));
|
||||||
@@ -171,6 +193,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
|
|||||||
'-output',
|
'-output',
|
||||||
await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)),
|
await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)),
|
||||||
]);
|
]);
|
||||||
|
knownMergedMachOFiles.add(machOFile.relativePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,8 +209,18 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
|
|||||||
path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'),
|
path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'),
|
||||||
{ compareSize: true, compareContent: true },
|
{ compareSize: true, compareContent: true },
|
||||||
);
|
);
|
||||||
|
const differences = comparison.diffSet!.filter((difference) => difference.state !== 'equal');
|
||||||
|
d(`Found ${differences.length} difference(s) between the x64 and arm64 folders`);
|
||||||
|
const nonMergedDifferences = differences.filter(
|
||||||
|
(difference) =>
|
||||||
|
!difference.name1 ||
|
||||||
|
!knownMergedMachOFiles.has(
|
||||||
|
path.join('Contents', 'Resources', 'app', difference.relativePath, difference.name1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
d(`After discluding MachO files merged with lipo ${nonMergedDifferences.length} remain.`);
|
||||||
|
|
||||||
if (!comparison.same) {
|
if (nonMergedDifferences.length > 0) {
|
||||||
d('x64 and arm64 app folders are different, creating dynamic entry ASAR');
|
d('x64 and arm64 app folders are different, creating dynamic entry ASAR');
|
||||||
await fs.move(
|
await fs.move(
|
||||||
path.resolve(tmpApp, 'Contents', 'Resources', 'app'),
|
path.resolve(tmpApp, 'Contents', 'Resources', 'app'),
|
||||||
@@ -200,14 +233,27 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
|
|||||||
|
|
||||||
const entryAsar = path.resolve(tmpDir, 'entry-asar');
|
const entryAsar = path.resolve(tmpDir, 'entry-asar');
|
||||||
await fs.mkdir(entryAsar);
|
await fs.mkdir(entryAsar);
|
||||||
await fs.copy(
|
|
||||||
path.resolve(__dirname, '..', '..', 'entry-asar', 'no-asar.js'),
|
|
||||||
path.resolve(entryAsar, 'index.js'),
|
|
||||||
);
|
|
||||||
let pj = await fs.readJson(
|
let pj = await fs.readJson(
|
||||||
path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json'),
|
path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json'),
|
||||||
);
|
);
|
||||||
pj.main = 'index.js';
|
|
||||||
|
// Load a shim that redirects to the correct folder for the architecture.
|
||||||
|
// This needs to be a different file depending on if the app entrypoint is CommonJS or ESM.
|
||||||
|
if (pj.type === 'module' || pj.main.endsWith('.mjs')) {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(__dirname, '..', '..', 'entry-asar', 'esm', 'no-asar.mjs'),
|
||||||
|
path.resolve(entryAsar, 'index.mjs'),
|
||||||
|
);
|
||||||
|
pj.main = 'index.mjs';
|
||||||
|
} else {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(__dirname, '..', '..', 'entry-asar', 'cjs', 'no-asar.js'),
|
||||||
|
path.resolve(entryAsar, 'index.js'),
|
||||||
|
);
|
||||||
|
pj.main = 'index.js';
|
||||||
|
}
|
||||||
|
|
||||||
await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj);
|
await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj);
|
||||||
await asar.createPackage(
|
await asar.createPackage(
|
||||||
entryAsar,
|
entryAsar,
|
||||||
@@ -280,10 +326,6 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
|
|||||||
|
|
||||||
const entryAsar = path.resolve(tmpDir, 'entry-asar');
|
const entryAsar = path.resolve(tmpDir, 'entry-asar');
|
||||||
await fs.mkdir(entryAsar);
|
await fs.mkdir(entryAsar);
|
||||||
await fs.copy(
|
|
||||||
path.resolve(__dirname, '..', '..', 'entry-asar', 'has-asar.js'),
|
|
||||||
path.resolve(entryAsar, 'index.js'),
|
|
||||||
);
|
|
||||||
let pj = JSON.parse(
|
let pj = JSON.parse(
|
||||||
(
|
(
|
||||||
await asar.extractFile(
|
await asar.extractFile(
|
||||||
@@ -292,7 +334,23 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
|
|||||||
)
|
)
|
||||||
).toString('utf8'),
|
).toString('utf8'),
|
||||||
);
|
);
|
||||||
pj.main = 'index.js';
|
|
||||||
|
// Load a shim that redirects to the correct `app.asar` for the architecture.
|
||||||
|
// This needs to be a different file depending on if the app entrypoint is CommonJS or ESM.
|
||||||
|
if (pj.type === 'module' || pj.main.endsWith('.mjs')) {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(__dirname, '..', '..', 'entry-asar', 'esm', 'has-asar.mjs'),
|
||||||
|
path.resolve(entryAsar, 'index.mjs'),
|
||||||
|
);
|
||||||
|
pj.main = 'index.mjs';
|
||||||
|
} else {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(__dirname, '..', '..', 'entry-asar', 'cjs', 'has-asar.js'),
|
||||||
|
path.resolve(entryAsar, 'index.js'),
|
||||||
|
);
|
||||||
|
pj.main = 'index.js';
|
||||||
|
}
|
||||||
|
|
||||||
await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj);
|
await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj);
|
||||||
const asarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar');
|
const asarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar');
|
||||||
await asar.createPackage(entryAsar, asarPath);
|
await asar.createPackage(entryAsar, asarPath);
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import * as fs from 'fs-extra';
|
import * as fs from 'fs-extra';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
import { pipeline } from 'stream/promises';
|
||||||
|
|
||||||
import { d } from './debug';
|
import { d } from './debug';
|
||||||
|
|
||||||
export const sha = async (filePath: string) => {
|
export const sha = async (filePath: string) => {
|
||||||
d('hashing', filePath);
|
d('hashing', filePath);
|
||||||
const hash = crypto.createHash('sha256');
|
const hash = crypto.createHash('sha256');
|
||||||
hash.setEncoding('hex');
|
hash.setEncoding('hex');
|
||||||
const fileStream = fs.createReadStream(filePath);
|
await pipeline(fs.createReadStream(filePath), hash);
|
||||||
fileStream.pipe(hash);
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
fileStream.on('end', () => resolve());
|
|
||||||
fileStream.on('error', (err) => reject(err));
|
|
||||||
});
|
|
||||||
return hash.read();
|
return hash.read();
|
||||||
};
|
};
|
||||||
|
|||||||
28
test/asar-utils.spec.ts
Normal file
28
test/asar-utils.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { AsarMode, detectAsarMode, generateAsarIntegrity } from '../src/asar-utils';
|
||||||
|
|
||||||
|
const asarsPath = path.resolve(__dirname, 'fixtures', 'asars');
|
||||||
|
const appsPath = path.resolve(__dirname, 'fixtures', 'apps');
|
||||||
|
|
||||||
|
describe('asar-utils', () => {
|
||||||
|
describe('detectAsarMode', () => {
|
||||||
|
it('should correctly detect an asar enabled app', async () => {
|
||||||
|
expect(await detectAsarMode(path.resolve(appsPath, 'Arm64Asar.app'))).toBe(AsarMode.HAS_ASAR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly detect an app without an asar', async () => {
|
||||||
|
expect(await detectAsarMode(path.resolve(appsPath, 'Arm64NoAsar.app'))).toBe(
|
||||||
|
AsarMode.NO_ASAR,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateAsarIntegrity', () => {
|
||||||
|
it('should deterministically hash an asar header', async () => {
|
||||||
|
expect(generateAsarIntegrity(path.resolve(asarsPath, 'app.asar')).hash).toEqual(
|
||||||
|
'85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
61
test/file-utils.spec.ts
Normal file
61
test/file-utils.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { AppFile, AppFileType, getAllAppFiles } from '../src/file-utils';
|
||||||
|
|
||||||
|
const appsPath = path.resolve(__dirname, 'fixtures', 'apps');
|
||||||
|
|
||||||
|
describe('file-utils', () => {
|
||||||
|
describe('getAllAppFiles', () => {
|
||||||
|
let asarFiles: AppFile[];
|
||||||
|
let noAsarFiles: AppFile[];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
asarFiles = await getAllAppFiles(path.resolve(appsPath, 'Arm64Asar.app'));
|
||||||
|
noAsarFiles = await getAllAppFiles(path.resolve(appsPath, 'Arm64NoAsar.app'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify plist files', async () => {
|
||||||
|
expect(asarFiles.find((f) => f.relativePath === 'Contents/Info.plist')?.type).toBe(
|
||||||
|
AppFileType.INFO_PLIST,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify asar files as app code', async () => {
|
||||||
|
expect(asarFiles.find((f) => f.relativePath === 'Contents/Resources/app.asar')?.type).toBe(
|
||||||
|
AppFileType.APP_CODE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify non-asar code files as plain text', async () => {
|
||||||
|
expect(
|
||||||
|
noAsarFiles.find((f) => f.relativePath === 'Contents/Resources/app/index.js')?.type,
|
||||||
|
).toBe(AppFileType.PLAIN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify the Electron binary as Mach-O', async () => {
|
||||||
|
expect(noAsarFiles.find((f) => f.relativePath === 'Contents/MacOS/Electron')?.type).toBe(
|
||||||
|
AppFileType.MACHO,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify the Electron Framework as Mach-O', async () => {
|
||||||
|
expect(
|
||||||
|
noAsarFiles.find(
|
||||||
|
(f) =>
|
||||||
|
f.relativePath ===
|
||||||
|
'Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework',
|
||||||
|
)?.type,
|
||||||
|
).toBe(AppFileType.MACHO);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify the v8 context snapshot', async () => {
|
||||||
|
expect(
|
||||||
|
noAsarFiles.find(
|
||||||
|
(f) =>
|
||||||
|
f.relativePath ===
|
||||||
|
'Contents/Frameworks/Electron Framework.framework/Versions/A/Resources/v8_context_snapshot.arm64.bin',
|
||||||
|
)?.type,
|
||||||
|
).toBe(AppFileType.SNAPSHOT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
test/fixtures/asars/app.asar
vendored
Normal file
BIN
test/fixtures/asars/app.asar
vendored
Normal file
Binary file not shown.
2
test/fixtures/asars/app/index.js
vendored
Normal file
2
test/fixtures/asars/app/index.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
console.log('I am an app folder', process.arch);
|
||||||
|
process.exit(0);
|
||||||
4
test/fixtures/asars/app/package.json
vendored
Normal file
4
test/fixtures/asars/app/package.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"main": "index.js"
|
||||||
|
}
|
||||||
BIN
test/fixtures/asars/app2.asar
vendored
Normal file
BIN
test/fixtures/asars/app2.asar
vendored
Normal file
Binary file not shown.
1
test/fixtures/asars/app2/extra-file.txt
vendored
Normal file
1
test/fixtures/asars/app2/extra-file.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
erick was here!
|
||||||
2
test/fixtures/asars/app2/index.js
vendored
Normal file
2
test/fixtures/asars/app2/index.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
console.log('I am an app.asar', process.arch);
|
||||||
|
process.exit(0);
|
||||||
4
test/fixtures/asars/app2/package.json
vendored
Normal file
4
test/fixtures/asars/app2/package.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"main": "index.js"
|
||||||
|
}
|
||||||
1
test/fixtures/tohash
vendored
Normal file
1
test/fixtures/tohash
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hello there
|
||||||
161
test/index.spec.ts
Normal file
161
test/index.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { spawn } from '@malept/cross-spawn-promise';
|
||||||
|
import * as fs from 'fs-extra';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { makeUniversalApp } from '../dist/cjs/index';
|
||||||
|
|
||||||
|
const appsPath = path.resolve(__dirname, 'fixtures', 'apps');
|
||||||
|
const appsOutPath = path.resolve(__dirname, 'fixtures', 'apps', 'out');
|
||||||
|
|
||||||
|
async function ensureUniversal(app: string) {
|
||||||
|
const exe = path.resolve(app, 'Contents', 'MacOS', 'Electron');
|
||||||
|
const result = await spawn(exe);
|
||||||
|
expect(result).toContain('arm64');
|
||||||
|
const result2 = await spawn('arch', ['-x86_64', exe]);
|
||||||
|
expect(result2).toContain('x64');
|
||||||
|
}
|
||||||
|
|
||||||
|
// See `jest.setup.ts` for app fixture setup process
|
||||||
|
describe('makeUniversalApp', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.emptyDir(appsOutPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws an error if asar is only detected in one arch', async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'Error.app');
|
||||||
|
await expect(
|
||||||
|
makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64NoAsar.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(
|
||||||
|
'Both the x64 and arm64 versions of your application need to have been built with the same asar settings (enabled vs disabled)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.todo('works for lipo binary resources');
|
||||||
|
|
||||||
|
describe('force', () => {
|
||||||
|
it('throws an error if `out` bundle already exists and `force` is `false`', async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'Error.app');
|
||||||
|
await fs.mkdirp(out);
|
||||||
|
await expect(
|
||||||
|
makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/The out path ".*" already exists and force is not set to true/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('packages successfully if `out` bundle already exists and `force` is `true`', async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'Error.app');
|
||||||
|
await fs.mkdirp(out);
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
await ensureUniversal(out);
|
||||||
|
// Only a single asar as they were identical
|
||||||
|
expect(
|
||||||
|
(await fs.readdir(path.resolve(out, 'Contents', 'Resources'))).filter((p) =>
|
||||||
|
p.endsWith('asar'),
|
||||||
|
),
|
||||||
|
).toEqual(['app.asar']);
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('asar mode', () => {
|
||||||
|
it('should correctly merge two identical asars', async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'MergedAsar.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
});
|
||||||
|
await ensureUniversal(out);
|
||||||
|
// Only a single asar as they were identical
|
||||||
|
expect(
|
||||||
|
(await fs.readdir(path.resolve(out, 'Contents', 'Resources'))).filter((p) =>
|
||||||
|
p.endsWith('asar'),
|
||||||
|
),
|
||||||
|
).toEqual(['app.asar']);
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it('should create a shim if asars are different between architectures', async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'ShimmedAsar.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
});
|
||||||
|
await ensureUniversal(out);
|
||||||
|
// We have three asars including the arch-agnostic shim
|
||||||
|
expect(
|
||||||
|
(await fs.readdir(path.resolve(out, 'Contents', 'Resources')))
|
||||||
|
.filter((p) => p.endsWith('asar'))
|
||||||
|
.sort(),
|
||||||
|
).toEqual(['app.asar', 'app-x64.asar', 'app-arm64.asar'].sort());
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it('should merge two different asars when `mergeASARs` is enabled', async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'MergedAsar.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
mergeASARs: true,
|
||||||
|
singleArchFiles: 'extra-file.txt',
|
||||||
|
});
|
||||||
|
await ensureUniversal(out);
|
||||||
|
// Only a single merged asar
|
||||||
|
expect(
|
||||||
|
(await fs.readdir(path.resolve(out, 'Contents', 'Resources'))).filter((p) =>
|
||||||
|
p.endsWith('asar'),
|
||||||
|
),
|
||||||
|
).toEqual(['app.asar']);
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it('throws an error if `mergeASARs` is enabled and `singleArchFiles` is missing a unique file', async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'Error.app');
|
||||||
|
await expect(
|
||||||
|
makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
mergeASARs: true,
|
||||||
|
singleArchFiles: 'bad-rule',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Detected unique file "extra-file\.txt"/);
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it.todo('should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('no asar mode', () => {
|
||||||
|
it('should correctly merge two identical app folders', async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'MergedNoAsar.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64NoAsar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64NoAsar.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
});
|
||||||
|
await ensureUniversal(out);
|
||||||
|
// Only a single app folder as they were identical
|
||||||
|
expect(
|
||||||
|
(await fs.readdir(path.resolve(out, 'Contents', 'Resources'))).filter((p) =>
|
||||||
|
p.startsWith('app'),
|
||||||
|
),
|
||||||
|
).toEqual(['app']);
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it.todo('should shim two different app folders');
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Add tests for
|
||||||
|
// * different app dirs with different macho files
|
||||||
|
// * identical app dirs with universal macho files
|
||||||
|
});
|
||||||
11
test/sha.spec.ts
Normal file
11
test/sha.spec.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { sha } from '../src/sha';
|
||||||
|
|
||||||
|
describe('sha', () => {
|
||||||
|
it('should correctly hash a file', async () => {
|
||||||
|
expect(await sha(path.resolve(__dirname, 'fixtures', 'tohash'))).toEqual(
|
||||||
|
'12998c017066eb0d2a70b94e6ed3192985855ce390f321bbdb832022888bd251',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
4
tsconfig.cjs.json
Normal file
4
tsconfig.cjs.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -3,5 +3,6 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"outDir": "dist/esm"
|
"outDir": "dist/esm"
|
||||||
}
|
},
|
||||||
}
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|||||||
11
tsconfig.jest.json
Normal file
11
tsconfig.jest.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "esnext",
|
||||||
|
"outDir": "dist/esm",
|
||||||
|
"types": [
|
||||||
|
"jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@
|
|||||||
],
|
],
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
"declaration": true
|
"declaration": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src"
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
6
typedoc.json
Normal file
6
typedoc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://typedoc.org/schema.json",
|
||||||
|
"entryPoints": ["./src/index.ts"],
|
||||||
|
"excludeInternal": true,
|
||||||
|
"sort": ["source-order"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user