diff --git a/patches/squirrel.mac/.patches b/patches/squirrel.mac/.patches index 0950bcb5a678..f16e10d95fdb 100644 --- a/patches/squirrel.mac/.patches +++ b/patches/squirrel.mac/.patches @@ -1,3 +1,4 @@ build_add_gn_config.patch fix_ensure_that_self_is_retained_until_the_racsignal_is_complete.patch fix_use_kseccschecknestedcode_kseccsstrictvalidate_in_the_sec.patch +feat_add_new_squirrel_mac_bundle_installation_method_behind_flag.patch diff --git a/patches/squirrel.mac/feat_add_new_squirrel_mac_bundle_installation_method_behind_flag.patch b/patches/squirrel.mac/feat_add_new_squirrel_mac_bundle_installation_method_behind_flag.patch new file mode 100644 index 000000000000..ed8006fe9630 --- /dev/null +++ b/patches/squirrel.mac/feat_add_new_squirrel_mac_bundle_installation_method_behind_flag.patch @@ -0,0 +1,128 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Samuel Attard +Date: Mon, 28 Mar 2022 02:43:18 -0700 +Subject: feat: add new Squirrel.Mac bundle installation method behind flag + +The 'SquirrelMacEnableDirectContentsWrite' user default in your apps defaults suite +can be used to control this new installation method. It is designed to remove the +requirement that the updating process have write access to its parent directory. + +With this feature enabled the updating process only needs write access to it's own +.app bundle folder, not the owning /Applications folder. This should allow more +non-admin users to update applications when appropriately granted group based permissions. + +E.g. 775 and chown :staff + +diff --git a/Squirrel/SQRLInstaller.m b/Squirrel/SQRLInstaller.m +index 7dd98ddee4ae0f4e01fd7aaa3486083bff7d0da1..c1f328fa8c3689218ef260347cb8f9d30b789efe 100644 +--- a/Squirrel/SQRLInstaller.m ++++ b/Squirrel/SQRLInstaller.m +@@ -249,6 +249,7 @@ - (RACSignal *)acquireTargetBundleURLForRequest:(SQRLShipItRequest *)request { + ] reduce:^(NSURL *directoryURL, SQRLCodeSignature *codeSignature) { + NSURL *targetBundleURL = request.targetBundleURL; + NSURL *newBundleURL = [directoryURL URLByAppendingPathComponent:targetBundleURL.lastPathComponent]; ++ [NSFileManager.defaultManager createDirectoryAtURL:newBundleURL withIntermediateDirectories:FALSE attributes:nil error:nil]; + + return [[SQRLInstallerOwnedBundle alloc] initWithOriginalURL:request.targetBundleURL temporaryURL:newBundleURL codeSignature:codeSignature]; + }] +@@ -481,10 +482,50 @@ - (RACSignal *)installItemToURL:(NSURL *)targetURL fromURL:(NSURL *)sourceURL { + NSParameterAssert(targetURL != nil); + NSParameterAssert(sourceURL != nil); + ++ NSLog(@"Moving bundle from %@ to %@", sourceURL, targetURL); ++ ++ // If both the sourceURL and the targetURL exist we can try to skip a permissions check ++ // by moving Thing.app/Contents directly. This allows us to update applications without ++ // permission to write files into the parent directory of Thing.app ++ // ++ // There is no known case where these directories don't exist but in order to handle ++ // edge cases / race conditions we'll handle it anyway. ++ // ++ // This exists check is non-atomic with the rename call below but that's OK ++ BOOL canRenameContentsDirectly = FALSE; ++ // For now while this is tested at scale this new option is behind a user default, this ++ // can be set by applications wishing to test this feature at runtime. If it causes issues ++ // it can be opted out by individual users by setting this key to false explicitly. ++ // Once this has bene tested at scale it will become the default for all Squirrel.Mac ++ // users. ++ NSUserDefaults *defaults = [[NSUserDefaults alloc] init]; ++ [defaults addSuiteNamed:_applicationIdentifier]; ++ // In cases where this code is being executed under the ShipIt executable it's running ++ // under an application identifier equal to {parent_identifier}.ShipIt ++ // In this case we need to use the true parent identifier too as that is 99% of the time ++ // where the key will be set. ++ if ([_applicationIdentifier hasSuffix:@".ShipIt"]) { ++ [defaults addSuiteNamed:[_applicationIdentifier substringToIndex:[_applicationIdentifier length] - 7]]; ++ } ++ ++ if ([defaults boolForKey:@"SquirrelMacEnableDirectContentsWrite"]) { ++ canRenameContentsDirectly = [NSFileManager.defaultManager fileExistsAtPath:targetURL.path] && [NSFileManager.defaultManager fileExistsAtPath:sourceURL.path]; ++ ++ if (canRenameContentsDirectly) { ++ NSLog(@"Moving bundles via 'Contents' folder rename"); ++ } else { ++ NSLog(@"Moving bundles directly as one of source / target does not exist. This is unexpected."); ++ } ++ } else { ++ NSLog(@"Moving bundles directly as SquirrelMacEnableDirectContentsWrite is disabled for app: %@", _applicationIdentifier); ++ } ++ NSURL *targetContentsURL = canRenameContentsDirectly ? [targetURL URLByAppendingPathComponent:@"Contents"] : targetURL; ++ NSURL *sourceContentsURL = canRenameContentsDirectly ? [sourceURL URLByAppendingPathComponent:@"Contents"] : sourceURL; ++ + return [[[[RACSignal + defer:^{ + // rename() is atomic, NSFileManager sucks. +- if (rename(sourceURL.path.fileSystemRepresentation, targetURL.path.fileSystemRepresentation) == 0) { ++ if (rename(sourceContentsURL.path.fileSystemRepresentation, targetContentsURL.path.fileSystemRepresentation) == 0) { + return [RACSignal empty]; + } else { + int code = errno; +@@ -497,24 +538,24 @@ - (RACSignal *)installItemToURL:(NSURL *)targetURL fromURL:(NSURL *)sourceURL { + } + }] + doCompleted:^{ +- NSLog(@"Moved bundle from %@ to %@", sourceURL, targetURL); ++ NSLog(@"Moved bundle contents from %@ to %@", sourceContentsURL, targetContentsURL); + }] + catch:^(NSError *error) { + if (![error.domain isEqual:NSPOSIXErrorDomain] || error.code != EXDEV) return [RACSignal error:error]; + + // If the locations lie on two different volumes, remove the + // destination by hand, then perform a move. +- [NSFileManager.defaultManager removeItemAtURL:targetURL error:NULL]; ++ [NSFileManager.defaultManager removeItemAtURL:targetContentsURL error:NULL]; + +- if ([NSFileManager.defaultManager moveItemAtURL:sourceURL toURL:targetURL error:&error]) { +- NSLog(@"Moved bundle across volumes from %@ to %@", sourceURL, targetURL); ++ if ([NSFileManager.defaultManager moveItemAtURL:sourceContentsURL toURL:targetContentsURL error:&error]) { ++ NSLog(@"Moved bundle contents across volumes from %@ to %@", sourceContentsURL, targetContentsURL); + return [RACSignal empty]; + } else { +- NSString *description = [NSString stringWithFormat:NSLocalizedString(@"Couldn't move bundle %@ across volumes to %@", nil), sourceURL, targetURL]; ++ NSString *description = [NSString stringWithFormat:NSLocalizedString(@"Couldn't move bundle contents %@ across volumes to %@", nil), sourceContentsURL, targetContentsURL]; + return [RACSignal error:[self errorByAddingDescription:description code:SQRLInstallerErrorMovingAcrossVolumes toError:error]]; + } + }] +- setNameWithFormat:@"%@ -installItemAtURL: %@ fromURL: %@", self, targetURL, sourceURL]; ++ setNameWithFormat:@"%@ -installItemAtURL: %@ fromURL: %@", self, targetContentsURL, sourceContentsURL]; + } + + #pragma mark Quarantine Bit Removal +diff --git a/Squirrel/SQRLUpdater.m b/Squirrel/SQRLUpdater.m +index c81c820d61da3c7d1cfd2c516147c954a5773a0c..4c703159a2bb0239b7d4e1793a985b5ec2edcfa9 100644 +--- a/Squirrel/SQRLUpdater.m ++++ b/Squirrel/SQRLUpdater.m +@@ -329,7 +329,12 @@ - (id)initWithUpdateRequest:(NSURLRequest *)updateRequest requestForDownload:(SQ + + BOOL targetWritable = [self canWriteToURL:targetURL]; + BOOL parentWritable = [self canWriteToURL:targetURL.URLByDeletingLastPathComponent]; +- return [SQRLShipItLauncher launchPrivileged:!targetWritable || !parentWritable]; ++ BOOL launchPrivileged = !targetWritable || !parentWritable; ++ if ([[NSUserDefaults standardUserDefaults] boolForKey:@"SquirrelMacEnableDirectContentsWrite"]) { ++ // If SquirrelMacEnableDirectContentsWrite is enabled we don't care if the parent directory is writeable or not ++ BOOL launchPrivileged = !targetWritable; ++ } ++ return [SQRLShipItLauncher launchPrivileged:launchPrivileged]; + }] + replayLazily] + setNameWithFormat:@"shipItLauncher"]; diff --git a/spec-main/api-autoupdater-darwin-spec.ts b/spec-main/api-autoupdater-darwin-spec.ts index c4535337f901..18c075f0c769 100644 --- a/spec-main/api-autoupdater-darwin-spec.ts +++ b/spec-main/api-autoupdater-darwin-spec.ts @@ -7,6 +7,8 @@ import * as os from 'os'; import * as path from 'path'; import { AddressInfo } from 'net'; import { ifdescribe, ifit } from './spec-helpers'; +import * as uuid from 'uuid'; +import { systemPreferences } from 'electron'; const features = process._linkedBinding('electron_common_features'); @@ -132,7 +134,7 @@ ifdescribe(process.platform === 'darwin' && !(process.env.CI && process.arch === await signApp(secondAppPath); await mutateAppPostSign?.mutate(secondAppPath); updateZipPath = path.resolve(dir, 'update.zip'); - await spawn('zip', ['-r', '--symlinks', updateZipPath, './'], { + await spawn('zip', ['-0', '-r', '--symlinks', updateZipPath, './'], { cwd: dir }); }, false); @@ -321,6 +323,64 @@ ifdescribe(process.platform === 'darwin' && !(process.env.CI && process.arch === }); }); + describe('with SquirrelMacEnableDirectContentsWrite enabled', () => { + let previousValue: any; + + beforeEach(() => { + previousValue = systemPreferences.getUserDefault('SquirrelMacEnableDirectContentsWrite', 'boolean'); + systemPreferences.setUserDefault('SquirrelMacEnableDirectContentsWrite', 'boolean', true as any); + }); + + afterEach(() => { + systemPreferences.setUserDefault('SquirrelMacEnableDirectContentsWrite', 'boolean', previousValue as any); + }); + + it('should hit the download endpoint when an update is available and update successfully when the zip is provided leaving the parent directory untouched', async () => { + await withUpdatableApp({ + nextVersion: '2.0.0', + startFixture: 'update', + endFixture: 'update' + }, async (appPath, updateZipPath) => { + const randomID = uuid.v4(); + cp.spawnSync('xattr', ['-w', 'spec-id', randomID, appPath]); + server.get('/update-file', (req, res) => { + res.download(updateZipPath); + }); + server.get('/update-check', (req, res) => { + 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'); + expect(requests).to.have.lengthOf(2); + expect(requests[0]).to.have.property('url', '/update-check'); + expect(requests[1]).to.have.property('url', '/update-file'); + expect(requests[0].header('user-agent')).to.include('Electron/'); + expect(requests[1].header('user-agent')).to.include('Electron/'); + }); + + await relaunchPromise; + expect(requests).to.have.lengthOf(3); + expect(requests[2].url).to.equal('/update-check/updated/2.0.0'); + expect(requests[2].header('user-agent')).to.include('Electron/'); + const result = cp.spawnSync('xattr', ['-l', appPath]); + expect(result.stdout.toString()).to.include(`spec-id: ${randomID}`); + }); + }); + }); + it('should hit the download endpoint when an update is available and fail when the zip signature is invalid', async () => { await withUpdatableApp({ nextVersion: '2.0.0',