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.
This commit is contained in:
loc
2026-02-03 08:04:06 -08:00
committed by GitHub
parent 86209f60eb
commit 233caf8469
3 changed files with 159 additions and 0 deletions

View File

@@ -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

View File

@@ -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];

View File

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