Repository: electron/electron
Pull request: feat: lazy electron download

#### Description of Change

First pass at implementing electron/rfcs#22.

This PR spawns a separate Node.js child process running the existing `install.js` script for the binary download to minimize the diff in the code. Now, there are two ways to get the binary installed:

* `npx electron` will automatically trigger `install.js` if the Electron binary path is missing.
* `npx install-electron` will manually trigger `install.js`.

The error hint now adds the `--no` flag to explicitly prevent installation of the [`install-electron`](http://npmjs.com/package/install-electron) package (which we've stubbed in the registry just in case).

I tested this manually by initializing a new npm project pointing to `file:path/to/src/electron/npm` and bypassing the version and checksum checks.

cc @MarshallOfSound

#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] PR description included and stakeholders cc'd
- [x] `npm test` passes
- [ ] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [x] relevant API documentation, tutorials, and examples are updated and follow the [documentation style guide](https://github.com/electron/electron/blob/main/docs/development/style-guide.md)
- [x] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: Electron now downloads its binary into `node_modules` dynamically on first launch instead of running a `postinstall` script. Added the `install-electron` script to manually trigger the download as well.


Changed files:
filename: docs/breaking-changes.md
patch: @@ -24,6 +24,35 @@ for more consistent output sizes.
 
 ## Planned Breaking API Changes (41.0)
 
+### Behavior Changed: `electron` no longer downloads itself via `postinstall` script
+
+Previously, the `electron` npm package would download the Electron binary from the repository's
+GitHub Releases in the package's `postinstall` script.
+
+With recent supply chain security attacks against the npm ecosystem with `postinstall` scripts as a
+common attack vector, Electron will now download itself dynamically the first time that its main
+`bin` script is run (e.g. via `npx electron`). With this change, you can now use Electron with the
+npm `--ignore-scripts` flag. See [RFC #22](https://github.com/electron/rfcs/pull/22) for more context.
+
+```sh
+# won't install binary to `node_modules/electron`
+npm install electron --save-dev --ignore-scripts
+
+# will download the binary on demand before starting electron process
+npx electron .
+
+# subsequent runs will used the binary downloaded from the first run
+npx electron .
+```
+
+If you need to download the Electron binary on-demand, you can now call the `install-electron` script,
+which contains the exact same code from the former `postinstall` script.
+
+```sh
+npm install electron --save-dev --ignore-scripts
+npx install-electron --no
+```
+
 ### Behavior Changed: PDFs no longer create a separate WebContents
 
 Previously, PDF resources created a separate guest [WebContents](https://www.electronjs.org/docs/latest/api/web-contents) for rendering. Now, PDFs are rendered within the same WebContents instead. If you have code to detect PDF resources, use the [frame tree](https://www.electronjs.org/docs/latest/api/web-frame-main) instead of WebContents.

filename: docs/tutorial/installation.md
patch: @@ -11,6 +11,30 @@ npm install electron --save-dev
 See the [Electron versioning doc][versioning] for info on how to
 manage Electron versions in your apps.
 
+## Binary download step
+
+Under the hood, Electron's JavaScript API binds to a binary that contains its
+implementations. This binary is crucial to the function of any Electron app, and
+is downloaded by default the first time you run Electron in development mode
+(i.e. `electron .`).
+
+If you want to install the binary on demand instead, you can run the `install-electron` bin script
+included in the `electron` package:
+
+```sh
+npx install-electron --no
+```
+
+If you want to install your project's dependencies but don't need to use
+Electron functionality, you can set the `ELECTRON_SKIP_BINARY_DOWNLOAD` environment
+variable to prevent the binary from being downloaded. For instance, this feature can
+be useful in continuous integration environments when running unit tests that mock
+out the `electron` module.
+
+```sh
+ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm install
+```
+
 ## Running Electron ad-hoc
 
 If you're in a pinch and would prefer to not use `npm install` in your local
@@ -49,7 +73,7 @@ value, plus additional environment variables depending on your host system's Nod
 * [Node 10 and above][proxy-env-10]
 * [Before Node 10][proxy-env]
 
-## Custom Mirrors and Caches
+## Custom mirrors and caches
 
 During installation, the `electron` module will call out to
 [`@electron/get`][electron-get] to download prebuilt binaries of
@@ -120,23 +144,6 @@ The cache contains the version's official zip file as well as a checksum, and is
 │   └── electron-v15.3.1-darwin-x64.zip
 ```
 
-## Postinstall script
-
-Under the hood, Electron's JavaScript API binds to a binary that contains its
-implementations. Because this binary is crucial to the function of any Electron app,
-it is downloaded by default in the `postinstall` step every time you install `electron`
-from the npm registry.
-
-However, if you want to install your project's dependencies but don't need to use
-Electron functionality, you can set the `ELECTRON_SKIP_BINARY_DOWNLOAD` environment
-variable to prevent the binary from being downloaded. For instance, this feature can
-be useful in continuous integration environments when running unit tests that mock
-out the `electron` module.
-
-```sh npm2yarn
-ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm install
-```
-
 ## Troubleshooting
 
 When running `npm install electron`, some users occasionally encounter

filename: npm/index.js
patch: @@ -1,8 +1,27 @@
+const { spawnSync } = require('child_process');
 const fs = require('fs');
 const path = require('path');
 
 const pathFile = path.join(__dirname, 'path.txt');
 
+function downloadElectron () {
+  console.log('Downloading Electron binary...');
+  const result = spawnSync(process.execPath, [path.join(__dirname, 'install.js')], {
+    stdio: 'inherit'
+  });
+  if (result.status !== 0) {
+    throw new Error(
+      'Electron failed to install correctly. Please delete `node_modules/electron` and run "npx install-electron --no" manually.'
+    );
+  }
+}
+
+/**
+ * Fetches the path to the Electron executable to use in development mode.
+ * If the executable is missing, attempt to download it first.
+ *
+ * @returns the path to the Electron executable to run
+ */
 function getElectronPath () {
   let executablePath;
   if (fs.existsSync(pathFile)) {
@@ -12,9 +31,21 @@ function getElectronPath () {
     return path.join(process.env.ELECTRON_OVERRIDE_DIST_PATH, executablePath || 'electron');
   }
   if (executablePath) {
-    return path.join(__dirname, 'dist', executablePath);
+    const fullPath = path.join(__dirname, 'dist', executablePath);
+    if (!fs.existsSync(fullPath)) {
+      downloadElectron();
+    }
+    return fullPath;
   } else {
-    throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again');
+    try {
+      downloadElectron();
+    } catch {
+      throw new Error(
+        'Electron failed to install correctly. Please delete `node_modules/electron` and run "npx install-electron --no" manually.'
+      );
+    }
+    executablePath = fs.readFileSync(pathFile, 'utf-8');
+    return path.join(__dirname, 'dist', executablePath);
   }
 }
 

filename: npm/package.json
patch: @@ -2,10 +2,8 @@
   "main": "index.js",
   "types": "electron.d.ts",
   "bin": {
-    "electron": "cli.js"
-  },
-  "scripts": {
-    "postinstall": "node install.js"
+    "electron": "cli.js",
+    "install-electron": "install.js"
   },
   "dependencies": {
     "@electron/get": "^2.0.0",