mirror of
https://github.com/electron/electron.git
synced 2026-02-19 03:14:51 -05:00
fix(squirrel.mac): clean up old staged updates before downloading new update (#49637)
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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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];
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user