Repository: electron/electron
Pull request: fix(squirrel.mac): clean up old staged updates before downloading new update

#### Description of Change

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 disk usage growth when new versions are released while the app hasn't restarted.

This 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.


<!--
Thank you for your Pull Request. Please provide a description above and review
the requirements below.

Contributors guide: https://github.com/electron/electron/blob/main/CONTRIBUTING.md
-->

#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] PR description included
- [x] `npm test` passes
- [x] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [ ] relevant API documentation, tutorials, and examples are updated and follow the [documentation style guide](https://github.com/electron/electron/blob/main/docs/development/style-guide.md)
- [x] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: fixed squirrel.mac stacked update behavior to old staged updates
<!-- Please add a one-line description for app developers to read in the release notes, or 'none' if no notes relevant to app developers. Examples and help on special cases: https://github.com/electron/clerk/blob/main/README.md#examples -->


Changed files:
filename: patches/squirrel.mac/.patches
patch: @@ -9,3 +9,4 @@ 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

filename: patches/squirrel.mac/fix_clean_up_old_staged_updates_before_downloading_new_update.patch
patch: @@ -0,0 +1,64 @@
+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];

filename: spec/api-autoupdater-darwin-spec.ts
patch: @@ -9,6 +9,7 @@ import * as cp from 'node:child_process';
 import * as fs from 'node:fs';
 import * as http from 'node:http';
 import { AddressInfo } from 'node:net';
+import * as os from 'node:os';
 import * as path from 'node:path';
 
 import { copyMacOSFixtureApp, getCodesignIdentity, shouldRunCodesignTests, signApp, spawn } from './lib/codesign-helpers';
@@ -67,6 +68,38 @@ ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
     }
   };
 
+  // Squirrel stores update directories in ~/Library/Caches/com.github.Electron.ShipIt/
+  // as subdirectories named like update.XXXXXXX
+  const getSquirrelCacheDirectory = () => {
+    return path.join(os.homedir(), 'Library', 'Caches', 'com.github.Electron.ShipIt');
+  };
+
+  const getUpdateDirectoriesInCache = async () => {
+    const cacheDir = getSquirrelCacheDirectory();
+    try {
+      const entries = await fs.promises.readdir(cacheDir, { withFileTypes: true });
+      return entries
+        .filter(entry => entry.isDirectory() && entry.name.startsWith('update.'))
+        .map(entry => path.join(cacheDir, entry.name));
+    } catch {
+      return [];
+    }
+  };
+
+  const cleanSquirrelCache = async () => {
+    const cacheDir = getSquirrelCacheDirectory();
+    try {
+      const entries = await fs.promises.readdir(cacheDir, { withFileTypes: true });
+      for (const entry of entries) {
+        if (entry.isDirectory() && entry.name.startsWith('update.')) {
+          await fs.promises.rm(path.join(cacheDir, entry.name), { recursive: true, force: true });
+        }
+      }
+    } catch {
+      // Cache dir may not exist yet
+    }
+  };
+
   const cachedZips: Record<string, string> = {};
 
   type Mutation = {
@@ -340,6 +373,67 @@ ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
       });
     });
 
+    it('should clean up old staged update directories when a new update is downloaded', async () => {
+      // Clean up any existing update directories before the test
+      await cleanSquirrelCache();
+
+      await withUpdatableApp({
+        nextVersion: '2.0.0',
+        startFixture: 'update-stack',
+        endFixture: 'update-stack'
+      }, async (appPath, updateZipPath2) => {
+        await withUpdatableApp({
+          nextVersion: '3.0.0',
+          startFixture: 'update-stack',
+          endFixture: 'update-stack'
+        }, async (_, updateZipPath3) => {
+          let updateCount = 0;
+          let downloadCount = 0;
+          let directoriesDuringSecondDownload: 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();
+            }
+            res.download(updateCount > 1 ? updateZipPath3 : updateZipPath2);
+          });
+          server.get('/update-check', (req, res) => {
+            updateCount++;
+            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;
+
+          // 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(', ')}`);
+        });
+      });
+    });
+
     it('should update to lower version numbers', async () => {
       await withUpdatableApp({
         nextVersion: '0.0.1',