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