From 233caf84695d56b6ffad83461e2728fe7588cddb Mon Sep 17 00:00:00 2001 From: loc Date: Tue, 3 Feb 2026 08:04:06 -0800 Subject: [PATCH] fix(squirrel.mac): clean up old staged updates before downloading new update (#49365) 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 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. --- patches/squirrel.mac/.patches | 1 + ...pdates_before_downloading_new_update.patch | 64 +++++++++++++ spec/api-autoupdater-darwin-spec.ts | 94 +++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 patches/squirrel.mac/fix_clean_up_old_staged_updates_before_downloading_new_update.patch diff --git a/patches/squirrel.mac/.patches b/patches/squirrel.mac/.patches index 0893372102..ea7939dcfd 100644 --- a/patches/squirrel.mac/.patches +++ b/patches/squirrel.mac/.patches @@ -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 diff --git a/patches/squirrel.mac/fix_clean_up_old_staged_updates_before_downloading_new_update.patch b/patches/squirrel.mac/fix_clean_up_old_staged_updates_before_downloading_new_update.patch new file mode 100644 index 0000000000..3114490a15 --- /dev/null +++ b/patches/squirrel.mac/fix_clean_up_old_staged_updates_before_downloading_new_update.patch @@ -0,0 +1,64 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Andy Locascio +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]; diff --git a/spec/api-autoupdater-darwin-spec.ts b/spec/api-autoupdater-darwin-spec.ts index 24709ef21b..9fad606898 100644 --- a/spec/api-autoupdater-darwin-spec.ts +++ b/spec/api-autoupdater-darwin-spec.ts @@ -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 = {}; 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((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',