40 Commits

Author SHA1 Message Date
David Sanders
421713cf80 feat!: bump engines to Node.js >=22.12.0 (#139)
Some checks failed
Publish documentation / docs (push) Failing after 1m9s
BREAKING CHANGE: Requires Node.js v22.12.0 LTS or higher. ESM-only.
2025-07-03 15:30:07 -07:00
David Sanders
175672e430 test: update snapshot to remove skipped test (#138) 2025-05-28 15:40:37 -07:00
David Sanders
1695dc9eac ci: timeout release job after 1 hour (#136) 2025-05-09 16:28:07 -07:00
Mike Maietta
64be29d2f7 fix: Skip lipo if native module is already universal. Add native module fixtures for lipo tests (#126)
* fix: when native modules are already universal, don't lipo. adds `node-mac-permissions` fixture from https://github.com/codebytere/node-mac-permissions and resolves 3 `it.todo` test cases

* add test `different app dirs with different macho files (shim and lipo)`

* add additional test

* PR feedback

* gotta close `fd`

* use `stream` to read first 4 bytes. copy native fixture before packing into asar to leverage `unpack: "**/*.node"` properly.

* convert params to object

* rename `createTestApp` to `createStagingAppDir` and add jsdoc to the function

* compiler error from merge conflict

* update snapshots

* update snapshots

* only check x64Content since it's the tmp app

* compile macho binaries at runtime using hellow-world.c for fixtures in lipo tests

* Update jest.setup.ts

Co-authored-by: Erik Moura <erikian@erikian.dev>

* Update jest.setup.ts

Co-authored-by: Erik Moura <erikian@erikian.dev>

* remove unstable properties for specific keys

* force redo

* update snapshots

* stripping only hello-world from snapshot and only hash from macho-specific asar integrity

* optimize logic :)

---------

Co-authored-by: Erik Moura <erikian@erikian.dev>
2025-05-02 13:10:36 -03:00
dependabot[bot]
ec7c971959 build(deps): bump actions/setup-node from 4.3.0 to 4.4.0 (#134)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.3.0 to 4.4.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](cdca7365b2...49933ea528)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-01 10:07:01 -07:00
dependabot[bot]
5b957e6858 build(deps): bump dsanders11/project-actions from 1.5.1 to 1.7.0 (#132)
Bumps [dsanders11/project-actions](https://github.com/dsanders11/project-actions) from 1.5.1 to 1.7.0.
- [Release notes](https://github.com/dsanders11/project-actions/releases)
- [Changelog](https://github.com/dsanders11/project-actions/blob/main/.releaserc.json)
- [Commits](9c80cd31f5...2134fe7cc7)

---
updated-dependencies:
- dependency-name: dsanders11/project-actions
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 13:27:42 -05:00
dependabot[bot]
4276c7cf38 build(deps): bump actions/setup-node from 4.2.0 to 4.3.0 (#131)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.2.0 to 4.3.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](1d0ff469b7...cdca7365b2)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 11:41:59 -05:00
Erik Moura
977baa4d42 test: add explicit imports for jest functions (#128)
* test: add explicit imports for `jest` functions

* add workaround for non-`.spec.ts` file
2025-03-31 23:17:05 -07:00
Mike Maietta
2b67c905a6 fix: Allow EnableEmbeddedAsarIntegrityValidation when multiple asars are present in app (#124)
- When an application uses multiple asars (`webapp.asar`, `anything.asar`, etc.), `EnableEmbeddedAsarIntegrityValidation` fuse breaks the application due to not all asars having integrity generated for them. Fixes: #116
- **Also fixes bug** to correctly test `makeUniversalApp no asar mode should shim two different app folders`, (it was not having an asar integrity generated for the shimmed asar)

Functionality added:
- Moves all asar integrity generation to **after** all app assets have been merged/shimmed/copied. This allows other asars that were provided to also be scanned and have asar integrity generated for them.
- Extracted common Integrity logic to a single file `integrity.ts`
- Adds unit test for multi-asar apps
2025-02-28 10:03:35 +08:00
Erik Moura
740dd4aab3 chore(deps): bump @electron/asar to 3.3.1 (#127)
* chore(deps): bump `@electron/asar` to `3.3.1`

* update snapshots
2025-02-22 18:35:55 -08:00
Mike Maietta
d90d573ccf test: add test should shim asars with different unpacked dirs (#125) 2025-02-21 16:21:20 -08:00
Mike Maietta
7c0ad6caa5 test: giving steroids to the test suite 💪 (#122)
* purely test suite on steroids

* verify stuff

* more fun verifies

* ok ok ok I'm done

* extend timeout and consolidate to constant for easier usage across tests

* PR feedback :)
Remove warnings by adding transform regex to `ts-jest` and `testMatch`.

* cleanup

* cleanup

* PR feedback & converting `export function` to `export const` in `util.ts`
2025-02-19 15:58:52 +08:00
dependabot[bot]
d76ca76072 build(deps): bump actions/setup-node from 4.1.0 to 4.2.0 (#118) 2025-02-01 16:16:44 +00:00
dependabot[bot]
4bf33415ec build(deps): bump dsanders11/project-actions from 1.4.0 to 1.5.1 (#119)
Bumps [dsanders11/project-actions](https://github.com/dsanders11/project-actions) from 1.4.0 to 1.5.1.
- [Release notes](https://github.com/dsanders11/project-actions/releases)
- [Changelog](https://github.com/dsanders11/project-actions/blob/main/.releaserc.json)
- [Commits](438b25e007...9c80cd31f5)

---
updated-dependencies:
- dependency-name: dsanders11/project-actions
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-01 11:11:32 -05:00
David Sanders
caa0567b76 ci: switch to GHA (#115) 2024-12-03 14:06:00 -08:00
dependabot[bot]
7f59407631 build(deps): bump cross-spawn from 7.0.3 to 7.0.6 (#114)
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-18 18:31:12 -05:00
dependabot[bot]
915c061908 build(deps): bump dsanders11/project-actions from 1.3.0 to 1.4.0 (#112)
Bumps [dsanders11/project-actions](https://github.com/dsanders11/project-actions) from 1.3.0 to 1.4.0.
- [Release notes](https://github.com/dsanders11/project-actions/releases)
- [Changelog](https://github.com/dsanders11/project-actions/blob/main/.releaserc.json)
- [Commits](eb760c4889...438b25e007)

---
updated-dependencies:
- dependency-name: dsanders11/project-actions
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-08 21:22:48 -08:00
electron-roller[bot]
ef4ce1f9ac chore: bump electronjs/node in .circleci/config.yml to 2.3.1 (#111)
Co-authored-by: electron-roller[bot] <84116207+electron-roller[bot]@users.noreply.github.com>
2024-10-26 10:08:58 -04:00
electron-roller[bot]
bf62ed4113 chore: bump continuousauth/npm in .circleci/config.yml to 2.1.1 (#110)
Co-authored-by: electron-roller[bot] <84116207+electron-roller[bot]@users.noreply.github.com>
2024-10-03 09:25:37 -05:00
David Sanders
dd52b47795 build: bump lint-staged to clear audit (#109) 2024-10-02 10:29:07 -07:00
David Sanders
9495fc3840 build: fix repository.url in package.json (#108) 2024-09-07 12:55:50 -07:00
dependabot[bot]
03b841956e build(deps): bump amannn/action-semantic-pull-request (#105)
Bumps [amannn/action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request) from 5.5.2 to 5.5.3.
- [Release notes](https://github.com/amannn/action-semantic-pull-request/releases)
- [Changelog](https://github.com/amannn/action-semantic-pull-request/blob/main/CHANGELOG.md)
- [Commits](cfb60706e1...0723387faa)

---
updated-dependencies:
- dependency-name: amannn/action-semantic-pull-request
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 13:58:02 -07:00
Erick Zhao
a7d68c490d docs: additional API docs (#100) 2024-06-21 14:21:47 -07:00
Erick Zhao
03e27e5a1d test: improve coverage (#102) 2024-06-17 15:15:20 -07:00
dependabot[bot]
dfe5236357 build(deps): bump braces from 3.0.2 to 3.0.3 (#104)
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-16 15:29:38 -03:00
electron-roller[bot]
89c99b438a chore: bump electronjs/node in .circleci/config.yml to 2.3.0 (#99)
Co-authored-by: electron-roller[bot] <84116207+electron-roller[bot]@users.noreply.github.com>
2024-06-12 11:13:31 -03:00
electron-roller[bot]
e6d2697cbc chore: bump electronjs/node in .circleci/config.yml to 2.2.3 (#98)
Co-authored-by: electron-roller[bot] <84116207+electron-roller[bot]@users.noreply.github.com>
2024-05-17 16:39:12 -07:00
electron-roller[bot]
c71eed1a06 chore: bump electronjs/node in .circleci/config.yml to 2.2.2 (#97)
Co-authored-by: electron-roller[bot] <84116207+electron-roller[bot]@users.noreply.github.com>
2024-05-10 09:28:52 -05:00
dependabot[bot]
5f8afd1b05 build(deps): bump amannn/action-semantic-pull-request (#96)
Bumps [amannn/action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request) from 5.4.0 to 5.5.2.
- [Release notes](https://github.com/amannn/action-semantic-pull-request/releases)
- [Changelog](https://github.com/amannn/action-semantic-pull-request/blob/main/CHANGELOG.md)
- [Commits](e9fabac35e...cfb60706e1)

---
updated-dependencies:
- dependency-name: amannn/action-semantic-pull-request
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-01 09:33:52 -07:00
dependabot[bot]
9236bd5853 build(deps): bump dsanders11/project-actions from 1.2.0 to 1.3.0 (#95)
Bumps [dsanders11/project-actions](https://github.com/dsanders11/project-actions) from 1.2.0 to 1.3.0.
- [Release notes](https://github.com/dsanders11/project-actions/releases)
- [Changelog](https://github.com/dsanders11/project-actions/blob/main/.releaserc.json)
- [Commits](82e99438bd...eb760c4889)

---
updated-dependencies:
- dependency-name: dsanders11/project-actions
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-01 13:50:01 -07:00
dependabot[bot]
a837738a09 build(deps): bump amannn/action-semantic-pull-request (#94)
Bumps [amannn/action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request) from 5.2.0 to 5.4.0.
- [Release notes](https://github.com/amannn/action-semantic-pull-request/releases)
- [Changelog](https://github.com/amannn/action-semantic-pull-request/blob/main/CHANGELOG.md)
- [Commits](c3cd5d1ea3...e9fabac35e)

---
updated-dependencies:
- dependency-name: amannn/action-semantic-pull-request
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-13 09:36:36 -07:00
dependabot[bot]
44baf3b723 build(deps): bump dsanders11/project-actions from 1.1.0 to 1.2.0 (#93)
Bumps [dsanders11/project-actions](https://github.com/dsanders11/project-actions) from 1.1.0 to 1.2.0.
- [Release notes](https://github.com/dsanders11/project-actions/releases)
- [Changelog](https://github.com/dsanders11/project-actions/blob/main/.releaserc.json)
- [Commits](3a81985616...82e99438bd)

---
updated-dependencies:
- dependency-name: dsanders11/project-actions
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-13 09:36:06 -07:00
David Sanders
0e3b472c9a chore: use Dependabot to update GitHub Actions deps (#92) 2024-03-13 09:29:12 -07:00
electron-roller[bot]
da359016d5 chore: bump electronjs/node in .circleci/config.yml to 2.2.1 (#91)
Co-authored-by: electron-roller[bot] <84116207+electron-roller[bot]@users.noreply.github.com>
2024-02-14 15:09:33 -08:00
electron-roller[bot]
5d7bdf3ff3 chore: bump electronjs/node in .circleci/config.yml to 2.2.0 (#89)
Co-authored-by: electron-roller[bot] <84116207+electron-roller[bot]@users.noreply.github.com>
2024-01-18 09:39:45 -08:00
electron-roller[bot]
16ce6ffbea chore: bump continuousauth/npm to 2.1.0 (main) (#87)
* chore: bump continuousauth/npm in .circleci/config.yml to 2.1.0

* chore: remove cfa devDependency

---------

Co-authored-by: electron-roller[bot] <84116207+electron-roller[bot]@users.noreply.github.com>
Co-authored-by: George Xu <george.xu99@hotmail.com>
2023-11-21 16:19:19 -08:00
Samuel Attard
df604873fa chore: fix audit output 2023-11-20 17:11:07 -08:00
Samuel Attard
57201b124c chore: fix lint 2023-11-20 17:02:16 -08:00
Jake
20b1b02c11 fix: ignore differences caused by merged machO files (#66)
* Ignore differences caused by merged machO files

* Fix filter indent

* Fix types & Fix error caught by type check
2023-11-20 16:59:14 -08:00
Samuel Attard
b6f0c88db4 feat: bump minimum node version to 16 and add tests (#86)
BREAKING CHANGE: Minimum node version is now 16
2023-11-09 11:08:39 -08:00
41 changed files with 3594 additions and 2554 deletions

View File

@@ -1,37 +0,0 @@
version: 2.1
orbs:
cfa: continuousauth/npm@1.0.2
node: electronjs/node@1.4.1
workflows:
test_and_release:
# Run the test jobs first, then the release only when all the test jobs are successful
jobs:
- node/test:
executor: node/macos
name: test-mac-<< matrix.node-version >>
override-ci-command: yarn install --frozen-lockfile --ignore-engines
test-steps:
- run: yarn build
- run: yarn lint
- run: yarn test
use-test-steps: true
matrix:
alias: test
parameters:
node-version:
- 20.5.0
- 18.17.0
- 16.20.1
- 14.21.3
- 12.22.12
- 10.24.1
- cfa/release:
requires:
- test
filters:
branches:
only:
- main
context: cfa-release

6
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"

View File

@@ -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@2134fe7cc71c58b7ae259c82a8e63c6058255678 # v1.7.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 }}

35
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Publish documentation
on:
push:
tags:
- v[0-9]+.[0-9]+.[0-9]+*
permissions:
id-token: write
contents: read
jobs:
docs:
runs-on: ubuntu-latest
environment: docs-publish
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: '.nvmrc'
- name: Install dependencies
run: yarn --frozen-lockfile
- name: Build API documentation
run: yarn build:docs
- name: Azure login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ secrets.AZURE_OIDC_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_OIDC_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_OIDC_SUBSCRIPTION_ID }}
- name: Upload to Azure Blob Storage
uses: azure/cli@089eac9d8cc39f5d003e94f8b65efc51076c9cbd # v2.1.0
with:
inlineScript: |
az storage blob upload-batch --account-name ${{ secrets.AZURE_ECOSYSTEM_PACKAGES_STORAGE_ACCOUNT_NAME }} -d '$web/${{ github.event.repository.name }}/${{ github.ref_name }}' -s ./docs --overwrite --auth-mode login

36
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Release
on:
push:
branches:
- main
jobs:
test:
uses: ./.github/workflows/test.yml
release:
name: Release
runs-on: ubuntu-latest
needs: test
environment: npm
permissions:
id-token: write # for CFA and npm provenance
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- name: Install
run: yarn install --frozen-lockfile
- uses: continuousauth/action@4e8a2573eeb706f6d7300d6a9f3ca6322740b72d # v1.0.5
timeout-minutes: 60
with:
project-id: ${{ secrets.CFA_PROJECT_ID }}
secret: ${{ secrets.CFA_SECRET }}
npm-token: ${{ secrets.NPM_TOKEN }}

View File

@@ -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@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:

38
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Test
on:
pull_request:
branches:
- main
schedule:
- cron: '0 22 * * 3'
workflow_call:
permissions:
contents: read
jobs:
test:
name: Test
strategy:
fail-fast: false
matrix:
node-version:
- 22.12.x
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "${{ matrix.node-version }}"
cache: 'yarn'
- name: Install
run: yarn install --frozen-lockfile
- name: Build
run: yarn build
- name: Lint
run: yarn lint
- name: Test
run: yarn test

4
.gitignore vendored
View File

@@ -3,3 +3,7 @@ dist
entry-asar/*.js* entry-asar/*.js*
entry-asar/*.ts entry-asar/*.ts
*.app *.app
test/fixtures/apps
coverage
docs
.vscode

View File

@@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
yarn lint-staged yarn lint-staged

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.12

View File

@@ -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"
}
}
]
}

106
README.md
View File

@@ -2,12 +2,16 @@
> Create universal macOS Electron applications > Create universal macOS Electron applications
[![CircleCI](https://circleci.com/gh/electron/universal/tree/main.svg?style=shield)](https://circleci.com/gh/electron/universal) [![Test](https://github.com/electron/universal/actions/workflows/test.yml/badge.svg)](https://github.com/electron/universal/actions/workflows/test.yml)
[![NPM package](https://img.shields.io/npm/v/@electron/universal)](https://npm.im/@electron/universal) [![NPM package](https://img.shields.io/npm/v/@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

View File

@@ -2,8 +2,8 @@
"name": "@electron/universal", "name": "@electron/universal",
"version": "0.0.0-development", "version": "0.0.0-development",
"description": "Utility for creating Universal macOS applications from two x64 and arm64 Electron applications", "description": "Utility for creating Universal macOS applications from two x64 and arm64 Electron applications",
"main": "dist/cjs/index.js", "type": "module",
"module": "dist/esm/index.js", "exports": "./dist/index.js",
"license": "MIT", "license": "MIT",
"keywords": [ "keywords": [
"electron", "electron",
@@ -12,10 +12,10 @@
], ],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/electron/universal.git" "url": "git+https://github.com/electron/universal.git"
}, },
"engines": { "engines": {
"node": ">=8.6" "node": ">=22.12.0"
}, },
"files": [ "files": [
"dist/*", "dist/*",
@@ -24,34 +24,42 @@
"README.md" "README.md"
], ],
"author": "Samuel Attard", "author": "Samuel Attard",
"publishConfig": {
"provenance": true
},
"scripts": { "scripts": {
"build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && tsc -p tsconfig.entry-asar.json", "build": "tsc -p tsconfig.json && tsc -p tsconfig.entry-asar.json",
"lint": "prettier --check \"{src,entry-asar}/**/*.ts\"", "build:docs": "npx typedoc",
"prettier:write": "prettier --write \"{src,entry-asar}/**/*.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", "pretest": "npm run build",
"prepare": "husky install" "test": "vitest run",
"prepare": "husky"
}, },
"devDependencies": { "devDependencies": {
"@continuous-auth/semantic-release-npm": "^3.0.0", "@electron/get": "^4.0.0",
"@types/debug": "^4.1.5", "@tsconfig/node22": "^22.0.1",
"@types/fs-extra": "^9.0.4", "@types/cross-zip": "^4.0.1",
"@types/minimatch": "^3.0.5", "@types/debug": "^4.1.10",
"@types/node": "^14.14.7", "@types/minimatch": "^5.1.2",
"@types/plist": "^3.0.2", "@types/node": "~22.10.7",
"husky": "^8.0.0", "@types/plist": "^3.0.4",
"lint-staged": "^10.5.1", "cross-zip": "^4.0.0",
"prettier": "^2.1.2", "husky": "^9.1.7",
"typescript": "^4.0.5" "lint-staged": "^16.1.0",
"prettier": "^3.5.3",
"typedoc": "~0.25.13",
"typescript": "^5.8.3",
"vitest": "^3.1.3"
}, },
"dependencies": { "dependencies": {
"@electron/asar": "^3.2.1", "@electron/asar": "^4.0.0",
"@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", "minimatch": "^9.0.3",
"minimatch": "^3.0.4", "plist": "^3.1.0"
"plist": "^3.0.4"
}, },
"lint-staged": { "lint-staged": {
"*.ts": [ "*.ts": [

View File

@@ -1,11 +1,13 @@
import asar from '@electron/asar'; import { execFileSync } from 'node:child_process';
import { execFileSync } from 'child_process'; import crypto from 'node:crypto';
import crypto from 'crypto'; import fs from 'node:fs';
import fs from 'fs-extra'; import os from 'node:os';
import path from 'path'; import path from 'node:path';
import minimatch from 'minimatch';
import os from 'os'; import * as asar from '@electron/asar';
import { d } from './debug'; import { minimatch } from 'minimatch';
import { d } from './debug.js';
const LIPO = 'lipo'; const LIPO = 'lipo';
@@ -25,25 +27,22 @@ 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) => {
d('checking asar mode of', appPath); d('checking asar mode of', appPath);
const asarPath = path.resolve(appPath, 'Contents', 'Resources', 'app.asar'); const asarPath = path.resolve(appPath, 'Contents', 'Resources', 'app.asar');
if (!(await fs.pathExists(asarPath))) { if (!fs.existsSync(asarPath)) {
d('determined no asar'); d('determined no asar');
return AsarMode.NO_ASAR; return AsarMode.NO_ASAR;
} }
@@ -87,8 +86,10 @@ export const mergeASARs = async ({
}: MergeASARsOptions): Promise<void> => { }: MergeASARsOptions): Promise<void> => {
d(`merging ${x64AsarPath} and ${arm64AsarPath}`); d(`merging ${x64AsarPath} and ${arm64AsarPath}`);
const x64Files = new Set(asar.listPackage(x64AsarPath).map(toRelativePath)); const x64Files = new Set(asar.listPackage(x64AsarPath, { isPack: false }).map(toRelativePath));
const arm64Files = new Set(asar.listPackage(arm64AsarPath).map(toRelativePath)); const arm64Files = new Set(
asar.listPackage(arm64AsarPath, { isPack: false }).map(toRelativePath),
);
// //
// Build set of unpacked directories and files // Build set of unpacked directories and files
@@ -149,14 +150,13 @@ export const mergeASARs = async ({
const x64Content = asar.extractFile(x64AsarPath, file); const x64Content = asar.extractFile(x64AsarPath, file);
const arm64Content = asar.extractFile(arm64AsarPath, file); const arm64Content = asar.extractFile(arm64AsarPath, file);
// Skip file if the same content
if (x64Content.compare(arm64Content) === 0) { if (x64Content.compare(arm64Content) === 0) {
continue; continue;
} }
if ( // Skip universal Mach-O files.
MACHO_UNIVERSAL_MAGIC.has(x64Content.readUInt32LE(0)) && if (isUniversalMachO(x64Content)) {
MACHO_UNIVERSAL_MAGIC.has(arm64Content.readUInt32LE(0))
) {
continue; continue;
} }
@@ -171,8 +171,8 @@ export const mergeASARs = async ({
// Extract both // Extract both
// //
const x64Dir = await fs.mkdtemp(path.join(os.tmpdir(), 'x64-')); const x64Dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'x64-'));
const arm64Dir = await fs.mkdtemp(path.join(os.tmpdir(), 'arm64-')); const arm64Dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'arm64-'));
try { try {
d(`extracting ${x64AsarPath} to ${x64Dir}`); d(`extracting ${x64AsarPath} to ${x64Dir}`);
@@ -187,18 +187,22 @@ export const mergeASARs = async ({
if (isDirectory(arm64AsarPath, file)) { if (isDirectory(arm64AsarPath, file)) {
d(`creating unique directory: ${file}`); d(`creating unique directory: ${file}`);
await fs.mkdirp(destination); await fs.promises.mkdir(destination, { recursive: true });
continue; continue;
} }
d(`xopying unique file: ${file}`); d(`copying unique file: ${file}`);
await fs.mkdirp(path.dirname(destination)); await fs.promises.mkdir(path.dirname(destination), { recursive: true });
await fs.copy(source, destination); await fs.promises.cp(source, destination, {
force: true,
recursive: true,
verbatimSymlinks: true,
});
} }
for (const binding of commonBindings) { for (const binding of commonBindings) {
const source = await fs.realpath(path.resolve(arm64Dir, binding)); const source = await fs.promises.realpath(path.resolve(arm64Dir, binding));
const destination = await fs.realpath(path.resolve(x64Dir, binding)); const destination = await fs.promises.realpath(path.resolve(x64Dir, binding));
d(`merging binding: ${binding}`); d(`merging binding: ${binding}`);
execFileSync(LIPO, [source, destination, '-create', '-output', destination]); execFileSync(LIPO, [source, destination, '-create', '-output', destination]);
@@ -221,6 +225,13 @@ export const mergeASARs = async ({
d('done merging'); d('done merging');
} finally { } finally {
await Promise.all([fs.remove(x64Dir), fs.remove(arm64Dir)]); await Promise.all([
fs.promises.rm(x64Dir, { recursive: true, force: true }),
fs.promises.rm(arm64Dir, { recursive: true, force: true }),
]);
} }
}; };
export const isUniversalMachO = (fileContent: Buffer) => {
return MACHO_UNIVERSAL_MAGIC.has(fileContent.readUInt32LE(0));
};

View File

@@ -1,6 +1,8 @@
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 { spawn, ExitCodeError } from '@malept/cross-spawn-promise';
import * as fs from 'fs-extra';
import * as path from 'path';
const MACHO_PREFIX = 'Mach-O '; const MACHO_PREFIX = 'Mach-O ';
@@ -26,11 +28,11 @@ export const getAllAppFiles = async (appPath: string): Promise<AppFile[]> => {
const visited = new Set<string>(); const visited = new Set<string>();
const traverse = async (p: string) => { const traverse = async (p: string) => {
p = await fs.realpath(p); p = await fs.promises.realpath(p);
if (visited.has(p)) return; if (visited.has(p)) return;
visited.add(p); visited.add(p);
const info = await fs.stat(p); const info = await fs.promises.stat(p);
if (info.isSymbolicLink()) return; if (info.isSymbolicLink()) return;
if (info.isFile()) { if (info.isFile()) {
let fileType = AppFileType.PLAIN; let fileType = AppFileType.PLAIN;
@@ -45,7 +47,7 @@ export const getAllAppFiles = async (appPath: string): Promise<AppFile[]> => {
throw e; throw e;
} }
} }
if (p.includes('app.asar')) { if (p.endsWith('.asar')) {
fileType = AppFileType.APP_CODE; fileType = AppFileType.APP_CODE;
} else if (fileOutput.startsWith(MACHO_PREFIX)) { } else if (fileOutput.startsWith(MACHO_PREFIX)) {
fileType = AppFileType.MACHO; fileType = AppFileType.MACHO;
@@ -62,7 +64,7 @@ export const getAllAppFiles = async (appPath: string): Promise<AppFile[]> => {
} }
if (info.isDirectory()) { if (info.isDirectory()) {
for (const child of await fs.readdir(p)) { for (const child of await fs.promises.readdir(p)) {
await traverse(path.resolve(p, child)); await traverse(path.resolve(p, child));
} }
} }
@@ -71,3 +73,32 @@ export const getAllAppFiles = async (appPath: string): Promise<AppFile[]> => {
return files; return files;
}; };
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) {
for await (const chunk of source) {
chunks.push(chunk);
}
});
return Buffer.concat(chunks);
};
export const fsMove = async (oldPath: string, newPath: string) => {
try {
await fs.promises.rename(oldPath, newPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
// Cross-device link, fallback to copy and delete
await fs.promises.cp(oldPath, newPath, {
force: true,
recursive: true,
verbatimSymlinks: true,
});
await fs.promises.rm(oldPath, { force: true, recursive: true });
} else {
throw err;
}
}
};

View File

@@ -1,51 +1,75 @@
import { spawn } from '@malept/cross-spawn-promise'; import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import * as asar from '@electron/asar'; import * as asar from '@electron/asar';
import * as crypto from 'crypto'; import { spawn } from '@malept/cross-spawn-promise';
import * as fs from 'fs-extra';
import minimatch from 'minimatch';
import * as os from 'os';
import * as path from 'path';
import * as plist from 'plist';
import * as dircompare from 'dir-compare'; import * as dircompare from 'dir-compare';
import { minimatch } from 'minimatch';
import * as plist from 'plist';
import { AppFile, AppFileType, getAllAppFiles } from './file-utils'; import { AsarMode, detectAsarMode, isUniversalMachO, mergeASARs } from './asar-utils.js';
import { AsarMode, detectAsarMode, generateAsarIntegrity, mergeASARs } from './asar-utils'; import { AppFile, AppFileType, fsMove, getAllAppFiles, readMachOHeader } from './file-utils.js';
import { sha } from './sha'; import { sha } from './sha.js';
import { d } from './debug'; import { d } from './debug.js';
import { computeIntegrityData } from './integrity.js';
/**
* 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;
}; };
@@ -65,7 +89,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
if (!opts.outAppPath || !path.isAbsolute(opts.outAppPath)) if (!opts.outAppPath || !path.isAbsolute(opts.outAppPath))
throw new Error('Expected opts.outAppPath to be an absolute path but it was not'); throw new Error('Expected opts.outAppPath to be an absolute path but it was not');
if (await fs.pathExists(opts.outAppPath)) { if (fs.existsSync(opts.outAppPath)) {
d('output path exists already'); d('output path exists already');
if (!opts.force) { if (!opts.force) {
throw new Error( throw new Error(
@@ -73,7 +97,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
); );
} else { } else {
d('overwriting existing application because force == true'); d('overwriting existing application because force == true');
await fs.remove(opts.outAppPath); await fs.promises.rm(opts.outAppPath, { recursive: true, force: true });
} }
} }
@@ -87,7 +111,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
'Both the x64 and arm64 versions of your application need to have been built with the same asar settings (enabled vs disabled)', 'Both the x64 and arm64 versions of your application need to have been built with the same asar settings (enabled vs disabled)',
); );
const tmpDir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-universal-')); const tmpDir = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'electron-universal-'));
d('building universal app in', tmpDir); d('building universal app in', tmpDir);
try { try {
@@ -97,8 +121,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.realpath(tmpApp)); const x64Files = await getAllAppFiles(await fs.promises.realpath(tmpApp));
const arm64Files = await getAllAppFiles(await fs.realpath(opts.arm64AppPath)); const arm64Files = await getAllAppFiles(await fs.promises.realpath(opts.arm64AppPath));
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))
@@ -134,10 +158,21 @@ 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.promises.realpath(path.resolve(tmpApp, machOFile.relativePath));
const second = await fs.realpath(path.resolve(opts.arm64AppPath, machOFile.relativePath)); const second = await fs.promises.realpath(
path.resolve(opts.arm64AppPath, machOFile.relativePath),
);
if (
isUniversalMachO(await readMachOHeader(first)) &&
isUniversalMachO(await readMachOHeader(second))
) {
d(machOFile.relativePath, `is already universal across builds, skipping lipo`);
knownMergedMachOFiles.add(machOFile.relativePath);
continue;
}
const x64Sha = await sha(path.resolve(opts.x64AppPath, machOFile.relativePath)); const x64Sha = await sha(path.resolve(opts.x64AppPath, machOFile.relativePath));
const arm64Sha = await sha(path.resolve(opts.arm64AppPath, machOFile.relativePath)); const arm64Sha = await sha(path.resolve(opts.arm64AppPath, machOFile.relativePath));
@@ -169,8 +204,9 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
second, second,
'-create', '-create',
'-output', '-output',
await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)), await fs.promises.realpath(path.resolve(tmpApp, machOFile.relativePath)),
]); ]);
knownMergedMachOFiles.add(machOFile.relativePath);
} }
/** /**
@@ -186,29 +222,47 @@ 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 fsMove(
path.resolve(tmpApp, 'Contents', 'Resources', 'app'), path.resolve(tmpApp, 'Contents', 'Resources', 'app'),
path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64'),
); );
await fs.copy( await fs.promises.cp(
path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'),
path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64'),
{ force: true, recursive: true, verbatimSymlinks: true },
); );
const entryAsar = path.resolve(tmpDir, 'entry-asar'); const entryAsar = path.resolve(tmpDir, 'entry-asar');
await fs.mkdir(entryAsar); await fs.promises.mkdir(entryAsar, { recursive: true });
await fs.copy( await fs.promises.cp(
path.resolve(__dirname, '..', '..', 'entry-asar', 'no-asar.js'), path.resolve(import.meta.dirname, '..', 'entry-asar', 'no-asar.js'),
path.resolve(entryAsar, 'index.js'), path.resolve(entryAsar, 'index.js'),
); );
let pj = await fs.readJson( let pj = JSON.parse(
path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json'), await fs.promises.readFile(
path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json'),
'utf8',
),
); );
pj.main = 'index.js'; pj.main = 'index.js';
await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); await fs.promises.writeFile(
path.resolve(entryAsar, 'package.json'),
JSON.stringify(pj) + '\n',
'utf8',
);
await asar.createPackage( await asar.createPackage(
entryAsar, entryAsar,
path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'),
@@ -218,9 +272,6 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
} }
} }
const generatedIntegrity: Record<string, { algorithm: 'SHA256'; hash: string }> = {};
let didSplitAsar = false;
/** /**
* If we have an ASAR we just need to check if the two "app.asar" files have the same hash, * If we have an ASAR we just need to check if the two "app.asar" files have the same hash,
* if they are, same as above, we can leave one there and call it a day. If they're different * if they are, same as above, we can leave one there and call it a day. If they're different
@@ -238,8 +289,6 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
outputAsarPath: output, outputAsarPath: output,
singleArchFiles: opts.singleArchFiles, singleArchFiles: opts.singleArchFiles,
}); });
generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity(output);
} else if (x64AsarMode === AsarMode.HAS_ASAR) { } else if (x64AsarMode === AsarMode.HAS_ASAR) {
d('checking if the x64 and arm64 asars are identical'); d('checking if the x64 and arm64 asars are identical');
const x64AsarSha = await sha(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar')); const x64AsarSha = await sha(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'));
@@ -248,22 +297,22 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
); );
if (x64AsarSha !== arm64AsarSha) { if (x64AsarSha !== arm64AsarSha) {
didSplitAsar = true;
d('x64 and arm64 asars are different'); d('x64 and arm64 asars are different');
const x64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar'); const x64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar');
await fs.move(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), x64AsarPath); await fsMove(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), x64AsarPath);
const x64Unpacked = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar.unpacked'); const x64Unpacked = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar.unpacked');
if (await fs.pathExists(x64Unpacked)) { if (fs.existsSync(x64Unpacked)) {
await fs.move( await fsMove(
x64Unpacked, x64Unpacked,
path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar.unpacked'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar.unpacked'),
); );
} }
const arm64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar'); const arm64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar');
await fs.copy( await fs.promises.cp(
path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'),
arm64AsarPath, arm64AsarPath,
{ force: true, recursive: true, verbatimSymlinks: true },
); );
const arm64Unpacked = path.resolve( const arm64Unpacked = path.resolve(
opts.arm64AppPath, opts.arm64AppPath,
@@ -271,17 +320,18 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
'Resources', 'Resources',
'app.asar.unpacked', 'app.asar.unpacked',
); );
if (await fs.pathExists(arm64Unpacked)) { if (fs.existsSync(arm64Unpacked)) {
await fs.copy( await fs.promises.cp(
arm64Unpacked, arm64Unpacked,
path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar.unpacked'), path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar.unpacked'),
{ force: true, recursive: true, verbatimSymlinks: true },
); );
} }
const entryAsar = path.resolve(tmpDir, 'entry-asar'); const entryAsar = path.resolve(tmpDir, 'entry-asar');
await fs.mkdir(entryAsar); await fs.promises.mkdir(entryAsar, { recursive: true });
await fs.copy( await fs.promises.cp(
path.resolve(__dirname, '..', '..', 'entry-asar', 'has-asar.js'), path.resolve(import.meta.dirname, '..', 'entry-asar', 'has-asar.js'),
path.resolve(entryAsar, 'index.js'), path.resolve(entryAsar, 'index.js'),
); );
let pj = JSON.parse( let pj = JSON.parse(
@@ -293,31 +343,30 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
).toString('utf8'), ).toString('utf8'),
); );
pj.main = 'index.js'; pj.main = 'index.js';
await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); await fs.promises.writeFile(
path.resolve(entryAsar, 'package.json'),
JSON.stringify(pj) + '\n',
'utf8',
);
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);
generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity(asarPath);
generatedIntegrity['Resources/app-x64.asar'] = generateAsarIntegrity(x64AsarPath);
generatedIntegrity['Resources/app-arm64.asar'] = generateAsarIntegrity(arm64AsarPath);
} else { } else {
d('x64 and arm64 asars are the same'); d('x64 and arm64 asars are the same');
generatedIntegrity['Resources/app.asar'] = generateAsarIntegrity(
path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'),
);
} }
} }
const generatedIntegrity = await computeIntegrityData(path.join(tmpApp, 'Contents'));
const plistFiles = x64Files.filter((f) => f.type === AppFileType.INFO_PLIST); const plistFiles = x64Files.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);
const { ElectronAsarIntegrity: x64Integrity, ...x64Plist } = plist.parse( const { ElectronAsarIntegrity: x64Integrity, ...x64Plist } = plist.parse(
await fs.readFile(x64PlistPath, 'utf8'), await fs.promises.readFile(x64PlistPath, 'utf8'),
) as any; ) as any;
const { ElectronAsarIntegrity: arm64Integrity, ...arm64Plist } = plist.parse( const { ElectronAsarIntegrity: arm64Integrity, ...arm64Plist } = plist.parse(
await fs.readFile(arm64PlistPath, 'utf8'), await fs.promises.readFile(arm64PlistPath, 'utf8'),
) as any; ) as any;
if (JSON.stringify(x64Plist) !== JSON.stringify(arm64Plist)) { if (JSON.stringify(x64Plist) !== JSON.stringify(arm64Plist)) {
throw new Error( throw new Error(
@@ -332,23 +381,26 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
? { ...x64Plist, ElectronAsarIntegrity: generatedIntegrity } ? { ...x64Plist, ElectronAsarIntegrity: generatedIntegrity }
: { ...x64Plist }; : { ...x64Plist };
await fs.writeFile(path.resolve(tmpApp, plistFile.relativePath), plist.build(mergedPlist)); await fs.promises.writeFile(
path.resolve(tmpApp, plistFile.relativePath),
plist.build(mergedPlist),
);
} }
for (const snapshotsFile of arm64Files.filter((f) => f.type === AppFileType.SNAPSHOT)) { for (const snapshotsFile of arm64Files.filter((f) => f.type === AppFileType.SNAPSHOT)) {
d('copying snapshot file', snapshotsFile.relativePath, 'to target application'); d('copying snapshot file', snapshotsFile.relativePath, 'to target application');
await fs.copy( await fs.promises.cp(
path.resolve(opts.arm64AppPath, snapshotsFile.relativePath), path.resolve(opts.arm64AppPath, snapshotsFile.relativePath),
path.resolve(tmpApp, snapshotsFile.relativePath), path.resolve(tmpApp, snapshotsFile.relativePath),
); );
} }
d('moving final universal app to target destination'); d('moving final universal app to target destination');
await fs.mkdirp(path.dirname(opts.outAppPath)); await fs.promises.mkdir(path.dirname(opts.outAppPath), { recursive: true });
await spawn('mv', [tmpApp, opts.outAppPath]); await spawn('mv', [tmpApp, opts.outAppPath]);
} catch (err) { } catch (err) {
throw err; throw err;
} finally { } finally {
await fs.remove(tmpDir); await fs.promises.rm(tmpDir, { recursive: true, force: true });
} }
}; };

51
src/integrity.ts Normal file
View File

@@ -0,0 +1,51 @@
import fs from 'node:fs';
import path from 'node:path';
import { AppFileType, getAllAppFiles } from './file-utils.js';
import { generateAsarIntegrity } from './asar-utils.js';
type IntegrityMap = {
[filepath: string]: string;
};
export interface HeaderHash {
algorithm: 'SHA256';
hash: string;
}
export interface AsarIntegrity {
[key: string]: HeaderHash;
}
export async function computeIntegrityData(contentsPath: string): Promise<AsarIntegrity> {
const root = await fs.promises.realpath(contentsPath);
const resourcesRelativePath = 'Resources';
const resourcesPath = path.resolve(root, resourcesRelativePath);
const resources = await getAllAppFiles(resourcesPath);
const resourceAsars = resources
.filter((file) => file.type === AppFileType.APP_CODE)
.reduce<IntegrityMap>(
(prev, file) => ({
...prev,
[path.join(resourcesRelativePath, file.relativePath)]: path.join(
resourcesPath,
file.relativePath,
),
}),
{},
);
// sort to produce constant result
const allAsars = Object.entries(resourceAsars).sort(([name1], [name2]) =>
name1.localeCompare(name2),
);
const hashes = await Promise.all(allAsars.map(async ([, from]) => generateAsarIntegrity(from)));
const asarIntegrity: AsarIntegrity = {};
for (let i = 0; i < allAsars.length; i++) {
const [asar] = allAsars[i];
asarIntegrity[asar] = hashes[i];
}
return asarIntegrity;
}

View File

@@ -1,16 +1,13 @@
import * as fs from 'fs-extra'; import fs from 'node:fs';
import * as crypto from 'crypto'; import crypto from 'node:crypto';
import { d } from './debug'; import { pipeline } from 'node:stream/promises';
import { d } from './debug.js';
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();
}; };

View File

@@ -0,0 +1,840 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`makeUniversalApp > asar mode > should correctly merge two identical asars 1`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
],
"hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
},
"size": 64,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
},
}
`;
exports[`makeUniversalApp > asar mode > should correctly merge two identical asars 2`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should create a shim if asars are different between architectures 1`] = `
{
"files": {
"extra-file.txt": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de",
],
"hash": "b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de",
},
"size": 15,
},
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
],
"hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
},
"size": 64,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
},
}
`;
exports[`makeUniversalApp > asar mode > should create a shim if asars are different between architectures 2`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
],
"hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
},
"size": 64,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
},
}
`;
exports[`makeUniversalApp > asar mode > should create a shim if asars are different between architectures 3`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"b7e5f58d3c0fddc1a57d1279a7f19a34a01784f4036920d4b60a1e33f6d1635b",
],
"hash": "b7e5f58d3c0fddc1a57d1279a7f19a34a01784f4036920d4b60a1e33f6d1635b",
},
"size": 1068,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
],
"hash": "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
},
"size": 33,
},
},
}
`;
exports[`makeUniversalApp > asar mode > should create a shim if asars are different between architectures 4`] = `
{
"Contents/Info.plist": {
"Resources/app-arm64.asar": {
"algorithm": "SHA256",
"hash": "71db54541357128943df64d54480a22d0cdf4c283f2044f48101fb1fc6e6fb2d",
},
"Resources/app-x64.asar": {
"algorithm": "SHA256",
"hash": "85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf",
},
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "b62aaaed07ff72dc33da1720d900e0443c060285ef374ce1bdaef1d4f28b5fe4",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should generate AsarIntegrity for all asars in the application 1`] = `
{
"files": {
"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 generate AsarIntegrity for all asars in the application 2`] = `
{
"files": {
"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 generate AsarIntegrity for all asars in the application 3`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "7e6af4d00f4cc737eff922e2b386128a269f80887b79a011022f1276bdbe7832",
},
"Resources/webbapp.asar": {
"algorithm": "SHA256",
"hash": "7e6af4d00f4cc737eff922e2b386128a269f80887b79a011022f1276bdbe7832",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should merge two different asars when \`mergeASARs\` is enabled 1`] = `
{
"files": {
"extra-file.txt": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de",
],
"hash": "b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de",
},
"size": 15,
},
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
],
"hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
},
"size": 64,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
},
}
`;
exports[`makeUniversalApp > asar mode > should merge two different asars when \`mergeASARs\` is enabled 2`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "71db54541357128943df64d54480a22d0cdf4c283f2044f48101fb1fc6e6fb2d",
},
},
}
`;
exports[`makeUniversalApp > asar mode > should not inject ElectronAsarIntegrity into \`infoPlistsToIgnore\` 1`] = `
{
"files": {
"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 not inject ElectronAsarIntegrity into \`infoPlistsToIgnore\` 2`] = `
{
"Contents/Info.plist": undefined,
"Contents/Resources/SubApp-1.app/Contents/Info.plist": undefined,
}
`;
exports[`makeUniversalApp > force > packages successfully if \`out\` bundle already exists and \`force\` is \`true\` 1`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
],
"hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026",
},
"size": 64,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
],
"hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3",
},
"size": 41,
},
},
}
`;
exports[`makeUniversalApp > force > packages successfully if \`out\` bundle already exists and \`force\` is \`true\` 2`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf",
},
},
}
`;
exports[`makeUniversalApp > no asar mode > different app dirs with different macho files (shim and lipo) 1`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e",
],
"hash": "f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e",
},
"size": 1063,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
],
"hash": "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
},
"size": 33,
},
},
}
`;
exports[`makeUniversalApp > no asar mode > different app dirs with different macho files (shim and lipo) 2`] = `
[
"private/var/i-aint-got-no-rhythm.bin",
]
`;
exports[`makeUniversalApp > no asar mode > different app dirs with different macho files (shim and lipo) 3`] = `
[
"hello-world",
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
"private/var/i-aint-got-no-rhythm.bin",
]
`;
exports[`makeUniversalApp > no asar mode > different app dirs with different macho files (shim and lipo) 4`] = `
[
"hello-world",
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
"private/var/hello-world.bin",
]
`;
exports[`makeUniversalApp > no asar mode > different app dirs with different macho files (shim and lipo) 5`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "<stripped>",
},
},
}
`;
exports[`makeUniversalApp > no asar mode > different app dirs with universal macho files (shim but don't lipo) 1`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e",
],
"hash": "f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e",
},
"size": 1063,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
],
"hash": "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
},
"size": 33,
},
},
}
`;
exports[`makeUniversalApp > no asar mode > different app dirs with universal macho files (shim but don't lipo) 2`] = `
[
"private/var/i-aint-got-no-rhythm.bin",
]
`;
exports[`makeUniversalApp > no asar mode > different app dirs with universal macho files (shim but don't lipo) 3`] = `
[
"hello-world",
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
"private/var/i-aint-got-no-rhythm.bin",
]
`;
exports[`makeUniversalApp > no asar mode > different app dirs with universal macho files (shim but don't lipo) 4`] = `
[
"hello-world",
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
"private/var/hello-world.bin",
]
`;
exports[`makeUniversalApp > no asar mode > different app dirs with universal macho files (shim but don't lipo) 5`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "<stripped>",
},
},
}
`;
exports[`makeUniversalApp > no asar mode > identical app dirs with different macho files (e.g. do not shim, but still lipo) 1`] = `
[
"hello-world",
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
]
`;
exports[`makeUniversalApp > no asar mode > identical app dirs with different macho files (e.g. do not shim, but still lipo) 2`] = `
{
"Contents/Info.plist": {},
}
`;
exports[`makeUniversalApp > no asar mode > identical app dirs with universal macho files (e.g., do not shim, just copy x64 dir) 1`] = `
[
"hello-world",
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
]
`;
exports[`makeUniversalApp > no asar mode > identical app dirs with universal macho files (e.g., do not shim, just copy x64 dir) 2`] = `
{
"Contents/Info.plist": {},
}
`;
exports[`makeUniversalApp > no asar mode > should correctly merge two identical app folders 1`] = `
[
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
]
`;
exports[`makeUniversalApp > no asar mode > should correctly merge two identical app folders 2`] = `
{
"Contents/Info.plist": {},
}
`;
exports[`makeUniversalApp > no asar mode > should shim two different app folders 1`] = `
{
"files": {
"index.js": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e",
],
"hash": "f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e",
},
"size": 1063,
},
"package.json": {
"integrity": {
"algorithm": "SHA256",
"blockSize": 4194304,
"blocks": [
"2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
],
"hash": "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7",
},
"size": 33,
},
},
}
`;
exports[`makeUniversalApp > no asar mode > should shim two different app folders 2`] = `
[
"private/var/i-aint-got-no-rhythm.bin",
]
`;
exports[`makeUniversalApp > no asar mode > should shim two different app folders 3`] = `
[
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
"private/var/i-aint-got-no-rhythm.bin",
]
`;
exports[`makeUniversalApp > no asar mode > should shim two different app folders 4`] = `
[
"index.js",
{
"content": "{
"name": "app",
"main": "index.js"
}",
"name": "package.json",
},
{
"content": "hello world",
"name": "private/var/file.txt",
},
"private/var/hello-world.bin",
]
`;
exports[`makeUniversalApp > no asar mode > should shim two different app folders 5`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "27433ee3e34b3b0dabb29d18d40646126e80c56dbce8c4bb2adef7278b5a46c0",
},
},
}
`;
exports[`makeUniversalApp > works for lipo binary resources 1`] = `
{
"files": {
"hello-world": "<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 > works for lipo binary resources 2`] = `[]`;
exports[`makeUniversalApp > works for lipo binary resources 3`] = `
[
"hello-world",
]
`;
exports[`makeUniversalApp > works for lipo binary resources 4`] = `
{
"Contents/Info.plist": {
"Resources/app.asar": {
"algorithm": "SHA256",
"hash": "<stripped>",
},
},
}
`;

30
test/asar-utils.spec.ts Normal file
View File

@@ -0,0 +1,30 @@
import * as path from 'node:path';
import { describe, expect, it } from 'vitest';
import { AsarMode, detectAsarMode, generateAsarIntegrity } from '../src/asar-utils.js';
const asarsPath = path.resolve(import.meta.dirname, 'fixtures', 'asars');
const appsPath = path.resolve(import.meta.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',
);
});
});
});

63
test/file-utils.spec.ts Normal file
View File

@@ -0,0 +1,63 @@
import * as path from 'node:path';
import { beforeAll, describe, expect, it } from 'vitest';
import { AppFile, AppFileType, getAllAppFiles } from '../src/file-utils.js';
const appsPath = path.resolve(import.meta.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

Binary file not shown.

2
test/fixtures/asars/app/index.js vendored Normal file
View 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
View File

@@ -0,0 +1,4 @@
{
"name": "app",
"main": "index.js"
}

BIN
test/fixtures/asars/app2.asar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
erick was here!

2
test/fixtures/asars/app2/index.js vendored Normal file
View 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
View File

@@ -0,0 +1,4 @@
{
"name": "app",
"main": "index.js"
}

6
test/fixtures/hello-world.c vendored Normal file
View File

@@ -0,0 +1,6 @@
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}

1
test/fixtures/tohash vendored Normal file
View File

@@ -0,0 +1 @@
hello there

82
test/globalSetup.ts Normal file
View File

@@ -0,0 +1,82 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { appsDir, asarsDir, fixtureDir, templateApp } from './util.js';
// generates binaries from hello-world.c
// hello-world-universal, hello-world-x86_64, hello-world-arm64
const generateMachO = () => {
const src = path.resolve(fixtureDir, 'hello-world.c');
const outputFiles = ['x86_64', 'arm64'].map((arch) => {
const machO = path.resolve(appsDir, `hello-world-${arch === 'x86_64' ? 'x64' : arch}`);
execFileSync('clang', ['-arch', arch, '-o', machO, src]);
return machO;
});
execFileSync('lipo', [
...outputFiles,
'-create',
'-output',
path.resolve(appsDir, 'hello-world-universal'),
]);
};
export default async () => {
await fs.promises.rm(appsDir, { recursive: true, force: true });
await fs.promises.mkdir(appsDir, { recursive: true });
// 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 },
);
});
// 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 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 },
);
});
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 },
);
});
// 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 },
);
});
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 },
);
});
};

416
test/index.spec.ts Normal file
View File

@@ -0,0 +1,416 @@
import fs from 'node:fs';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { makeUniversalApp } from '../dist/index.js';
import { fsMove } from '../src/file-utils.js';
import {
createStagingAppDir,
generateNativeApp,
templateApp,
VERIFY_APP_TIMEOUT,
verifyApp,
} from './util.js';
import { createPackage, createPackageWithOptions } from '@electron/asar';
const appsPath = path.resolve(import.meta.dirname, 'fixtures', 'apps');
const appsOutPath = path.resolve(import.meta.dirname, 'fixtures', 'apps', 'out');
// See `globalSetup.ts` for app fixture setup process
describe('makeUniversalApp', () => {
afterEach(async () => {
await fs.promises.rm(appsOutPath, { force: true, recursive: true });
await fs.promises.mkdir(appsOutPath, { recursive: true });
});
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('works for lipo binary resources', { timeout: VERIFY_APP_TIMEOUT }, async () => {
const x64AppPath = await generateNativeApp({
appNameWithExtension: 'LipoX64.app',
arch: 'x64',
createAsar: true,
});
const arm64AppPath = await generateNativeApp({
appNameWithExtension: 'LipoArm64.app',
arch: 'arm64',
createAsar: true,
});
const out = path.resolve(appsOutPath, 'Lipo.app');
await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath: out, mergeASARs: true });
await verifyApp(out, true);
});
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.promises.mkdir(out, { recursive: true });
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`',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const out = path.resolve(appsOutPath, 'NoError.app');
await fs.promises.mkdir(out, { recursive: true });
await makeUniversalApp({
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'),
outAppPath: out,
force: true,
});
await verifyApp(out);
},
);
});
describe('asar mode', () => {
it('should correctly merge two identical asars', { timeout: VERIFY_APP_TIMEOUT }, 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 verifyApp(out);
});
it(
'should create a shim if asars are different between architectures',
{ timeout: VERIFY_APP_TIMEOUT },
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 verifyApp(out);
},
);
it(
'should merge two different asars when `mergeASARs` is enabled',
{ timeout: VERIFY_APP_TIMEOUT },
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 verifyApp(out);
},
);
it(
'throws an error if `mergeASARs` is enabled and `singleArchFiles` is missing a unique file',
{ timeout: VERIFY_APP_TIMEOUT },
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"/);
},
);
it(
'should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const arm64AppPath = await templateApp('Arm64-1.app', 'arm64', async (appPath) => {
const { testPath } = await createStagingAppDir('Arm64-1');
await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar'));
await templateApp('SubApp-1.app', 'arm64', async (subArm64AppPath) => {
await fsMove(
subArm64AppPath,
path.resolve(appPath, 'Contents', 'Resources', path.basename(subArm64AppPath)),
);
});
});
const x64AppPath = await templateApp('X64-1.app', 'x64', async (appPath) => {
const { testPath } = await createStagingAppDir('X64-1');
await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar'));
await templateApp('SubApp-1.app', 'x64', async (subArm64AppPath) => {
await fsMove(
subArm64AppPath,
path.resolve(appPath, 'Contents', 'Resources', path.basename(subArm64AppPath)),
);
});
});
const outAppPath = path.resolve(appsOutPath, 'UnmodifiedPlist.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath,
mergeASARs: true,
infoPlistsToIgnore: 'SubApp-1.app/Contents/Info.plist',
});
await verifyApp(outAppPath);
},
);
// TODO: Investigate if this should even be allowed.
// Current logic detects all unpacked files as APP_CODE, which doesn't seem correct since it could also be a macho file requiring lipo
// https://github.com/electron/universal/blob/d90d573ccf69a5b14b91aa818c8b97e0e6840399/src/file-utils.ts#L48-L49
it.skip(
'should shim asars with different unpacked dirs',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const arm64AppPath = await templateApp('UnpackedArm64.app', 'arm64', async (appPath) => {
const { testPath } = await createStagingAppDir('UnpackedAppArm64');
await createPackageWithOptions(
testPath,
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
{
unpackDir: 'var',
unpack: '*.txt',
},
);
});
const x64AppPath = await templateApp('UnpackedX64.app', 'x64', async (appPath) => {
const { testPath } = await createStagingAppDir('UnpackedAppX64');
await createPackageWithOptions(
testPath,
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
{},
);
});
const outAppPath = path.resolve(appsOutPath, 'UnpackedDir.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath,
});
await verifyApp(outAppPath);
},
);
it(
'should generate AsarIntegrity for all asars in the application',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const { testPath } = await createStagingAppDir('app-2');
const testAsarPath = path.resolve(appsOutPath, 'app-2.asar');
await createPackage(testPath, testAsarPath);
const arm64AppPath = await templateApp('Arm64-2.app', 'arm64', async (appPath) => {
await fs.promises.copyFile(
testAsarPath,
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
);
await fs.promises.copyFile(
testAsarPath,
path.resolve(appPath, 'Contents', 'Resources', 'webapp.asar'),
);
});
const x64AppPath = await templateApp('X64-2.app', 'x64', async (appPath) => {
await fs.promises.copyFile(
testAsarPath,
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
);
await fs.promises.copyFile(
testAsarPath,
path.resolve(appPath, 'Contents', 'Resources', 'webbapp.asar'),
);
});
const outAppPath = path.resolve(appsOutPath, 'MultipleAsars.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath,
mergeASARs: true,
});
await verifyApp(outAppPath);
},
);
});
describe('no asar mode', () => {
it(
'should correctly merge two identical app folders',
{ timeout: VERIFY_APP_TIMEOUT },
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 verifyApp(out);
},
);
it('should shim two different app folders', { timeout: VERIFY_APP_TIMEOUT }, async () => {
const arm64AppPath = await templateApp('ShimArm64.app', 'arm64', async (appPath) => {
const { testPath } = await createStagingAppDir('shimArm64', {
'i-aint-got-no-rhythm.bin': 'boomshakalaka',
});
await fs.promises.cp(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app'), {
recursive: true,
verbatimSymlinks: true,
});
});
const x64AppPath = await templateApp('ShimX64.app', 'x64', async (appPath) => {
const { testPath } = await createStagingAppDir('shimX64', {
'hello-world.bin': 'Hello World',
});
await fs.promises.cp(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app'), {
recursive: true,
verbatimSymlinks: true,
});
});
const outAppPath = path.resolve(appsOutPath, 'ShimNoAsar.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath,
});
await verifyApp(outAppPath);
});
it(
'different app dirs with different macho files (shim and lipo)',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const x64AppPath = await generateNativeApp({
appNameWithExtension: 'DifferentMachoAppX64-1.app',
arch: 'x64',
createAsar: false,
additionalFiles: {
'hello-world.bin': 'Hello World',
},
});
const arm64AppPath = await generateNativeApp({
appNameWithExtension: 'DifferentMachoAppArm64-1.app',
arch: 'arm64',
createAsar: false,
additionalFiles: {
'i-aint-got-no-rhythm.bin': 'boomshakalaka',
},
});
const outAppPath = path.resolve(appsOutPath, 'DifferentMachoApp1.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath,
});
await verifyApp(outAppPath, true);
},
);
it(
"different app dirs with universal macho files (shim but don't lipo)",
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const x64AppPath = await generateNativeApp({
appNameWithExtension: 'DifferentButUniversalMachoAppX64-2.app',
arch: 'x64',
createAsar: false,
nativeModuleArch: 'universal',
additionalFiles: {
'hello-world.bin': 'Hello World',
},
});
const arm64AppPath = await generateNativeApp({
appNameWithExtension: 'DifferentButUniversalMachoAppArm64-2.app',
arch: 'arm64',
createAsar: false,
nativeModuleArch: 'universal',
additionalFiles: {
'i-aint-got-no-rhythm.bin': 'boomshakalaka',
},
});
const outAppPath = path.resolve(appsOutPath, 'DifferentButUniversalMachoApp.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath,
});
await verifyApp(outAppPath, true);
},
);
it(
'identical app dirs with different macho files (e.g. do not shim, but still lipo)',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const x64AppPath = await generateNativeApp({
appNameWithExtension: 'DifferentMachoAppX64-2.app',
arch: 'x64',
createAsar: false,
});
const arm64AppPath = await generateNativeApp({
appNameWithExtension: 'DifferentMachoAppArm64-2.app',
arch: 'arm64',
createAsar: false,
});
const out = path.resolve(appsOutPath, 'DifferentMachoApp2.app');
await makeUniversalApp({
x64AppPath,
arm64AppPath,
outAppPath: out,
});
await verifyApp(out, true);
},
);
it(
'identical app dirs with universal macho files (e.g., do not shim, just copy x64 dir)',
{ timeout: VERIFY_APP_TIMEOUT },
async () => {
const x64AppPath = await generateNativeApp({
appNameWithExtension: 'UniversalMachoAppX64.app',
arch: 'x64',
createAsar: false,
nativeModuleArch: 'universal',
});
const arm64AppPath = await generateNativeApp({
appNameWithExtension: 'UniversalMachoAppArm64.app',
arch: 'arm64',
createAsar: false,
nativeModuleArch: 'universal',
});
const out = path.resolve(appsOutPath, 'UniversalMachoApp.app');
await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath: out });
await verifyApp(out, true);
},
);
});
});

13
test/sha.spec.ts Normal file
View File

@@ -0,0 +1,13 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { sha } from '../src/sha.js';
describe('sha', () => {
it('should correctly hash a file', async () => {
expect(await sha(path.resolve(import.meta.dirname, 'fixtures', 'tohash'))).toEqual(
'12998c017066eb0d2a70b94e6ed3192985855ce390f321bbdb832022888bd251',
);
});
});

264
test/util.ts Normal file
View File

@@ -0,0 +1,264 @@
import fs from 'node:fs';
import path from 'node:path';
import { createPackageWithOptions, getRawHeader } from '@electron/asar';
import { downloadArtifact } from '@electron/get';
import { spawn } from '@malept/cross-spawn-promise';
import * as zip from 'cross-zip';
import plist from 'plist';
import * as fileUtils from '../dist/file-utils.js';
// We do a LOT of verifications in `verifyApp` 😅
// exec universal binary -> verify ALL asars -> verify ALL app dirs -> verify ALL asar integrity entries
// plus some tests create fixtures at runtime
export const VERIFY_APP_TIMEOUT = 80 * 1000;
export const fixtureDir = path.resolve(import.meta.dirname, 'fixtures');
export const asarsDir = path.resolve(fixtureDir, 'asars');
export const appsDir = path.resolve(fixtureDir, 'apps');
export const appsOutPath = path.resolve(appsDir, 'out');
export const verifyApp = async (appPath: string, containsRuntimeGeneratedMacho = false) => {
const { expect } = await import('vitest');
await ensureUniversal(appPath);
const resourcesDir = path.resolve(appPath, 'Contents', 'Resources');
const resourcesDirContents = await fs.promises.readdir(resourcesDir);
// sort for consistent result
const asars = resourcesDirContents.filter((p) => p.endsWith('.asar')).sort();
for await (const asar of asars) {
// verify header
const asarFs = getRawHeader(path.resolve(resourcesDir, asar));
expect(
removeUnstableProperties(asarFs.header, containsRuntimeGeneratedMacho ? ['hello-world'] : []),
).toMatchSnapshot();
}
// check all app and unpacked dirs (incl. shimmed)
const dirsToSnapshot = [
'app',
'app.asar.unpacked',
'app-x64',
'app-x64.asar.unpacked',
'app-arm64',
'app-arm64.asar.unpacked',
];
const appDirs = resourcesDirContents
.filter((p) => dirsToSnapshot.includes(path.basename(p)))
.sort();
for await (const dir of appDirs) {
await verifyFileTree(path.resolve(resourcesDir, dir));
}
const allFiles = await fileUtils.getAllAppFiles(appPath);
const infoPlists = allFiles
.filter(
(appFile) =>
appFile.type === fileUtils.AppFileType.INFO_PLIST &&
// These are test app fixtures, no need to snapshot within `TestApp.app/Contents/Frameworks`
!appFile.relativePath.includes(path.join('Contents', 'Frameworks')),
)
.map((af) => af.relativePath)
.sort();
const integrityMap: Record<string, string> = {};
const integrity = await Promise.all(
infoPlists.map((ip) => extractAsarIntegrity(path.resolve(appPath, ip))),
);
for (let i = 0; i < integrity.length; i++) {
const relativePath = infoPlists[i];
const asarIntegrity = integrity[i];
// note: `infoPlistsToIgnore` will not have integrity in sub-app plists
integrityMap[relativePath] = asarIntegrity
? removeUnstableProperties(asarIntegrity, containsRuntimeGeneratedMacho ? ['hash'] : [])
: undefined;
}
expect(integrityMap).toMatchSnapshot();
};
const extractAsarIntegrity = async (infoPlist: string) => {
const { ElectronAsarIntegrity: integrity, ...otherData } = plist.parse(
await fs.promises.readFile(infoPlist, 'utf-8'),
) as any;
return integrity;
};
export const verifyFileTree = async (dirPath: string) => {
const { expect } = await import('vitest');
const dirFiles = await fileUtils.getAllAppFiles(dirPath);
const files = dirFiles.map((file) => {
const it = path.join(dirPath, file.relativePath);
const name = toSystemIndependentPath(file.relativePath);
if (it.endsWith('.txt') || it.endsWith('.json')) {
return { name, content: fs.readFileSync(it, 'utf-8') };
}
return name;
});
expect(files).toMatchSnapshot();
};
export const ensureUniversal = async (app: string) => {
const { expect } = await import('vitest');
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');
};
export const toSystemIndependentPath = (s: string): string => {
return path.sep === '/' ? s : s.replace(/\\/g, '/');
};
export const removeUnstableProperties = (data: any, stripKeys: string[]) => {
const removeKeysRecursively: (obj: any, keysToRemove: string[]) => any = (obj, keysToRemove) => {
if (!obj || typeof obj !== 'object') {
return obj;
}
// if the value is an array, map over it
if (Array.isArray(obj)) {
return obj.map((item: any) => removeKeysRecursively(item, keysToRemove));
}
return Object.keys(obj).reduce<any>((acc, key) => {
// if the value of the current key is another object, make a recursive call to remove the key from the nested object
if (!keysToRemove.includes(key)) {
acc[key] = removeKeysRecursively(obj[key], keysToRemove);
} else {
acc[key] = '<stripped>';
}
return acc;
}, {});
};
const filteredData = removeKeysRecursively(data, stripKeys);
return JSON.parse(
JSON.stringify(filteredData, (name, value) => {
if (name === 'offset') {
return undefined;
}
return value;
}),
);
};
/**
* Creates an app directory at runtime for usage:
* - `testPath` can be used with `asar.createPackage`. Just set the output `.asar` path to `Test.app/Contents/Resources/<asar_name>.asar`
* - `testPath` can be utilized for logic paths involving `AsarMode.NO_ASAR` and copied directly to `Test.app/Contents/Resources`
*
* Directory structure:
* testName
* ├── private
* │ └── var
* │ ├── app
* │ │ └── file.txt -> ../file.txt
* │ └── file.txt
* └── var -> private/var
* ├── index.js
* ├── package.json
*/
export const createStagingAppDir = async (
testName: string | undefined,
additionalFiles: Record<string, string> = {},
) => {
const outDir = (testName || 'app') + Math.floor(Math.random() * 100); // tests run in parallel, randomize dir suffix to prevent naming collisions
const testPath = path.join(appsDir, outDir);
await fs.promises.rm(testPath, { recursive: true, force: true });
await fs.promises.cp(path.join(asarsDir, 'app'), testPath, {
recursive: true,
verbatimSymlinks: true,
});
const privateVarPath = path.join(testPath, 'private', 'var');
const varPath = path.join(testPath, 'var');
await fs.promises.mkdir(privateVarPath, { recursive: true });
await fs.promises.symlink(path.relative(testPath, privateVarPath), varPath);
const files = {
'file.txt': 'hello world',
...additionalFiles,
};
for await (const [filename, fileData] of Object.entries(files)) {
const originFilePath = path.join(varPath, filename);
await fs.promises.writeFile(originFilePath, fileData);
}
const appPath = path.join(varPath, 'app');
await fs.promises.mkdir(appPath, { recursive: true });
await fs.promises.symlink('../file.txt', path.join(appPath, 'file.txt'));
return {
testPath,
varPath,
appPath,
};
};
export 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.promises.rename(path.resolve(appsDir, 'Electron.app'), appPath);
await fs.promises.rm(path.resolve(appPath, 'Contents', 'Resources', 'default_app.asar'), {
recursive: true,
force: true,
});
await modify(appPath);
return appPath;
};
export const generateNativeApp = async (options: {
appNameWithExtension: string;
arch: string;
createAsar: boolean;
nativeModuleArch?: string;
additionalFiles?: Record<string, string>;
}) => {
const {
appNameWithExtension,
arch,
createAsar,
nativeModuleArch = arch,
additionalFiles,
} = options;
const appPath = await templateApp(appNameWithExtension, arch, async (appPath) => {
const resources = path.join(appPath, 'Contents', 'Resources');
const resourcesApp = path.resolve(resources, 'app');
if (!fs.existsSync(resourcesApp)) {
await fs.promises.mkdir(resourcesApp, { recursive: true });
}
const { testPath } = await createStagingAppDir(
path.basename(appNameWithExtension, '.app'),
additionalFiles,
);
await fs.promises.cp(
path.join(appsDir, `hello-world-${nativeModuleArch}`),
path.join(testPath, 'hello-world'),
{ recursive: true, verbatimSymlinks: true },
);
if (createAsar) {
await createPackageWithOptions(testPath, path.resolve(resources, 'app.asar'), {
unpack: '**/hello-world',
});
} else {
await fs.promises.cp(testPath, resourcesApp, { recursive: true, verbatimSymlinks: true });
}
});
return appPath;
};

View File

@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"include": ["src"]
}

View File

@@ -1,7 +1,20 @@
{ {
"extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"module": "commonjs",
"target": "es2017",
"lib": [
"es2017"
],
"sourceMap": true,
"strict": true,
"outDir": "entry-asar", "outDir": "entry-asar",
"types": [
"node",
],
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"esModuleInterop": true,
"declaration": false
}, },
"include": [ "include": [
"entry-asar" "entry-asar"

View File

@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "esnext",
"outDir": "dist/esm"
},
"include": ["src"]
}

View File

@@ -1,23 +1,15 @@
{ {
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"module": "commonjs",
"target": "es2017",
"lib": [
"es2017"
],
"sourceMap": true, "sourceMap": true,
"strict": true, "outDir": "dist",
"outDir": "dist/cjs",
"types": [ "types": [
"node", "node",
], ],
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"esModuleInterop": true,
"declaration": true "declaration": true
}, },
"include": [ "include": [
"src", "src"
"entry-asar"
] ]
} }

6
typedoc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://typedoc.org/schema.json",
"entryPoints": ["./src/index.ts"],
"excludeInternal": true,
"sort": ["source-order"]
}

7
vitest.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globalSetup: './test/globalSetup.ts',
},
});

3660
yarn.lock

File diff suppressed because it is too large Load Diff