Skip to content

Commit f874dba

Browse files
trop[bot]loc
andauthored
fix(squirrel.mac): clean up old staged updates before downloading new update (#49639)
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. Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com> Co-authored-by: Andy Locascio <loc@anthropic.com>
1 parent 47990a3 commit f874dba

3 files changed

Lines changed: 159 additions & 0 deletions

File tree

patches/squirrel.mac/.patches

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ refactor_use_non-deprecated_nskeyedarchiver_apis.patch
99
chore_turn_off_launchapplicationaturl_deprecation_errors_in_squirrel.patch
1010
fix_crash_when_process_to_extract_zip_cannot_be_launched.patch
1111
use_uttype_class_instead_of_deprecated_uttypeconformsto.patch
12+
fix_clean_up_old_staged_updates_before_downloading_new_update.patch
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
2+
From: Andy Locascio <loc@anthropic.com>
3+
Date: Tue, 6 Jan 2026 08:23:03 -0800
4+
Subject: fix: clean up old staged updates before downloading new update
5+
6+
When checkForUpdates() is called while an update is already staged,
7+
Squirrel creates a new temporary directory for the download without
8+
cleaning up the old one. This can lead to significant disk usage if
9+
the app keeps checking for updates without restarting.
10+
11+
This change adds a force parameter to pruneUpdateDirectories that
12+
bypasses the AwaitingRelaunch state check. This is called before
13+
creating a new temp directory, ensuring old staged updates are
14+
cleaned up when a new download starts.
15+
16+
diff --git a/Squirrel/SQRLUpdater.m b/Squirrel/SQRLUpdater.m
17+
index d156616e81e6f25a3bded30e6216b8fc311f31bc..6cd4346bf43b191147aff819cb93387e71275a46 100644
18+
--- a/Squirrel/SQRLUpdater.m
19+
+++ b/Squirrel/SQRLUpdater.m
20+
@@ -543,11 +543,17 @@ - (RACSignal *)downloadBundleForUpdate:(SQRLUpdate *)update intoDirectory:(NSURL
21+
#pragma mark File Management
22+
23+
- (RACSignal *)uniqueTemporaryDirectoryForUpdate {
24+
- return [[[RACSignal
25+
+ // Clean up any old staged update directories before creating a new one.
26+
+ // This prevents disk usage from growing when checkForUpdates() is called
27+
+ // multiple times without the app restarting.
28+
+ return [[[[[self
29+
+ pruneUpdateDirectoriesWithForce:YES]
30+
+ ignoreValues]
31+
+ concat:[RACSignal
32+
defer:^{
33+
SQRLDirectoryManager *directoryManager = [[SQRLDirectoryManager alloc] initWithApplicationIdentifier:SQRLShipItLauncher.shipItJobLabel];
34+
return [directoryManager storageURL];
35+
- }]
36+
+ }]]
37+
flattenMap:^(NSURL *storageURL) {
38+
NSURL *updateDirectoryTemplate = [storageURL URLByAppendingPathComponent:[SQRLUpdaterUniqueTemporaryDirectoryPrefix stringByAppendingString:@"XXXXXXX"]];
39+
char *updateDirectoryCString = strdup(updateDirectoryTemplate.path.fileSystemRepresentation);
40+
@@ -643,7 +649,7 @@ - (BOOL)isRunningOnReadOnlyVolume {
41+
42+
- (RACSignal *)performHousekeeping {
43+
return [[RACSignal
44+
- merge:@[ [self pruneUpdateDirectories], [self truncateLogs] ]]
45+
+ merge:@[ [self pruneUpdateDirectoriesWithForce:NO], [self truncateLogs] ]]
46+
catch:^(NSError *error) {
47+
NSLog(@"Error doing housekeeping: %@", error);
48+
return [RACSignal empty];
49+
@@ -658,11 +664,12 @@ - (RACSignal *)performHousekeeping {
50+
///
51+
/// Sends each removed directory then completes, or errors, on an unspecified
52+
/// thread.
53+
-- (RACSignal *)pruneUpdateDirectories {
54+
+- (RACSignal *)pruneUpdateDirectoriesWithForce:(BOOL)force {
55+
return [[[RACSignal
56+
defer:^{
57+
- // If we already have updates downloaded we don't wanna prune them.
58+
- if (self.state == SQRLUpdaterStateAwaitingRelaunch) return [RACSignal empty];
59+
+ // If we already have updates downloaded we don't wanna prune them,
60+
+ // unless force is YES (used when starting a new download).
61+
+ if (!force && self.state == SQRLUpdaterStateAwaitingRelaunch) return [RACSignal empty];
62+
63+
SQRLDirectoryManager *directoryManager = [[SQRLDirectoryManager alloc] initWithApplicationIdentifier:SQRLShipItLauncher.shipItJobLabel];
64+
return [directoryManager storageURL];

spec/api-autoupdater-darwin-spec.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as cp from 'node:child_process';
99
import * as fs from 'node:fs';
1010
import * as http from 'node:http';
1111
import { AddressInfo } from 'node:net';
12+
import * as os from 'node:os';
1213
import * as path from 'node:path';
1314

1415
import { copyMacOSFixtureApp, getCodesignIdentity, shouldRunCodesignTests, signApp, spawn } from './lib/codesign-helpers';
@@ -67,6 +68,38 @@ ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
6768
}
6869
};
6970

71+
// Squirrel stores update directories in ~/Library/Caches/com.github.Electron.ShipIt/
72+
// as subdirectories named like update.XXXXXXX
73+
const getSquirrelCacheDirectory = () => {
74+
return path.join(os.homedir(), 'Library', 'Caches', 'com.github.Electron.ShipIt');
75+
};
76+
77+
const getUpdateDirectoriesInCache = async () => {
78+
const cacheDir = getSquirrelCacheDirectory();
79+
try {
80+
const entries = await fs.promises.readdir(cacheDir, { withFileTypes: true });
81+
return entries
82+
.filter(entry => entry.isDirectory() && entry.name.startsWith('update.'))
83+
.map(entry => path.join(cacheDir, entry.name));
84+
} catch {
85+
return [];
86+
}
87+
};
88+
89+
const cleanSquirrelCache = async () => {
90+
const cacheDir = getSquirrelCacheDirectory();
91+
try {
92+
const entries = await fs.promises.readdir(cacheDir, { withFileTypes: true });
93+
for (const entry of entries) {
94+
if (entry.isDirectory() && entry.name.startsWith('update.')) {
95+
await fs.promises.rm(path.join(cacheDir, entry.name), { recursive: true, force: true });
96+
}
97+
}
98+
} catch {
99+
// Cache dir may not exist yet
100+
}
101+
};
102+
70103
const cachedZips: Record<string, string> = {};
71104

72105
type Mutation = {
@@ -340,6 +373,67 @@ ifdescribe(shouldRunCodesignTests)('autoUpdater behavior', function () {
340373
});
341374
});
342375

376+
it('should clean up old staged update directories when a new update is downloaded', async () => {
377+
// Clean up any existing update directories before the test
378+
await cleanSquirrelCache();
379+
380+
await withUpdatableApp({
381+
nextVersion: '2.0.0',
382+
startFixture: 'update-stack',
383+
endFixture: 'update-stack'
384+
}, async (appPath, updateZipPath2) => {
385+
await withUpdatableApp({
386+
nextVersion: '3.0.0',
387+
startFixture: 'update-stack',
388+
endFixture: 'update-stack'
389+
}, async (_, updateZipPath3) => {
390+
let updateCount = 0;
391+
let downloadCount = 0;
392+
let directoriesDuringSecondDownload: string[] = [];
393+
394+
server.get('/update-file', async (req, res) => {
395+
downloadCount++;
396+
// When the second download request arrives, Squirrel has already
397+
// called uniqueTemporaryDirectoryForUpdate which (with our patch)
398+
// cleans up old directories before creating the new one.
399+
// Without the patch, both directories would exist at this point.
400+
if (downloadCount === 2) {
401+
directoriesDuringSecondDownload = await getUpdateDirectoriesInCache();
402+
}
403+
res.download(updateCount > 1 ? updateZipPath3 : updateZipPath2);
404+
});
405+
server.get('/update-check', (req, res) => {
406+
updateCount++;
407+
res.json({
408+
url: `http://localhost:${port}/update-file`,
409+
name: 'My Release Name',
410+
notes: 'Theses are some release notes innit',
411+
pub_date: (new Date()).toString()
412+
});
413+
});
414+
const relaunchPromise = new Promise<void>((resolve) => {
415+
server.get('/update-check/updated/:version', (req, res) => {
416+
res.status(204).send();
417+
resolve();
418+
});
419+
});
420+
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
421+
logOnError(launchResult, () => {
422+
expect(launchResult).to.have.property('code', 0);
423+
expect(launchResult.out).to.include('Update Downloaded');
424+
});
425+
426+
await relaunchPromise;
427+
428+
// During the second download, the old staged update directory should
429+
// have been cleaned up. With our patch, there should be exactly 1
430+
// directory (the new one). Without the patch, there would be 2.
431+
expect(directoriesDuringSecondDownload).to.have.lengthOf(1,
432+
`Expected 1 update directory during second download but found ${directoriesDuringSecondDownload.length}: ${directoriesDuringSecondDownload.join(', ')}`);
433+
});
434+
});
435+
});
436+
343437
it('should update to lower version numbers', async () => {
344438
await withUpdatableApp({
345439
nextVersion: '0.0.1',

0 commit comments

Comments
 (0)