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

Backport of #49365

See that PR for details.


Notes: fixed squirrel.mac stacked update behavior to old staged updates


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',