mirror of
https://github.com/electron/electron.git
synced 2026-03-19 03:02:02 -04:00
Compare commits
5 Commits
dispositio
...
alt-space-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
740c919bfc | ||
|
|
313e501454 | ||
|
|
1f6dbb0917 | ||
|
|
378659c535 | ||
|
|
6be775ad83 |
@@ -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
|
||||
|
||||
@@ -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
58
docs/CLAUDE.md
Normal 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`)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
@@ -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);
|
||||
+ }
|
||||
+ }];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
82
spec/fixtures/auto-update/update-race/index.js
vendored
Normal file
82
spec/fixtures/auto-update/update-race/index.js
vendored
Normal 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);
|
||||
});
|
||||
}
|
||||
5
spec/fixtures/auto-update/update-race/package.json
vendored
Normal file
5
spec/fixtures/auto-update/update-race/package.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "electron-test-update-race",
|
||||
"version": "1.0.0",
|
||||
"main": "./index.js"
|
||||
}
|
||||
57
spec/fixtures/auto-update/update-triple-stack/index.js
vendored
Normal file
57
spec/fixtures/auto-update/update-triple-stack/index.js
vendored
Normal 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);
|
||||
});
|
||||
}
|
||||
5
spec/fixtures/auto-update/update-triple-stack/package.json
vendored
Normal file
5
spec/fixtures/auto-update/update-triple-stack/package.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "electron-test-update-triple-stack",
|
||||
"version": "1.0.0",
|
||||
"main": "./index.js"
|
||||
}
|
||||
Reference in New Issue
Block a user