├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── add-to-project.yml │ ├── release.yml │ ├── semantic.yml │ └── test.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc.json ├── .releaserc.json ├── LICENSE ├── README.md ├── entry-asar ├── ambient.d.ts ├── has-asar.ts └── no-asar.ts ├── jest.config.js ├── jest.setup.ts ├── package.json ├── src ├── asar-utils.ts ├── debug.ts ├── file-utils.ts ├── index.ts ├── integrity.ts └── sha.ts ├── test ├── __snapshots__ │ └── index.spec.ts.snap ├── asar-utils.spec.ts ├── file-utils.spec.ts ├── fixtures │ ├── asars │ │ ├── app.asar │ │ ├── app │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── app2.asar │ │ └── app2 │ │ │ ├── extra-file.txt │ │ │ ├── index.js │ │ │ └── package.json │ ├── hello-world.c │ └── tohash ├── index.spec.ts ├── sha.spec.ts └── util.ts ├── tsconfig.cjs.json ├── tsconfig.entry-asar.json ├── tsconfig.esm.json ├── tsconfig.jest.json ├── tsconfig.json ├── typedoc.json └── yarn.lock /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @electron/wg-ecosystem 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/workflows/add-to-project.yml: -------------------------------------------------------------------------------- 1 | name: Add to Ecosystem WG Project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | pull_request_target: 8 | types: 9 | - opened 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | add-to-project: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Generate GitHub App token 18 | uses: electron/github-app-auth-action@384fd19694fe7b6dcc9a684746c6976ad78228ae # v1.1.1 19 | id: generate-token 20 | with: 21 | creds: ${{ secrets.ECOSYSTEM_ISSUE_TRIAGE_GH_APP_CREDS }} 22 | org: electron 23 | - name: Add to Project 24 | uses: dsanders11/project-actions/add-item@2134fe7cc71c58b7ae259c82a8e63c6058255678 # v1.7.0 25 | with: 26 | field: Opened 27 | field-value: ${{ github.event.pull_request.created_at || github.event.issue.created_at }} 28 | project-number: 89 29 | token: ${{ steps.generate-token.outputs.token }} 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test: 10 | uses: ./.github/workflows/test.yml 11 | 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | needs: test 16 | environment: npm 17 | permissions: 18 | id-token: write # for CFA and npm provenance 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 22 | with: 23 | persist-credentials: false 24 | - name: Setup Node.js 25 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 26 | with: 27 | node-version: 20.x 28 | cache: 'yarn' 29 | - name: Install 30 | run: yarn install --frozen-lockfile 31 | - uses: continuousauth/action@4e8a2573eeb706f6d7300d6a9f3ca6322740b72d # v1.0.5 32 | timeout-minutes: 60 33 | with: 34 | project-id: ${{ secrets.CFA_PROJECT_ID }} 35 | secret: ${{ secrets.CFA_SECRET }} 36 | npm-token: ${{ secrets.NPM_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/semantic.yml: -------------------------------------------------------------------------------- 1 | name: "Check Semantic Commit" 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | main: 15 | permissions: 16 | pull-requests: read # for amannn/action-semantic-pull-request to analyze PRs 17 | statuses: write # for amannn/action-semantic-pull-request to mark status of analyzed PR 18 | name: Validate PR Title 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: semantic-pull-request 22 | uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | with: 26 | validateSingleCommit: false 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | schedule: 8 | - cron: '0 22 * * 3' 9 | workflow_call: 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | test: 16 | name: Test 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | node-version: 21 | - '20.5' 22 | - '18.17' 23 | - '16.20' 24 | runs-on: macos-latest 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 28 | - name: Setup Node.js 29 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 30 | with: 31 | node-version: "${{ matrix.node-version }}" 32 | cache: 'yarn' 33 | - name: Install (Node.js v18+) 34 | if : ${{ matrix.node-version != '16.20' }} 35 | run: yarn install --frozen-lockfile 36 | - name: Install (Node.js < v18) 37 | if : ${{ matrix.node-version == '16.20' }} 38 | run: yarn install --frozen-lockfile --ignore-engines 39 | - name: Build 40 | run: yarn build 41 | - name: Lint 42 | run: yarn lint 43 | - name: Test 44 | run: yarn test 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | entry-asar/*.js* 4 | entry-asar/*.ts 5 | *.app 6 | test/fixtures/apps 7 | coverage 8 | docs 9 | .vscode -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "parser": "typescript", 7 | "overrides": [ 8 | { 9 | "files": ["*.json", "*.jsonc", "*.json5"], 10 | "options": { 11 | "parser": "json" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | "@continuous-auth/semantic-release-npm", 6 | "@semantic-release/github" 7 | ], 8 | "branches": [ "main" ] 9 | } 10 | 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Contributors to the Electron project 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @electron/universal 2 | 3 | > Create universal macOS Electron applications 4 | 5 | [![Test](https://github.com/electron/universal/actions/workflows/test.yml/badge.svg)](https://github.com/electron/universal/actions/workflows/test.yml) 6 | [![NPM package](https://img.shields.io/npm/v/@electron/universal)](https://npm.im/@electron/universal) 7 | 8 | ## Usage 9 | 10 | This package takes an x64 app and an arm64 app and glues them together into a 11 | [Universal macOS binary](https://developer.apple.com/documentation/apple-silicon/building-a-universal-macos-binary). 12 | 13 | Note that parameters need to be **absolute** paths. 14 | 15 | ```typescript 16 | import { makeUniversalApp } from '@electron/universal'; 17 | 18 | await makeUniversalApp({ 19 | x64AppPath: 'path/to/App_x64.app', 20 | arm64AppPath: 'path/to/App_arm64.app', 21 | outAppPath: 'path/to/App_universal.app', 22 | }); 23 | ``` 24 | 25 | ## Advanced configuration 26 | 27 | The basic usage patterns will work for most apps out of the box. Additional configuration 28 | options are available for advanced usecases. 29 | 30 | ### Merging ASAR archives to reduce app size 31 | 32 | **Added in [v1.2.0](https://github.com/electron/universal/commit/38ab1c3559e25382957d608e49e624dc72a4409c)** 33 | 34 | If you are using ASAR archives to store your Electron app's JavaScript code, you can use the 35 | `mergeASARs` option to merge your x64 and arm64 ASAR files to reduce the bundle size of 36 | the output Universal app. 37 | 38 | If some files are present in only the x64 app but not the arm64 version (or vice-versa), 39 | you can exclude them from the merging process by specifying a `minimatch` pattern 40 | in `singleArchFiles`. 41 | 42 | ```typescript 43 | import { makeUniversalApp } from '@electron/universal'; 44 | 45 | await makeUniversalApp({ 46 | x64AppPath: 'path/to/App_x64.app', 47 | arm64AppPath: 'path/to/App_arm64.app', 48 | outAppPath: 'path/to/App_universal.app', 49 | mergeASARs: true, 50 | singleArchFiles: 'node_modules/some-native-module/lib/binding/Release/**', // if you have files in your asar that are unique to x64 or arm64 apps 51 | }); 52 | ``` 53 | 54 | If `@electron/universal` detects an architecture-unique file that isn't covered by the 55 | `singleArchFiles` rule, an error will be thrown. 56 | 57 | ### Skip lipo for certain binaries in your Universal app 58 | 59 | **Added in [1.3.0](https://github.com/electron/universal/commit/01dfb8a9636965fe154192b07934670dd42509f3)** 60 | 61 | If your Electron app contains binary resources that are already merged with the 62 | `lipo` tool, providing a [`minimatch`] pattern to matching files in the `x64ArchFiles` 63 | parameter will prevent `@electron/universal` from attempting to merge them a second time. 64 | 65 | ```typescript 66 | import { makeUniversalApp } from '@electron/universal'; 67 | 68 | await makeUniversalApp({ 69 | x64AppPath: 'path/to/App_x64.app', 70 | arm64AppPath: 'path/to/App_arm64.app', 71 | outAppPath: 'path/to/App_universal.app', 72 | mergeASARs: true, 73 | x64ArchFiles: '*/electron-helper', // `electron-helper` is a binary merged using `lipo` 74 | }); 75 | ``` 76 | 77 | If `@electron/universal` detects a lipo'd file that isn't covered by the `x64ArchFiles` rule, 78 | an error will be thrown. 79 | 80 | ### Including already codesigned app bundles into your Universal app 81 | 82 | **Added in [v1.4.0](https://github.com/electron/universal/commit/b02ce7697fd2a3c2c79e1f6ab6bf7052125865cc)** 83 | 84 | By default, the merging process will generate an `ElectronAsarIntegrity` key for 85 | any `Info.plist` files in your Electron app. 86 | 87 | If your Electron app bundles another `.app` that is already signed, you need to use 88 | the `infoPlistsToIgnore` option to avoid modifying that app's plist. 89 | 90 | ```typescript 91 | import { makeUniversalApp } from '@electron/universal'; 92 | 93 | await makeUniversalApp({ 94 | x64AppPath: 'path/to/App_x64.app', 95 | arm64AppPath: 'path/to/App_arm64.app', 96 | outAppPath: 'path/to/App_universal.app', 97 | infoPlistsToIgnore: 'my-internal.app/Contents/Info.plist' 98 | }); 99 | ``` 100 | 101 | ## FAQ 102 | 103 | #### The app is twice as big now, why? 104 | 105 | A Universal app is just the x64 app and the arm64 app glued together into a single application. 106 | It's twice as big because it contains two apps in one. 107 | 108 | Merging your ASAR bundles can yield significant app size reductions depending on how large 109 | your `app.asar` file is. 110 | 111 | #### What about native modules? 112 | 113 | Out of the box, you don't need to worry about building universal versions of your 114 | native modules. As long as your x64 and arm64 apps work in isolation, the Universal 115 | app will work as well. 116 | 117 | Note that if you are using `mergeASARs`, you may need to add architecture-specific 118 | binary resources to the `singleArchFiles` pattern. 119 | See [Merging ASARs usage](#merging-asar-archives-to-reduce-app-size) for an example. 120 | 121 | #### How do I build my app for Apple silicon in the first place? 122 | 123 | Check out the [Electron Apple silicon blog post](https://www.electronjs.org/blog/apple-silicon). 124 | 125 | [`minimatch`]: https://github.com/isaacs/minimatch?tab=readme-ov-file#features 126 | -------------------------------------------------------------------------------- /entry-asar/ambient.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface Process extends EventEmitter { 3 | // This is an undocumented private API. It exists. 4 | _archPath: string; 5 | } 6 | } 7 | 8 | declare module 'electron' { 9 | const app: Electron.App; 10 | 11 | namespace Electron { 12 | interface App { 13 | getAppPath: () => string; 14 | setAppPath: (p: string) => void; 15 | } 16 | } 17 | 18 | export { app }; 19 | } 20 | -------------------------------------------------------------------------------- /entry-asar/has-asar.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import path from 'path'; 3 | 4 | if (process.arch === 'arm64') { 5 | setPaths('arm64'); 6 | } else { 7 | setPaths('x64'); 8 | } 9 | 10 | function setPaths(platform: string) { 11 | // This should return the full path, ending in something like 12 | // Notion.app/Contents/Resources/app.asar 13 | const appPath = app.getAppPath(); 14 | const asarFile = `app-${platform}.asar`; 15 | 16 | // Maybe we'll handle this in Electron one day 17 | if (path.basename(appPath) === 'app.asar') { 18 | const platformAppPath = path.join(path.dirname(appPath), asarFile); 19 | 20 | // This is an undocumented API. It exists. 21 | app.setAppPath(platformAppPath); 22 | } 23 | 24 | process._archPath = require.resolve(`../${asarFile}`); 25 | } 26 | 27 | require(process._archPath); 28 | -------------------------------------------------------------------------------- /entry-asar/no-asar.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import path from 'path'; 3 | 4 | if (process.arch === 'arm64') { 5 | setPaths('arm64'); 6 | } else { 7 | setPaths('x64'); 8 | } 9 | 10 | function setPaths(platform: string) { 11 | // This should return the full path, ending in something like 12 | // Notion.app/Contents/Resources/app 13 | const appPath = app.getAppPath(); 14 | const appFolder = `app-${platform}`; 15 | 16 | // Maybe we'll handle this in Electron one day 17 | if (path.basename(appPath) === 'app') { 18 | const platformAppPath = path.join(path.dirname(appPath), appFolder); 19 | 20 | // This is an undocumented private API. It exists. 21 | app.setAppPath(platformAppPath); 22 | } 23 | 24 | process._archPath = require.resolve(`../${appFolder}`); 25 | } 26 | 27 | require(process._archPath); 28 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | transform: { 6 | '^.+\\.ts?$': [ 7 | 'ts-jest', 8 | { 9 | tsconfig: 'tsconfig.jest.json', 10 | }, 11 | ], 12 | }, 13 | testMatch: ['/test/**/*.spec.ts'], 14 | globalSetup: './jest.setup.ts', 15 | testTimeout: 10000, 16 | }; 17 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import { execFileSync } from 'child_process'; 2 | import * as fs from 'fs-extra'; 3 | import * as path from 'path'; 4 | import { appsDir, asarsDir, fixtureDir, templateApp } from './test/util'; 5 | 6 | // generates binaries from hello-world.c 7 | // hello-world-universal, hello-world-x86_64, hello-world-arm64 8 | const generateMachO = () => { 9 | const src = path.resolve(fixtureDir, 'hello-world.c'); 10 | 11 | const outputFiles = ['x86_64', 'arm64'].map((arch) => { 12 | const machO = path.resolve(appsDir, `hello-world-${arch === 'x86_64' ? 'x64' : arch}`); 13 | execFileSync('clang', ['-arch', arch, '-o', machO, src]); 14 | return machO; 15 | }); 16 | 17 | execFileSync('lipo', [ 18 | ...outputFiles, 19 | '-create', 20 | '-output', 21 | path.resolve(appsDir, 'hello-world-universal'), 22 | ]); 23 | }; 24 | 25 | export default async () => { 26 | await fs.remove(appsDir); 27 | await fs.mkdirp(appsDir); 28 | 29 | // generate mach-o binaries to be leveraged in lipo tests 30 | generateMachO(); 31 | 32 | await templateApp('Arm64Asar.app', 'arm64', async (appPath) => { 33 | await fs.copy( 34 | path.resolve(asarsDir, 'app.asar'), 35 | path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), 36 | ); 37 | }); 38 | 39 | // contains `extra-file.txt` 40 | await templateApp('Arm64AsarExtraFile.app', 'arm64', async (appPath) => { 41 | await fs.copy( 42 | path.resolve(asarsDir, 'app2.asar'), 43 | path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), 44 | ); 45 | }); 46 | 47 | await templateApp('X64Asar.app', 'x64', async (appPath) => { 48 | await fs.copy( 49 | path.resolve(asarsDir, 'app.asar'), 50 | path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), 51 | ); 52 | }); 53 | 54 | await templateApp('Arm64NoAsar.app', 'arm64', async (appPath) => { 55 | await fs.copy( 56 | path.resolve(asarsDir, 'app'), 57 | path.resolve(appPath, 'Contents', 'Resources', 'app'), 58 | ); 59 | }); 60 | 61 | // contains `extra-file.txt` 62 | await templateApp('Arm64NoAsarExtraFile.app', 'arm64', async (appPath) => { 63 | await fs.copy( 64 | path.resolve(asarsDir, 'app2'), 65 | path.resolve(appPath, 'Contents', 'Resources', 'app'), 66 | ); 67 | }); 68 | 69 | await templateApp('X64NoAsar.app', 'x64', async (appPath) => { 70 | await fs.copy( 71 | path.resolve(asarsDir, 'app'), 72 | path.resolve(appPath, 'Contents', 'Resources', 'app'), 73 | ); 74 | }); 75 | }; 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@electron/universal", 3 | "version": "0.0.0-development", 4 | "description": "Utility for creating Universal macOS applications from two x64 and arm64 Electron applications", 5 | "main": "dist/cjs/index.js", 6 | "module": "dist/esm/index.js", 7 | "license": "MIT", 8 | "keywords": [ 9 | "electron", 10 | "apple silicon", 11 | "universal" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/electron/universal.git" 16 | }, 17 | "engines": { 18 | "node": ">=16.4" 19 | }, 20 | "files": [ 21 | "dist/*", 22 | "entry-asar/*", 23 | "!entry-asar/**/*.ts", 24 | "README.md" 25 | ], 26 | "author": "Samuel Attard", 27 | "publishConfig": { 28 | "provenance": true 29 | }, 30 | "scripts": { 31 | "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && tsc -p tsconfig.entry-asar.json", 32 | "build:docs": "npx typedoc", 33 | "lint": "prettier --check \"{src,entry-asar,test}/**/*.ts\" \"*.ts\"", 34 | "prettier:write": "prettier --write \"{src,entry-asar,test}/**/*.ts\" \"*.ts\"", 35 | "prepublishOnly": "npm run build", 36 | "test": "jest", 37 | "prepare": "husky install" 38 | }, 39 | "devDependencies": { 40 | "@electron/get": "^3.0.0", 41 | "@types/cross-zip": "^4.0.1", 42 | "@types/debug": "^4.1.10", 43 | "@types/fs-extra": "^11.0.3", 44 | "@types/jest": "^29.5.7", 45 | "@types/minimatch": "^5.1.2", 46 | "@types/node": "^20.8.10", 47 | "@types/plist": "^3.0.4", 48 | "cross-zip": "^4.0.0", 49 | "husky": "^8.0.3", 50 | "jest": "^29.7.0", 51 | "lint-staged": "^15.2.10", 52 | "prettier": "^3.0.3", 53 | "ts-jest": "^29.1.1", 54 | "typedoc": "~0.25.13", 55 | "typescript": "^5.2.2" 56 | }, 57 | "dependencies": { 58 | "@electron/asar": "^3.3.1", 59 | "@malept/cross-spawn-promise": "^2.0.0", 60 | "debug": "^4.3.1", 61 | "dir-compare": "^4.2.0", 62 | "fs-extra": "^11.1.1", 63 | "minimatch": "^9.0.3", 64 | "plist": "^3.1.0" 65 | }, 66 | "lint-staged": { 67 | "*.ts": [ 68 | "prettier --write" 69 | ] 70 | }, 71 | "resolutions": { 72 | "jackspeak": "2.1.1" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/asar-utils.ts: -------------------------------------------------------------------------------- 1 | import asar from '@electron/asar'; 2 | import { execFileSync } from 'child_process'; 3 | import crypto from 'crypto'; 4 | import fs from 'fs-extra'; 5 | import path from 'path'; 6 | import { minimatch } from 'minimatch'; 7 | import os from 'os'; 8 | import { d } from './debug'; 9 | 10 | const LIPO = 'lipo'; 11 | 12 | export enum AsarMode { 13 | NO_ASAR, 14 | HAS_ASAR, 15 | } 16 | 17 | export type MergeASARsOptions = { 18 | x64AsarPath: string; 19 | arm64AsarPath: string; 20 | outputAsarPath: string; 21 | 22 | singleArchFiles?: string; 23 | }; 24 | 25 | // See: https://github.com/apple-opensource-mirror/llvmCore/blob/0c60489d96c87140db9a6a14c6e82b15f5e5d252/include/llvm/Object/MachOFormat.h#L108-L112 26 | const MACHO_MAGIC = new Set([ 27 | // 32-bit Mach-O 28 | 0xfeedface, 0xcefaedfe, 29 | 30 | // 64-bit Mach-O 31 | 0xfeedfacf, 0xcffaedfe, 32 | ]); 33 | 34 | const MACHO_UNIVERSAL_MAGIC = new Set([ 35 | // universal 36 | 0xcafebabe, 0xbebafeca, 37 | ]); 38 | 39 | export const detectAsarMode = async (appPath: string) => { 40 | d('checking asar mode of', appPath); 41 | const asarPath = path.resolve(appPath, 'Contents', 'Resources', 'app.asar'); 42 | 43 | if (!(await fs.pathExists(asarPath))) { 44 | d('determined no asar'); 45 | return AsarMode.NO_ASAR; 46 | } 47 | 48 | d('determined has asar'); 49 | return AsarMode.HAS_ASAR; 50 | }; 51 | 52 | export const generateAsarIntegrity = (asarPath: string) => { 53 | return { 54 | algorithm: 'SHA256' as const, 55 | hash: crypto 56 | .createHash('SHA256') 57 | .update(asar.getRawHeader(asarPath).headerString) 58 | .digest('hex'), 59 | }; 60 | }; 61 | 62 | function toRelativePath(file: string): string { 63 | return file.replace(/^\//, ''); 64 | } 65 | 66 | function isDirectory(a: string, file: string): boolean { 67 | return Boolean('files' in asar.statFile(a, file)); 68 | } 69 | 70 | function checkSingleArch(archive: string, file: string, allowList?: string): void { 71 | if (allowList === undefined || !minimatch(file, allowList, { matchBase: true })) { 72 | throw new Error( 73 | `Detected unique file "${file}" in "${archive}" not covered by ` + 74 | `allowList rule: "${allowList}"`, 75 | ); 76 | } 77 | } 78 | 79 | export const mergeASARs = async ({ 80 | x64AsarPath, 81 | arm64AsarPath, 82 | outputAsarPath, 83 | singleArchFiles, 84 | }: MergeASARsOptions): Promise => { 85 | d(`merging ${x64AsarPath} and ${arm64AsarPath}`); 86 | 87 | const x64Files = new Set(asar.listPackage(x64AsarPath, { isPack: false }).map(toRelativePath)); 88 | const arm64Files = new Set( 89 | asar.listPackage(arm64AsarPath, { isPack: false }).map(toRelativePath), 90 | ); 91 | 92 | // 93 | // Build set of unpacked directories and files 94 | // 95 | 96 | const unpackedFiles = new Set(); 97 | 98 | function buildUnpacked(a: string, fileList: Set): void { 99 | for (const file of fileList) { 100 | const stat = asar.statFile(a, file); 101 | 102 | if (!('unpacked' in stat) || !stat.unpacked) { 103 | continue; 104 | } 105 | 106 | if ('files' in stat) { 107 | continue; 108 | } 109 | unpackedFiles.add(file); 110 | } 111 | } 112 | 113 | buildUnpacked(x64AsarPath, x64Files); 114 | buildUnpacked(arm64AsarPath, arm64Files); 115 | 116 | // 117 | // Build list of files/directories unique to each asar 118 | // 119 | 120 | for (const file of x64Files) { 121 | if (!arm64Files.has(file)) { 122 | checkSingleArch(x64AsarPath, file, singleArchFiles); 123 | } 124 | } 125 | const arm64Unique = []; 126 | for (const file of arm64Files) { 127 | if (!x64Files.has(file)) { 128 | checkSingleArch(arm64AsarPath, file, singleArchFiles); 129 | arm64Unique.push(file); 130 | } 131 | } 132 | 133 | // 134 | // Find common bindings with different content 135 | // 136 | 137 | const commonBindings = []; 138 | for (const file of x64Files) { 139 | if (!arm64Files.has(file)) { 140 | continue; 141 | } 142 | 143 | // Skip directories 144 | if (isDirectory(x64AsarPath, file)) { 145 | continue; 146 | } 147 | 148 | const x64Content = asar.extractFile(x64AsarPath, file); 149 | const arm64Content = asar.extractFile(arm64AsarPath, file); 150 | 151 | // Skip file if the same content 152 | if (x64Content.compare(arm64Content) === 0) { 153 | continue; 154 | } 155 | 156 | // Skip universal Mach-O files. 157 | if (isUniversalMachO(x64Content)) { 158 | continue; 159 | } 160 | 161 | if (!MACHO_MAGIC.has(x64Content.readUInt32LE(0))) { 162 | throw new Error(`Can't reconcile two non-macho files ${file}`); 163 | } 164 | 165 | commonBindings.push(file); 166 | } 167 | 168 | // 169 | // Extract both 170 | // 171 | 172 | const x64Dir = await fs.mkdtemp(path.join(os.tmpdir(), 'x64-')); 173 | const arm64Dir = await fs.mkdtemp(path.join(os.tmpdir(), 'arm64-')); 174 | 175 | try { 176 | d(`extracting ${x64AsarPath} to ${x64Dir}`); 177 | asar.extractAll(x64AsarPath, x64Dir); 178 | 179 | d(`extracting ${arm64AsarPath} to ${arm64Dir}`); 180 | asar.extractAll(arm64AsarPath, arm64Dir); 181 | 182 | for (const file of arm64Unique) { 183 | const source = path.resolve(arm64Dir, file); 184 | const destination = path.resolve(x64Dir, file); 185 | 186 | if (isDirectory(arm64AsarPath, file)) { 187 | d(`creating unique directory: ${file}`); 188 | await fs.mkdirp(destination); 189 | continue; 190 | } 191 | 192 | d(`xopying unique file: ${file}`); 193 | await fs.mkdirp(path.dirname(destination)); 194 | await fs.copy(source, destination); 195 | } 196 | 197 | for (const binding of commonBindings) { 198 | const source = await fs.realpath(path.resolve(arm64Dir, binding)); 199 | const destination = await fs.realpath(path.resolve(x64Dir, binding)); 200 | 201 | d(`merging binding: ${binding}`); 202 | execFileSync(LIPO, [source, destination, '-create', '-output', destination]); 203 | } 204 | 205 | d(`creating archive at ${outputAsarPath}`); 206 | 207 | const resolvedUnpack = Array.from(unpackedFiles).map((file) => path.join(x64Dir, file)); 208 | 209 | let unpack: string | undefined; 210 | if (resolvedUnpack.length > 1) { 211 | unpack = `{${resolvedUnpack.join(',')}}`; 212 | } else if (resolvedUnpack.length === 1) { 213 | unpack = resolvedUnpack[0]; 214 | } 215 | 216 | await asar.createPackageWithOptions(x64Dir, outputAsarPath, { 217 | unpack, 218 | }); 219 | 220 | d('done merging'); 221 | } finally { 222 | await Promise.all([fs.remove(x64Dir), fs.remove(arm64Dir)]); 223 | } 224 | }; 225 | 226 | export const isUniversalMachO = (fileContent: Buffer) => { 227 | return MACHO_UNIVERSAL_MAGIC.has(fileContent.readUInt32LE(0)); 228 | }; 229 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | 3 | export const d = debug('electron-universal'); 4 | -------------------------------------------------------------------------------- /src/file-utils.ts: -------------------------------------------------------------------------------- 1 | import { spawn, ExitCodeError } from '@malept/cross-spawn-promise'; 2 | import * as fs from 'fs-extra'; 3 | import * as path from 'path'; 4 | import { promises as stream } from 'node:stream'; 5 | 6 | const MACHO_PREFIX = 'Mach-O '; 7 | 8 | export enum AppFileType { 9 | MACHO, 10 | PLAIN, 11 | INFO_PLIST, 12 | SNAPSHOT, 13 | APP_CODE, 14 | } 15 | 16 | export type AppFile = { 17 | relativePath: string; 18 | type: AppFileType; 19 | }; 20 | 21 | /** 22 | * 23 | * @param appPath Path to the application 24 | */ 25 | export const getAllAppFiles = async (appPath: string): Promise => { 26 | const files: AppFile[] = []; 27 | 28 | const visited = new Set(); 29 | const traverse = async (p: string) => { 30 | p = await fs.realpath(p); 31 | if (visited.has(p)) return; 32 | visited.add(p); 33 | 34 | const info = await fs.stat(p); 35 | if (info.isSymbolicLink()) return; 36 | if (info.isFile()) { 37 | let fileType = AppFileType.PLAIN; 38 | 39 | var fileOutput = ''; 40 | try { 41 | fileOutput = await spawn('file', ['--brief', '--no-pad', p]); 42 | } catch (e) { 43 | if (e instanceof ExitCodeError) { 44 | /* silently accept error codes from "file" */ 45 | } else { 46 | throw e; 47 | } 48 | } 49 | if (p.endsWith('.asar')) { 50 | fileType = AppFileType.APP_CODE; 51 | } else if (fileOutput.startsWith(MACHO_PREFIX)) { 52 | fileType = AppFileType.MACHO; 53 | } else if (p.endsWith('.bin')) { 54 | fileType = AppFileType.SNAPSHOT; 55 | } else if (path.basename(p) === 'Info.plist') { 56 | fileType = AppFileType.INFO_PLIST; 57 | } 58 | 59 | files.push({ 60 | relativePath: path.relative(appPath, p), 61 | type: fileType, 62 | }); 63 | } 64 | 65 | if (info.isDirectory()) { 66 | for (const child of await fs.readdir(p)) { 67 | await traverse(path.resolve(p, child)); 68 | } 69 | } 70 | }; 71 | await traverse(appPath); 72 | 73 | return files; 74 | }; 75 | 76 | export const readMachOHeader = async (path: string) => { 77 | const chunks: Buffer[] = []; 78 | // no need to read the entire file, we only need the first 4 bytes of the file to determine the header 79 | await stream.pipeline(fs.createReadStream(path, { start: 0, end: 3 }), async function* (source) { 80 | for await (const chunk of source) { 81 | chunks.push(chunk); 82 | } 83 | }); 84 | return Buffer.concat(chunks); 85 | }; 86 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as asar from '@electron/asar'; 2 | import { spawn } from '@malept/cross-spawn-promise'; 3 | import * as dircompare from 'dir-compare'; 4 | import * as fs from 'fs-extra'; 5 | import { minimatch } from 'minimatch'; 6 | import * as os from 'os'; 7 | import * as path from 'path'; 8 | import * as plist from 'plist'; 9 | 10 | import { AsarMode, detectAsarMode, isUniversalMachO, mergeASARs } from './asar-utils'; 11 | import { AppFile, AppFileType, getAllAppFiles, readMachOHeader } from './file-utils'; 12 | import { sha } from './sha'; 13 | import { d } from './debug'; 14 | import { computeIntegrityData } from './integrity'; 15 | 16 | /** 17 | * Options to pass into the {@link makeUniversalApp} function. 18 | * 19 | * Requires absolute paths for input x64 and arm64 apps and an absolute path to the 20 | * output universal app. 21 | */ 22 | export type MakeUniversalOpts = { 23 | /** 24 | * Absolute file system path to the x64 version of your application (e.g. `/Foo/bar/MyApp_x64.app`). 25 | */ 26 | x64AppPath: string; 27 | /** 28 | * Absolute file system path to the arm64 version of your application (e.g. `/Foo/bar/MyApp_arm64.app`). 29 | */ 30 | arm64AppPath: string; 31 | /** 32 | * Absolute file system path you want the universal app to be written to (e.g. `/Foo/var/MyApp_universal.app`). 33 | * 34 | * If this file exists on disk already, it will be overwritten ONLY if {@link MakeUniversalOpts.force} is set to `true`. 35 | */ 36 | outAppPath: string; 37 | /** 38 | * Forcefully overwrite any existing files that are in the way of generating the universal application. 39 | * 40 | * @defaultValue `false` 41 | */ 42 | force?: boolean; 43 | /** 44 | * Merge x64 and arm64 ASARs into one. 45 | * 46 | * @defaultValue `false` 47 | */ 48 | mergeASARs?: boolean; 49 | /** 50 | * If {@link MakeUniversalOpts.mergeASARs} is enabled, this property provides a 51 | * {@link https://github.com/isaacs/minimatch?tab=readme-ov-file#features | minimatch} 52 | * pattern of paths that are allowed to be present in one of the ASAR files, but not in the other. 53 | * 54 | */ 55 | singleArchFiles?: string; 56 | /** 57 | * A {@link https://github.com/isaacs/minimatch?tab=readme-ov-file#features | minimatch} 58 | * pattern of binaries that are expected to be the same x64 binary in both 59 | * 60 | * Use this if your application contains binaries that have already been merged into a universal file 61 | * using the `lipo` tool. 62 | * 63 | * @see Apple's {@link https://developer.apple.com/documentation/apple-silicon/building-a-universal-macos-binary | Building a universal macOS binary} documentation 64 | * 65 | */ 66 | x64ArchFiles?: string; 67 | /** 68 | * A {@link https://github.com/isaacs/minimatch?tab=readme-ov-file#features | minimatch} pattern of `Info.plist` 69 | * paths that should not receive an injected `ElectronAsarIntegrity` value. 70 | * 71 | * Use this if your application contains another bundle that's already signed. 72 | */ 73 | infoPlistsToIgnore?: string; 74 | }; 75 | 76 | const dupedFiles = (files: AppFile[]) => 77 | files.filter((f) => f.type !== AppFileType.SNAPSHOT && f.type !== AppFileType.APP_CODE); 78 | 79 | export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise => { 80 | d('making a universal app with options', opts); 81 | 82 | if (process.platform !== 'darwin') 83 | throw new Error('@electron/universal is only supported on darwin platforms'); 84 | if (!opts.x64AppPath || !path.isAbsolute(opts.x64AppPath)) 85 | throw new Error('Expected opts.x64AppPath to be an absolute path but it was not'); 86 | if (!opts.arm64AppPath || !path.isAbsolute(opts.arm64AppPath)) 87 | throw new Error('Expected opts.arm64AppPath to be an absolute path but it was not'); 88 | if (!opts.outAppPath || !path.isAbsolute(opts.outAppPath)) 89 | throw new Error('Expected opts.outAppPath to be an absolute path but it was not'); 90 | 91 | if (await fs.pathExists(opts.outAppPath)) { 92 | d('output path exists already'); 93 | if (!opts.force) { 94 | throw new Error( 95 | `The out path "${opts.outAppPath}" already exists and force is not set to true`, 96 | ); 97 | } else { 98 | d('overwriting existing application because force == true'); 99 | await fs.remove(opts.outAppPath); 100 | } 101 | } 102 | 103 | const x64AsarMode = await detectAsarMode(opts.x64AppPath); 104 | const arm64AsarMode = await detectAsarMode(opts.arm64AppPath); 105 | d('detected x64AsarMode =', x64AsarMode); 106 | d('detected arm64AsarMode =', arm64AsarMode); 107 | 108 | if (x64AsarMode !== arm64AsarMode) 109 | throw new Error( 110 | 'Both the x64 and arm64 versions of your application need to have been built with the same asar settings (enabled vs disabled)', 111 | ); 112 | 113 | const tmpDir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-universal-')); 114 | d('building universal app in', tmpDir); 115 | 116 | try { 117 | d('copying x64 app as starter template'); 118 | const tmpApp = path.resolve(tmpDir, 'Tmp.app'); 119 | await spawn('cp', ['-R', opts.x64AppPath, tmpApp]); 120 | 121 | const uniqueToX64: string[] = []; 122 | const uniqueToArm64: string[] = []; 123 | const x64Files = await getAllAppFiles(await fs.realpath(tmpApp)); 124 | const arm64Files = await getAllAppFiles(await fs.realpath(opts.arm64AppPath)); 125 | 126 | for (const file of dupedFiles(x64Files)) { 127 | if (!arm64Files.some((f) => f.relativePath === file.relativePath)) 128 | uniqueToX64.push(file.relativePath); 129 | } 130 | for (const file of dupedFiles(arm64Files)) { 131 | if (!x64Files.some((f) => f.relativePath === file.relativePath)) 132 | uniqueToArm64.push(file.relativePath); 133 | } 134 | if (uniqueToX64.length !== 0 || uniqueToArm64.length !== 0) { 135 | d('some files were not in both builds, aborting'); 136 | console.error({ 137 | uniqueToX64, 138 | uniqueToArm64, 139 | }); 140 | throw new Error( 141 | 'While trying to merge mach-o files across your apps we found a mismatch, the number of mach-o files is not the same between the arm64 and x64 builds', 142 | ); 143 | } 144 | 145 | for (const file of x64Files.filter((f) => f.type === AppFileType.PLAIN)) { 146 | const x64Sha = await sha(path.resolve(opts.x64AppPath, file.relativePath)); 147 | const arm64Sha = await sha(path.resolve(opts.arm64AppPath, file.relativePath)); 148 | if (x64Sha !== arm64Sha) { 149 | d('SHA for file', file.relativePath, `does not match across builds ${x64Sha}!=${arm64Sha}`); 150 | // The MainMenu.nib files generated by Xcode13 are deterministic in effect but not deterministic in generated sequence 151 | if (path.basename(path.dirname(file.relativePath)) === 'MainMenu.nib') { 152 | // The mismatch here is OK so we just move on to the next one 153 | continue; 154 | } 155 | throw new Error( 156 | `Expected all non-binary files to have identical SHAs when creating a universal build but "${file.relativePath}" did not`, 157 | ); 158 | } 159 | } 160 | const knownMergedMachOFiles = new Set(); 161 | for (const machOFile of x64Files.filter((f) => f.type === AppFileType.MACHO)) { 162 | const first = await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)); 163 | const second = await fs.realpath(path.resolve(opts.arm64AppPath, machOFile.relativePath)); 164 | 165 | if ( 166 | isUniversalMachO(await readMachOHeader(first)) && 167 | isUniversalMachO(await readMachOHeader(second)) 168 | ) { 169 | d(machOFile.relativePath, `is already universal across builds, skipping lipo`); 170 | knownMergedMachOFiles.add(machOFile.relativePath); 171 | continue; 172 | } 173 | 174 | const x64Sha = await sha(path.resolve(opts.x64AppPath, machOFile.relativePath)); 175 | const arm64Sha = await sha(path.resolve(opts.arm64AppPath, machOFile.relativePath)); 176 | if (x64Sha === arm64Sha) { 177 | if ( 178 | opts.x64ArchFiles === undefined || 179 | !minimatch(machOFile.relativePath, opts.x64ArchFiles, { matchBase: true }) 180 | ) { 181 | throw new Error( 182 | `Detected file "${machOFile.relativePath}" that's the same in both x64 and arm64 builds and not covered by the ` + 183 | `x64ArchFiles rule: "${opts.x64ArchFiles}"`, 184 | ); 185 | } 186 | 187 | d( 188 | 'SHA for Mach-O file', 189 | machOFile.relativePath, 190 | `matches across builds ${x64Sha}===${arm64Sha}, skipping lipo`, 191 | ); 192 | continue; 193 | } 194 | 195 | d('joining two MachO files with lipo', { 196 | first, 197 | second, 198 | }); 199 | await spawn('lipo', [ 200 | first, 201 | second, 202 | '-create', 203 | '-output', 204 | await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)), 205 | ]); 206 | knownMergedMachOFiles.add(machOFile.relativePath); 207 | } 208 | 209 | /** 210 | * If we don't have an ASAR we need to check if the two "app" folders are identical, if 211 | * they are then we can just leave one there and call it a day. If the app folders for x64 212 | * and arm64 are different though we need to rename each folder and create a new fake "app" 213 | * entrypoint to dynamically load the correct app folder 214 | */ 215 | if (x64AsarMode === AsarMode.NO_ASAR) { 216 | d('checking if the x64 and arm64 app folders are identical'); 217 | const comparison = await dircompare.compare( 218 | path.resolve(tmpApp, 'Contents', 'Resources', 'app'), 219 | path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), 220 | { compareSize: true, compareContent: true }, 221 | ); 222 | const differences = comparison.diffSet!.filter((difference) => difference.state !== 'equal'); 223 | d(`Found ${differences.length} difference(s) between the x64 and arm64 folders`); 224 | const nonMergedDifferences = differences.filter( 225 | (difference) => 226 | !difference.name1 || 227 | !knownMergedMachOFiles.has( 228 | path.join('Contents', 'Resources', 'app', difference.relativePath, difference.name1), 229 | ), 230 | ); 231 | d(`After discluding MachO files merged with lipo ${nonMergedDifferences.length} remain.`); 232 | 233 | if (nonMergedDifferences.length > 0) { 234 | d('x64 and arm64 app folders are different, creating dynamic entry ASAR'); 235 | await fs.move( 236 | path.resolve(tmpApp, 'Contents', 'Resources', 'app'), 237 | path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64'), 238 | ); 239 | await fs.copy( 240 | path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), 241 | path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64'), 242 | ); 243 | 244 | const entryAsar = path.resolve(tmpDir, 'entry-asar'); 245 | await fs.mkdir(entryAsar); 246 | await fs.copy( 247 | path.resolve(__dirname, '..', '..', 'entry-asar', 'no-asar.js'), 248 | path.resolve(entryAsar, 'index.js'), 249 | ); 250 | let pj = await fs.readJson( 251 | path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json'), 252 | ); 253 | pj.main = 'index.js'; 254 | await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); 255 | await asar.createPackage( 256 | entryAsar, 257 | path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), 258 | ); 259 | } else { 260 | d('x64 and arm64 app folders are the same'); 261 | } 262 | } 263 | 264 | /** 265 | * If we have an ASAR we just need to check if the two "app.asar" files have the same hash, 266 | * if they are, same as above, we can leave one there and call it a day. If they're different 267 | * we have to make a dynamic entrypoint. There is an assumption made here that every file in 268 | * app.asar.unpacked is a native node module. This assumption _may_ not be true so we should 269 | * look at codifying that assumption as actual logic. 270 | */ 271 | // FIXME: Codify the assumption that app.asar.unpacked only contains native modules 272 | if (x64AsarMode === AsarMode.HAS_ASAR && opts.mergeASARs) { 273 | d('merging x64 and arm64 asars'); 274 | const output = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'); 275 | await mergeASARs({ 276 | x64AsarPath: path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), 277 | arm64AsarPath: path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), 278 | outputAsarPath: output, 279 | singleArchFiles: opts.singleArchFiles, 280 | }); 281 | } else if (x64AsarMode === AsarMode.HAS_ASAR) { 282 | d('checking if the x64 and arm64 asars are identical'); 283 | const x64AsarSha = await sha(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar')); 284 | const arm64AsarSha = await sha( 285 | path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), 286 | ); 287 | 288 | if (x64AsarSha !== arm64AsarSha) { 289 | d('x64 and arm64 asars are different'); 290 | const x64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar'); 291 | await fs.move(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), x64AsarPath); 292 | const x64Unpacked = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar.unpacked'); 293 | if (await fs.pathExists(x64Unpacked)) { 294 | await fs.move( 295 | x64Unpacked, 296 | path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar.unpacked'), 297 | ); 298 | } 299 | 300 | const arm64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar'); 301 | await fs.copy( 302 | path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), 303 | arm64AsarPath, 304 | ); 305 | const arm64Unpacked = path.resolve( 306 | opts.arm64AppPath, 307 | 'Contents', 308 | 'Resources', 309 | 'app.asar.unpacked', 310 | ); 311 | if (await fs.pathExists(arm64Unpacked)) { 312 | await fs.copy( 313 | arm64Unpacked, 314 | path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar.unpacked'), 315 | ); 316 | } 317 | 318 | const entryAsar = path.resolve(tmpDir, 'entry-asar'); 319 | await fs.mkdir(entryAsar); 320 | await fs.copy( 321 | path.resolve(__dirname, '..', '..', 'entry-asar', 'has-asar.js'), 322 | path.resolve(entryAsar, 'index.js'), 323 | ); 324 | let pj = JSON.parse( 325 | ( 326 | await asar.extractFile( 327 | path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app.asar'), 328 | 'package.json', 329 | ) 330 | ).toString('utf8'), 331 | ); 332 | pj.main = 'index.js'; 333 | await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj); 334 | const asarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'); 335 | await asar.createPackage(entryAsar, asarPath); 336 | } else { 337 | d('x64 and arm64 asars are the same'); 338 | } 339 | } 340 | 341 | const generatedIntegrity = await computeIntegrityData(path.join(tmpApp, 'Contents')); 342 | 343 | const plistFiles = x64Files.filter((f) => f.type === AppFileType.INFO_PLIST); 344 | for (const plistFile of plistFiles) { 345 | const x64PlistPath = path.resolve(opts.x64AppPath, plistFile.relativePath); 346 | const arm64PlistPath = path.resolve(opts.arm64AppPath, plistFile.relativePath); 347 | 348 | const { ElectronAsarIntegrity: x64Integrity, ...x64Plist } = plist.parse( 349 | await fs.readFile(x64PlistPath, 'utf8'), 350 | ) as any; 351 | const { ElectronAsarIntegrity: arm64Integrity, ...arm64Plist } = plist.parse( 352 | await fs.readFile(arm64PlistPath, 'utf8'), 353 | ) as any; 354 | if (JSON.stringify(x64Plist) !== JSON.stringify(arm64Plist)) { 355 | throw new Error( 356 | `Expected all Info.plist files to be identical when ignoring integrity when creating a universal build but "${plistFile.relativePath}" was not`, 357 | ); 358 | } 359 | 360 | const injectAsarIntegrity = 361 | !opts.infoPlistsToIgnore || 362 | minimatch(plistFile.relativePath, opts.infoPlistsToIgnore, { matchBase: true }); 363 | const mergedPlist = injectAsarIntegrity 364 | ? { ...x64Plist, ElectronAsarIntegrity: generatedIntegrity } 365 | : { ...x64Plist }; 366 | 367 | await fs.writeFile(path.resolve(tmpApp, plistFile.relativePath), plist.build(mergedPlist)); 368 | } 369 | 370 | for (const snapshotsFile of arm64Files.filter((f) => f.type === AppFileType.SNAPSHOT)) { 371 | d('copying snapshot file', snapshotsFile.relativePath, 'to target application'); 372 | await fs.copy( 373 | path.resolve(opts.arm64AppPath, snapshotsFile.relativePath), 374 | path.resolve(tmpApp, snapshotsFile.relativePath), 375 | ); 376 | } 377 | 378 | d('moving final universal app to target destination'); 379 | await fs.mkdirp(path.dirname(opts.outAppPath)); 380 | await spawn('mv', [tmpApp, opts.outAppPath]); 381 | } catch (err) { 382 | throw err; 383 | } finally { 384 | await fs.remove(tmpDir); 385 | } 386 | }; 387 | -------------------------------------------------------------------------------- /src/integrity.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra'; 2 | import path from 'path'; 3 | import { AppFileType, getAllAppFiles } from './file-utils'; 4 | import { sha } from './sha'; 5 | import { generateAsarIntegrity } from './asar-utils'; 6 | 7 | type IntegrityMap = { 8 | [filepath: string]: string; 9 | }; 10 | 11 | export interface HeaderHash { 12 | algorithm: 'SHA256'; 13 | hash: string; 14 | } 15 | 16 | export interface AsarIntegrity { 17 | [key: string]: HeaderHash; 18 | } 19 | 20 | export async function computeIntegrityData(contentsPath: string): Promise { 21 | const root = await fs.realpath(contentsPath); 22 | 23 | const resourcesRelativePath = 'Resources'; 24 | const resourcesPath = path.resolve(root, resourcesRelativePath); 25 | 26 | const resources = await getAllAppFiles(resourcesPath); 27 | const resourceAsars = resources 28 | .filter((file) => file.type === AppFileType.APP_CODE) 29 | .reduce( 30 | (prev, file) => ({ 31 | ...prev, 32 | [path.join(resourcesRelativePath, file.relativePath)]: path.join( 33 | resourcesPath, 34 | file.relativePath, 35 | ), 36 | }), 37 | {}, 38 | ); 39 | 40 | // sort to produce constant result 41 | const allAsars = Object.entries(resourceAsars).sort(([name1], [name2]) => 42 | name1.localeCompare(name2), 43 | ); 44 | const hashes = await Promise.all(allAsars.map(async ([, from]) => generateAsarIntegrity(from))); 45 | const asarIntegrity: AsarIntegrity = {}; 46 | for (let i = 0; i < allAsars.length; i++) { 47 | const [asar] = allAsars[i]; 48 | asarIntegrity[asar] = hashes[i]; 49 | } 50 | return asarIntegrity; 51 | } 52 | -------------------------------------------------------------------------------- /src/sha.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra'; 2 | import * as crypto from 'crypto'; 3 | import { pipeline } from 'stream/promises'; 4 | 5 | import { d } from './debug'; 6 | 7 | export const sha = async (filePath: string) => { 8 | d('hashing', filePath); 9 | const hash = crypto.createHash('sha256'); 10 | hash.setEncoding('hex'); 11 | await pipeline(fs.createReadStream(filePath), hash); 12 | return hash.read(); 13 | }; 14 | -------------------------------------------------------------------------------- /test/__snapshots__/index.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`makeUniversalApp asar mode should correctly merge two identical asars 1`] = ` 4 | { 5 | "files": { 6 | "index.js": { 7 | "integrity": { 8 | "algorithm": "SHA256", 9 | "blockSize": 4194304, 10 | "blocks": [ 11 | "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", 12 | ], 13 | "hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", 14 | }, 15 | "size": 64, 16 | }, 17 | "package.json": { 18 | "integrity": { 19 | "algorithm": "SHA256", 20 | "blockSize": 4194304, 21 | "blocks": [ 22 | "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 23 | ], 24 | "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 25 | }, 26 | "size": 41, 27 | }, 28 | }, 29 | } 30 | `; 31 | 32 | exports[`makeUniversalApp asar mode should correctly merge two identical asars 2`] = ` 33 | { 34 | "Contents/Info.plist": { 35 | "Resources/app.asar": { 36 | "algorithm": "SHA256", 37 | "hash": "85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf", 38 | }, 39 | }, 40 | } 41 | `; 42 | 43 | exports[`makeUniversalApp asar mode should create a shim if asars are different between architectures 1`] = ` 44 | { 45 | "files": { 46 | "extra-file.txt": { 47 | "integrity": { 48 | "algorithm": "SHA256", 49 | "blockSize": 4194304, 50 | "blocks": [ 51 | "b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de", 52 | ], 53 | "hash": "b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de", 54 | }, 55 | "size": 15, 56 | }, 57 | "index.js": { 58 | "integrity": { 59 | "algorithm": "SHA256", 60 | "blockSize": 4194304, 61 | "blocks": [ 62 | "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", 63 | ], 64 | "hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", 65 | }, 66 | "size": 64, 67 | }, 68 | "package.json": { 69 | "integrity": { 70 | "algorithm": "SHA256", 71 | "blockSize": 4194304, 72 | "blocks": [ 73 | "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 74 | ], 75 | "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 76 | }, 77 | "size": 41, 78 | }, 79 | }, 80 | } 81 | `; 82 | 83 | exports[`makeUniversalApp asar mode should create a shim if asars are different between architectures 2`] = ` 84 | { 85 | "files": { 86 | "index.js": { 87 | "integrity": { 88 | "algorithm": "SHA256", 89 | "blockSize": 4194304, 90 | "blocks": [ 91 | "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", 92 | ], 93 | "hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", 94 | }, 95 | "size": 64, 96 | }, 97 | "package.json": { 98 | "integrity": { 99 | "algorithm": "SHA256", 100 | "blockSize": 4194304, 101 | "blocks": [ 102 | "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 103 | ], 104 | "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 105 | }, 106 | "size": 41, 107 | }, 108 | }, 109 | } 110 | `; 111 | 112 | exports[`makeUniversalApp asar mode should create a shim if asars are different between architectures 3`] = ` 113 | { 114 | "files": { 115 | "index.js": { 116 | "integrity": { 117 | "algorithm": "SHA256", 118 | "blockSize": 4194304, 119 | "blocks": [ 120 | "b7e5f58d3c0fddc1a57d1279a7f19a34a01784f4036920d4b60a1e33f6d1635b", 121 | ], 122 | "hash": "b7e5f58d3c0fddc1a57d1279a7f19a34a01784f4036920d4b60a1e33f6d1635b", 123 | }, 124 | "size": 1068, 125 | }, 126 | "package.json": { 127 | "integrity": { 128 | "algorithm": "SHA256", 129 | "blockSize": 4194304, 130 | "blocks": [ 131 | "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7", 132 | ], 133 | "hash": "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7", 134 | }, 135 | "size": 33, 136 | }, 137 | }, 138 | } 139 | `; 140 | 141 | exports[`makeUniversalApp asar mode should create a shim if asars are different between architectures 4`] = ` 142 | { 143 | "Contents/Info.plist": { 144 | "Resources/app-arm64.asar": { 145 | "algorithm": "SHA256", 146 | "hash": "71db54541357128943df64d54480a22d0cdf4c283f2044f48101fb1fc6e6fb2d", 147 | }, 148 | "Resources/app-x64.asar": { 149 | "algorithm": "SHA256", 150 | "hash": "85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf", 151 | }, 152 | "Resources/app.asar": { 153 | "algorithm": "SHA256", 154 | "hash": "b62aaaed07ff72dc33da1720d900e0443c060285ef374ce1bdaef1d4f28b5fe4", 155 | }, 156 | }, 157 | } 158 | `; 159 | 160 | exports[`makeUniversalApp asar mode should generate AsarIntegrity for all asars in the application 1`] = ` 161 | { 162 | "files": { 163 | "index.js": { 164 | "integrity": { 165 | "algorithm": "SHA256", 166 | "blockSize": 4194304, 167 | "blocks": [ 168 | "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8", 169 | ], 170 | "hash": "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8", 171 | }, 172 | "size": 66, 173 | }, 174 | "package.json": { 175 | "integrity": { 176 | "algorithm": "SHA256", 177 | "blockSize": 4194304, 178 | "blocks": [ 179 | "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 180 | ], 181 | "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 182 | }, 183 | "size": 41, 184 | }, 185 | "private": { 186 | "files": { 187 | "var": { 188 | "files": { 189 | "app": { 190 | "files": { 191 | "file.txt": { 192 | "link": "private/var/file.txt", 193 | }, 194 | }, 195 | }, 196 | "file.txt": { 197 | "integrity": { 198 | "algorithm": "SHA256", 199 | "blockSize": 4194304, 200 | "blocks": [ 201 | "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", 202 | ], 203 | "hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", 204 | }, 205 | "size": 11, 206 | }, 207 | }, 208 | }, 209 | }, 210 | }, 211 | "var": { 212 | "link": "private/var", 213 | }, 214 | }, 215 | } 216 | `; 217 | 218 | exports[`makeUniversalApp asar mode should generate AsarIntegrity for all asars in the application 2`] = ` 219 | { 220 | "files": { 221 | "index.js": { 222 | "integrity": { 223 | "algorithm": "SHA256", 224 | "blockSize": 4194304, 225 | "blocks": [ 226 | "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8", 227 | ], 228 | "hash": "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8", 229 | }, 230 | "size": 66, 231 | }, 232 | "package.json": { 233 | "integrity": { 234 | "algorithm": "SHA256", 235 | "blockSize": 4194304, 236 | "blocks": [ 237 | "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 238 | ], 239 | "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 240 | }, 241 | "size": 41, 242 | }, 243 | "private": { 244 | "files": { 245 | "var": { 246 | "files": { 247 | "app": { 248 | "files": { 249 | "file.txt": { 250 | "link": "private/var/file.txt", 251 | }, 252 | }, 253 | }, 254 | "file.txt": { 255 | "integrity": { 256 | "algorithm": "SHA256", 257 | "blockSize": 4194304, 258 | "blocks": [ 259 | "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", 260 | ], 261 | "hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", 262 | }, 263 | "size": 11, 264 | }, 265 | }, 266 | }, 267 | }, 268 | }, 269 | "var": { 270 | "link": "private/var", 271 | }, 272 | }, 273 | } 274 | `; 275 | 276 | exports[`makeUniversalApp asar mode should generate AsarIntegrity for all asars in the application 3`] = ` 277 | { 278 | "Contents/Info.plist": { 279 | "Resources/app.asar": { 280 | "algorithm": "SHA256", 281 | "hash": "7e6af4d00f4cc737eff922e2b386128a269f80887b79a011022f1276bdbe7832", 282 | }, 283 | "Resources/webbapp.asar": { 284 | "algorithm": "SHA256", 285 | "hash": "7e6af4d00f4cc737eff922e2b386128a269f80887b79a011022f1276bdbe7832", 286 | }, 287 | }, 288 | } 289 | `; 290 | 291 | exports[`makeUniversalApp asar mode should merge two different asars when \`mergeASARs\` is enabled 1`] = ` 292 | { 293 | "files": { 294 | "extra-file.txt": { 295 | "integrity": { 296 | "algorithm": "SHA256", 297 | "blockSize": 4194304, 298 | "blocks": [ 299 | "b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de", 300 | ], 301 | "hash": "b8f261b95f81761658c8875b33a68001d8750fd898f447373bf6347e779bc3de", 302 | }, 303 | "size": 15, 304 | }, 305 | "index.js": { 306 | "integrity": { 307 | "algorithm": "SHA256", 308 | "blockSize": 4194304, 309 | "blocks": [ 310 | "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", 311 | ], 312 | "hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", 313 | }, 314 | "size": 64, 315 | }, 316 | "package.json": { 317 | "integrity": { 318 | "algorithm": "SHA256", 319 | "blockSize": 4194304, 320 | "blocks": [ 321 | "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 322 | ], 323 | "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 324 | }, 325 | "size": 41, 326 | }, 327 | }, 328 | } 329 | `; 330 | 331 | exports[`makeUniversalApp asar mode should merge two different asars when \`mergeASARs\` is enabled 2`] = ` 332 | { 333 | "Contents/Info.plist": { 334 | "Resources/app.asar": { 335 | "algorithm": "SHA256", 336 | "hash": "71db54541357128943df64d54480a22d0cdf4c283f2044f48101fb1fc6e6fb2d", 337 | }, 338 | }, 339 | } 340 | `; 341 | 342 | exports[`makeUniversalApp asar mode should not inject ElectronAsarIntegrity into \`infoPlistsToIgnore\` 1`] = ` 343 | { 344 | "files": { 345 | "index.js": { 346 | "integrity": { 347 | "algorithm": "SHA256", 348 | "blockSize": 4194304, 349 | "blocks": [ 350 | "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8", 351 | ], 352 | "hash": "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8", 353 | }, 354 | "size": 66, 355 | }, 356 | "package.json": { 357 | "integrity": { 358 | "algorithm": "SHA256", 359 | "blockSize": 4194304, 360 | "blocks": [ 361 | "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 362 | ], 363 | "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 364 | }, 365 | "size": 41, 366 | }, 367 | "private": { 368 | "files": { 369 | "var": { 370 | "files": { 371 | "app": { 372 | "files": { 373 | "file.txt": { 374 | "link": "private/var/file.txt", 375 | }, 376 | }, 377 | }, 378 | "file.txt": { 379 | "integrity": { 380 | "algorithm": "SHA256", 381 | "blockSize": 4194304, 382 | "blocks": [ 383 | "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", 384 | ], 385 | "hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", 386 | }, 387 | "size": 11, 388 | }, 389 | }, 390 | }, 391 | }, 392 | }, 393 | "var": { 394 | "link": "private/var", 395 | }, 396 | }, 397 | } 398 | `; 399 | 400 | exports[`makeUniversalApp asar mode should not inject ElectronAsarIntegrity into \`infoPlistsToIgnore\` 2`] = ` 401 | { 402 | "Contents/Info.plist": undefined, 403 | "Contents/Resources/SubApp-1.app/Contents/Info.plist": undefined, 404 | } 405 | `; 406 | 407 | exports[`makeUniversalApp force packages successfully if \`out\` bundle already exists and \`force\` is \`true\` 1`] = ` 408 | { 409 | "files": { 410 | "index.js": { 411 | "integrity": { 412 | "algorithm": "SHA256", 413 | "blockSize": 4194304, 414 | "blocks": [ 415 | "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", 416 | ], 417 | "hash": "8c8cefe616b330a70980c457e479360417a320f53f484d34df65227ce3add026", 418 | }, 419 | "size": 64, 420 | }, 421 | "package.json": { 422 | "integrity": { 423 | "algorithm": "SHA256", 424 | "blockSize": 4194304, 425 | "blocks": [ 426 | "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 427 | ], 428 | "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 429 | }, 430 | "size": 41, 431 | }, 432 | }, 433 | } 434 | `; 435 | 436 | exports[`makeUniversalApp force packages successfully if \`out\` bundle already exists and \`force\` is \`true\` 2`] = ` 437 | { 438 | "Contents/Info.plist": { 439 | "Resources/app.asar": { 440 | "algorithm": "SHA256", 441 | "hash": "85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf", 442 | }, 443 | }, 444 | } 445 | `; 446 | 447 | exports[`makeUniversalApp no asar mode different app dirs with different macho files (shim and lipo) 1`] = ` 448 | { 449 | "files": { 450 | "index.js": { 451 | "integrity": { 452 | "algorithm": "SHA256", 453 | "blockSize": 4194304, 454 | "blocks": [ 455 | "f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e", 456 | ], 457 | "hash": "f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e", 458 | }, 459 | "size": 1063, 460 | }, 461 | "package.json": { 462 | "integrity": { 463 | "algorithm": "SHA256", 464 | "blockSize": 4194304, 465 | "blocks": [ 466 | "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7", 467 | ], 468 | "hash": "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7", 469 | }, 470 | "size": 33, 471 | }, 472 | }, 473 | } 474 | `; 475 | 476 | exports[`makeUniversalApp no asar mode different app dirs with different macho files (shim and lipo) 2`] = ` 477 | [ 478 | "private/var/i-aint-got-no-rhythm.bin", 479 | ] 480 | `; 481 | 482 | exports[`makeUniversalApp no asar mode different app dirs with different macho files (shim and lipo) 3`] = ` 483 | [ 484 | "hello-world", 485 | "index.js", 486 | { 487 | "content": "{ 488 | "name": "app", 489 | "main": "index.js" 490 | }", 491 | "name": "package.json", 492 | }, 493 | { 494 | "content": "hello world", 495 | "name": "private/var/file.txt", 496 | }, 497 | "private/var/i-aint-got-no-rhythm.bin", 498 | ] 499 | `; 500 | 501 | exports[`makeUniversalApp no asar mode different app dirs with different macho files (shim and lipo) 4`] = ` 502 | [ 503 | "hello-world", 504 | "index.js", 505 | { 506 | "content": "{ 507 | "name": "app", 508 | "main": "index.js" 509 | }", 510 | "name": "package.json", 511 | }, 512 | { 513 | "content": "hello world", 514 | "name": "private/var/file.txt", 515 | }, 516 | "private/var/hello-world.bin", 517 | ] 518 | `; 519 | 520 | exports[`makeUniversalApp no asar mode different app dirs with different macho files (shim and lipo) 5`] = ` 521 | { 522 | "Contents/Info.plist": { 523 | "Resources/app.asar": { 524 | "algorithm": "SHA256", 525 | "hash": "", 526 | }, 527 | }, 528 | } 529 | `; 530 | 531 | exports[`makeUniversalApp no asar mode different app dirs with universal macho files (shim but don't lipo) 1`] = ` 532 | { 533 | "files": { 534 | "index.js": { 535 | "integrity": { 536 | "algorithm": "SHA256", 537 | "blockSize": 4194304, 538 | "blocks": [ 539 | "f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e", 540 | ], 541 | "hash": "f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e", 542 | }, 543 | "size": 1063, 544 | }, 545 | "package.json": { 546 | "integrity": { 547 | "algorithm": "SHA256", 548 | "blockSize": 4194304, 549 | "blocks": [ 550 | "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7", 551 | ], 552 | "hash": "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7", 553 | }, 554 | "size": 33, 555 | }, 556 | }, 557 | } 558 | `; 559 | 560 | exports[`makeUniversalApp no asar mode different app dirs with universal macho files (shim but don't lipo) 2`] = ` 561 | [ 562 | "private/var/i-aint-got-no-rhythm.bin", 563 | ] 564 | `; 565 | 566 | exports[`makeUniversalApp no asar mode different app dirs with universal macho files (shim but don't lipo) 3`] = ` 567 | [ 568 | "hello-world", 569 | "index.js", 570 | { 571 | "content": "{ 572 | "name": "app", 573 | "main": "index.js" 574 | }", 575 | "name": "package.json", 576 | }, 577 | { 578 | "content": "hello world", 579 | "name": "private/var/file.txt", 580 | }, 581 | "private/var/i-aint-got-no-rhythm.bin", 582 | ] 583 | `; 584 | 585 | exports[`makeUniversalApp no asar mode different app dirs with universal macho files (shim but don't lipo) 4`] = ` 586 | [ 587 | "hello-world", 588 | "index.js", 589 | { 590 | "content": "{ 591 | "name": "app", 592 | "main": "index.js" 593 | }", 594 | "name": "package.json", 595 | }, 596 | { 597 | "content": "hello world", 598 | "name": "private/var/file.txt", 599 | }, 600 | "private/var/hello-world.bin", 601 | ] 602 | `; 603 | 604 | exports[`makeUniversalApp no asar mode different app dirs with universal macho files (shim but don't lipo) 5`] = ` 605 | { 606 | "Contents/Info.plist": { 607 | "Resources/app.asar": { 608 | "algorithm": "SHA256", 609 | "hash": "", 610 | }, 611 | }, 612 | } 613 | `; 614 | 615 | exports[`makeUniversalApp no asar mode identical app dirs with different macho files (e.g. do not shim, but still lipo) 1`] = ` 616 | [ 617 | "hello-world", 618 | "index.js", 619 | { 620 | "content": "{ 621 | "name": "app", 622 | "main": "index.js" 623 | }", 624 | "name": "package.json", 625 | }, 626 | { 627 | "content": "hello world", 628 | "name": "private/var/file.txt", 629 | }, 630 | ] 631 | `; 632 | 633 | exports[`makeUniversalApp no asar mode identical app dirs with different macho files (e.g. do not shim, but still lipo) 2`] = ` 634 | { 635 | "Contents/Info.plist": {}, 636 | } 637 | `; 638 | 639 | exports[`makeUniversalApp no asar mode identical app dirs with universal macho files (e.g., do not shim, just copy x64 dir) 1`] = ` 640 | [ 641 | "hello-world", 642 | "index.js", 643 | { 644 | "content": "{ 645 | "name": "app", 646 | "main": "index.js" 647 | }", 648 | "name": "package.json", 649 | }, 650 | { 651 | "content": "hello world", 652 | "name": "private/var/file.txt", 653 | }, 654 | ] 655 | `; 656 | 657 | exports[`makeUniversalApp no asar mode identical app dirs with universal macho files (e.g., do not shim, just copy x64 dir) 2`] = ` 658 | { 659 | "Contents/Info.plist": {}, 660 | } 661 | `; 662 | 663 | exports[`makeUniversalApp no asar mode should correctly merge two identical app folders 1`] = ` 664 | [ 665 | "index.js", 666 | { 667 | "content": "{ 668 | "name": "app", 669 | "main": "index.js" 670 | }", 671 | "name": "package.json", 672 | }, 673 | ] 674 | `; 675 | 676 | exports[`makeUniversalApp no asar mode should correctly merge two identical app folders 2`] = ` 677 | { 678 | "Contents/Info.plist": {}, 679 | } 680 | `; 681 | 682 | exports[`makeUniversalApp no asar mode should shim two different app folders 1`] = ` 683 | { 684 | "files": { 685 | "index.js": { 686 | "integrity": { 687 | "algorithm": "SHA256", 688 | "blockSize": 4194304, 689 | "blocks": [ 690 | "f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e", 691 | ], 692 | "hash": "f1e14240f7c833900fca84fabc2f0ff27084efdf1c5b228b015515de3f8fa28e", 693 | }, 694 | "size": 1063, 695 | }, 696 | "package.json": { 697 | "integrity": { 698 | "algorithm": "SHA256", 699 | "blockSize": 4194304, 700 | "blocks": [ 701 | "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7", 702 | ], 703 | "hash": "2873266521e41d58d02e7acfbbbdb046edfa04b6ce262b8987de8e8548671fc7", 704 | }, 705 | "size": 33, 706 | }, 707 | }, 708 | } 709 | `; 710 | 711 | exports[`makeUniversalApp no asar mode should shim two different app folders 2`] = ` 712 | [ 713 | "private/var/i-aint-got-no-rhythm.bin", 714 | ] 715 | `; 716 | 717 | exports[`makeUniversalApp no asar mode should shim two different app folders 3`] = ` 718 | [ 719 | "index.js", 720 | { 721 | "content": "{ 722 | "name": "app", 723 | "main": "index.js" 724 | }", 725 | "name": "package.json", 726 | }, 727 | { 728 | "content": "hello world", 729 | "name": "private/var/file.txt", 730 | }, 731 | "private/var/i-aint-got-no-rhythm.bin", 732 | ] 733 | `; 734 | 735 | exports[`makeUniversalApp no asar mode should shim two different app folders 4`] = ` 736 | [ 737 | "index.js", 738 | { 739 | "content": "{ 740 | "name": "app", 741 | "main": "index.js" 742 | }", 743 | "name": "package.json", 744 | }, 745 | { 746 | "content": "hello world", 747 | "name": "private/var/file.txt", 748 | }, 749 | "private/var/hello-world.bin", 750 | ] 751 | `; 752 | 753 | exports[`makeUniversalApp no asar mode should shim two different app folders 5`] = ` 754 | { 755 | "Contents/Info.plist": { 756 | "Resources/app.asar": { 757 | "algorithm": "SHA256", 758 | "hash": "27433ee3e34b3b0dabb29d18d40646126e80c56dbce8c4bb2adef7278b5a46c0", 759 | }, 760 | }, 761 | } 762 | `; 763 | 764 | exports[`makeUniversalApp works for lipo binary resources 1`] = ` 765 | { 766 | "files": { 767 | "hello-world": "", 768 | "index.js": { 769 | "integrity": { 770 | "algorithm": "SHA256", 771 | "blockSize": 4194304, 772 | "blocks": [ 773 | "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8", 774 | ], 775 | "hash": "0f6311dac07f0876c436ce2be042eb88c96e17eaf140b39627cf720dd87ad5b8", 776 | }, 777 | "size": 66, 778 | }, 779 | "package.json": { 780 | "integrity": { 781 | "algorithm": "SHA256", 782 | "blockSize": 4194304, 783 | "blocks": [ 784 | "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 785 | ], 786 | "hash": "d6226276d47adc7aa20e6c46e842e258f5157313074a035051a89612acdd6be3", 787 | }, 788 | "size": 41, 789 | }, 790 | "private": { 791 | "files": { 792 | "var": { 793 | "files": { 794 | "app": { 795 | "files": { 796 | "file.txt": { 797 | "link": "private/var/file.txt", 798 | }, 799 | }, 800 | }, 801 | "file.txt": { 802 | "integrity": { 803 | "algorithm": "SHA256", 804 | "blockSize": 4194304, 805 | "blocks": [ 806 | "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", 807 | ], 808 | "hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", 809 | }, 810 | "size": 11, 811 | }, 812 | }, 813 | }, 814 | }, 815 | }, 816 | "var": { 817 | "link": "private/var", 818 | }, 819 | }, 820 | } 821 | `; 822 | 823 | exports[`makeUniversalApp works for lipo binary resources 2`] = `[]`; 824 | 825 | exports[`makeUniversalApp works for lipo binary resources 3`] = ` 826 | [ 827 | "hello-world", 828 | ] 829 | `; 830 | 831 | exports[`makeUniversalApp works for lipo binary resources 4`] = ` 832 | { 833 | "Contents/Info.plist": { 834 | "Resources/app.asar": { 835 | "algorithm": "SHA256", 836 | "hash": "", 837 | }, 838 | }, 839 | } 840 | `; 841 | -------------------------------------------------------------------------------- /test/asar-utils.spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import { AsarMode, detectAsarMode, generateAsarIntegrity } from '../src/asar-utils'; 4 | import { describe, expect, it } from '@jest/globals'; 5 | 6 | const asarsPath = path.resolve(__dirname, 'fixtures', 'asars'); 7 | const appsPath = path.resolve(__dirname, 'fixtures', 'apps'); 8 | 9 | describe('asar-utils', () => { 10 | describe('detectAsarMode', () => { 11 | it('should correctly detect an asar enabled app', async () => { 12 | expect(await detectAsarMode(path.resolve(appsPath, 'Arm64Asar.app'))).toBe(AsarMode.HAS_ASAR); 13 | }); 14 | 15 | it('should correctly detect an app without an asar', async () => { 16 | expect(await detectAsarMode(path.resolve(appsPath, 'Arm64NoAsar.app'))).toBe( 17 | AsarMode.NO_ASAR, 18 | ); 19 | }); 20 | }); 21 | 22 | describe('generateAsarIntegrity', () => { 23 | it('should deterministically hash an asar header', async () => { 24 | expect(generateAsarIntegrity(path.resolve(asarsPath, 'app.asar')).hash).toEqual( 25 | '85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf', 26 | ); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/file-utils.spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import { AppFile, AppFileType, getAllAppFiles } from '../src/file-utils'; 4 | import { beforeAll, describe, expect, it } from '@jest/globals'; 5 | 6 | const appsPath = path.resolve(__dirname, 'fixtures', 'apps'); 7 | 8 | describe('file-utils', () => { 9 | describe('getAllAppFiles', () => { 10 | let asarFiles: AppFile[]; 11 | let noAsarFiles: AppFile[]; 12 | 13 | beforeAll(async () => { 14 | asarFiles = await getAllAppFiles(path.resolve(appsPath, 'Arm64Asar.app')); 15 | noAsarFiles = await getAllAppFiles(path.resolve(appsPath, 'Arm64NoAsar.app')); 16 | }); 17 | 18 | it('should correctly identify plist files', async () => { 19 | expect(asarFiles.find((f) => f.relativePath === 'Contents/Info.plist')?.type).toBe( 20 | AppFileType.INFO_PLIST, 21 | ); 22 | }); 23 | 24 | it('should correctly identify asar files as app code', async () => { 25 | expect(asarFiles.find((f) => f.relativePath === 'Contents/Resources/app.asar')?.type).toBe( 26 | AppFileType.APP_CODE, 27 | ); 28 | }); 29 | 30 | it('should correctly identify non-asar code files as plain text', async () => { 31 | expect( 32 | noAsarFiles.find((f) => f.relativePath === 'Contents/Resources/app/index.js')?.type, 33 | ).toBe(AppFileType.PLAIN); 34 | }); 35 | 36 | it('should correctly identify the Electron binary as Mach-O', async () => { 37 | expect(noAsarFiles.find((f) => f.relativePath === 'Contents/MacOS/Electron')?.type).toBe( 38 | AppFileType.MACHO, 39 | ); 40 | }); 41 | 42 | it('should correctly identify the Electron Framework as Mach-O', async () => { 43 | expect( 44 | noAsarFiles.find( 45 | (f) => 46 | f.relativePath === 47 | 'Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework', 48 | )?.type, 49 | ).toBe(AppFileType.MACHO); 50 | }); 51 | 52 | it('should correctly identify the v8 context snapshot', async () => { 53 | expect( 54 | noAsarFiles.find( 55 | (f) => 56 | f.relativePath === 57 | 'Contents/Frameworks/Electron Framework.framework/Versions/A/Resources/v8_context_snapshot.arm64.bin', 58 | )?.type, 59 | ).toBe(AppFileType.SNAPSHOT); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/fixtures/asars/app.asar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electron/universal/175672e430340ae5ac863ae02619a3d2ea438298/test/fixtures/asars/app.asar -------------------------------------------------------------------------------- /test/fixtures/asars/app/index.js: -------------------------------------------------------------------------------- 1 | console.log('I am an app folder', process.arch); 2 | process.exit(0); 3 | -------------------------------------------------------------------------------- /test/fixtures/asars/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "main": "index.js" 4 | } -------------------------------------------------------------------------------- /test/fixtures/asars/app2.asar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electron/universal/175672e430340ae5ac863ae02619a3d2ea438298/test/fixtures/asars/app2.asar -------------------------------------------------------------------------------- /test/fixtures/asars/app2/extra-file.txt: -------------------------------------------------------------------------------- 1 | erick was here! -------------------------------------------------------------------------------- /test/fixtures/asars/app2/index.js: -------------------------------------------------------------------------------- 1 | console.log('I am an app.asar', process.arch); 2 | process.exit(0); 3 | -------------------------------------------------------------------------------- /test/fixtures/asars/app2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "main": "index.js" 4 | } -------------------------------------------------------------------------------- /test/fixtures/hello-world.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | int main() { 4 | printf("Hello, World!\n"); 5 | return 0; 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/tohash: -------------------------------------------------------------------------------- 1 | hello there -------------------------------------------------------------------------------- /test/index.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra'; 2 | import * as path from 'path'; 3 | 4 | import { makeUniversalApp } from '../dist/cjs/index'; 5 | import { 6 | createStagingAppDir, 7 | generateNativeApp, 8 | templateApp, 9 | VERIFY_APP_TIMEOUT, 10 | verifyApp, 11 | } from './util'; 12 | import { createPackage, createPackageWithOptions } from '@electron/asar'; 13 | import { afterEach, describe, expect, it } from '@jest/globals'; 14 | 15 | const appsPath = path.resolve(__dirname, 'fixtures', 'apps'); 16 | const appsOutPath = path.resolve(__dirname, 'fixtures', 'apps', 'out'); 17 | 18 | // See `jest.setup.ts` for app fixture setup process 19 | describe('makeUniversalApp', () => { 20 | afterEach(async () => { 21 | await fs.emptyDir(appsOutPath); 22 | }); 23 | 24 | it('throws an error if asar is only detected in one arch', async () => { 25 | const out = path.resolve(appsOutPath, 'Error.app'); 26 | await expect( 27 | makeUniversalApp({ 28 | x64AppPath: path.resolve(appsPath, 'X64Asar.app'), 29 | arm64AppPath: path.resolve(appsPath, 'Arm64NoAsar.app'), 30 | outAppPath: out, 31 | }), 32 | ).rejects.toThrow( 33 | 'Both the x64 and arm64 versions of your application need to have been built with the same asar settings (enabled vs disabled)', 34 | ); 35 | }); 36 | 37 | it( 38 | 'works for lipo binary resources', 39 | async () => { 40 | const x64AppPath = await generateNativeApp({ 41 | appNameWithExtension: 'LipoX64.app', 42 | arch: 'x64', 43 | createAsar: true, 44 | }); 45 | const arm64AppPath = await generateNativeApp({ 46 | appNameWithExtension: 'LipoArm64.app', 47 | arch: 'arm64', 48 | createAsar: true, 49 | }); 50 | 51 | const out = path.resolve(appsOutPath, 'Lipo.app'); 52 | await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath: out, mergeASARs: true }); 53 | await verifyApp(out, true); 54 | }, 55 | VERIFY_APP_TIMEOUT, 56 | ); 57 | 58 | describe('force', () => { 59 | it('throws an error if `out` bundle already exists and `force` is `false`', async () => { 60 | const out = path.resolve(appsOutPath, 'Error.app'); 61 | await fs.mkdirp(out); 62 | await expect( 63 | makeUniversalApp({ 64 | x64AppPath: path.resolve(appsPath, 'X64Asar.app'), 65 | arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'), 66 | outAppPath: out, 67 | }), 68 | ).rejects.toThrow(/The out path ".*" already exists and force is not set to true/); 69 | }); 70 | 71 | it( 72 | 'packages successfully if `out` bundle already exists and `force` is `true`', 73 | async () => { 74 | const out = path.resolve(appsOutPath, 'NoError.app'); 75 | await fs.mkdirp(out); 76 | await makeUniversalApp({ 77 | x64AppPath: path.resolve(appsPath, 'X64Asar.app'), 78 | arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'), 79 | outAppPath: out, 80 | force: true, 81 | }); 82 | await verifyApp(out); 83 | }, 84 | VERIFY_APP_TIMEOUT, 85 | ); 86 | }); 87 | 88 | describe('asar mode', () => { 89 | it( 90 | 'should correctly merge two identical asars', 91 | async () => { 92 | const out = path.resolve(appsOutPath, 'MergedAsar.app'); 93 | await makeUniversalApp({ 94 | x64AppPath: path.resolve(appsPath, 'X64Asar.app'), 95 | arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'), 96 | outAppPath: out, 97 | }); 98 | await verifyApp(out); 99 | }, 100 | VERIFY_APP_TIMEOUT, 101 | ); 102 | 103 | it( 104 | 'should create a shim if asars are different between architectures', 105 | async () => { 106 | const out = path.resolve(appsOutPath, 'ShimmedAsar.app'); 107 | await makeUniversalApp({ 108 | x64AppPath: path.resolve(appsPath, 'X64Asar.app'), 109 | arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'), 110 | outAppPath: out, 111 | }); 112 | await verifyApp(out); 113 | }, 114 | VERIFY_APP_TIMEOUT, 115 | ); 116 | 117 | it( 118 | 'should merge two different asars when `mergeASARs` is enabled', 119 | async () => { 120 | const out = path.resolve(appsOutPath, 'MergedAsar.app'); 121 | await makeUniversalApp({ 122 | x64AppPath: path.resolve(appsPath, 'X64Asar.app'), 123 | arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'), 124 | outAppPath: out, 125 | mergeASARs: true, 126 | singleArchFiles: 'extra-file.txt', 127 | }); 128 | await verifyApp(out); 129 | }, 130 | VERIFY_APP_TIMEOUT, 131 | ); 132 | 133 | it( 134 | 'throws an error if `mergeASARs` is enabled and `singleArchFiles` is missing a unique file', 135 | async () => { 136 | const out = path.resolve(appsOutPath, 'Error.app'); 137 | await expect( 138 | makeUniversalApp({ 139 | x64AppPath: path.resolve(appsPath, 'X64Asar.app'), 140 | arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'), 141 | outAppPath: out, 142 | mergeASARs: true, 143 | singleArchFiles: 'bad-rule', 144 | }), 145 | ).rejects.toThrow(/Detected unique file "extra-file\.txt"/); 146 | }, 147 | VERIFY_APP_TIMEOUT, 148 | ); 149 | 150 | it( 151 | 'should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`', 152 | async () => { 153 | const arm64AppPath = await templateApp('Arm64-1.app', 'arm64', async (appPath) => { 154 | const { testPath } = await createStagingAppDir('Arm64-1'); 155 | await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar')); 156 | await templateApp('SubApp-1.app', 'arm64', async (subArm64AppPath) => { 157 | await fs.move( 158 | subArm64AppPath, 159 | path.resolve(appPath, 'Contents', 'Resources', path.basename(subArm64AppPath)), 160 | ); 161 | }); 162 | }); 163 | const x64AppPath = await templateApp('X64-1.app', 'x64', async (appPath) => { 164 | const { testPath } = await createStagingAppDir('X64-1'); 165 | await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar')); 166 | await templateApp('SubApp-1.app', 'x64', async (subArm64AppPath) => { 167 | await fs.move( 168 | subArm64AppPath, 169 | path.resolve(appPath, 'Contents', 'Resources', path.basename(subArm64AppPath)), 170 | ); 171 | }); 172 | }); 173 | const outAppPath = path.resolve(appsOutPath, 'UnmodifiedPlist.app'); 174 | await makeUniversalApp({ 175 | x64AppPath, 176 | arm64AppPath, 177 | outAppPath, 178 | mergeASARs: true, 179 | infoPlistsToIgnore: 'SubApp-1.app/Contents/Info.plist', 180 | }); 181 | await verifyApp(outAppPath); 182 | }, 183 | VERIFY_APP_TIMEOUT, 184 | ); 185 | 186 | // TODO: Investigate if this should even be allowed. 187 | // Current logic detects all unpacked files as APP_CODE, which doesn't seem correct since it could also be a macho file requiring lipo 188 | // https://github.com/electron/universal/blob/d90d573ccf69a5b14b91aa818c8b97e0e6840399/src/file-utils.ts#L48-L49 189 | it.skip( 190 | 'should shim asars with different unpacked dirs', 191 | async () => { 192 | const arm64AppPath = await templateApp('UnpackedArm64.app', 'arm64', async (appPath) => { 193 | const { testPath } = await createStagingAppDir('UnpackedAppArm64'); 194 | await createPackageWithOptions( 195 | testPath, 196 | path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), 197 | { 198 | unpackDir: 'var', 199 | unpack: '*.txt', 200 | }, 201 | ); 202 | }); 203 | 204 | const x64AppPath = await templateApp('UnpackedX64.app', 'x64', async (appPath) => { 205 | const { testPath } = await createStagingAppDir('UnpackedAppX64'); 206 | await createPackageWithOptions( 207 | testPath, 208 | path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), 209 | {}, 210 | ); 211 | }); 212 | 213 | const outAppPath = path.resolve(appsOutPath, 'UnpackedDir.app'); 214 | await makeUniversalApp({ 215 | x64AppPath, 216 | arm64AppPath, 217 | outAppPath, 218 | }); 219 | await verifyApp(outAppPath); 220 | }, 221 | VERIFY_APP_TIMEOUT, 222 | ); 223 | 224 | it( 225 | 'should generate AsarIntegrity for all asars in the application', 226 | async () => { 227 | const { testPath } = await createStagingAppDir('app-2'); 228 | const testAsarPath = path.resolve(appsOutPath, 'app-2.asar'); 229 | await createPackage(testPath, testAsarPath); 230 | 231 | const arm64AppPath = await templateApp('Arm64-2.app', 'arm64', async (appPath) => { 232 | await fs.copyFile( 233 | testAsarPath, 234 | path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), 235 | ); 236 | await fs.copyFile( 237 | testAsarPath, 238 | path.resolve(appPath, 'Contents', 'Resources', 'webapp.asar'), 239 | ); 240 | }); 241 | const x64AppPath = await templateApp('X64-2.app', 'x64', async (appPath) => { 242 | await fs.copyFile( 243 | testAsarPath, 244 | path.resolve(appPath, 'Contents', 'Resources', 'app.asar'), 245 | ); 246 | await fs.copyFile( 247 | testAsarPath, 248 | path.resolve(appPath, 'Contents', 'Resources', 'webbapp.asar'), 249 | ); 250 | }); 251 | const outAppPath = path.resolve(appsOutPath, 'MultipleAsars.app'); 252 | await makeUniversalApp({ 253 | x64AppPath, 254 | arm64AppPath, 255 | outAppPath, 256 | mergeASARs: true, 257 | }); 258 | await verifyApp(outAppPath); 259 | }, 260 | VERIFY_APP_TIMEOUT, 261 | ); 262 | }); 263 | 264 | describe('no asar mode', () => { 265 | it( 266 | 'should correctly merge two identical app folders', 267 | async () => { 268 | const out = path.resolve(appsOutPath, 'MergedNoAsar.app'); 269 | await makeUniversalApp({ 270 | x64AppPath: path.resolve(appsPath, 'X64NoAsar.app'), 271 | arm64AppPath: path.resolve(appsPath, 'Arm64NoAsar.app'), 272 | outAppPath: out, 273 | }); 274 | await verifyApp(out); 275 | }, 276 | VERIFY_APP_TIMEOUT, 277 | ); 278 | 279 | it( 280 | 'should shim two different app folders', 281 | async () => { 282 | const arm64AppPath = await templateApp('ShimArm64.app', 'arm64', async (appPath) => { 283 | const { testPath } = await createStagingAppDir('shimArm64', { 284 | 'i-aint-got-no-rhythm.bin': 'boomshakalaka', 285 | }); 286 | await fs.copy(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app')); 287 | }); 288 | 289 | const x64AppPath = await templateApp('ShimX64.app', 'x64', async (appPath) => { 290 | const { testPath } = await createStagingAppDir('shimX64', { 291 | 'hello-world.bin': 'Hello World', 292 | }); 293 | await fs.copy(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app')); 294 | }); 295 | 296 | const outAppPath = path.resolve(appsOutPath, 'ShimNoAsar.app'); 297 | await makeUniversalApp({ 298 | x64AppPath, 299 | arm64AppPath, 300 | outAppPath, 301 | }); 302 | await verifyApp(outAppPath); 303 | }, 304 | VERIFY_APP_TIMEOUT, 305 | ); 306 | 307 | it( 308 | 'different app dirs with different macho files (shim and lipo)', 309 | async () => { 310 | const x64AppPath = await generateNativeApp({ 311 | appNameWithExtension: 'DifferentMachoAppX64-1.app', 312 | arch: 'x64', 313 | createAsar: false, 314 | additionalFiles: { 315 | 'hello-world.bin': 'Hello World', 316 | }, 317 | }); 318 | const arm64AppPath = await generateNativeApp({ 319 | appNameWithExtension: 'DifferentMachoAppArm64-1.app', 320 | arch: 'arm64', 321 | createAsar: false, 322 | additionalFiles: { 323 | 'i-aint-got-no-rhythm.bin': 'boomshakalaka', 324 | }, 325 | }); 326 | 327 | const outAppPath = path.resolve(appsOutPath, 'DifferentMachoApp1.app'); 328 | await makeUniversalApp({ 329 | x64AppPath, 330 | arm64AppPath, 331 | outAppPath, 332 | }); 333 | await verifyApp(outAppPath, true); 334 | }, 335 | VERIFY_APP_TIMEOUT, 336 | ); 337 | 338 | it( 339 | "different app dirs with universal macho files (shim but don't lipo)", 340 | async () => { 341 | const x64AppPath = await generateNativeApp({ 342 | appNameWithExtension: 'DifferentButUniversalMachoAppX64-2.app', 343 | arch: 'x64', 344 | createAsar: false, 345 | nativeModuleArch: 'universal', 346 | additionalFiles: { 347 | 'hello-world.bin': 'Hello World', 348 | }, 349 | }); 350 | const arm64AppPath = await generateNativeApp({ 351 | appNameWithExtension: 'DifferentButUniversalMachoAppArm64-2.app', 352 | arch: 'arm64', 353 | createAsar: false, 354 | nativeModuleArch: 'universal', 355 | additionalFiles: { 356 | 'i-aint-got-no-rhythm.bin': 'boomshakalaka', 357 | }, 358 | }); 359 | 360 | const outAppPath = path.resolve(appsOutPath, 'DifferentButUniversalMachoApp.app'); 361 | await makeUniversalApp({ 362 | x64AppPath, 363 | arm64AppPath, 364 | outAppPath, 365 | }); 366 | await verifyApp(outAppPath, true); 367 | }, 368 | VERIFY_APP_TIMEOUT, 369 | ); 370 | 371 | it( 372 | 'identical app dirs with different macho files (e.g. do not shim, but still lipo)', 373 | async () => { 374 | const x64AppPath = await generateNativeApp({ 375 | appNameWithExtension: 'DifferentMachoAppX64-2.app', 376 | arch: 'x64', 377 | createAsar: false, 378 | }); 379 | const arm64AppPath = await generateNativeApp({ 380 | appNameWithExtension: 'DifferentMachoAppArm64-2.app', 381 | arch: 'arm64', 382 | createAsar: false, 383 | }); 384 | 385 | const out = path.resolve(appsOutPath, 'DifferentMachoApp2.app'); 386 | await makeUniversalApp({ 387 | x64AppPath, 388 | arm64AppPath, 389 | outAppPath: out, 390 | }); 391 | await verifyApp(out, true); 392 | }, 393 | VERIFY_APP_TIMEOUT, 394 | ); 395 | 396 | it( 397 | 'identical app dirs with universal macho files (e.g., do not shim, just copy x64 dir)', 398 | async () => { 399 | const x64AppPath = await generateNativeApp({ 400 | appNameWithExtension: 'UniversalMachoAppX64.app', 401 | arch: 'x64', 402 | createAsar: false, 403 | nativeModuleArch: 'universal', 404 | }); 405 | const arm64AppPath = await generateNativeApp({ 406 | appNameWithExtension: 'UniversalMachoAppArm64.app', 407 | arch: 'arm64', 408 | createAsar: false, 409 | nativeModuleArch: 'universal', 410 | }); 411 | 412 | const out = path.resolve(appsOutPath, 'UniversalMachoApp.app'); 413 | await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath: out }); 414 | await verifyApp(out, true); 415 | }, 416 | VERIFY_APP_TIMEOUT, 417 | ); 418 | }); 419 | }); 420 | -------------------------------------------------------------------------------- /test/sha.spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import { sha } from '../src/sha'; 4 | import { describe, expect, it } from '@jest/globals'; 5 | 6 | describe('sha', () => { 7 | it('should correctly hash a file', async () => { 8 | expect(await sha(path.resolve(__dirname, 'fixtures', 'tohash'))).toEqual( 9 | '12998c017066eb0d2a70b94e6ed3192985855ce390f321bbdb832022888bd251', 10 | ); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/util.ts: -------------------------------------------------------------------------------- 1 | import { downloadArtifact } from '@electron/get'; 2 | import { spawn } from '@malept/cross-spawn-promise'; 3 | import * as zip from 'cross-zip'; 4 | import * as fs from 'fs-extra'; 5 | import * as path from 'path'; 6 | import plist from 'plist'; 7 | import * as fileUtils from '../dist/cjs/file-utils'; 8 | import { createPackageWithOptions, getRawHeader } from '@electron/asar'; 9 | 10 | declare const expect: typeof import('@jest/globals').expect; 11 | 12 | // We do a LOT of verifications in `verifyApp` 😅 13 | // exec universal binary -> verify ALL asars -> verify ALL app dirs -> verify ALL asar integrity entries 14 | // plus some tests create fixtures at runtime 15 | export const VERIFY_APP_TIMEOUT = 80 * 1000; 16 | 17 | export const fixtureDir = path.resolve(__dirname, 'fixtures'); 18 | export const asarsDir = path.resolve(fixtureDir, 'asars'); 19 | export const appsDir = path.resolve(fixtureDir, 'apps'); 20 | export const appsOutPath = path.resolve(appsDir, 'out'); 21 | 22 | export const verifyApp = async (appPath: string, containsRuntimeGeneratedMacho = false) => { 23 | await ensureUniversal(appPath); 24 | 25 | const resourcesDir = path.resolve(appPath, 'Contents', 'Resources'); 26 | const resourcesDirContents = await fs.readdir(resourcesDir); 27 | 28 | // sort for consistent result 29 | const asars = resourcesDirContents.filter((p) => p.endsWith('.asar')).sort(); 30 | for await (const asar of asars) { 31 | // verify header 32 | const asarFs = getRawHeader(path.resolve(resourcesDir, asar)); 33 | expect( 34 | removeUnstableProperties(asarFs.header, containsRuntimeGeneratedMacho ? ['hello-world'] : []), 35 | ).toMatchSnapshot(); 36 | } 37 | 38 | // check all app and unpacked dirs (incl. shimmed) 39 | const dirsToSnapshot = [ 40 | 'app', 41 | 'app.asar.unpacked', 42 | 'app-x64', 43 | 'app-x64.asar.unpacked', 44 | 'app-arm64', 45 | 'app-arm64.asar.unpacked', 46 | ]; 47 | const appDirs = resourcesDirContents 48 | .filter((p) => dirsToSnapshot.includes(path.basename(p))) 49 | .sort(); 50 | for await (const dir of appDirs) { 51 | await verifyFileTree(path.resolve(resourcesDir, dir)); 52 | } 53 | 54 | const allFiles = await fileUtils.getAllAppFiles(appPath); 55 | const infoPlists = allFiles 56 | .filter( 57 | (appFile) => 58 | appFile.type === fileUtils.AppFileType.INFO_PLIST && 59 | // These are test app fixtures, no need to snapshot within `TestApp.app/Contents/Frameworks` 60 | !appFile.relativePath.includes(path.join('Contents', 'Frameworks')), 61 | ) 62 | .map((af) => af.relativePath) 63 | .sort(); 64 | 65 | const integrityMap: Record = {}; 66 | const integrity = await Promise.all( 67 | infoPlists.map((ip) => extractAsarIntegrity(path.resolve(appPath, ip))), 68 | ); 69 | for (let i = 0; i < integrity.length; i++) { 70 | const relativePath = infoPlists[i]; 71 | const asarIntegrity = integrity[i]; 72 | // note: `infoPlistsToIgnore` will not have integrity in sub-app plists 73 | integrityMap[relativePath] = asarIntegrity 74 | ? removeUnstableProperties(asarIntegrity, containsRuntimeGeneratedMacho ? ['hash'] : []) 75 | : undefined; 76 | } 77 | expect(integrityMap).toMatchSnapshot(); 78 | }; 79 | 80 | const extractAsarIntegrity = async (infoPlist: string) => { 81 | const { ElectronAsarIntegrity: integrity, ...otherData } = plist.parse( 82 | await fs.readFile(infoPlist, 'utf-8'), 83 | ) as any; 84 | return integrity; 85 | }; 86 | 87 | export const verifyFileTree = async (dirPath: string) => { 88 | const dirFiles = await fileUtils.getAllAppFiles(dirPath); 89 | const files = dirFiles.map((file) => { 90 | const it = path.join(dirPath, file.relativePath); 91 | const name = toSystemIndependentPath(file.relativePath); 92 | if (it.endsWith('.txt') || it.endsWith('.json')) { 93 | return { name, content: fs.readFileSync(it, 'utf-8') }; 94 | } 95 | return name; 96 | }); 97 | expect(files).toMatchSnapshot(); 98 | }; 99 | 100 | export const ensureUniversal = async (app: string) => { 101 | const exe = path.resolve(app, 'Contents', 'MacOS', 'Electron'); 102 | const result = await spawn(exe); 103 | expect(result).toContain('arm64'); 104 | const result2 = await spawn('arch', ['-x86_64', exe]); 105 | expect(result2).toContain('x64'); 106 | }; 107 | 108 | export const toSystemIndependentPath = (s: string): string => { 109 | return path.sep === '/' ? s : s.replace(/\\/g, '/'); 110 | }; 111 | 112 | export const removeUnstableProperties = (data: any, stripKeys: string[]) => { 113 | const removeKeysRecursively: (obj: any, keysToRemove: string[]) => any = (obj, keysToRemove) => { 114 | if (!obj || typeof obj !== 'object') { 115 | return obj; 116 | } 117 | // if the value is an array, map over it 118 | if (Array.isArray(obj)) { 119 | return obj.map((item: any) => removeKeysRecursively(item, keysToRemove)); 120 | } 121 | return Object.keys(obj).reduce((acc, key) => { 122 | // if the value of the current key is another object, make a recursive call to remove the key from the nested object 123 | if (!keysToRemove.includes(key)) { 124 | acc[key] = removeKeysRecursively(obj[key], keysToRemove); 125 | } else { 126 | acc[key] = ''; 127 | } 128 | return acc; 129 | }, {}); 130 | }; 131 | 132 | const filteredData = removeKeysRecursively(data, stripKeys); 133 | return JSON.parse( 134 | JSON.stringify(filteredData, (name, value) => { 135 | if (name === 'offset') { 136 | return undefined; 137 | } 138 | return value; 139 | }), 140 | ); 141 | }; 142 | 143 | /** 144 | * Creates an app directory at runtime for usage: 145 | * - `testPath` can be used with `asar.createPackage`. Just set the output `.asar` path to `Test.app/Contents/Resources/.asar` 146 | * - `testPath` can be utilized for logic paths involving `AsarMode.NO_ASAR` and copied directly to `Test.app/Contents/Resources` 147 | * 148 | * Directory structure: 149 | * testName 150 | * ├── private 151 | * │ └── var 152 | * │ ├── app 153 | * │ │ └── file.txt -> ../file.txt 154 | * │ └── file.txt 155 | * └── var -> private/var 156 | * ├── index.js 157 | * ├── package.json 158 | */ 159 | export const createStagingAppDir = async ( 160 | testName: string | undefined, 161 | additionalFiles: Record = {}, 162 | ) => { 163 | const outDir = (testName || 'app') + Math.floor(Math.random() * 100); // tests run in parallel, randomize dir suffix to prevent naming collisions 164 | const testPath = path.join(appsDir, outDir); 165 | await fs.remove(testPath); 166 | 167 | await fs.copy(path.join(asarsDir, 'app'), testPath); 168 | 169 | const privateVarPath = path.join(testPath, 'private', 'var'); 170 | const varPath = path.join(testPath, 'var'); 171 | 172 | await fs.mkdir(privateVarPath, { recursive: true }); 173 | await fs.symlink(path.relative(testPath, privateVarPath), varPath); 174 | 175 | const files = { 176 | 'file.txt': 'hello world', 177 | ...additionalFiles, 178 | }; 179 | for await (const [filename, fileData] of Object.entries(files)) { 180 | const originFilePath = path.join(varPath, filename); 181 | await fs.writeFile(originFilePath, fileData); 182 | } 183 | const appPath = path.join(varPath, 'app'); 184 | await fs.mkdirp(appPath); 185 | await fs.symlink('../file.txt', path.join(appPath, 'file.txt')); 186 | 187 | return { 188 | testPath, 189 | varPath, 190 | appPath, 191 | }; 192 | }; 193 | 194 | export const templateApp = async ( 195 | name: string, 196 | arch: string, 197 | modify: (appPath: string) => Promise, 198 | ) => { 199 | const electronZip = await downloadArtifact({ 200 | artifactName: 'electron', 201 | version: '27.0.0', 202 | platform: 'darwin', 203 | arch, 204 | }); 205 | const appPath = path.resolve(appsDir, name); 206 | zip.unzipSync(electronZip, appsDir); 207 | await fs.rename(path.resolve(appsDir, 'Electron.app'), appPath); 208 | await fs.remove(path.resolve(appPath, 'Contents', 'Resources', 'default_app.asar')); 209 | await modify(appPath); 210 | 211 | return appPath; 212 | }; 213 | 214 | export const generateNativeApp = async (options: { 215 | appNameWithExtension: string; 216 | arch: string; 217 | createAsar: boolean; 218 | nativeModuleArch?: string; 219 | additionalFiles?: Record; 220 | }) => { 221 | const { 222 | appNameWithExtension, 223 | arch, 224 | createAsar, 225 | nativeModuleArch = arch, 226 | additionalFiles, 227 | } = options; 228 | const appPath = await templateApp(appNameWithExtension, arch, async (appPath) => { 229 | const resources = path.join(appPath, 'Contents', 'Resources'); 230 | const resourcesApp = path.resolve(resources, 'app'); 231 | if (!fs.existsSync(resourcesApp)) { 232 | await fs.mkdir(resourcesApp); 233 | } 234 | const { testPath } = await createStagingAppDir( 235 | path.basename(appNameWithExtension, '.app'), 236 | additionalFiles, 237 | ); 238 | await fs.copy( 239 | path.join(appsDir, `hello-world-${nativeModuleArch}`), 240 | path.join(testPath, 'hello-world'), 241 | ); 242 | if (createAsar) { 243 | await createPackageWithOptions(testPath, path.resolve(resources, 'app.asar'), { 244 | unpack: '**/hello-world', 245 | }); 246 | } else { 247 | await fs.copy(testPath, resourcesApp); 248 | } 249 | }); 250 | return appPath; 251 | }; 252 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.entry-asar.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "entry-asar", 5 | }, 6 | "include": [ 7 | "entry-asar" 8 | ], 9 | "exclude": [] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "dist/esm" 6 | }, 7 | "include": ["src"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "dist/esm", 6 | "types": [ 7 | "jest" 8 | ] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2017", 5 | "lib": [ 6 | "es2017" 7 | ], 8 | "sourceMap": true, 9 | "strict": true, 10 | "outDir": "dist/cjs", 11 | "types": [ 12 | "node", 13 | ], 14 | "allowSyntheticDefaultImports": true, 15 | "moduleResolution": "node", 16 | "esModuleInterop": true, 17 | "declaration": true 18 | }, 19 | "include": [ 20 | "src", 21 | "entry-asar" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": ["./src/index.ts"], 4 | "excludeInternal": true, 5 | "sort": ["source-order"] 6 | } 7 | --------------------------------------------------------------------------------