Compare commits

...

5 Commits

Author SHA1 Message Date
Shelley Vohr
740c919bfc fix: alt-space not firing keydown event 2026-03-12 16:33:10 +01:00
Erick Zhao
313e501454 feat: add ELECTRON_INSTALL_ env vars (#49981)
* feat: add `ELECTRON_INSTALL_` env vars

* add tip for available arches

* clarify!

* use `arm` instead of `armv7l`
2026-03-12 15:09:48 +01:00
Erick Zhao
1f6dbb0917 docs: add API history (G-I) (#50194) 2026-03-12 14:43:39 +01:00
Niklas Wenzel
378659c535 build: update NMV to 146 (#50214) 2026-03-11 23:32:42 +00:00
Samuel Attard
6be775ad83 fix: preserve staged update dir when pruning orphaned updates on macOS (#50210)
fix: preserve staged update dir when pruning orphaned update dirs on macOS

The previous squirrel.mac patch cleaned up all staged update directories
before starting a new download. This kept disk usage bounded but broke
quitAndInstall() if called while a subsequent checkForUpdates() was in
flight — the already-staged bundle would be deleted out from under it.

This reworks the patch to read ShipItState.plist and preserve the
directory it references, deleting only truly orphaned update.XXXXXXX
directories. Disk footprint stays bounded (at most 2 dirs: staged +
in-progress) and quitAndInstall() remains safe mid-check.

Also adds test coverage for the quitAndInstall/checkForUpdates race and
a triple-stack scenario where 3 updates arrive without a restart.

Refs https://github.com/electron/electron/issues/50200
2026-03-11 15:42:23 -07:00
22 changed files with 797 additions and 89 deletions

View File

@@ -192,6 +192,7 @@ gh label list --repo electron/electron --search target/ --json name,color --jq '
```bash
npm run lint # Run all linters
npm run lint:clang-format # C++ formatting
npm run lint:api-history # Validate API history YAML blocks in docs
```
## Key Files

View File

@@ -2,7 +2,7 @@ is_electron_build = true
root_extra_deps = [ "//electron" ]
# Registry of NMVs --> https://github.com/nodejs/node/blob/main/doc/abi_version_registry.json
node_module_version = 145
node_module_version = 146
v8_promise_internal_field_count = 1
v8_embedder_string = "-electron.0"

58
docs/CLAUDE.md Normal file
View File

@@ -0,0 +1,58 @@
# Electron Documentation Guide
## API History Migration
Guide: `docs/development/api-history-migration-guide.md`
Style rules: `docs/development/style-guide.md` (see "API History" section)
Schema: `docs/api-history.schema.json`
Lint: `npm run lint:api-history`
### Format
Place YAML history block directly after the Markdown header, before parameters:
````md
### `module.method(args)`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/XXXXX
```
-->
* `arg` type - Description.
````
### Finding when an API was added
- `git log --all --reverse --oneline -S "methodName" -- docs/api/file.md` — find first commit adding a method name
- `git log --reverse -L :FunctionName:path/to/source.cc` — trace C++ implementation history
- `git log --grep="keyword" --oneline` — find merge commits referencing PRs
- `gh pr view <number> --repo electron/electron --json baseRefName` — verify PR targets main (not a backport)
- Always use the main-branch PR URL in history blocks, not backport PRs
### Cross-referencing breaking changes
- Search `docs/breaking-changes.md` for the API name to find deprecations/removals
- Use `git blame` on the breaking-changes entry to find the associated PR
- Add `breaking-changes-header` field using the heading ID from breaking-changes.md
### Placement rules
- Only add blocks to actual API entries (methods, events, properties with backtick signatures)
- Do NOT add blocks to section headers like `## Methods`, `### Instance Methods`, `## Events`
- Module-level blocks go after the `# moduleName` heading, before the module description quote
- For changes affecting multiple APIs, add a block under each affected top-level heading (see style guide "Change affecting multiple APIs")
### Key details
- `added` and `deprecated` arrays have `maxItems: 1`; `changes` can have multiple items
- `changes` entries require a `description` field; `added`/`deprecated` do not
- Wrap descriptions in double quotes to avoid YAML parsing issues with special characters
- Early Electron APIs (pre-2015) use merge-commit PRs (e.g., `Merge pull request #534`)
- Very early APIs (2013-2014, e.g., `ipcMain.on`, `ipcRenderer.send`) predate GitHub PRs — skip history blocks for these
- When multiple APIs were added in the same PR, they all reference the same PR URL
- Promisification PRs (e.g., #17355) count as `changes` entries with a description
- These PRs are breaking changes and should have their notes as "This method now returns a Promise instead of using a callback function."
- APIs that were deprecated and then removed from docs don't need history blocks (the removal is in `breaking-changes.md`)

View File

@@ -55,6 +55,13 @@ The `globalShortcut` module has the following methods:
### `globalShortcut.register(accelerator, callback)`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/534
```
-->
* `accelerator` string - An [accelerator](../tutorial/keyboard-shortcuts.md#accelerators) shortcut.
* `callback` Function
@@ -77,6 +84,13 @@ the app has been authorized as a [trusted accessibility client](https://develope
### `globalShortcut.registerAll(accelerators, callback)`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/15542
```
-->
* `accelerators` string[] - An array of [accelerator](../tutorial/keyboard-shortcuts.md#accelerators) shortcuts.
* `callback` Function
@@ -96,6 +110,13 @@ the app has been authorized as a [trusted accessibility client](https://develope
### `globalShortcut.isRegistered(accelerator)`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/534
```
-->
* `accelerator` string - An [accelerator](../tutorial/keyboard-shortcuts.md#accelerators) shortcut.
Returns `boolean` - Whether this application has registered `accelerator`.
@@ -106,10 +127,24 @@ don't want applications to fight for global shortcuts.
### `globalShortcut.unregister(accelerator)`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/534
```
-->
* `accelerator` string - An [accelerator](../tutorial/keyboard-shortcuts.md#accelerators) shortcut.
Unregisters the global shortcut of `accelerator`.
### `globalShortcut.unregisterAll()`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/534
```
-->
Unregisters all of the global shortcuts.

View File

@@ -35,6 +35,13 @@ webContentsView.webContents.loadURL('https://electronjs.org')
## Class: ImageView extends `View`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/46760
```
-->
> A View that displays an image.
Process: [Main](../glossary.md#main-process)
@@ -49,6 +56,13 @@ Process: [Main](../glossary.md#main-process)
### `new ImageView()` _Experimental_
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/46760
```
-->
Creates an ImageView.
### Instance Methods
@@ -58,6 +72,13 @@ addition to those inherited from [View](view.md):
#### `image.setImage(image)` _Experimental_
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/46760
```
-->
* `image` NativeImage
Sets the image for this `ImageView`. Note that only image formats supported by

View File

@@ -1,5 +1,12 @@
# inAppPurchase
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/11292
```
-->
> In-app purchases on Mac App Store.
Process: [Main](../glossary.md#main-process)
@@ -10,6 +17,13 @@ The `inAppPurchase` module emits the following events:
### Event: 'transactions-updated'
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/11292
```
-->
Returns:
* `event` Event
@@ -23,6 +37,19 @@ The `inAppPurchase` module has the following methods:
### `inAppPurchase.purchaseProduct(productID[, opts])`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/11292
changes:
- pr-url: https://github.com/electron/electron/pull/17355
description: "This method now returns a Promise instead of using a callback function."
breaking-changes-header: api-changed-callback-based-versions-of-promisified-apis
- pr-url: https://github.com/electron/electron/pull/35902
description: "Added `username` option to `opts` parameter."
```
-->
* `productID` string
* `opts` Integer | Object (optional) - If specified as an integer, defines the quantity.
* `quantity` Integer (optional) - The number of items the user wants to purchase.
@@ -34,6 +61,17 @@ You should listen for the `transactions-updated` event as soon as possible and c
### `inAppPurchase.getProducts(productIDs)`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/12464
changes:
- pr-url: https://github.com/electron/electron/pull/17355
description: "This method now returns a Promise instead of using a callback function."
breaking-changes-header: api-changed-callback-based-versions-of-promisified-apis
```
-->
* `productIDs` string[] - The identifiers of the products to get.
Returns `Promise<Product[]>` - Resolves with an array of [`Product`](structures/product.md) objects.
@@ -42,24 +80,59 @@ Retrieves the product descriptions.
### `inAppPurchase.canMakePayments()`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/11292
```
-->
Returns `boolean` - whether a user can make a payment.
### `inAppPurchase.restoreCompletedTransactions()`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/21461
```
-->
Restores finished transactions. This method can be called either to install purchases on additional devices, or to restore purchases for an application that the user deleted and reinstalled.
[The payment queue](https://developer.apple.com/documentation/storekit/skpaymentqueue?language=objc) delivers a new transaction for each previously completed transaction that can be restored. Each transaction includes a copy of the original transaction.
### `inAppPurchase.getReceiptURL()`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/11292
```
-->
Returns `string` - the path to the receipt.
### `inAppPurchase.finishAllTransactions()`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/12464
```
-->
Completes all pending transactions.
### `inAppPurchase.finishTransactionByDate(date)`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/12464
```
-->
* `date` string - The ISO formatted date of the transaction to finish.
Completes the pending transactions corresponding to the date.

View File

@@ -46,6 +46,13 @@ Listens to `channel`, when a new message arrives `listener` would be called with
### `ipcMain.off(channel, listener)`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/44651
```
-->
* `channel` string
* `listener` Function
* `event` [IpcMainEvent][ipc-main-event]
@@ -89,6 +96,13 @@ Removes all listeners from the specified `channel`. Removes all listeners from a
### `ipcMain.handle(channel, listener)`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/18449
```
-->
* `channel` string
* `listener` Function\<Promise\<any\> | any\>
* `event` [IpcMainInvokeEvent][ipc-main-invoke-event]
@@ -126,6 +140,13 @@ provided to the renderer process. Please refer to
### `ipcMain.handleOnce(channel, listener)`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/18449
```
-->
* `channel` string
* `listener` Function\<Promise\<any\> | any\>
* `event` [IpcMainInvokeEvent][ipc-main-invoke-event]
@@ -136,6 +157,13 @@ Handles a single `invoke`able IPC message, then removes the listener. See
### `ipcMain.removeHandler(channel)`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/18449
```
-->
* `channel` string
Removes any handler for `channel`, if present.

View File

@@ -59,6 +59,13 @@ for more info.
### `ipcRenderer.off(channel, listener)`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/39816
```
-->
* `channel` string
* `listener` Function
* `event` [IpcRendererEvent][ipc-renderer-event]
@@ -79,6 +86,13 @@ only the next time a message is sent to `channel`, after which it is removed.
### `ipcRenderer.addListener(channel, listener)`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/39816
```
-->
* `channel` string
* `listener` Function
* `event` [IpcRendererEvent][ipc-renderer-event]
@@ -129,6 +143,13 @@ If you want to receive a single response from the main process, like the result
### `ipcRenderer.invoke(channel, ...args)`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/18449
```
-->
* `channel` string
* `...args` any[]
@@ -209,6 +230,13 @@ and replies by setting `event.returnValue`.
### `ipcRenderer.postMessage(channel, message, [transfer])`
<!--
```YAML history
added:
- pr-url: https://github.com/electron/electron/pull/22404
```
-->
* `channel` string
* `message` any
* `transfer` MessagePort[] (optional)

View File

@@ -59,6 +59,17 @@ npm install electron --save-dev --ignore-scripts
npx install-electron --no
```
If you need to test changes across platforms or architectures, you should now use the
`ELECTRON_INSTALL_ARCH` and `ELECTRON_INSTALL_PLATFORM` environment variables.
```sh
# before: pass npm config flag on install command
npm install --platform=mas electron --save-dev
# after: add env var when you first run the Electron command
npm install electron --save-dev
ELECTRON_INSTALL_PLATFORM=mas npx electron . --no
```
### Removed: `quotas` object from `Session.clearStorageData(options)`
When calling `Session.clearStorageData(options)`, the `options.quotas` object is no longer supported because it has been

View File

@@ -51,20 +51,40 @@ any dependencies in your app will not be installed.
## Customization
If you want to change the architecture that is downloaded (e.g., `x64` on an
`arm64` machine), you can use the `--arch` flag with npm install or set the
`npm_config_arch` environment variable:
`arm64` machine), you can set the `ELECTRON_INSTALL_ARCH` environment variable:
```shell
npm install --arch=x64 electron
```sh
# Inside an npm script or with npx
ELECTRON_INSTALL_ARCH=x64 electron .
```
Supported architectures are a subset of Node.js [`process.arch`](https://nodejs.org/api/process.html#processarch)
values, and include:
* `x64` (Intel Mac and 64-bit Windows)
* `ia32` (32-bit Windows)
* `arm64` (Apple silicon, Windows on ARM, ARM64 Linux)
* `arm` (32-bit ARM)
In addition to changing the architecture, you can also specify the platform
(e.g., `win32`, `linux`, etc.) using the `--platform` flag:
```shell
npm install --platform=win32 electron
```sh
# Inside an npm script or with npx
ELECTRON_INSTALL_PLATFORM=mas electron .
```
Supported platforms are Node-like [platform strings](https://nodejs.org/api/process.html#processplatform):
* `darwin`
* `mas` ([Mac App Store](./mac-app-store-submission-guide.md))
* `win32`
* `linux`
> [!TIP]
> To see all available platform/architecture combinations for a particular release, see the artifacts
> on [Electron's GitHub Releases](https://github.com/electron/electron/releases).
## Proxies
If you need to use an HTTP proxy, you need to set the `ELECTRON_GET_USE_PROXY` variable to any

View File

@@ -78,6 +78,7 @@ auto_filenames = {
"docs/api/web-utils.md",
"docs/api/webview-tag.md",
"docs/api/window-open.md",
"docs/api/structures/activation-arguments.md",
"docs/api/structures/base-window-options.md",
"docs/api/structures/bluetooth-device.md",
"docs/api/structures/browser-window-options.md",

View File

@@ -21,8 +21,11 @@ if (isInstalled()) {
process.exit(0);
}
const platform = process.env.npm_config_platform || process.platform;
let arch = process.env.npm_config_arch || process.arch;
const platform = process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || process.platform;
let arch =
process.env.ELECTRON_INSTALL_ARCH ||
process.env.npm_config_arch ||
process.arch;
if (platform === 'darwin' && process.platform === 'darwin' && arch === 'x64' &&
process.env.npm_config_arch === undefined) {

View File

@@ -9,5 +9,5 @@ refactor_use_non-deprecated_nskeyedarchiver_apis.patch
chore_turn_off_launchapplicationaturl_deprecation_errors_in_squirrel.patch
fix_crash_when_process_to_extract_zip_cannot_be_launched.patch
use_uttype_class_instead_of_deprecated_uttypeconformsto.patch
fix_clean_up_old_staged_updates_before_downloading_new_update.patch
fix_clean_up_orphaned_staged_updates_before_downloading_new_update.patch
fix_add_explicit_json_property_mappings_for_shipit_request_model.patch

View File

@@ -1,64 +0,0 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Andy Locascio <loc@anthropic.com>
Date: Tue, 6 Jan 2026 08:23:03 -0800
Subject: fix: clean up old staged updates before downloading new update
When checkForUpdates() is called while an update is already staged,
Squirrel creates a new temporary directory for the download without
cleaning up the old one. This can lead to significant disk usage if
the app keeps checking for updates without restarting.
This change adds a force parameter to pruneUpdateDirectories that
bypasses the AwaitingRelaunch state check. This is called before
creating a new temp directory, ensuring old staged updates are
cleaned up when a new download starts.
diff --git a/Squirrel/SQRLUpdater.m b/Squirrel/SQRLUpdater.m
index d156616e81e6f25a3bded30e6216b8fc311f31bc..6cd4346bf43b191147aff819cb93387e71275a46 100644
--- a/Squirrel/SQRLUpdater.m
+++ b/Squirrel/SQRLUpdater.m
@@ -543,11 +543,17 @@ - (RACSignal *)downloadBundleForUpdate:(SQRLUpdate *)update intoDirectory:(NSURL
#pragma mark File Management
- (RACSignal *)uniqueTemporaryDirectoryForUpdate {
- return [[[RACSignal
+ // Clean up any old staged update directories before creating a new one.
+ // This prevents disk usage from growing when checkForUpdates() is called
+ // multiple times without the app restarting.
+ return [[[[[self
+ pruneUpdateDirectoriesWithForce:YES]
+ ignoreValues]
+ concat:[RACSignal
defer:^{
SQRLDirectoryManager *directoryManager = [[SQRLDirectoryManager alloc] initWithApplicationIdentifier:SQRLShipItLauncher.shipItJobLabel];
return [directoryManager storageURL];
- }]
+ }]]
flattenMap:^(NSURL *storageURL) {
NSURL *updateDirectoryTemplate = [storageURL URLByAppendingPathComponent:[SQRLUpdaterUniqueTemporaryDirectoryPrefix stringByAppendingString:@"XXXXXXX"]];
char *updateDirectoryCString = strdup(updateDirectoryTemplate.path.fileSystemRepresentation);
@@ -643,7 +649,7 @@ - (BOOL)isRunningOnReadOnlyVolume {
- (RACSignal *)performHousekeeping {
return [[RACSignal
- merge:@[ [self pruneUpdateDirectories], [self truncateLogs] ]]
+ merge:@[ [self pruneUpdateDirectoriesWithForce:NO], [self truncateLogs] ]]
catch:^(NSError *error) {
NSLog(@"Error doing housekeeping: %@", error);
return [RACSignal empty];
@@ -658,11 +664,12 @@ - (RACSignal *)performHousekeeping {
///
/// Sends each removed directory then completes, or errors, on an unspecified
/// thread.
-- (RACSignal *)pruneUpdateDirectories {
+- (RACSignal *)pruneUpdateDirectoriesWithForce:(BOOL)force {
return [[[RACSignal
defer:^{
- // If we already have updates downloaded we don't wanna prune them.
- if (self.state == SQRLUpdaterStateAwaitingRelaunch) return [RACSignal empty];
+ // If we already have updates downloaded we don't wanna prune them,
+ // unless force is YES (used when starting a new download).
+ if (!force && self.state == SQRLUpdaterStateAwaitingRelaunch) return [RACSignal empty];
SQRLDirectoryManager *directoryManager = [[SQRLDirectoryManager alloc] initWithApplicationIdentifier:SQRLShipItLauncher.shipItJobLabel];
return [directoryManager storageURL];

View File

@@ -0,0 +1,130 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Andy Locascio <loc@anthropic.com>
Date: Tue, 6 Jan 2026 08:23:03 -0800
Subject: fix: clean up orphaned staged updates before downloading new update
When checkForUpdates() is called while an update is already staged,
Squirrel creates a new temporary directory for the download without
cleaning up the old one. This can lead to significant disk usage if
the app keeps checking for updates without restarting.
This change adds a pruneOrphanedUpdateDirectories step before creating
a new temp directory. Unlike a blanket prune, this reads the current
ShipItState.plist and preserves the directory it references, deleting
only truly orphaned update directories. This keeps the on-disk
footprint bounded (at most 2 dirs) while ensuring quitAndInstall
remains safe to call even when a new check is in progress.
Refs https://github.com/electron/electron/issues/50200
diff --git a/Squirrel/SQRLUpdater.m b/Squirrel/SQRLUpdater.m
index d156616e81e6f25a3bded30e6216b8fc311f31bc..41856e5754228d33982db72f97f2ff241615a357 100644
--- a/Squirrel/SQRLUpdater.m
+++ b/Squirrel/SQRLUpdater.m
@@ -543,11 +543,19 @@ - (RACSignal *)downloadBundleForUpdate:(SQRLUpdate *)update intoDirectory:(NSURL
#pragma mark File Management
- (RACSignal *)uniqueTemporaryDirectoryForUpdate {
- return [[[RACSignal
+ // Clean up any orphaned update directories before creating a new one.
+ // This prevents disk usage from growing when checkForUpdates() is called
+ // multiple times without the app restarting. The currently staged update
+ // (referenced by ShipItState.plist) is always preserved so quitAndInstall
+ // remains safe to call while a new check is in progress.
+ return [[[[[self
+ pruneOrphanedUpdateDirectories]
+ ignoreValues]
+ concat:[RACSignal
defer:^{
SQRLDirectoryManager *directoryManager = [[SQRLDirectoryManager alloc] initWithApplicationIdentifier:SQRLShipItLauncher.shipItJobLabel];
return [directoryManager storageURL];
- }]
+ }]]
flattenMap:^(NSURL *storageURL) {
NSURL *updateDirectoryTemplate = [storageURL URLByAppendingPathComponent:[SQRLUpdaterUniqueTemporaryDirectoryPrefix stringByAppendingString:@"XXXXXXX"]];
char *updateDirectoryCString = strdup(updateDirectoryTemplate.path.fileSystemRepresentation);
@@ -668,25 +676,68 @@ - (RACSignal *)pruneUpdateDirectories {
return [directoryManager storageURL];
}]
flattenMap:^(NSURL *storageURL) {
- NSFileManager *manager = [[NSFileManager alloc] init];
- NSDirectoryEnumerator *enumerator = [manager enumeratorAtURL:storageURL includingPropertiesForKeys:nil options:NSDirectoryEnumerationSkipsSubdirectoryDescendants errorHandler:^(NSURL *URL, NSError *error) {
- NSLog(@"Error enumerating item %@ within directory %@: %@", URL, storageURL, error);
- return YES;
- }];
+ return [self removeUpdateDirectoriesInStorageURL:storageURL excludingURL:nil];
+ }]
+ setNameWithFormat:@"%@ -prunedUpdateDirectories", self];
+}
- return [[enumerator.rac_sequence.signal
- filter:^(NSURL *enumeratedURL) {
- NSString *name = enumeratedURL.lastPathComponent;
- return [name hasPrefix:SQRLUpdaterUniqueTemporaryDirectoryPrefix];
- }]
- doNext:^(NSURL *directoryURL) {
- NSError *error = nil;
- if (![manager removeItemAtURL:directoryURL error:&error]) {
- NSLog(@"Error removing old update directory at %@: %@", directoryURL, error.sqrl_verboseDescription);
- }
+/// Lazily removes orphaned temporary directories upon subscription, always
+/// preserving the directory currently referenced by ShipItState.plist so that
+/// quitAndInstall remains safe to call mid-check.
+///
+/// Safe to call in any state. Sends each removed directory then completes on
+/// an unspecified thread. Errors reading the staged request are swallowed
+/// (treated as "nothing staged").
+- (RACSignal *)pruneOrphanedUpdateDirectories {
+ return [[[[[SQRLShipItRequest
+ readUsingURL:self.shipItStateURL]
+ map:^(SQRLShipItRequest *request) {
+ // The request holds the URL to the staged .app bundle; its parent
+ // is the update.XXXXXXX directory we must preserve.
+ return [request.updateBundleURL URLByDeletingLastPathComponent];
+ }]
+ catch:^(NSError *error) {
+ // No staged request (or unreadable) — nothing to preserve.
+ return [RACSignal return:nil];
+ }]
+ flattenMap:^(NSURL *stagedDirectoryURL) {
+ SQRLDirectoryManager *directoryManager = [[SQRLDirectoryManager alloc] initWithApplicationIdentifier:SQRLShipItLauncher.shipItJobLabel];
+ return [[directoryManager storageURL]
+ flattenMap:^(NSURL *storageURL) {
+ return [self removeUpdateDirectoriesInStorageURL:storageURL excludingURL:stagedDirectoryURL];
}];
}]
- setNameWithFormat:@"%@ -prunedUpdateDirectories", self];
+ setNameWithFormat:@"%@ -pruneOrphanedUpdateDirectories", self];
+}
+
+/// Shared enumerate-and-delete logic for update temp directories.
+///
+/// storageURL - The Squirrel storage root to enumerate. Must not be nil.
+/// excludedURL - Directory to skip (compared by standardized path). May be nil.
+- (RACSignal *)removeUpdateDirectoriesInStorageURL:(NSURL *)storageURL excludingURL:(NSURL *)excludedURL {
+ NSParameterAssert(storageURL != nil);
+
+ NSFileManager *manager = [[NSFileManager alloc] init];
+ NSDirectoryEnumerator *enumerator = [manager enumeratorAtURL:storageURL includingPropertiesForKeys:nil options:NSDirectoryEnumerationSkipsSubdirectoryDescendants errorHandler:^(NSURL *URL, NSError *error) {
+ NSLog(@"Error enumerating item %@ within directory %@: %@", URL, storageURL, error);
+ return YES;
+ }];
+
+ NSString *excludedPath = excludedURL.URLByStandardizingPath.path;
+
+ return [[enumerator.rac_sequence.signal
+ filter:^(NSURL *enumeratedURL) {
+ NSString *name = enumeratedURL.lastPathComponent;
+ if (![name hasPrefix:SQRLUpdaterUniqueTemporaryDirectoryPrefix]) return NO;
+ if (excludedPath != nil && [enumeratedURL.URLByStandardizingPath.path isEqualToString:excludedPath]) return NO;
+ return YES;
+ }]
+ doNext:^(NSURL *directoryURL) {
+ NSError *error = nil;
+ if (![manager removeItemAtURL:directoryURL error:&error]) {
+ NSLog(@"Error removing old update directory at %@: %@", directoryURL, error.sqrl_verboseDescription);
+ }
+ }];
}

View File

@@ -4,6 +4,8 @@
#include "shell/browser/ui/win/electron_desktop_window_tree_host_win.h"
#include <memory>
#include "base/win/windows_version.h"
#include "electron/buildflags/buildflags.h"
#include "shell/browser/api/electron_api_web_contents.h"
@@ -11,6 +13,7 @@
#include "shell/browser/ui/views/win_frame_view.h"
#include "shell/browser/win/dark_mode.h"
#include "ui/base/win/hwnd_metrics.h"
#include "ui/events/keycodes/keyboard_codes.h"
namespace electron {
@@ -150,6 +153,25 @@ bool ElectronDesktopWindowTreeHostWin::HandleMouseEvent(ui::MouseEvent* event) {
return views::DesktopWindowTreeHostWin::HandleMouseEvent(event);
}
void ElectronDesktopWindowTreeHostWin::HandleKeyEvent(ui::KeyEvent* event) {
// Chromium's base implementation intentionally drops ALT+SPACE keydown
// events so that the subsequent WM_SYSCHAR can open the system menu.
// We defer the event instead, so that if the system-context-menu event
// is prevented by the user, we can replay it to the renderer.
if ((event->type() == ui::EventType::kKeyPressed) &&
(event->key_code() == ui::VKEY_SPACE) &&
(event->flags() & ui::EF_ALT_DOWN) &&
!(event->flags() & ui::EF_CONTROL_DOWN)) {
if (views::Widget* widget = GetWidget();
widget && widget->non_client_view()) {
deferred_alt_space_event_ = std::make_unique<ui::KeyEvent>(*event);
return;
}
}
views::DesktopWindowTreeHostWin::HandleKeyEvent(event);
}
bool ElectronDesktopWindowTreeHostWin::HandleIMEMessage(UINT message,
WPARAM w_param,
LPARAM l_param,
@@ -165,8 +187,20 @@ bool ElectronDesktopWindowTreeHostWin::HandleIMEMessage(UINT message,
native_window_view_->NotifyWindowSystemContextMenu(
location.x(), location.y(), &prevent_default);
return prevent_default ||
views::DesktopWindowTreeHostWin::HandleIMEMessage(message, w_param,
if (prevent_default) {
// The system menu was suppressed. Replay the deferred ALT+SPACE
// keydown so it reaches before-input-event and the renderer.
if (deferred_alt_space_event_) {
SendEventToSink(deferred_alt_space_event_.get());
deferred_alt_space_event_.reset();
}
return true;
}
// System menu not prevented — discard the deferred event and let
// the default WM_SYSCHAR handling open the system menu.
deferred_alt_space_event_.reset();
return views::DesktopWindowTreeHostWin::HandleIMEMessage(message, w_param,
l_param, result);
}
}

View File

@@ -7,8 +7,10 @@
#include <windows.h>
#include <memory>
#include <optional>
#include "ui/events/event.h"
#include "ui/views/widget/desktop_aura/desktop_window_tree_host_win.h"
namespace electron {
@@ -44,6 +46,7 @@ class ElectronDesktopWindowTreeHostWin : public views::DesktopWindowTreeHostWin,
int frame_thickness) const override;
bool HandleMouseEventForCaption(UINT message) const override;
bool HandleMouseEvent(ui::MouseEvent* event) override;
void HandleKeyEvent(ui::KeyEvent* event) override;
bool HandleIMEMessage(UINT message,
WPARAM w_param,
LPARAM l_param,
@@ -63,6 +66,10 @@ class ElectronDesktopWindowTreeHostWin : public views::DesktopWindowTreeHostWin,
std::optional<bool> force_should_paint_as_active_;
bool allow_screenshots_ = true;
bool widget_init_done_ = false;
// Stores the ALT+SPACE key event deferred from HandleKeyEvent so it can be
// replayed in HandleIMEMessage if the system-context-menu is prevented.
std::unique_ptr<ui::KeyEvent> deferred_alt_space_event_;
};
} // namespace electron

View File

@@ -403,7 +403,7 @@ ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
});
});
it('should clean up old staged update directories when a new update is downloaded', async () => {
it('should preserve the staged update directory and prune orphaned ones when a new update is downloaded', async () => {
// Clean up any existing update directories before the test
await cleanSquirrelCache();
@@ -419,16 +419,23 @@ ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
}, async (_, updateZipPath3) => {
let updateCount = 0;
let downloadCount = 0;
let directoriesDuringSecondDownload: string[] = [];
let dirsDuringFirstDownload: string[] = [];
let dirsDuringSecondDownload: string[] = [];
server.get('/update-file', async (req, res) => {
downloadCount++;
// When the second download request arrives, Squirrel has already
// called uniqueTemporaryDirectoryForUpdate which (with our patch)
// cleans up old directories before creating the new one.
// Without the patch, both directories would exist at this point.
if (downloadCount === 2) {
directoriesDuringSecondDownload = await getUpdateDirectoriesInCache();
// Snapshot update directories at the moment each download begins.
// By this point uniqueTemporaryDirectoryForUpdate has already run
// (prune + mkdtemp). We want to verify:
// 1st download: 1 dir (nothing to preserve, nothing to prune)
// 2nd download: 2 dirs (staged dir from 1st check is preserved
// so quitAndInstall stays safe, + new temp dir)
// The count never exceeds 2 across repeated checks — orphaned dirs
// (no longer referenced by ShipItState.plist) get pruned.
if (downloadCount === 1) {
dirsDuringFirstDownload = await getUpdateDirectoriesInCache();
} else if (downloadCount === 2) {
dirsDuringSecondDownload = await getUpdateDirectoriesInCache();
}
res.download(updateCount > 1 ? updateZipPath3 : updateZipPath2);
});
@@ -455,15 +462,181 @@ ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
await relaunchPromise;
// During the second download, the old staged update directory should
// have been cleaned up. With our patch, there should be exactly 1
// directory (the new one). Without the patch, there would be 2.
expect(directoriesDuringSecondDownload).to.have.lengthOf(1,
`Expected 1 update directory during second download but found ${directoriesDuringSecondDownload.length}: ${directoriesDuringSecondDownload.join(', ')}`);
// First download: exactly one temp dir (the first update).
expect(dirsDuringFirstDownload).to.have.lengthOf(1,
`Expected 1 update directory during first download but found ${dirsDuringFirstDownload.length}: ${dirsDuringFirstDownload.join(', ')}`);
// Second download: exactly two — the staged one preserved + the new
// one. Crucially the first download's directory must still be present,
// otherwise a mid-download quitAndInstall would find a dangling
// ShipItState.plist.
expect(dirsDuringSecondDownload).to.have.lengthOf(2,
`Expected 2 update directories during second download (staged + new) but found ${dirsDuringSecondDownload.length}: ${dirsDuringSecondDownload.join(', ')}`);
expect(dirsDuringSecondDownload).to.include(dirsDuringFirstDownload[0],
'The staged update directory from the first download must be preserved during the second download');
});
});
});
it('should keep the update directory count bounded across repeated checks', async () => {
// Verifies the orphan prune actually fires: after a second download
// completes and rewrites ShipItState.plist, the first directory is no
// longer referenced and must be removed when a third check begins.
// Without this, directories would accumulate forever.
await cleanSquirrelCache();
await withUpdatableApp({
nextVersion: '2.0.0',
startFixture: 'update-triple-stack',
endFixture: 'update-triple-stack'
}, async (appPath, updateZipPath2) => {
await withUpdatableApp({
nextVersion: '3.0.0',
startFixture: 'update-triple-stack',
endFixture: 'update-triple-stack'
}, async (_, updateZipPath3) => {
await withUpdatableApp({
nextVersion: '4.0.0',
startFixture: 'update-triple-stack',
endFixture: 'update-triple-stack'
}, async (__, updateZipPath4) => {
let downloadCount = 0;
const dirsPerDownload: string[][] = [];
server.get('/update-file', async (req, res) => {
downloadCount++;
// Snapshot after prune+mkdtemp but before the payload transfers.
dirsPerDownload.push(await getUpdateDirectoriesInCache());
const zips = [updateZipPath2, updateZipPath3, updateZipPath4];
res.download(zips[Math.min(downloadCount, zips.length) - 1]);
});
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
});
});
const relaunchPromise = new Promise<void>((resolve) => {
server.get('/update-check/updated/:version', (req, res) => {
res.status(204).send();
resolve();
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 0);
expect(launchResult.out).to.include('Update Downloaded');
});
await relaunchPromise;
expect(requests[requests.length - 1].url).to.equal('/update-check/updated/4.0.0');
expect(dirsPerDownload).to.have.lengthOf(3);
// 1st: fresh cache, 1 dir.
expect(dirsPerDownload[0]).to.have.lengthOf(1,
`1st download: ${dirsPerDownload[0].join(', ')}`);
// 2nd: staged (1st) preserved + new = 2 dirs.
expect(dirsPerDownload[1]).to.have.lengthOf(2,
`2nd download: ${dirsPerDownload[1].join(', ')}`);
expect(dirsPerDownload[1]).to.include(dirsPerDownload[0][0]);
// 3rd: 1st is now orphaned (plist points to 2nd) — must be pruned.
// Staged (2nd) preserved + new = still 2 dirs. Bounded.
expect(dirsPerDownload[2]).to.have.lengthOf(2,
`3rd download: ${dirsPerDownload[2].join(', ')}`);
expect(dirsPerDownload[2]).to.not.include(dirsPerDownload[0][0],
'The first (now orphaned) update directory must be pruned on the third check');
const secondDir = dirsPerDownload[1].find(d => d !== dirsPerDownload[0][0]);
expect(dirsPerDownload[2]).to.include(secondDir,
'The second (currently staged) update directory must be preserved on the third check');
});
});
});
});
// Regression test for https://github.com/electron/electron/issues/50200
//
// When checkForUpdates() is called again after an update has been staged,
// Squirrel creates a new temporary directory and prunes old ones. If the
// prune removes the directory that ShipItState.plist references while the
// second download is still in flight, a subsequent quitAndInstall() will
// fail with ENOENT and the app will never relaunch.
it('should install the staged update when quitAndInstall is called while a second check is in flight', async () => {
await cleanSquirrelCache();
await withUpdatableApp({
nextVersion: '2.0.0',
startFixture: 'update-race',
endFixture: 'update-race'
}, async (appPath, updateZipPath) => {
let downloadCount = 0;
let stalledResponse: express.Response | null = null;
server.get('/update-file', (req, res) => {
downloadCount++;
if (downloadCount === 1) {
// First download completes normally and stages the update.
res.download(updateZipPath);
} else {
// Second download: stall indefinitely to simulate a slow
// network. This keeps the second check "in progress" when
// quitAndInstall() fires. Hold onto the response so we can
// clean it up later.
stalledResponse = res;
}
});
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
});
});
const relaunchPromise = new Promise<void>((resolve) => {
server.get('/update-check/updated/:version', (req, res) => {
res.status(204).send();
resolve();
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 0);
expect(launchResult.out).to.include('Update Downloaded');
expect(launchResult.out).to.include('Calling quitAndInstall mid-download');
// First check + first download + second check + stalled second download.
expect(requests).to.have.lengthOf(4);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[2]).to.have.property('url', '/update-check');
expect(requests[3]).to.have.property('url', '/update-file');
// The second download must have been in flight (never completed)
// when quitAndInstall was called.
expect(launchResult.out).to.not.include('Unexpected second download completion');
});
// Unblock the stalled response now that the initial app has exited
// so the express server can shut down cleanly.
if (stalledResponse) {
(stalledResponse as express.Response).status(500).end();
}
// The originally staged update (2.0.0) must have been applied and
// the app must relaunch, proving the staged update directory was
// not pruned out from under ShipItState.plist.
await relaunchPromise;
expect(requests).to.have.lengthOf(5);
expect(requests[4].url).to.equal('/update-check/updated/2.0.0');
expect(requests[4].header('user-agent')).to.include('Electron/');
});
});
it('should update to lower version numbers', async () => {
await withUpdatableApp({
nextVersion: '0.0.1',

View File

@@ -0,0 +1,82 @@
const { app, autoUpdater } = require('electron');
const fs = require('node:fs');
const path = require('node:path');
process.on('uncaughtException', (err) => {
console.error(err);
process.exit(1);
});
let installInvoked = false;
autoUpdater.on('error', (err) => {
// Once quitAndInstall() has been invoked the second in-flight check may
// surface a cancellation/network error as the process tears down; ignore
// errors after that point so we test the actual install race, not teardown.
if (installInvoked) {
console.log('Ignoring post-install error:', err && err.message);
return;
}
console.error(err);
process.exit(1);
});
const urlPath = path.resolve(__dirname, '../../../../url.txt');
let feedUrl = process.argv[1];
if (feedUrl === 'remain-open') {
// Hold the event loop
setInterval(() => {});
} else {
if (!feedUrl || !feedUrl.startsWith('http')) {
feedUrl = `${fs.readFileSync(urlPath, 'utf8')}/${app.getVersion()}`;
} else {
fs.writeFileSync(urlPath, `${feedUrl}/updated`);
}
autoUpdater.setFeedURL({
url: feedUrl
});
autoUpdater.checkForUpdates();
autoUpdater.on('update-available', () => {
console.log('Update Available');
});
let downloadedOnce = false;
autoUpdater.on('update-downloaded', () => {
console.log('Update Downloaded');
if (!downloadedOnce) {
downloadedOnce = true;
// Simulate a periodic update check firing after an update was already
// staged. The test server is expected to stall this second download so
// that it remains in flight while we call quitAndInstall().
// The short delay lets checkForUpdatesCommand's RACCommand executing
// state settle; calling immediately would hit the command's "disabled"
// guard since RACCommand disallows concurrent execution.
setTimeout(() => {
autoUpdater.checkForUpdates();
// Give Squirrel enough time to enter the second check (creating a new
// temporary directory, which with the regression prunes the directory
// that the staged update lives in) before invoking the install.
setTimeout(() => {
console.log('Calling quitAndInstall mid-download');
installInvoked = true;
autoUpdater.quitAndInstall();
}, 3000);
}, 1000);
} else {
// Should not reach here — the second download is stalled on purpose.
console.log('Unexpected second download completion');
autoUpdater.quitAndInstall();
}
});
autoUpdater.on('update-not-available', () => {
console.error('No update available');
process.exit(1);
});
}

View File

@@ -0,0 +1,5 @@
{
"name": "electron-test-update-race",
"version": "1.0.0",
"main": "./index.js"
}

View File

@@ -0,0 +1,57 @@
const { app, autoUpdater } = require('electron');
const fs = require('node:fs');
const path = require('node:path');
process.on('uncaughtException', (err) => {
console.error(err);
process.exit(1);
});
autoUpdater.on('error', (err) => {
console.error(err);
process.exit(1);
});
const urlPath = path.resolve(__dirname, '../../../../url.txt');
let feedUrl = process.argv[1];
if (feedUrl === 'remain-open') {
// Hold the event loop
setInterval(() => {});
} else {
if (!feedUrl || !feedUrl.startsWith('http')) {
feedUrl = `${fs.readFileSync(urlPath, 'utf8')}/${app.getVersion()}`;
} else {
fs.writeFileSync(urlPath, `${feedUrl}/updated`);
}
autoUpdater.setFeedURL({
url: feedUrl
});
autoUpdater.checkForUpdates();
autoUpdater.on('update-available', () => {
console.log('Update Available');
});
let updateStackCount = 0;
autoUpdater.on('update-downloaded', () => {
updateStackCount++;
console.log('Update Downloaded');
if (updateStackCount > 2) {
autoUpdater.quitAndInstall();
} else {
setTimeout(() => {
autoUpdater.checkForUpdates();
}, 1000);
}
});
autoUpdater.on('update-not-available', () => {
console.error('No update available');
process.exit(1);
});
}

View File

@@ -0,0 +1,5 @@
{
"name": "electron-test-update-triple-stack",
"version": "1.0.0",
"main": "./index.js"
}