Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64be29d2f7 | ||
|
|
ec7c971959 | ||
|
|
5b957e6858 | ||
|
|
4276c7cf38 | ||
|
|
977baa4d42 | ||
|
|
2b67c905a6 | ||
|
|
740dd4aab3 | ||
|
|
d90d573ccf | ||
|
|
7c0ad6caa5 | ||
|
|
d76ca76072 | ||
|
|
4bf33415ec | ||
|
|
caa0567b76 | ||
|
|
7f59407631 | ||
|
|
915c061908 | ||
|
|
ef4ce1f9ac | ||
|
|
bf62ed4113 | ||
|
|
dd52b47795 | ||
|
|
9495fc3840 | ||
|
|
03b841956e | ||
|
|
a7d68c490d | ||
|
|
03e27e5a1d | ||
|
|
dfe5236357 | ||
|
|
89c99b438a | ||
|
|
e6d2697cbc | ||
|
|
c71eed1a06 | ||
|
|
5f8afd1b05 | ||
|
|
9236bd5853 | ||
|
|
a837738a09 | ||
|
|
44baf3b723 | ||
|
|
0e3b472c9a | ||
|
|
da359016d5 | ||
|
|
5d7bdf3ff3 | ||
|
|
16ce6ffbea | ||
|
|
df604873fa | ||
|
|
57201b124c | ||
|
|
20b1b02c11 | ||
|
|
b6f0c88db4 | ||
|
|
bb304ce10b | ||
|
|
8e2842b4a3 | ||
|
|
02119d5a83 | ||
|
|
1948f1caa9 | ||
|
|
52fa9a2a78 | ||
|
|
4e631b7ca2 | ||
|
|
fe1a0e06b0 | ||
|
|
9a808beecc | ||
|
|
381ca1a748 | ||
|
|
0d2b974dcc | ||
|
|
0cfaddcc77 | ||
|
|
fddff57c15 | ||
|
|
b02ce7697f | ||
|
|
85b1f90f2c | ||
|
|
3ecbbd5710 | ||
|
|
8bff0b7579 | ||
|
|
283773eb45 | ||
|
|
a99ae94fc2 | ||
|
|
0d70c1c39f | ||
|
|
ba587f0a08 | ||
|
|
580002844b | ||
|
|
365775311f | ||
|
|
1fc0005ae8 | ||
|
|
64cbc83faf | ||
|
|
691e4ef31d | ||
|
|
d902197267 | ||
|
|
72a3f83d27 | ||
|
|
3cc1365561 | ||
|
|
3a30fe989b | ||
|
|
01dfb8a963 | ||
|
|
3bd173d61a | ||
|
|
479e80d6a9 | ||
|
|
2c3c1a60a0 | ||
|
|
cdcbe58dee | ||
|
|
38ab1c3559 | ||
|
|
9f86e1dd2b | ||
|
|
a626463c95 | ||
|
|
36b58a84f3 | ||
|
|
fe8d99e31d | ||
|
|
d336231787 | ||
|
|
6cd85d89aa | ||
|
|
7b1c610963 | ||
|
|
060a299188 | ||
|
|
64420e4c32 | ||
|
|
2b411ce98b | ||
|
|
e80eed7f69 | ||
|
|
6053796432 | ||
|
|
0a1d0f916c | ||
|
|
1d4e198ba5 | ||
|
|
2f06fcab5f | ||
|
|
e7d57dd1e5 | ||
|
|
d9b1b4104f | ||
|
|
b445fa1974 | ||
|
|
f265d1f5e2 | ||
|
|
a05a5e6db8 | ||
|
|
8c55e5b4f3 | ||
|
|
477a52e779 | ||
|
|
107823fc2c | ||
|
|
621083fe1f | ||
|
|
0770238718 | ||
|
|
c01deb5576 | ||
|
|
82acb6fc72 | ||
|
|
8bb61593b2 | ||
|
|
3ebf924651 | ||
|
|
46a3b7e94d |
@@ -1,46 +0,0 @@
|
|||||||
step-restore-cache: &step-restore-cache
|
|
||||||
restore_cache:
|
|
||||||
keys:
|
|
||||||
- v1-dependencies-{{ arch }}-{{ checksum "yarn.lock" }}
|
|
||||||
- v1-dependencies-{{ arch }}
|
|
||||||
|
|
||||||
steps-test: &steps-test
|
|
||||||
steps:
|
|
||||||
- checkout
|
|
||||||
- *step-restore-cache
|
|
||||||
- run: yarn --frozen-lockfile
|
|
||||||
- save_cache:
|
|
||||||
paths:
|
|
||||||
- node_modules
|
|
||||||
key: v1-dependencies-{{ arch }}-{{ checksum "yarn.lock" }}
|
|
||||||
- run: yarn build
|
|
||||||
- run: yarn test
|
|
||||||
|
|
||||||
version: 2.1
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
macos:
|
|
||||||
xcode: "12.2.0"
|
|
||||||
<<: *steps-test
|
|
||||||
|
|
||||||
release:
|
|
||||||
docker:
|
|
||||||
- image: circleci/node:14.15
|
|
||||||
steps:
|
|
||||||
- checkout
|
|
||||||
- *step-restore-cache
|
|
||||||
- run: yarn --frozen-lockfile
|
|
||||||
- run: npx semantic-release
|
|
||||||
workflows:
|
|
||||||
version: 2
|
|
||||||
test_and_release:
|
|
||||||
jobs:
|
|
||||||
- test
|
|
||||||
- release:
|
|
||||||
requires:
|
|
||||||
- test
|
|
||||||
filters:
|
|
||||||
branches:
|
|
||||||
only:
|
|
||||||
- master
|
|
||||||
|
|
||||||
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* @electron/wg-ecosystem
|
||||||
6
.github/dependabot.yml
vendored
Normal file
6
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
29
.github/workflows/add-to-project.yml
vendored
Normal file
29
.github/workflows/add-to-project.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: Add to Ecosystem WG Project
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
add-to-project:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Generate GitHub App token
|
||||||
|
uses: electron/github-app-auth-action@384fd19694fe7b6dcc9a684746c6976ad78228ae # v1.1.1
|
||||||
|
id: generate-token
|
||||||
|
with:
|
||||||
|
creds: ${{ secrets.ECOSYSTEM_ISSUE_TRIAGE_GH_APP_CREDS }}
|
||||||
|
org: electron
|
||||||
|
- name: Add to Project
|
||||||
|
uses: dsanders11/project-actions/add-item@2134fe7cc71c58b7ae259c82a8e63c6058255678 # v1.7.0
|
||||||
|
with:
|
||||||
|
field: Opened
|
||||||
|
field-value: ${{ github.event.pull_request.created_at || github.event.issue.created_at }}
|
||||||
|
project-number: 89
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
35
.github/workflows/release.yml
vendored
Normal file
35
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: ./.github/workflows/test.yml
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
environment: npm
|
||||||
|
permissions:
|
||||||
|
id-token: write # for CFA and npm provenance
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: 20.x
|
||||||
|
cache: 'yarn'
|
||||||
|
- name: Install
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
- uses: continuousauth/action@4e8a2573eeb706f6d7300d6a9f3ca6322740b72d # v1.0.5
|
||||||
|
with:
|
||||||
|
project-id: ${{ secrets.CFA_PROJECT_ID }}
|
||||||
|
secret: ${{ secrets.CFA_SECRET }}
|
||||||
|
npm-token: ${{ secrets.NPM_TOKEN }}
|
||||||
26
.github/workflows/semantic.yml
vendored
Normal file
26
.github/workflows/semantic.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: "Check Semantic Commit"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- synchronize
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
main:
|
||||||
|
permissions:
|
||||||
|
pull-requests: read # for amannn/action-semantic-pull-request to analyze PRs
|
||||||
|
statuses: write # for amannn/action-semantic-pull-request to mark status of analyzed PR
|
||||||
|
name: Validate PR Title
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: semantic-pull-request
|
||||||
|
uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
validateSingleCommit: false
|
||||||
44
.github/workflows/test.yml
vendored
Normal file
44
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
schedule:
|
||||||
|
- cron: '0 22 * * 3'
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
node-version:
|
||||||
|
- '20.5'
|
||||||
|
- '18.17'
|
||||||
|
- '16.20'
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "${{ matrix.node-version }}"
|
||||||
|
cache: 'yarn'
|
||||||
|
- name: Install (Node.js v18+)
|
||||||
|
if : ${{ matrix.node-version != '16.20' }}
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
- name: Install (Node.js < v18)
|
||||||
|
if : ${{ matrix.node-version == '16.20' }}
|
||||||
|
run: yarn install --frozen-lockfile --ignore-engines
|
||||||
|
- name: Build
|
||||||
|
run: yarn build
|
||||||
|
- name: Lint
|
||||||
|
run: yarn lint
|
||||||
|
- name: Test
|
||||||
|
run: yarn test
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,3 +1,9 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
entry-asar/*.js*
|
||||||
|
entry-asar/*.ts
|
||||||
*.app
|
*.app
|
||||||
|
test/fixtures/apps
|
||||||
|
coverage
|
||||||
|
docs
|
||||||
|
.vscode
|
||||||
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
yarn lint-staged
|
||||||
@@ -3,5 +3,13 @@
|
|||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"parser": "typescript"
|
"parser": "typescript",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.json", "*.jsonc", "*.json5"],
|
||||||
|
"options": {
|
||||||
|
"parser": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"@semantic-release/release-notes-generator",
|
"@semantic-release/release-notes-generator",
|
||||||
"@continuous-auth/semantic-release-npm",
|
"@continuous-auth/semantic-release-npm",
|
||||||
"@semantic-release/github"
|
"@semantic-release/github"
|
||||||
]
|
],
|
||||||
|
"branches": [ "main" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Contributors to the Electron project
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
109
README.md
109
README.md
@@ -2,11 +2,16 @@
|
|||||||
|
|
||||||
> Create universal macOS Electron applications
|
> Create universal macOS Electron applications
|
||||||
|
|
||||||
[](https://circleci.com/gh/electron/universal)
|
[](https://github.com/electron/universal/actions/workflows/test.yml)
|
||||||
|
[](https://npm.im/@electron/universal)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
This package takes an x64 app and an arm64 app and glues them together into a
|
||||||
|
[Universal macOS binary](https://developer.apple.com/documentation/apple-silicon/building-a-universal-macos-binary).
|
||||||
|
|
||||||
|
Note that parameters need to be **absolute** paths.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { makeUniversalApp } from '@electron/universal';
|
import { makeUniversalApp } from '@electron/universal';
|
||||||
|
|
||||||
@@ -17,16 +22,104 @@ await makeUniversalApp({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Advanced configuration
|
||||||
|
|
||||||
|
The basic usage patterns will work for most apps out of the box. Additional configuration
|
||||||
|
options are available for advanced usecases.
|
||||||
|
|
||||||
|
### Merging ASAR archives to reduce app size
|
||||||
|
|
||||||
|
**Added in [v1.2.0](https://github.com/electron/universal/commit/38ab1c3559e25382957d608e49e624dc72a4409c)**
|
||||||
|
|
||||||
|
If you are using ASAR archives to store your Electron app's JavaScript code, you can use the
|
||||||
|
`mergeASARs` option to merge your x64 and arm64 ASAR files to reduce the bundle size of
|
||||||
|
the output Universal app.
|
||||||
|
|
||||||
|
If some files are present in only the x64 app but not the arm64 version (or vice-versa),
|
||||||
|
you can exclude them from the merging process by specifying a `minimatch` pattern
|
||||||
|
in `singleArchFiles`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { makeUniversalApp } from '@electron/universal';
|
||||||
|
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: 'path/to/App_x64.app',
|
||||||
|
arm64AppPath: 'path/to/App_arm64.app',
|
||||||
|
outAppPath: 'path/to/App_universal.app',
|
||||||
|
mergeASARs: true,
|
||||||
|
singleArchFiles: 'node_modules/some-native-module/lib/binding/Release/**', // if you have files in your asar that are unique to x64 or arm64 apps
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
If `@electron/universal` detects an architecture-unique file that isn't covered by the
|
||||||
|
`singleArchFiles` rule, an error will be thrown.
|
||||||
|
|
||||||
|
### Skip lipo for certain binaries in your Universal app
|
||||||
|
|
||||||
|
**Added in [1.3.0](https://github.com/electron/universal/commit/01dfb8a9636965fe154192b07934670dd42509f3)**
|
||||||
|
|
||||||
|
If your Electron app contains binary resources that are already merged with the
|
||||||
|
`lipo` tool, providing a [`minimatch`] pattern to matching files in the `x64ArchFiles`
|
||||||
|
parameter will prevent `@electron/universal` from attempting to merge them a second time.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { makeUniversalApp } from '@electron/universal';
|
||||||
|
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: 'path/to/App_x64.app',
|
||||||
|
arm64AppPath: 'path/to/App_arm64.app',
|
||||||
|
outAppPath: 'path/to/App_universal.app',
|
||||||
|
mergeASARs: true,
|
||||||
|
x64ArchFiles: '*/electron-helper', // `electron-helper` is a binary merged using `lipo`
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
If `@electron/universal` detects a lipo'd file that isn't covered by the `x64ArchFiles` rule,
|
||||||
|
an error will be thrown.
|
||||||
|
|
||||||
|
### Including already codesigned app bundles into your Universal app
|
||||||
|
|
||||||
|
**Added in [v1.4.0](https://github.com/electron/universal/commit/b02ce7697fd2a3c2c79e1f6ab6bf7052125865cc)**
|
||||||
|
|
||||||
|
By default, the merging process will generate an `ElectronAsarIntegrity` key for
|
||||||
|
any `Info.plist` files in your Electron app.
|
||||||
|
|
||||||
|
If your Electron app bundles another `.app` that is already signed, you need to use
|
||||||
|
the `infoPlistsToIgnore` option to avoid modifying that app's plist.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { makeUniversalApp } from '@electron/universal';
|
||||||
|
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: 'path/to/App_x64.app',
|
||||||
|
arm64AppPath: 'path/to/App_arm64.app',
|
||||||
|
outAppPath: 'path/to/App_universal.app',
|
||||||
|
infoPlistsToIgnore: 'my-internal.app/Contents/Info.plist'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
#### The app is twice as big now, why?
|
#### The app is twice as big now, why?
|
||||||
|
|
||||||
Well, a Universal app isn't anything magical. It is literally the x64 app and
|
A Universal app is just the x64 app and the arm64 app glued together into a single application.
|
||||||
the arm64 app glued together into a single application. It's twice as big
|
It's twice as big because it contains two apps in one.
|
||||||
because it contains two apps in one.
|
|
||||||
|
Merging your ASAR bundles can yield significant app size reductions depending on how large
|
||||||
|
your `app.asar` file is.
|
||||||
|
|
||||||
#### What about native modules?
|
#### What about native modules?
|
||||||
|
|
||||||
The way `@electron/universal` works today means you don't need to worry about
|
Out of the box, you don't need to worry about building universal versions of your
|
||||||
things like building universal versions of your native modules. As long as
|
native modules. As long as your x64 and arm64 apps work in isolation, the Universal
|
||||||
your x64 and arm64 apps work in isolation the Universal app will work as well.
|
app will work as well.
|
||||||
|
|
||||||
|
Note that if you are using `mergeASARs`, you may need to add architecture-specific
|
||||||
|
binary resources to the `singleArchFiles` pattern.
|
||||||
|
See [Merging ASARs usage](#merging-asar-archives-to-reduce-app-size) for an example.
|
||||||
|
|
||||||
|
#### How do I build my app for Apple silicon in the first place?
|
||||||
|
|
||||||
|
Check out the [Electron Apple silicon blog post](https://www.electronjs.org/blog/apple-silicon).
|
||||||
|
|
||||||
|
[`minimatch`]: https://github.com/isaacs/minimatch?tab=readme-ov-file#features
|
||||||
|
|||||||
19
entry-asar/ambient.d.ts
vendored
Normal file
19
entry-asar/ambient.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
declare namespace NodeJS {
|
||||||
|
interface Process extends EventEmitter {
|
||||||
|
// This is an undocumented private API. It exists.
|
||||||
|
_archPath: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'electron' {
|
||||||
|
const app: Electron.App;
|
||||||
|
|
||||||
|
namespace Electron {
|
||||||
|
interface App {
|
||||||
|
getAppPath: () => string;
|
||||||
|
setAppPath: (p: string) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { app };
|
||||||
|
}
|
||||||
27
entry-asar/has-asar.ts
Normal file
27
entry-asar/has-asar.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
if (process.arch === 'arm64') {
|
||||||
|
setPaths('arm64');
|
||||||
|
} else {
|
||||||
|
setPaths('x64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPaths(platform: string) {
|
||||||
|
// This should return the full path, ending in something like
|
||||||
|
// Notion.app/Contents/Resources/app.asar
|
||||||
|
const appPath = app.getAppPath();
|
||||||
|
const asarFile = `app-${platform}.asar`;
|
||||||
|
|
||||||
|
// Maybe we'll handle this in Electron one day
|
||||||
|
if (path.basename(appPath) === 'app.asar') {
|
||||||
|
const platformAppPath = path.join(path.dirname(appPath), asarFile);
|
||||||
|
|
||||||
|
// This is an undocumented API. It exists.
|
||||||
|
app.setAppPath(platformAppPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
process._archPath = require.resolve(`../${asarFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
require(process._archPath);
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
if (process.arch === 'arm64') {
|
|
||||||
process._asarPath = require.resolve('../arm64.app.asar');
|
|
||||||
} else {
|
|
||||||
process._asarPath = require.resolve('../x64.app.asar');
|
|
||||||
}
|
|
||||||
|
|
||||||
require(process._asarPath);
|
|
||||||
27
entry-asar/no-asar.ts
Normal file
27
entry-asar/no-asar.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
if (process.arch === 'arm64') {
|
||||||
|
setPaths('arm64');
|
||||||
|
} else {
|
||||||
|
setPaths('x64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPaths(platform: string) {
|
||||||
|
// This should return the full path, ending in something like
|
||||||
|
// Notion.app/Contents/Resources/app
|
||||||
|
const appPath = app.getAppPath();
|
||||||
|
const appFolder = `app-${platform}`;
|
||||||
|
|
||||||
|
// Maybe we'll handle this in Electron one day
|
||||||
|
if (path.basename(appPath) === 'app') {
|
||||||
|
const platformAppPath = path.join(path.dirname(appPath), appFolder);
|
||||||
|
|
||||||
|
// This is an undocumented private API. It exists.
|
||||||
|
app.setAppPath(platformAppPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
process._archPath = require.resolve(`../${appFolder}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
require(process._archPath);
|
||||||
16
jest.config.js
Normal file
16
jest.config.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.ts?$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
tsconfig: 'tsconfig.jest.json',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
testMatch: ['<rootDir>/test/**/*.spec.ts'],
|
||||||
|
globalSetup: './jest.setup.ts',
|
||||||
|
testTimeout: 10000,
|
||||||
|
};
|
||||||
75
jest.setup.ts
Normal file
75
jest.setup.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import * as fs from 'fs-extra';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { appsDir, asarsDir, fixtureDir, templateApp } from './test/util';
|
||||||
|
|
||||||
|
// generates binaries from hello-world.c
|
||||||
|
// hello-world-universal, hello-world-x86_64, hello-world-arm64
|
||||||
|
const generateMachO = () => {
|
||||||
|
const src = path.resolve(fixtureDir, 'hello-world.c');
|
||||||
|
|
||||||
|
const outputFiles = ['x86_64', 'arm64'].map((arch) => {
|
||||||
|
const machO = path.resolve(appsDir, `hello-world-${arch === 'x86_64' ? 'x64' : arch}`);
|
||||||
|
execFileSync('clang', ['-arch', arch, '-o', machO, src]);
|
||||||
|
return machO;
|
||||||
|
});
|
||||||
|
|
||||||
|
execFileSync('lipo', [
|
||||||
|
...outputFiles,
|
||||||
|
'-create',
|
||||||
|
'-output',
|
||||||
|
path.resolve(appsDir, 'hello-world-universal'),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async () => {
|
||||||
|
await fs.remove(appsDir);
|
||||||
|
await fs.mkdirp(appsDir);
|
||||||
|
|
||||||
|
// generate mach-o binaries to be leveraged in lipo tests
|
||||||
|
generateMachO();
|
||||||
|
|
||||||
|
await templateApp('Arm64Asar.app', 'arm64', async (appPath) => {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(asarsDir, 'app.asar'),
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// contains `extra-file.txt`
|
||||||
|
await templateApp('Arm64AsarExtraFile.app', 'arm64', async (appPath) => {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(asarsDir, 'app2.asar'),
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await templateApp('X64Asar.app', 'x64', async (appPath) => {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(asarsDir, 'app.asar'),
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await templateApp('Arm64NoAsar.app', 'arm64', async (appPath) => {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(asarsDir, 'app'),
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// contains `extra-file.txt`
|
||||||
|
await templateApp('Arm64NoAsarExtraFile.app', 'arm64', async (appPath) => {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(asarsDir, 'app2'),
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await templateApp('X64NoAsar.app', 'x64', async (appPath) => {
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(asarsDir, 'app'),
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
62
package.json
62
package.json
@@ -10,33 +10,65 @@
|
|||||||
"apple silicon",
|
"apple silicon",
|
||||||
"universal"
|
"universal"
|
||||||
],
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/electron/universal.git"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=16.4"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/*",
|
"dist/*",
|
||||||
|
"entry-asar/*",
|
||||||
|
"!entry-asar/**/*.ts",
|
||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"author": "Samuel Attard",
|
"author": "Samuel Attard",
|
||||||
|
"publishConfig": {
|
||||||
|
"provenance": true
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && tsc -p tsconfig.esm.json",
|
"build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && tsc -p tsconfig.entry-asar.json",
|
||||||
"lint": "prettier --check \"src/**/*.ts\"",
|
"build:docs": "npx typedoc",
|
||||||
|
"lint": "prettier --check \"{src,entry-asar,test}/**/*.ts\" \"*.ts\"",
|
||||||
|
"prettier:write": "prettier --write \"{src,entry-asar,test}/**/*.ts\" \"*.ts\"",
|
||||||
"prepublishOnly": "npm run build",
|
"prepublishOnly": "npm run build",
|
||||||
"test": "exit 0"
|
"test": "jest",
|
||||||
|
"prepare": "husky install"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@continuous-auth/semantic-release-npm": "^2.0.0",
|
"@electron/get": "^3.0.0",
|
||||||
"@types/fs-extra": "^9.0.4",
|
"@types/cross-zip": "^4.0.1",
|
||||||
"@types/node": "^14.14.7",
|
"@types/debug": "^4.1.10",
|
||||||
"husky": "^4.3.0",
|
"@types/fs-extra": "^11.0.3",
|
||||||
"lint-staged": "^10.5.1",
|
"@types/jest": "^29.5.7",
|
||||||
"prettier": "^2.1.2",
|
"@types/minimatch": "^5.1.2",
|
||||||
"semantic-release": "^17.2.2",
|
"@types/node": "^20.8.10",
|
||||||
"typescript": "^4.0.5"
|
"@types/plist": "^3.0.4",
|
||||||
|
"cross-zip": "^4.0.0",
|
||||||
|
"husky": "^8.0.3",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"lint-staged": "^15.2.10",
|
||||||
|
"prettier": "^3.0.3",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"typedoc": "~0.25.13",
|
||||||
|
"typescript": "^5.2.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malept/cross-spawn-promise": "^1.1.0",
|
"@electron/asar": "^3.3.1",
|
||||||
"asar": "^3.0.3",
|
"@malept/cross-spawn-promise": "^2.0.0",
|
||||||
"fs-extra": "^9.0.1"
|
"debug": "^4.3.1",
|
||||||
|
"dir-compare": "^4.2.0",
|
||||||
|
"fs-extra": "^11.1.1",
|
||||||
|
"minimatch": "^9.0.3",
|
||||||
|
"plist": "^3.1.0"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.ts": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"jackspeak": "2.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
228
src/asar-utils.ts
Normal file
228
src/asar-utils.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import asar from '@electron/asar';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { minimatch } from 'minimatch';
|
||||||
|
import os from 'os';
|
||||||
|
import { d } from './debug';
|
||||||
|
|
||||||
|
const LIPO = 'lipo';
|
||||||
|
|
||||||
|
export enum AsarMode {
|
||||||
|
NO_ASAR,
|
||||||
|
HAS_ASAR,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MergeASARsOptions = {
|
||||||
|
x64AsarPath: string;
|
||||||
|
arm64AsarPath: string;
|
||||||
|
outputAsarPath: string;
|
||||||
|
|
||||||
|
singleArchFiles?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// See: https://github.com/apple-opensource-mirror/llvmCore/blob/0c60489d96c87140db9a6a14c6e82b15f5e5d252/include/llvm/Object/MachOFormat.h#L108-L112
|
||||||
|
const MACHO_MAGIC = new Set([
|
||||||
|
// 32-bit Mach-O
|
||||||
|
0xfeedface, 0xcefaedfe,
|
||||||
|
|
||||||
|
// 64-bit Mach-O
|
||||||
|
0xfeedfacf, 0xcffaedfe,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MACHO_UNIVERSAL_MAGIC = new Set([
|
||||||
|
// universal
|
||||||
|
0xcafebabe, 0xbebafeca,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const detectAsarMode = async (appPath: string) => {
|
||||||
|
d('checking asar mode of', appPath);
|
||||||
|
const asarPath = path.resolve(appPath, 'Contents', 'Resources', 'app.asar');
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(asarPath))) {
|
||||||
|
d('determined no asar');
|
||||||
|
return AsarMode.NO_ASAR;
|
||||||
|
}
|
||||||
|
|
||||||
|
d('determined has asar');
|
||||||
|
return AsarMode.HAS_ASAR;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateAsarIntegrity = (asarPath: string) => {
|
||||||
|
return {
|
||||||
|
algorithm: 'SHA256' as const,
|
||||||
|
hash: crypto
|
||||||
|
.createHash('SHA256')
|
||||||
|
.update(asar.getRawHeader(asarPath).headerString)
|
||||||
|
.digest('hex'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function toRelativePath(file: string): string {
|
||||||
|
return file.replace(/^\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectory(a: string, file: string): boolean {
|
||||||
|
return Boolean('files' in asar.statFile(a, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSingleArch(archive: string, file: string, allowList?: string): void {
|
||||||
|
if (allowList === undefined || !minimatch(file, allowList, { matchBase: true })) {
|
||||||
|
throw new Error(
|
||||||
|
`Detected unique file "${file}" in "${archive}" not covered by ` +
|
||||||
|
`allowList rule: "${allowList}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mergeASARs = async ({
|
||||||
|
x64AsarPath,
|
||||||
|
arm64AsarPath,
|
||||||
|
outputAsarPath,
|
||||||
|
singleArchFiles,
|
||||||
|
}: MergeASARsOptions): Promise<void> => {
|
||||||
|
d(`merging ${x64AsarPath} and ${arm64AsarPath}`);
|
||||||
|
|
||||||
|
const x64Files = new Set(asar.listPackage(x64AsarPath, { isPack: false }).map(toRelativePath));
|
||||||
|
const arm64Files = new Set(
|
||||||
|
asar.listPackage(arm64AsarPath, { isPack: false }).map(toRelativePath),
|
||||||
|
);
|
||||||
|
|
||||||
|
//
|
||||||
|
// Build set of unpacked directories and files
|
||||||
|
//
|
||||||
|
|
||||||
|
const unpackedFiles = new Set<string>();
|
||||||
|
|
||||||
|
function buildUnpacked(a: string, fileList: Set<string>): void {
|
||||||
|
for (const file of fileList) {
|
||||||
|
const stat = asar.statFile(a, file);
|
||||||
|
|
||||||
|
if (!('unpacked' in stat) || !stat.unpacked) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('files' in stat) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
unpackedFiles.add(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildUnpacked(x64AsarPath, x64Files);
|
||||||
|
buildUnpacked(arm64AsarPath, arm64Files);
|
||||||
|
|
||||||
|
//
|
||||||
|
// Build list of files/directories unique to each asar
|
||||||
|
//
|
||||||
|
|
||||||
|
for (const file of x64Files) {
|
||||||
|
if (!arm64Files.has(file)) {
|
||||||
|
checkSingleArch(x64AsarPath, file, singleArchFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const arm64Unique = [];
|
||||||
|
for (const file of arm64Files) {
|
||||||
|
if (!x64Files.has(file)) {
|
||||||
|
checkSingleArch(arm64AsarPath, file, singleArchFiles);
|
||||||
|
arm64Unique.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Find common bindings with different content
|
||||||
|
//
|
||||||
|
|
||||||
|
const commonBindings = [];
|
||||||
|
for (const file of x64Files) {
|
||||||
|
if (!arm64Files.has(file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories
|
||||||
|
if (isDirectory(x64AsarPath, file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const x64Content = asar.extractFile(x64AsarPath, file);
|
||||||
|
const arm64Content = asar.extractFile(arm64AsarPath, file);
|
||||||
|
|
||||||
|
// Skip file if the same content
|
||||||
|
if (x64Content.compare(arm64Content) === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip universal Mach-O files.
|
||||||
|
if (isUniversalMachO(x64Content)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!MACHO_MAGIC.has(x64Content.readUInt32LE(0))) {
|
||||||
|
throw new Error(`Can't reconcile two non-macho files ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
commonBindings.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Extract both
|
||||||
|
//
|
||||||
|
|
||||||
|
const x64Dir = await fs.mkdtemp(path.join(os.tmpdir(), 'x64-'));
|
||||||
|
const arm64Dir = await fs.mkdtemp(path.join(os.tmpdir(), 'arm64-'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
d(`extracting ${x64AsarPath} to ${x64Dir}`);
|
||||||
|
asar.extractAll(x64AsarPath, x64Dir);
|
||||||
|
|
||||||
|
d(`extracting ${arm64AsarPath} to ${arm64Dir}`);
|
||||||
|
asar.extractAll(arm64AsarPath, arm64Dir);
|
||||||
|
|
||||||
|
for (const file of arm64Unique) {
|
||||||
|
const source = path.resolve(arm64Dir, file);
|
||||||
|
const destination = path.resolve(x64Dir, file);
|
||||||
|
|
||||||
|
if (isDirectory(arm64AsarPath, file)) {
|
||||||
|
d(`creating unique directory: ${file}`);
|
||||||
|
await fs.mkdirp(destination);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
d(`xopying unique file: ${file}`);
|
||||||
|
await fs.mkdirp(path.dirname(destination));
|
||||||
|
await fs.copy(source, destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const binding of commonBindings) {
|
||||||
|
const source = await fs.realpath(path.resolve(arm64Dir, binding));
|
||||||
|
const destination = await fs.realpath(path.resolve(x64Dir, binding));
|
||||||
|
|
||||||
|
d(`merging binding: ${binding}`);
|
||||||
|
execFileSync(LIPO, [source, destination, '-create', '-output', destination]);
|
||||||
|
}
|
||||||
|
|
||||||
|
d(`creating archive at ${outputAsarPath}`);
|
||||||
|
|
||||||
|
const resolvedUnpack = Array.from(unpackedFiles).map((file) => path.join(x64Dir, file));
|
||||||
|
|
||||||
|
let unpack: string | undefined;
|
||||||
|
if (resolvedUnpack.length > 1) {
|
||||||
|
unpack = `{${resolvedUnpack.join(',')}}`;
|
||||||
|
} else if (resolvedUnpack.length === 1) {
|
||||||
|
unpack = resolvedUnpack[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
await asar.createPackageWithOptions(x64Dir, outputAsarPath, {
|
||||||
|
unpack,
|
||||||
|
});
|
||||||
|
|
||||||
|
d('done merging');
|
||||||
|
} finally {
|
||||||
|
await Promise.all([fs.remove(x64Dir), fs.remove(arm64Dir)]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isUniversalMachO = (fileContent: Buffer) => {
|
||||||
|
return MACHO_UNIVERSAL_MAGIC.has(fileContent.readUInt32LE(0));
|
||||||
|
};
|
||||||
3
src/debug.ts
Normal file
3
src/debug.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import debug from 'debug';
|
||||||
|
|
||||||
|
export const d = debug('electron-universal');
|
||||||
85
src/file-utils.ts
Normal file
85
src/file-utils.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { spawn, ExitCodeError } from '@malept/cross-spawn-promise';
|
||||||
|
import * as fs from 'fs-extra';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { promises as stream } from 'node:stream';
|
||||||
|
|
||||||
|
const MACHO_PREFIX = 'Mach-O ';
|
||||||
|
|
||||||
|
export enum AppFileType {
|
||||||
|
MACHO,
|
||||||
|
PLAIN,
|
||||||
|
INFO_PLIST,
|
||||||
|
SNAPSHOT,
|
||||||
|
APP_CODE,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppFile = {
|
||||||
|
relativePath: string;
|
||||||
|
type: AppFileType;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param appPath Path to the application
|
||||||
|
*/
|
||||||
|
export const getAllAppFiles = async (appPath: string): Promise<AppFile[]> => {
|
||||||
|
const files: AppFile[] = [];
|
||||||
|
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const traverse = async (p: string) => {
|
||||||
|
p = await fs.realpath(p);
|
||||||
|
if (visited.has(p)) return;
|
||||||
|
visited.add(p);
|
||||||
|
|
||||||
|
const info = await fs.stat(p);
|
||||||
|
if (info.isSymbolicLink()) return;
|
||||||
|
if (info.isFile()) {
|
||||||
|
let fileType = AppFileType.PLAIN;
|
||||||
|
|
||||||
|
var fileOutput = '';
|
||||||
|
try {
|
||||||
|
fileOutput = await spawn('file', ['--brief', '--no-pad', p]);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ExitCodeError) {
|
||||||
|
/* silently accept error codes from "file" */
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (p.endsWith('.asar')) {
|
||||||
|
fileType = AppFileType.APP_CODE;
|
||||||
|
} else if (fileOutput.startsWith(MACHO_PREFIX)) {
|
||||||
|
fileType = AppFileType.MACHO;
|
||||||
|
} else if (p.endsWith('.bin')) {
|
||||||
|
fileType = AppFileType.SNAPSHOT;
|
||||||
|
} else if (path.basename(p) === 'Info.plist') {
|
||||||
|
fileType = AppFileType.INFO_PLIST;
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
relativePath: path.relative(appPath, p),
|
||||||
|
type: fileType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.isDirectory()) {
|
||||||
|
for (const child of await fs.readdir(p)) {
|
||||||
|
await traverse(path.resolve(p, child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await traverse(appPath);
|
||||||
|
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readMachOHeader = async (path: string) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
// no need to read the entire file, we only need the first 4 bytes of the file to determine the header
|
||||||
|
await stream.pipeline(fs.createReadStream(path, { start: 0, end: 3 }), async function* (source) {
|
||||||
|
for await (const chunk of source) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
};
|
||||||
388
src/index.ts
388
src/index.ts
@@ -1,105 +1,84 @@
|
|||||||
|
import * as asar from '@electron/asar';
|
||||||
import { spawn } from '@malept/cross-spawn-promise';
|
import { spawn } from '@malept/cross-spawn-promise';
|
||||||
import * as asar from 'asar';
|
import * as dircompare from 'dir-compare';
|
||||||
import * as crypto from 'crypto';
|
|
||||||
import * as fs from 'fs-extra';
|
import * as fs from 'fs-extra';
|
||||||
|
import { minimatch } from 'minimatch';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as plist from 'plist';
|
||||||
|
|
||||||
const MACHO_PREFIX = 'Mach-O ';
|
import { AsarMode, detectAsarMode, isUniversalMachO, mergeASARs } from './asar-utils';
|
||||||
|
import { AppFile, AppFileType, getAllAppFiles, readMachOHeader } from './file-utils';
|
||||||
|
import { sha } from './sha';
|
||||||
|
import { d } from './debug';
|
||||||
|
import { computeIntegrityData } from './integrity';
|
||||||
|
|
||||||
type MakeUniversalOpts = {
|
|
||||||
/**
|
/**
|
||||||
* Absolute file system path to the x64 version of your application. E.g. /Foo/bar/MyApp_x64.app
|
* Options to pass into the {@link makeUniversalApp} function.
|
||||||
|
*
|
||||||
|
* Requires absolute paths for input x64 and arm64 apps and an absolute path to the
|
||||||
|
* output universal app.
|
||||||
|
*/
|
||||||
|
export type MakeUniversalOpts = {
|
||||||
|
/**
|
||||||
|
* Absolute file system path to the x64 version of your application (e.g. `/Foo/bar/MyApp_x64.app`).
|
||||||
*/
|
*/
|
||||||
x64AppPath: string;
|
x64AppPath: string;
|
||||||
/**
|
/**
|
||||||
* Absolute file system path to the arm64 version of your application. E.g. /Foo/bar/MyApp_arm64.app
|
* Absolute file system path to the arm64 version of your application (e.g. `/Foo/bar/MyApp_arm64.app`).
|
||||||
*/
|
*/
|
||||||
arm64AppPath: string;
|
arm64AppPath: string;
|
||||||
/**
|
/**
|
||||||
* Absolute file system path you want the universal app to be written to. E.g. /Foo/var/MyApp_universal.app
|
* Absolute file system path you want the universal app to be written to (e.g. `/Foo/var/MyApp_universal.app`).
|
||||||
*
|
*
|
||||||
* If this file exists it will be overwritten ONLY if "force" is set to true
|
* If this file exists on disk already, it will be overwritten ONLY if {@link MakeUniversalOpts.force} is set to `true`.
|
||||||
*/
|
*/
|
||||||
outAppPath: string;
|
outAppPath: string;
|
||||||
/**
|
/**
|
||||||
* Forcefully overwrite any existing files that are in the way of generating the universal application
|
* Forcefully overwrite any existing files that are in the way of generating the universal application.
|
||||||
|
*
|
||||||
|
* @defaultValue `false`
|
||||||
*/
|
*/
|
||||||
force: boolean;
|
force?: boolean;
|
||||||
|
/**
|
||||||
|
* Merge x64 and arm64 ASARs into one.
|
||||||
|
*
|
||||||
|
* @defaultValue `false`
|
||||||
|
*/
|
||||||
|
mergeASARs?: boolean;
|
||||||
|
/**
|
||||||
|
* If {@link MakeUniversalOpts.mergeASARs} is enabled, this property provides a
|
||||||
|
* {@link https://github.com/isaacs/minimatch?tab=readme-ov-file#features | minimatch}
|
||||||
|
* pattern of paths that are allowed to be present in one of the ASAR files, but not in the other.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
singleArchFiles?: string;
|
||||||
|
/**
|
||||||
|
* A {@link https://github.com/isaacs/minimatch?tab=readme-ov-file#features | minimatch}
|
||||||
|
* pattern of binaries that are expected to be the same x64 binary in both
|
||||||
|
*
|
||||||
|
* Use this if your application contains binaries that have already been merged into a universal file
|
||||||
|
* using the `lipo` tool.
|
||||||
|
*
|
||||||
|
* @see Apple's {@link https://developer.apple.com/documentation/apple-silicon/building-a-universal-macos-binary | Building a universal macOS binary} documentation
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
x64ArchFiles?: string;
|
||||||
|
/**
|
||||||
|
* A {@link https://github.com/isaacs/minimatch?tab=readme-ov-file#features | minimatch} pattern of `Info.plist`
|
||||||
|
* paths that should not receive an injected `ElectronAsarIntegrity` value.
|
||||||
|
*
|
||||||
|
* Use this if your application contains another bundle that's already signed.
|
||||||
|
*/
|
||||||
|
infoPlistsToIgnore?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
enum AsarMode {
|
const dupedFiles = (files: AppFile[]) =>
|
||||||
NO_ASAR,
|
files.filter((f) => f.type !== AppFileType.SNAPSHOT && f.type !== AppFileType.APP_CODE);
|
||||||
HAS_ASAR,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const detectAsarMode = async (appPath: string) => {
|
|
||||||
const asarPath = path.resolve(appPath, 'Contents', 'Resources', 'app.asar');
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(asarPath))) return AsarMode.NO_ASAR;
|
|
||||||
|
|
||||||
return AsarMode.HAS_ASAR;
|
|
||||||
};
|
|
||||||
|
|
||||||
enum AppFileType {
|
|
||||||
MACHO,
|
|
||||||
PLAIN,
|
|
||||||
SNAPSHOT,
|
|
||||||
APP_CODE,
|
|
||||||
}
|
|
||||||
|
|
||||||
type AppFile = {
|
|
||||||
relativePath: string;
|
|
||||||
type: AppFileType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAllFiles = async (appPath: string): Promise<AppFile[]> => {
|
|
||||||
const files: AppFile[] = [];
|
|
||||||
|
|
||||||
const visited = new Set<string>();
|
|
||||||
const traverse = async (p: string) => {
|
|
||||||
p = await fs.realpath(p);
|
|
||||||
if (visited.has(p)) return;
|
|
||||||
visited.add(p);
|
|
||||||
|
|
||||||
const info = await fs.stat(p);
|
|
||||||
if (info.isSymbolicLink()) return;
|
|
||||||
if (info.isFile()) {
|
|
||||||
let fileType = AppFileType.PLAIN;
|
|
||||||
|
|
||||||
const fileOutput = await spawn('file', ['--brief', '--no-pad', p]);
|
|
||||||
if (p.includes('app.asar')) {
|
|
||||||
fileType = AppFileType.APP_CODE;
|
|
||||||
} else if (fileOutput.startsWith(MACHO_PREFIX)) {
|
|
||||||
fileType = AppFileType.MACHO;
|
|
||||||
} else if (p.endsWith('.bin')) {
|
|
||||||
fileType = AppFileType.SNAPSHOT;
|
|
||||||
}
|
|
||||||
|
|
||||||
files.push({
|
|
||||||
relativePath: path.relative(appPath, p),
|
|
||||||
type: fileType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.isDirectory()) {
|
|
||||||
for (const child of await fs.readdir(p)) {
|
|
||||||
await traverse(path.resolve(p, child));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await traverse(appPath);
|
|
||||||
|
|
||||||
return files;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dupedFiles = (files: AppFile[]) => files.filter(f => f.type !== AppFileType.SNAPSHOT && f.type !== AppFileType.APP_CODE);
|
|
||||||
|
|
||||||
const sha = async (filePath: string) => {
|
|
||||||
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> => {
|
export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> => {
|
||||||
|
d('making a universal app with options', opts);
|
||||||
|
|
||||||
if (process.platform !== 'darwin')
|
if (process.platform !== 'darwin')
|
||||||
throw new Error('@electron/universal is only supported on darwin platforms');
|
throw new Error('@electron/universal is only supported on darwin platforms');
|
||||||
if (!opts.x64AppPath || !path.isAbsolute(opts.x64AppPath))
|
if (!opts.x64AppPath || !path.isAbsolute(opts.x64AppPath))
|
||||||
@@ -110,17 +89,21 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
|
|||||||
throw new Error('Expected opts.outAppPath to be an absolute path but it was not');
|
throw new Error('Expected opts.outAppPath to be an absolute path but it was not');
|
||||||
|
|
||||||
if (await fs.pathExists(opts.outAppPath)) {
|
if (await fs.pathExists(opts.outAppPath)) {
|
||||||
|
d('output path exists already');
|
||||||
if (!opts.force) {
|
if (!opts.force) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`The out path "${opts.outAppPath}" already exists and force is not set to true`,
|
`The out path "${opts.outAppPath}" already exists and force is not set to true`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
d('overwriting existing application because force == true');
|
||||||
await fs.remove(opts.outAppPath);
|
await fs.remove(opts.outAppPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const x64AsarMode = await detectAsarMode(opts.x64AppPath);
|
const x64AsarMode = await detectAsarMode(opts.x64AppPath);
|
||||||
const arm64AsarMode = await detectAsarMode(opts.arm64AppPath);
|
const arm64AsarMode = await detectAsarMode(opts.arm64AppPath);
|
||||||
|
d('detected x64AsarMode =', x64AsarMode);
|
||||||
|
d('detected arm64AsarMode =', arm64AsarMode);
|
||||||
|
|
||||||
if (x64AsarMode !== arm64AsarMode)
|
if (x64AsarMode !== arm64AsarMode)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -128,23 +111,28 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tmpDir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-universal-'));
|
const tmpDir = await fs.mkdtemp(path.resolve(os.tmpdir(), 'electron-universal-'));
|
||||||
|
d('building universal app in', tmpDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
d('copying x64 app as starter template');
|
||||||
const tmpApp = path.resolve(tmpDir, 'Tmp.app');
|
const tmpApp = path.resolve(tmpDir, 'Tmp.app');
|
||||||
await spawn('cp', ['-R', opts.x64AppPath, tmpApp]);
|
await spawn('cp', ['-R', opts.x64AppPath, tmpApp]);
|
||||||
|
|
||||||
const uniqueToX64: string[] = [];
|
const uniqueToX64: string[] = [];
|
||||||
const uniqueToArm64: string[] = [];
|
const uniqueToArm64: string[] = [];
|
||||||
const x64Files = await getAllFiles(await fs.realpath(tmpApp));
|
const x64Files = await getAllAppFiles(await fs.realpath(tmpApp));
|
||||||
const arm64Files = await getAllFiles(opts.arm64AppPath);
|
const arm64Files = await getAllAppFiles(await fs.realpath(opts.arm64AppPath));
|
||||||
|
|
||||||
for (const file of dupedFiles(x64Files)) {
|
for (const file of dupedFiles(x64Files)) {
|
||||||
if (!arm64Files.some(f => f.relativePath === file.relativePath)) uniqueToX64.push(file.relativePath);
|
if (!arm64Files.some((f) => f.relativePath === file.relativePath))
|
||||||
|
uniqueToX64.push(file.relativePath);
|
||||||
}
|
}
|
||||||
for (const file of dupedFiles(arm64Files)) {
|
for (const file of dupedFiles(arm64Files)) {
|
||||||
if (!x64Files.some(f => f.relativePath === file.relativePath)) uniqueToArm64.push(file.relativePath);
|
if (!x64Files.some((f) => f.relativePath === file.relativePath))
|
||||||
|
uniqueToArm64.push(file.relativePath);
|
||||||
}
|
}
|
||||||
if (uniqueToX64.length !== 0 || uniqueToArm64.length !== 0) {
|
if (uniqueToX64.length !== 0 || uniqueToArm64.length !== 0) {
|
||||||
|
d('some files were not in both builds, aborting');
|
||||||
console.error({
|
console.error({
|
||||||
uniqueToX64,
|
uniqueToX64,
|
||||||
uniqueToArm64,
|
uniqueToArm64,
|
||||||
@@ -154,59 +142,241 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise<void> =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of x64Files.filter(f => f.type === AppFileType.PLAIN)) {
|
for (const file of x64Files.filter((f) => f.type === AppFileType.PLAIN)) {
|
||||||
const x64Sha = await sha(path.resolve(opts.x64AppPath, file.relativePath));
|
const x64Sha = await sha(path.resolve(opts.x64AppPath, file.relativePath));
|
||||||
const arm64Sha = await sha(path.resolve(opts.arm64AppPath, file.relativePath));
|
const arm64Sha = await sha(path.resolve(opts.arm64AppPath, file.relativePath));
|
||||||
if (x64Sha !== arm64Sha) {
|
if (x64Sha !== arm64Sha) {
|
||||||
console.error(`${x64Sha} !== ${arm64Sha}`);
|
d('SHA for file', file.relativePath, `does not match across builds ${x64Sha}!=${arm64Sha}`);
|
||||||
throw new Error(`Expected all non-binary files to have identical SHAs when creating a universal build but "${file.relativePath}" did not`);
|
// The MainMenu.nib files generated by Xcode13 are deterministic in effect but not deterministic in generated sequence
|
||||||
|
if (path.basename(path.dirname(file.relativePath)) === 'MainMenu.nib') {
|
||||||
|
// The mismatch here is OK so we just move on to the next one
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Expected all non-binary files to have identical SHAs when creating a universal build but "${file.relativePath}" did not`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const knownMergedMachOFiles = new Set();
|
||||||
|
for (const machOFile of x64Files.filter((f) => f.type === AppFileType.MACHO)) {
|
||||||
|
const first = await fs.realpath(path.resolve(tmpApp, machOFile.relativePath));
|
||||||
|
const second = await fs.realpath(path.resolve(opts.arm64AppPath, machOFile.relativePath));
|
||||||
|
|
||||||
|
if (
|
||||||
|
isUniversalMachO(await readMachOHeader(first)) &&
|
||||||
|
isUniversalMachO(await readMachOHeader(second))
|
||||||
|
) {
|
||||||
|
d(machOFile.relativePath, `is already universal across builds, skipping lipo`);
|
||||||
|
knownMergedMachOFiles.add(machOFile.relativePath);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const machOFile of x64Files.filter(f => f.type === AppFileType.MACHO)) {
|
const x64Sha = await sha(path.resolve(opts.x64AppPath, machOFile.relativePath));
|
||||||
|
const arm64Sha = await sha(path.resolve(opts.arm64AppPath, machOFile.relativePath));
|
||||||
|
if (x64Sha === arm64Sha) {
|
||||||
|
if (
|
||||||
|
opts.x64ArchFiles === undefined ||
|
||||||
|
!minimatch(machOFile.relativePath, opts.x64ArchFiles, { matchBase: true })
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Detected file "${machOFile.relativePath}" that's the same in both x64 and arm64 builds and not covered by the ` +
|
||||||
|
`x64ArchFiles rule: "${opts.x64ArchFiles}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
d(
|
||||||
|
'SHA for Mach-O file',
|
||||||
|
machOFile.relativePath,
|
||||||
|
`matches across builds ${x64Sha}===${arm64Sha}, skipping lipo`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
d('joining two MachO files with lipo', {
|
||||||
|
first,
|
||||||
|
second,
|
||||||
|
});
|
||||||
await spawn('lipo', [
|
await spawn('lipo', [
|
||||||
await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)),
|
first,
|
||||||
await fs.realpath(path.resolve(opts.arm64AppPath, machOFile.relativePath)),
|
second,
|
||||||
'-create',
|
'-create',
|
||||||
'-output',
|
'-output',
|
||||||
await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)),
|
await fs.realpath(path.resolve(tmpApp, machOFile.relativePath)),
|
||||||
]);
|
]);
|
||||||
|
knownMergedMachOFiles.add(machOFile.relativePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If we don't have an ASAR we need to check if the two "app" folders are identical, if
|
||||||
|
* they are then we can just leave one there and call it a day. If the app folders for x64
|
||||||
|
* and arm64 are different though we need to rename each folder and create a new fake "app"
|
||||||
|
* entrypoint to dynamically load the correct app folder
|
||||||
|
*/
|
||||||
if (x64AsarMode === AsarMode.NO_ASAR) {
|
if (x64AsarMode === AsarMode.NO_ASAR) {
|
||||||
await fs.move(path.resolve(tmpApp, 'Contents', 'Resources', 'app'), path.resolve(tmpApp, 'Contents', 'Resources', 'x64.app'));
|
d('checking if the x64 and arm64 app folders are identical');
|
||||||
await fs.copy(path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), path.resolve(tmpApp, 'Contents', 'Resources', 'arm64.app'));
|
const comparison = await dircompare.compare(
|
||||||
|
path.resolve(tmpApp, 'Contents', 'Resources', 'app'),
|
||||||
|
path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'),
|
||||||
|
{ compareSize: true, compareContent: true },
|
||||||
|
);
|
||||||
|
const differences = comparison.diffSet!.filter((difference) => difference.state !== 'equal');
|
||||||
|
d(`Found ${differences.length} difference(s) between the x64 and arm64 folders`);
|
||||||
|
const nonMergedDifferences = differences.filter(
|
||||||
|
(difference) =>
|
||||||
|
!difference.name1 ||
|
||||||
|
!knownMergedMachOFiles.has(
|
||||||
|
path.join('Contents', 'Resources', 'app', difference.relativePath, difference.name1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
d(`After discluding MachO files merged with lipo ${nonMergedDifferences.length} remain.`);
|
||||||
|
|
||||||
|
if (nonMergedDifferences.length > 0) {
|
||||||
|
d('x64 and arm64 app folders are different, creating dynamic entry ASAR');
|
||||||
|
await fs.move(
|
||||||
|
path.resolve(tmpApp, 'Contents', 'Resources', 'app'),
|
||||||
|
path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64'),
|
||||||
|
);
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'),
|
||||||
|
path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const entryAsar = path.resolve(tmpDir, 'entry-asar');
|
||||||
|
await fs.mkdir(entryAsar);
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(__dirname, '..', '..', 'entry-asar', 'no-asar.js'),
|
||||||
|
path.resolve(entryAsar, 'index.js'),
|
||||||
|
);
|
||||||
|
let pj = await fs.readJson(
|
||||||
|
path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json'),
|
||||||
|
);
|
||||||
|
pj.main = 'index.js';
|
||||||
|
await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj);
|
||||||
|
await asar.createPackage(
|
||||||
|
entryAsar,
|
||||||
|
path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await fs.move(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), path.resolve(tmpApp, 'Contents', 'Resources', 'x64.app.asar'));
|
d('x64 and arm64 app folders are the same');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If we have an ASAR we just need to check if the two "app.asar" files have the same hash,
|
||||||
|
* if they are, same as above, we can leave one there and call it a day. If they're different
|
||||||
|
* we have to make a dynamic entrypoint. There is an assumption made here that every file in
|
||||||
|
* app.asar.unpacked is a native node module. This assumption _may_ not be true so we should
|
||||||
|
* look at codifying that assumption as actual logic.
|
||||||
|
*/
|
||||||
|
// FIXME: Codify the assumption that app.asar.unpacked only contains native modules
|
||||||
|
if (x64AsarMode === AsarMode.HAS_ASAR && opts.mergeASARs) {
|
||||||
|
d('merging x64 and arm64 asars');
|
||||||
|
const output = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar');
|
||||||
|
await mergeASARs({
|
||||||
|
x64AsarPath: path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
arm64AsarPath: path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
outputAsarPath: output,
|
||||||
|
singleArchFiles: opts.singleArchFiles,
|
||||||
|
});
|
||||||
|
} else if (x64AsarMode === AsarMode.HAS_ASAR) {
|
||||||
|
d('checking if the x64 and arm64 asars are identical');
|
||||||
|
const x64AsarSha = await sha(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'));
|
||||||
|
const arm64AsarSha = await sha(
|
||||||
|
path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (x64AsarSha !== arm64AsarSha) {
|
||||||
|
d('x64 and arm64 asars are different');
|
||||||
|
const x64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar');
|
||||||
|
await fs.move(path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'), x64AsarPath);
|
||||||
const x64Unpacked = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar.unpacked');
|
const x64Unpacked = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar.unpacked');
|
||||||
if (await fs.pathExists(x64Unpacked)) {
|
if (await fs.pathExists(x64Unpacked)) {
|
||||||
await fs.move(x64Unpacked, path.resolve(tmpApp, 'Contents', 'Resources', 'x64.app.asar.unpacked'));
|
await fs.move(
|
||||||
|
x64Unpacked,
|
||||||
|
path.resolve(tmpApp, 'Contents', 'Resources', 'app-x64.asar.unpacked'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.copy(path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'), path.resolve(tmpApp, 'Contents', 'Resources', 'arm64.app.asar'));
|
const arm64AsarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar');
|
||||||
const arm64Unpacked = path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar.unpacked');
|
await fs.copy(
|
||||||
|
path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
arm64AsarPath,
|
||||||
|
);
|
||||||
|
const arm64Unpacked = path.resolve(
|
||||||
|
opts.arm64AppPath,
|
||||||
|
'Contents',
|
||||||
|
'Resources',
|
||||||
|
'app.asar.unpacked',
|
||||||
|
);
|
||||||
if (await fs.pathExists(arm64Unpacked)) {
|
if (await fs.pathExists(arm64Unpacked)) {
|
||||||
await fs.copy(arm64Unpacked, path.resolve(tmpApp, 'Contents', 'Resources', 'arm64.app.asar.unpacked'));
|
await fs.copy(
|
||||||
}
|
arm64Unpacked,
|
||||||
|
path.resolve(tmpApp, 'Contents', 'Resources', 'app-arm64.asar.unpacked'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const entryAsar = path.resolve(tmpDir, 'entry-asar');
|
const entryAsar = path.resolve(tmpDir, 'entry-asar');
|
||||||
await fs.mkdir(entryAsar);
|
await fs.mkdir(entryAsar);
|
||||||
await fs.copy(path.resolve(__dirname, '..', '..', 'entry-asar', 'index.js'), path.resolve(entryAsar, 'index.js'));
|
await fs.copy(
|
||||||
let pj: any;
|
path.resolve(__dirname, '..', '..', 'entry-asar', 'has-asar.js'),
|
||||||
if (x64AsarMode === AsarMode.NO_ASAR) {
|
path.resolve(entryAsar, 'index.js'),
|
||||||
pj = await fs.readJson(path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app', 'package.json'));
|
);
|
||||||
} else {
|
let pj = JSON.parse(
|
||||||
pj = JSON.parse((await asar.extractFile(path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app.asar'), 'package.json')).toString('utf8'));
|
(
|
||||||
}
|
await asar.extractFile(
|
||||||
|
path.resolve(opts.x64AppPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
'package.json',
|
||||||
|
)
|
||||||
|
).toString('utf8'),
|
||||||
|
);
|
||||||
pj.main = 'index.js';
|
pj.main = 'index.js';
|
||||||
await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj);
|
await fs.writeJson(path.resolve(entryAsar, 'package.json'), pj);
|
||||||
await asar.createPackage(entryAsar, path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar'));
|
const asarPath = path.resolve(tmpApp, 'Contents', 'Resources', 'app.asar');
|
||||||
|
await asar.createPackage(entryAsar, asarPath);
|
||||||
for (const snapshotsFile of arm64Files.filter(f => f.type === AppFileType.SNAPSHOT)) {
|
} else {
|
||||||
await fs.copy(path.resolve(opts.arm64AppPath, snapshotsFile.relativePath), path.resolve(tmpApp, snapshotsFile.relativePath));
|
d('x64 and arm64 asars are the same');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generatedIntegrity = await computeIntegrityData(path.join(tmpApp, 'Contents'));
|
||||||
|
|
||||||
|
const plistFiles = x64Files.filter((f) => f.type === AppFileType.INFO_PLIST);
|
||||||
|
for (const plistFile of plistFiles) {
|
||||||
|
const x64PlistPath = path.resolve(opts.x64AppPath, plistFile.relativePath);
|
||||||
|
const arm64PlistPath = path.resolve(opts.arm64AppPath, plistFile.relativePath);
|
||||||
|
|
||||||
|
const { ElectronAsarIntegrity: x64Integrity, ...x64Plist } = plist.parse(
|
||||||
|
await fs.readFile(x64PlistPath, 'utf8'),
|
||||||
|
) as any;
|
||||||
|
const { ElectronAsarIntegrity: arm64Integrity, ...arm64Plist } = plist.parse(
|
||||||
|
await fs.readFile(arm64PlistPath, 'utf8'),
|
||||||
|
) as any;
|
||||||
|
if (JSON.stringify(x64Plist) !== JSON.stringify(arm64Plist)) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected all Info.plist files to be identical when ignoring integrity when creating a universal build but "${plistFile.relativePath}" was not`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const injectAsarIntegrity =
|
||||||
|
!opts.infoPlistsToIgnore ||
|
||||||
|
minimatch(plistFile.relativePath, opts.infoPlistsToIgnore, { matchBase: true });
|
||||||
|
const mergedPlist = injectAsarIntegrity
|
||||||
|
? { ...x64Plist, ElectronAsarIntegrity: generatedIntegrity }
|
||||||
|
: { ...x64Plist };
|
||||||
|
|
||||||
|
await fs.writeFile(path.resolve(tmpApp, plistFile.relativePath), plist.build(mergedPlist));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const snapshotsFile of arm64Files.filter((f) => f.type === AppFileType.SNAPSHOT)) {
|
||||||
|
d('copying snapshot file', snapshotsFile.relativePath, 'to target application');
|
||||||
|
await fs.copy(
|
||||||
|
path.resolve(opts.arm64AppPath, snapshotsFile.relativePath),
|
||||||
|
path.resolve(tmpApp, snapshotsFile.relativePath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
d('moving final universal app to target destination');
|
||||||
|
await fs.mkdirp(path.dirname(opts.outAppPath));
|
||||||
await spawn('mv', [tmpApp, opts.outAppPath]);
|
await spawn('mv', [tmpApp, opts.outAppPath]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
51
src/integrity.ts
Normal file
51
src/integrity.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import * as fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { AppFileType, getAllAppFiles } from './file-utils';
|
||||||
|
import { sha } from './sha';
|
||||||
|
import { generateAsarIntegrity } from './asar-utils';
|
||||||
|
|
||||||
|
type IntegrityMap = {
|
||||||
|
[filepath: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface HeaderHash {
|
||||||
|
algorithm: 'SHA256';
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AsarIntegrity {
|
||||||
|
[key: string]: HeaderHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeIntegrityData(contentsPath: string): Promise<AsarIntegrity> {
|
||||||
|
const root = await fs.realpath(contentsPath);
|
||||||
|
|
||||||
|
const resourcesRelativePath = 'Resources';
|
||||||
|
const resourcesPath = path.resolve(root, resourcesRelativePath);
|
||||||
|
|
||||||
|
const resources = await getAllAppFiles(resourcesPath);
|
||||||
|
const resourceAsars = resources
|
||||||
|
.filter((file) => file.type === AppFileType.APP_CODE)
|
||||||
|
.reduce<IntegrityMap>(
|
||||||
|
(prev, file) => ({
|
||||||
|
...prev,
|
||||||
|
[path.join(resourcesRelativePath, file.relativePath)]: path.join(
|
||||||
|
resourcesPath,
|
||||||
|
file.relativePath,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
// sort to produce constant result
|
||||||
|
const allAsars = Object.entries(resourceAsars).sort(([name1], [name2]) =>
|
||||||
|
name1.localeCompare(name2),
|
||||||
|
);
|
||||||
|
const hashes = await Promise.all(allAsars.map(async ([, from]) => generateAsarIntegrity(from)));
|
||||||
|
const asarIntegrity: AsarIntegrity = {};
|
||||||
|
for (let i = 0; i < allAsars.length; i++) {
|
||||||
|
const [asar] = allAsars[i];
|
||||||
|
asarIntegrity[asar] = hashes[i];
|
||||||
|
}
|
||||||
|
return asarIntegrity;
|
||||||
|
}
|
||||||
13
src/sha.ts
Normal file
13
src/sha.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import * as fs from 'fs-extra';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { pipeline } from 'stream/promises';
|
||||||
|
|
||||||
|
import { d } from './debug';
|
||||||
|
|
||||||
|
export const sha = async (filePath: string) => {
|
||||||
|
d('hashing', filePath);
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
hash.setEncoding('hex');
|
||||||
|
await pipeline(fs.createReadStream(filePath), hash);
|
||||||
|
return hash.read();
|
||||||
|
};
|
||||||
1016
test/__snapshots__/index.spec.ts.snap
Normal file
1016
test/__snapshots__/index.spec.ts.snap
Normal file
File diff suppressed because it is too large
Load Diff
29
test/asar-utils.spec.ts
Normal file
29
test/asar-utils.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { AsarMode, detectAsarMode, generateAsarIntegrity } from '../src/asar-utils';
|
||||||
|
import { describe, expect, it } from '@jest/globals';
|
||||||
|
|
||||||
|
const asarsPath = path.resolve(__dirname, 'fixtures', 'asars');
|
||||||
|
const appsPath = path.resolve(__dirname, 'fixtures', 'apps');
|
||||||
|
|
||||||
|
describe('asar-utils', () => {
|
||||||
|
describe('detectAsarMode', () => {
|
||||||
|
it('should correctly detect an asar enabled app', async () => {
|
||||||
|
expect(await detectAsarMode(path.resolve(appsPath, 'Arm64Asar.app'))).toBe(AsarMode.HAS_ASAR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly detect an app without an asar', async () => {
|
||||||
|
expect(await detectAsarMode(path.resolve(appsPath, 'Arm64NoAsar.app'))).toBe(
|
||||||
|
AsarMode.NO_ASAR,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateAsarIntegrity', () => {
|
||||||
|
it('should deterministically hash an asar header', async () => {
|
||||||
|
expect(generateAsarIntegrity(path.resolve(asarsPath, 'app.asar')).hash).toEqual(
|
||||||
|
'85fff474383bd8df11cd9c5784e8fcd1525af71ff140a8a882e1dc9d5b39fcbf',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
62
test/file-utils.spec.ts
Normal file
62
test/file-utils.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { AppFile, AppFileType, getAllAppFiles } from '../src/file-utils';
|
||||||
|
import { beforeAll, describe, expect, it } from '@jest/globals';
|
||||||
|
|
||||||
|
const appsPath = path.resolve(__dirname, 'fixtures', 'apps');
|
||||||
|
|
||||||
|
describe('file-utils', () => {
|
||||||
|
describe('getAllAppFiles', () => {
|
||||||
|
let asarFiles: AppFile[];
|
||||||
|
let noAsarFiles: AppFile[];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
asarFiles = await getAllAppFiles(path.resolve(appsPath, 'Arm64Asar.app'));
|
||||||
|
noAsarFiles = await getAllAppFiles(path.resolve(appsPath, 'Arm64NoAsar.app'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify plist files', async () => {
|
||||||
|
expect(asarFiles.find((f) => f.relativePath === 'Contents/Info.plist')?.type).toBe(
|
||||||
|
AppFileType.INFO_PLIST,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify asar files as app code', async () => {
|
||||||
|
expect(asarFiles.find((f) => f.relativePath === 'Contents/Resources/app.asar')?.type).toBe(
|
||||||
|
AppFileType.APP_CODE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify non-asar code files as plain text', async () => {
|
||||||
|
expect(
|
||||||
|
noAsarFiles.find((f) => f.relativePath === 'Contents/Resources/app/index.js')?.type,
|
||||||
|
).toBe(AppFileType.PLAIN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify the Electron binary as Mach-O', async () => {
|
||||||
|
expect(noAsarFiles.find((f) => f.relativePath === 'Contents/MacOS/Electron')?.type).toBe(
|
||||||
|
AppFileType.MACHO,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify the Electron Framework as Mach-O', async () => {
|
||||||
|
expect(
|
||||||
|
noAsarFiles.find(
|
||||||
|
(f) =>
|
||||||
|
f.relativePath ===
|
||||||
|
'Contents/Frameworks/Electron Framework.framework/Versions/A/Electron Framework',
|
||||||
|
)?.type,
|
||||||
|
).toBe(AppFileType.MACHO);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify the v8 context snapshot', async () => {
|
||||||
|
expect(
|
||||||
|
noAsarFiles.find(
|
||||||
|
(f) =>
|
||||||
|
f.relativePath ===
|
||||||
|
'Contents/Frameworks/Electron Framework.framework/Versions/A/Resources/v8_context_snapshot.arm64.bin',
|
||||||
|
)?.type,
|
||||||
|
).toBe(AppFileType.SNAPSHOT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
test/fixtures/asars/app.asar
vendored
Normal file
BIN
test/fixtures/asars/app.asar
vendored
Normal file
Binary file not shown.
2
test/fixtures/asars/app/index.js
vendored
Normal file
2
test/fixtures/asars/app/index.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
console.log('I am an app folder', process.arch);
|
||||||
|
process.exit(0);
|
||||||
4
test/fixtures/asars/app/package.json
vendored
Normal file
4
test/fixtures/asars/app/package.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"main": "index.js"
|
||||||
|
}
|
||||||
BIN
test/fixtures/asars/app2.asar
vendored
Normal file
BIN
test/fixtures/asars/app2.asar
vendored
Normal file
Binary file not shown.
1
test/fixtures/asars/app2/extra-file.txt
vendored
Normal file
1
test/fixtures/asars/app2/extra-file.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
erick was here!
|
||||||
2
test/fixtures/asars/app2/index.js
vendored
Normal file
2
test/fixtures/asars/app2/index.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
console.log('I am an app.asar', process.arch);
|
||||||
|
process.exit(0);
|
||||||
4
test/fixtures/asars/app2/package.json
vendored
Normal file
4
test/fixtures/asars/app2/package.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"main": "index.js"
|
||||||
|
}
|
||||||
6
test/fixtures/hello-world.c
vendored
Normal file
6
test/fixtures/hello-world.c
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
printf("Hello, World!\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
1
test/fixtures/tohash
vendored
Normal file
1
test/fixtures/tohash
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hello there
|
||||||
419
test/index.spec.ts
Normal file
419
test/index.spec.ts
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import * as fs from 'fs-extra';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { makeUniversalApp } from '../dist/cjs/index';
|
||||||
|
import {
|
||||||
|
createStagingAppDir,
|
||||||
|
generateNativeApp,
|
||||||
|
templateApp,
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
verifyApp,
|
||||||
|
} from './util';
|
||||||
|
import { createPackage, createPackageWithOptions } from '@electron/asar';
|
||||||
|
import { afterEach, describe, expect, it } from '@jest/globals';
|
||||||
|
|
||||||
|
const appsPath = path.resolve(__dirname, 'fixtures', 'apps');
|
||||||
|
const appsOutPath = path.resolve(__dirname, 'fixtures', 'apps', 'out');
|
||||||
|
|
||||||
|
// See `jest.setup.ts` for app fixture setup process
|
||||||
|
describe('makeUniversalApp', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.emptyDir(appsOutPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws an error if asar is only detected in one arch', async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'Error.app');
|
||||||
|
await expect(
|
||||||
|
makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64NoAsar.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(
|
||||||
|
'Both the x64 and arm64 versions of your application need to have been built with the same asar settings (enabled vs disabled)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'works for lipo binary resources',
|
||||||
|
async () => {
|
||||||
|
const x64AppPath = await generateNativeApp({
|
||||||
|
appNameWithExtension: 'LipoX64.app',
|
||||||
|
arch: 'x64',
|
||||||
|
createAsar: true,
|
||||||
|
});
|
||||||
|
const arm64AppPath = await generateNativeApp({
|
||||||
|
appNameWithExtension: 'LipoArm64.app',
|
||||||
|
arch: 'arm64',
|
||||||
|
createAsar: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = path.resolve(appsOutPath, 'Lipo.app');
|
||||||
|
await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath: out, mergeASARs: true });
|
||||||
|
await verifyApp(out, true);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('force', () => {
|
||||||
|
it('throws an error if `out` bundle already exists and `force` is `false`', async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'Error.app');
|
||||||
|
await fs.mkdirp(out);
|
||||||
|
await expect(
|
||||||
|
makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/The out path ".*" already exists and force is not set to true/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'packages successfully if `out` bundle already exists and `force` is `true`',
|
||||||
|
async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'NoError.app');
|
||||||
|
await fs.mkdirp(out);
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
await verifyApp(out);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('asar mode', () => {
|
||||||
|
it(
|
||||||
|
'should correctly merge two identical asars',
|
||||||
|
async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'MergedAsar.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64Asar.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
});
|
||||||
|
await verifyApp(out);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should create a shim if asars are different between architectures',
|
||||||
|
async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'ShimmedAsar.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
});
|
||||||
|
await verifyApp(out);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should merge two different asars when `mergeASARs` is enabled',
|
||||||
|
async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'MergedAsar.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
mergeASARs: true,
|
||||||
|
singleArchFiles: 'extra-file.txt',
|
||||||
|
});
|
||||||
|
await verifyApp(out);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'throws an error if `mergeASARs` is enabled and `singleArchFiles` is missing a unique file',
|
||||||
|
async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'Error.app');
|
||||||
|
await expect(
|
||||||
|
makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64Asar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64AsarExtraFile.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
mergeASARs: true,
|
||||||
|
singleArchFiles: 'bad-rule',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Detected unique file "extra-file\.txt"/);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should not inject ElectronAsarIntegrity into `infoPlistsToIgnore`',
|
||||||
|
async () => {
|
||||||
|
const arm64AppPath = await templateApp('Arm64-1.app', 'arm64', async (appPath) => {
|
||||||
|
const { testPath } = await createStagingAppDir('Arm64-1');
|
||||||
|
await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar'));
|
||||||
|
await templateApp('SubApp-1.app', 'arm64', async (subArm64AppPath) => {
|
||||||
|
await fs.move(
|
||||||
|
subArm64AppPath,
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', path.basename(subArm64AppPath)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const x64AppPath = await templateApp('X64-1.app', 'x64', async (appPath) => {
|
||||||
|
const { testPath } = await createStagingAppDir('X64-1');
|
||||||
|
await createPackage(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app.asar'));
|
||||||
|
await templateApp('SubApp-1.app', 'x64', async (subArm64AppPath) => {
|
||||||
|
await fs.move(
|
||||||
|
subArm64AppPath,
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', path.basename(subArm64AppPath)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const outAppPath = path.resolve(appsOutPath, 'UnmodifiedPlist.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath,
|
||||||
|
arm64AppPath,
|
||||||
|
outAppPath,
|
||||||
|
mergeASARs: true,
|
||||||
|
infoPlistsToIgnore: 'SubApp-1.app/Contents/Info.plist',
|
||||||
|
});
|
||||||
|
await verifyApp(outAppPath);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Investigate if this should even be allowed.
|
||||||
|
// Current logic detects all unpacked files as APP_CODE, which doesn't seem correct since it could also be a macho file requiring lipo
|
||||||
|
// https://github.com/electron/universal/blob/d90d573ccf69a5b14b91aa818c8b97e0e6840399/src/file-utils.ts#L48-L49
|
||||||
|
it.skip(
|
||||||
|
'should shim asars with different unpacked dirs',
|
||||||
|
async () => {
|
||||||
|
const arm64AppPath = await templateApp('UnpackedArm64.app', 'arm64', async (appPath) => {
|
||||||
|
const { testPath } = await createStagingAppDir('UnpackedAppArm64');
|
||||||
|
await createPackageWithOptions(
|
||||||
|
testPath,
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
{
|
||||||
|
unpackDir: 'var',
|
||||||
|
unpack: '*.txt',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const x64AppPath = await templateApp('UnpackedX64.app', 'x64', async (appPath) => {
|
||||||
|
const { testPath } = await createStagingAppDir('UnpackedAppX64');
|
||||||
|
await createPackageWithOptions(
|
||||||
|
testPath,
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const outAppPath = path.resolve(appsOutPath, 'UnpackedDir.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath,
|
||||||
|
arm64AppPath,
|
||||||
|
outAppPath,
|
||||||
|
});
|
||||||
|
await verifyApp(outAppPath);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should generate AsarIntegrity for all asars in the application',
|
||||||
|
async () => {
|
||||||
|
const { testPath } = await createStagingAppDir('app-2');
|
||||||
|
const testAsarPath = path.resolve(appsOutPath, 'app-2.asar');
|
||||||
|
await createPackage(testPath, testAsarPath);
|
||||||
|
|
||||||
|
const arm64AppPath = await templateApp('Arm64-2.app', 'arm64', async (appPath) => {
|
||||||
|
await fs.copyFile(
|
||||||
|
testAsarPath,
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
);
|
||||||
|
await fs.copyFile(
|
||||||
|
testAsarPath,
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'webapp.asar'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const x64AppPath = await templateApp('X64-2.app', 'x64', async (appPath) => {
|
||||||
|
await fs.copyFile(
|
||||||
|
testAsarPath,
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'app.asar'),
|
||||||
|
);
|
||||||
|
await fs.copyFile(
|
||||||
|
testAsarPath,
|
||||||
|
path.resolve(appPath, 'Contents', 'Resources', 'webbapp.asar'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const outAppPath = path.resolve(appsOutPath, 'MultipleAsars.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath,
|
||||||
|
arm64AppPath,
|
||||||
|
outAppPath,
|
||||||
|
mergeASARs: true,
|
||||||
|
});
|
||||||
|
await verifyApp(outAppPath);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('no asar mode', () => {
|
||||||
|
it(
|
||||||
|
'should correctly merge two identical app folders',
|
||||||
|
async () => {
|
||||||
|
const out = path.resolve(appsOutPath, 'MergedNoAsar.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath: path.resolve(appsPath, 'X64NoAsar.app'),
|
||||||
|
arm64AppPath: path.resolve(appsPath, 'Arm64NoAsar.app'),
|
||||||
|
outAppPath: out,
|
||||||
|
});
|
||||||
|
await verifyApp(out);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should shim two different app folders',
|
||||||
|
async () => {
|
||||||
|
const arm64AppPath = await templateApp('ShimArm64.app', 'arm64', async (appPath) => {
|
||||||
|
const { testPath } = await createStagingAppDir('shimArm64', {
|
||||||
|
'i-aint-got-no-rhythm.bin': 'boomshakalaka',
|
||||||
|
});
|
||||||
|
await fs.copy(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const x64AppPath = await templateApp('ShimX64.app', 'x64', async (appPath) => {
|
||||||
|
const { testPath } = await createStagingAppDir('shimX64', {
|
||||||
|
'hello-world.bin': 'Hello World',
|
||||||
|
});
|
||||||
|
await fs.copy(testPath, path.resolve(appPath, 'Contents', 'Resources', 'app'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const outAppPath = path.resolve(appsOutPath, 'ShimNoAsar.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath,
|
||||||
|
arm64AppPath,
|
||||||
|
outAppPath,
|
||||||
|
});
|
||||||
|
await verifyApp(outAppPath);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'different app dirs with different macho files (shim and lipo)',
|
||||||
|
async () => {
|
||||||
|
const x64AppPath = await generateNativeApp({
|
||||||
|
appNameWithExtension: 'DifferentMachoAppX64-1.app',
|
||||||
|
arch: 'x64',
|
||||||
|
createAsar: false,
|
||||||
|
additionalFiles: {
|
||||||
|
'hello-world.bin': 'Hello World',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const arm64AppPath = await generateNativeApp({
|
||||||
|
appNameWithExtension: 'DifferentMachoAppArm64-1.app',
|
||||||
|
arch: 'arm64',
|
||||||
|
createAsar: false,
|
||||||
|
additionalFiles: {
|
||||||
|
'i-aint-got-no-rhythm.bin': 'boomshakalaka',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const outAppPath = path.resolve(appsOutPath, 'DifferentMachoApp1.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath,
|
||||||
|
arm64AppPath,
|
||||||
|
outAppPath,
|
||||||
|
});
|
||||||
|
await verifyApp(outAppPath, true);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"different app dirs with universal macho files (shim but don't lipo)",
|
||||||
|
async () => {
|
||||||
|
const x64AppPath = await generateNativeApp({
|
||||||
|
appNameWithExtension: 'DifferentButUniversalMachoAppX64-2.app',
|
||||||
|
arch: 'x64',
|
||||||
|
createAsar: false,
|
||||||
|
nativeModuleArch: 'universal',
|
||||||
|
additionalFiles: {
|
||||||
|
'hello-world.bin': 'Hello World',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const arm64AppPath = await generateNativeApp({
|
||||||
|
appNameWithExtension: 'DifferentButUniversalMachoAppArm64-2.app',
|
||||||
|
arch: 'arm64',
|
||||||
|
createAsar: false,
|
||||||
|
nativeModuleArch: 'universal',
|
||||||
|
additionalFiles: {
|
||||||
|
'i-aint-got-no-rhythm.bin': 'boomshakalaka',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const outAppPath = path.resolve(appsOutPath, 'DifferentButUniversalMachoApp.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath,
|
||||||
|
arm64AppPath,
|
||||||
|
outAppPath,
|
||||||
|
});
|
||||||
|
await verifyApp(outAppPath, true);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'identical app dirs with different macho files (e.g. do not shim, but still lipo)',
|
||||||
|
async () => {
|
||||||
|
const x64AppPath = await generateNativeApp({
|
||||||
|
appNameWithExtension: 'DifferentMachoAppX64-2.app',
|
||||||
|
arch: 'x64',
|
||||||
|
createAsar: false,
|
||||||
|
});
|
||||||
|
const arm64AppPath = await generateNativeApp({
|
||||||
|
appNameWithExtension: 'DifferentMachoAppArm64-2.app',
|
||||||
|
arch: 'arm64',
|
||||||
|
createAsar: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = path.resolve(appsOutPath, 'DifferentMachoApp2.app');
|
||||||
|
await makeUniversalApp({
|
||||||
|
x64AppPath,
|
||||||
|
arm64AppPath,
|
||||||
|
outAppPath: out,
|
||||||
|
});
|
||||||
|
await verifyApp(out, true);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'identical app dirs with universal macho files (e.g., do not shim, just copy x64 dir)',
|
||||||
|
async () => {
|
||||||
|
const x64AppPath = await generateNativeApp({
|
||||||
|
appNameWithExtension: 'UniversalMachoAppX64.app',
|
||||||
|
arch: 'x64',
|
||||||
|
createAsar: false,
|
||||||
|
nativeModuleArch: 'universal',
|
||||||
|
});
|
||||||
|
const arm64AppPath = await generateNativeApp({
|
||||||
|
appNameWithExtension: 'UniversalMachoAppArm64.app',
|
||||||
|
arch: 'arm64',
|
||||||
|
createAsar: false,
|
||||||
|
nativeModuleArch: 'universal',
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = path.resolve(appsOutPath, 'UniversalMachoApp.app');
|
||||||
|
await makeUniversalApp({ x64AppPath, arm64AppPath, outAppPath: out });
|
||||||
|
await verifyApp(out, true);
|
||||||
|
},
|
||||||
|
VERIFY_APP_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
12
test/sha.spec.ts
Normal file
12
test/sha.spec.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { sha } from '../src/sha';
|
||||||
|
import { describe, expect, it } from '@jest/globals';
|
||||||
|
|
||||||
|
describe('sha', () => {
|
||||||
|
it('should correctly hash a file', async () => {
|
||||||
|
expect(await sha(path.resolve(__dirname, 'fixtures', 'tohash'))).toEqual(
|
||||||
|
'12998c017066eb0d2a70b94e6ed3192985855ce390f321bbdb832022888bd251',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
251
test/util.ts
Normal file
251
test/util.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { downloadArtifact } from '@electron/get';
|
||||||
|
import { spawn } from '@malept/cross-spawn-promise';
|
||||||
|
import * as zip from 'cross-zip';
|
||||||
|
import * as fs from 'fs-extra';
|
||||||
|
import * as path from 'path';
|
||||||
|
import plist from 'plist';
|
||||||
|
import * as fileUtils from '../dist/cjs/file-utils';
|
||||||
|
import { createPackageWithOptions, getRawHeader } from '@electron/asar';
|
||||||
|
|
||||||
|
declare const expect: typeof import('@jest/globals').expect;
|
||||||
|
|
||||||
|
// We do a LOT of verifications in `verifyApp` 😅
|
||||||
|
// exec universal binary -> verify ALL asars -> verify ALL app dirs -> verify ALL asar integrity entries
|
||||||
|
// plus some tests create fixtures at runtime
|
||||||
|
export const VERIFY_APP_TIMEOUT = 80 * 1000;
|
||||||
|
|
||||||
|
export const fixtureDir = path.resolve(__dirname, 'fixtures');
|
||||||
|
export const asarsDir = path.resolve(fixtureDir, 'asars');
|
||||||
|
export const appsDir = path.resolve(fixtureDir, 'apps');
|
||||||
|
export const appsOutPath = path.resolve(appsDir, 'out');
|
||||||
|
|
||||||
|
export const verifyApp = async (appPath: string, containsRuntimeGeneratedMacho = false) => {
|
||||||
|
await ensureUniversal(appPath);
|
||||||
|
|
||||||
|
const resourcesDir = path.resolve(appPath, 'Contents', 'Resources');
|
||||||
|
const resourcesDirContents = await fs.readdir(resourcesDir);
|
||||||
|
|
||||||
|
// sort for consistent result
|
||||||
|
const asars = resourcesDirContents.filter((p) => p.endsWith('.asar')).sort();
|
||||||
|
for await (const asar of asars) {
|
||||||
|
// verify header
|
||||||
|
const asarFs = getRawHeader(path.resolve(resourcesDir, asar));
|
||||||
|
expect(
|
||||||
|
removeUnstableProperties(asarFs.header, containsRuntimeGeneratedMacho ? ['hello-world'] : []),
|
||||||
|
).toMatchSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
// check all app and unpacked dirs (incl. shimmed)
|
||||||
|
const dirsToSnapshot = [
|
||||||
|
'app',
|
||||||
|
'app.asar.unpacked',
|
||||||
|
'app-x64',
|
||||||
|
'app-x64.asar.unpacked',
|
||||||
|
'app-arm64',
|
||||||
|
'app-arm64.asar.unpacked',
|
||||||
|
];
|
||||||
|
const appDirs = resourcesDirContents
|
||||||
|
.filter((p) => dirsToSnapshot.includes(path.basename(p)))
|
||||||
|
.sort();
|
||||||
|
for await (const dir of appDirs) {
|
||||||
|
await verifyFileTree(path.resolve(resourcesDir, dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFiles = await fileUtils.getAllAppFiles(appPath);
|
||||||
|
const infoPlists = allFiles
|
||||||
|
.filter(
|
||||||
|
(appFile) =>
|
||||||
|
appFile.type === fileUtils.AppFileType.INFO_PLIST &&
|
||||||
|
// These are test app fixtures, no need to snapshot within `TestApp.app/Contents/Frameworks`
|
||||||
|
!appFile.relativePath.includes(path.join('Contents', 'Frameworks')),
|
||||||
|
)
|
||||||
|
.map((af) => af.relativePath)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const integrityMap: Record<string, string> = {};
|
||||||
|
const integrity = await Promise.all(
|
||||||
|
infoPlists.map((ip) => extractAsarIntegrity(path.resolve(appPath, ip))),
|
||||||
|
);
|
||||||
|
for (let i = 0; i < integrity.length; i++) {
|
||||||
|
const relativePath = infoPlists[i];
|
||||||
|
const asarIntegrity = integrity[i];
|
||||||
|
// note: `infoPlistsToIgnore` will not have integrity in sub-app plists
|
||||||
|
integrityMap[relativePath] = asarIntegrity
|
||||||
|
? removeUnstableProperties(asarIntegrity, containsRuntimeGeneratedMacho ? ['hash'] : [])
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
expect(integrityMap).toMatchSnapshot();
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractAsarIntegrity = async (infoPlist: string) => {
|
||||||
|
const { ElectronAsarIntegrity: integrity, ...otherData } = plist.parse(
|
||||||
|
await fs.readFile(infoPlist, 'utf-8'),
|
||||||
|
) as any;
|
||||||
|
return integrity;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyFileTree = async (dirPath: string) => {
|
||||||
|
const dirFiles = await fileUtils.getAllAppFiles(dirPath);
|
||||||
|
const files = dirFiles.map((file) => {
|
||||||
|
const it = path.join(dirPath, file.relativePath);
|
||||||
|
const name = toSystemIndependentPath(file.relativePath);
|
||||||
|
if (it.endsWith('.txt') || it.endsWith('.json')) {
|
||||||
|
return { name, content: fs.readFileSync(it, 'utf-8') };
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
});
|
||||||
|
expect(files).toMatchSnapshot();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ensureUniversal = async (app: string) => {
|
||||||
|
const exe = path.resolve(app, 'Contents', 'MacOS', 'Electron');
|
||||||
|
const result = await spawn(exe);
|
||||||
|
expect(result).toContain('arm64');
|
||||||
|
const result2 = await spawn('arch', ['-x86_64', exe]);
|
||||||
|
expect(result2).toContain('x64');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toSystemIndependentPath = (s: string): string => {
|
||||||
|
return path.sep === '/' ? s : s.replace(/\\/g, '/');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeUnstableProperties = (data: any, stripKeys: string[]) => {
|
||||||
|
const removeKeysRecursively: (obj: any, keysToRemove: string[]) => any = (obj, keysToRemove) => {
|
||||||
|
if (!obj || typeof obj !== 'object') {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
// if the value is an array, map over it
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map((item: any) => removeKeysRecursively(item, keysToRemove));
|
||||||
|
}
|
||||||
|
return Object.keys(obj).reduce<any>((acc, key) => {
|
||||||
|
// if the value of the current key is another object, make a recursive call to remove the key from the nested object
|
||||||
|
if (!keysToRemove.includes(key)) {
|
||||||
|
acc[key] = removeKeysRecursively(obj[key], keysToRemove);
|
||||||
|
} else {
|
||||||
|
acc[key] = '<stripped>';
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredData = removeKeysRecursively(data, stripKeys);
|
||||||
|
return JSON.parse(
|
||||||
|
JSON.stringify(filteredData, (name, value) => {
|
||||||
|
if (name === 'offset') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an app directory at runtime for usage:
|
||||||
|
* - `testPath` can be used with `asar.createPackage`. Just set the output `.asar` path to `Test.app/Contents/Resources/<asar_name>.asar`
|
||||||
|
* - `testPath` can be utilized for logic paths involving `AsarMode.NO_ASAR` and copied directly to `Test.app/Contents/Resources`
|
||||||
|
*
|
||||||
|
* Directory structure:
|
||||||
|
* testName
|
||||||
|
* ├── private
|
||||||
|
* │ └── var
|
||||||
|
* │ ├── app
|
||||||
|
* │ │ └── file.txt -> ../file.txt
|
||||||
|
* │ └── file.txt
|
||||||
|
* └── var -> private/var
|
||||||
|
* ├── index.js
|
||||||
|
* ├── package.json
|
||||||
|
*/
|
||||||
|
export const createStagingAppDir = async (
|
||||||
|
testName: string | undefined,
|
||||||
|
additionalFiles: Record<string, string> = {},
|
||||||
|
) => {
|
||||||
|
const outDir = (testName || 'app') + Math.floor(Math.random() * 100); // tests run in parallel, randomize dir suffix to prevent naming collisions
|
||||||
|
const testPath = path.join(appsDir, outDir);
|
||||||
|
await fs.remove(testPath);
|
||||||
|
|
||||||
|
await fs.copy(path.join(asarsDir, 'app'), testPath);
|
||||||
|
|
||||||
|
const privateVarPath = path.join(testPath, 'private', 'var');
|
||||||
|
const varPath = path.join(testPath, 'var');
|
||||||
|
|
||||||
|
await fs.mkdir(privateVarPath, { recursive: true });
|
||||||
|
await fs.symlink(path.relative(testPath, privateVarPath), varPath);
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
'file.txt': 'hello world',
|
||||||
|
...additionalFiles,
|
||||||
|
};
|
||||||
|
for await (const [filename, fileData] of Object.entries(files)) {
|
||||||
|
const originFilePath = path.join(varPath, filename);
|
||||||
|
await fs.writeFile(originFilePath, fileData);
|
||||||
|
}
|
||||||
|
const appPath = path.join(varPath, 'app');
|
||||||
|
await fs.mkdirp(appPath);
|
||||||
|
await fs.symlink('../file.txt', path.join(appPath, 'file.txt'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
testPath,
|
||||||
|
varPath,
|
||||||
|
appPath,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const templateApp = async (
|
||||||
|
name: string,
|
||||||
|
arch: string,
|
||||||
|
modify: (appPath: string) => Promise<void>,
|
||||||
|
) => {
|
||||||
|
const electronZip = await downloadArtifact({
|
||||||
|
artifactName: 'electron',
|
||||||
|
version: '27.0.0',
|
||||||
|
platform: 'darwin',
|
||||||
|
arch,
|
||||||
|
});
|
||||||
|
const appPath = path.resolve(appsDir, name);
|
||||||
|
zip.unzipSync(electronZip, appsDir);
|
||||||
|
await fs.rename(path.resolve(appsDir, 'Electron.app'), appPath);
|
||||||
|
await fs.remove(path.resolve(appPath, 'Contents', 'Resources', 'default_app.asar'));
|
||||||
|
await modify(appPath);
|
||||||
|
|
||||||
|
return appPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateNativeApp = async (options: {
|
||||||
|
appNameWithExtension: string;
|
||||||
|
arch: string;
|
||||||
|
createAsar: boolean;
|
||||||
|
nativeModuleArch?: string;
|
||||||
|
additionalFiles?: Record<string, string>;
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
appNameWithExtension,
|
||||||
|
arch,
|
||||||
|
createAsar,
|
||||||
|
nativeModuleArch = arch,
|
||||||
|
additionalFiles,
|
||||||
|
} = options;
|
||||||
|
const appPath = await templateApp(appNameWithExtension, arch, async (appPath) => {
|
||||||
|
const resources = path.join(appPath, 'Contents', 'Resources');
|
||||||
|
const resourcesApp = path.resolve(resources, 'app');
|
||||||
|
if (!fs.existsSync(resourcesApp)) {
|
||||||
|
await fs.mkdir(resourcesApp);
|
||||||
|
}
|
||||||
|
const { testPath } = await createStagingAppDir(
|
||||||
|
path.basename(appNameWithExtension, '.app'),
|
||||||
|
additionalFiles,
|
||||||
|
);
|
||||||
|
await fs.copy(
|
||||||
|
path.join(appsDir, `hello-world-${nativeModuleArch}`),
|
||||||
|
path.join(testPath, 'hello-world'),
|
||||||
|
);
|
||||||
|
if (createAsar) {
|
||||||
|
await createPackageWithOptions(testPath, path.resolve(resources, 'app.asar'), {
|
||||||
|
unpack: '**/hello-world',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await fs.copy(testPath, resourcesApp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return appPath;
|
||||||
|
};
|
||||||
4
tsconfig.cjs.json
Normal file
4
tsconfig.cjs.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
10
tsconfig.entry-asar.json
Normal file
10
tsconfig.entry-asar.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "entry-asar",
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"entry-asar"
|
||||||
|
],
|
||||||
|
"exclude": []
|
||||||
|
}
|
||||||
@@ -3,5 +3,6 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"outDir": "dist/esm"
|
"outDir": "dist/esm"
|
||||||
}
|
},
|
||||||
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
11
tsconfig.jest.json
Normal file
11
tsconfig.jest.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "esnext",
|
||||||
|
"outDir": "dist/esm",
|
||||||
|
"types": [
|
||||||
|
"jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -13,9 +13,11 @@
|
|||||||
],
|
],
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
"declaration": true
|
"declaration": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src",
|
||||||
|
"entry-asar"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
6
typedoc.json
Normal file
6
typedoc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://typedoc.org/schema.json",
|
||||||
|
"entryPoints": ["./src/index.ts"],
|
||||||
|
"excludeInternal": true,
|
||||||
|
"sort": ["source-order"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user