electron/shell/browser/ui/cocoa/atom_bundle_mover.mm
Shelley Vohr f6a29707b6
feat: app.moveToApplicationsFolder conflict handling (#18916)
Resolves #18805.

We want to keep default move conflict handling behavior in that it's still what most users would expect, but there exist edge cases in which users may not want to be forced into that behavior.

This thus introduces an optional conflict handler that allows developers access to more granular move actions. They could now allow the user to choose whether to delete an existing app in favor of the current one being moved, or whether to quit the current app and focus on the existing one should it both exist and be running. I added a fair amount of new documentation outlining this behavior, but if there are things users may benefit from seeing examples of or nuances that should be added please leave feedback!
2019-07-15 09:34:20 -07:00

473 lines
16 KiB
Text

// Copyright (c) 2017 GitHub, Inc.
// Use of this source code is governed by the MIT license that can be
// found in the LICENSE file.
#import "shell/browser/ui/cocoa/atom_bundle_mover.h"
#include <string>
#import <AppKit/AppKit.h>
#import <Foundation/Foundation.h>
#import <Security/Security.h>
#import <dlfcn.h>
#import <sys/mount.h>
#import <sys/param.h>
#import "shell/browser/browser.h"
#include "shell/common/native_mate_converters/once_callback.h"
namespace mate {
template <>
struct Converter<electron::BundlerMoverConflictType> {
static v8::Local<v8::Value> ToV8(v8::Isolate* isolate,
electron::BundlerMoverConflictType value) {
switch (value) {
case electron::BundlerMoverConflictType::EXISTS:
return mate::StringToV8(isolate, "exists");
case electron::BundlerMoverConflictType::EXISTS_AND_RUNNING:
return mate::StringToV8(isolate, "existsAndRunning");
default:
return mate::StringToV8(isolate, "");
}
}
};
} // namespace mate
namespace electron {
namespace ui {
namespace cocoa {
bool AtomBundleMover::ShouldContinueMove(BundlerMoverConflictType type,
mate::Arguments* args) {
mate::Dictionary options;
bool hasOptions = args->GetNext(&options);
base::OnceCallback<v8::Local<v8::Value>(BundlerMoverConflictType)>
conflict_cb;
if (hasOptions && options.Get("conflictHandler", &conflict_cb)) {
v8::Local<v8::Value> value = std::move(conflict_cb).Run(type);
if (value->IsBoolean()) {
if (!value.As<v8::Boolean>()->Value())
return false;
} else if (!value->IsUndefined()) {
// we only want to throw an error if a user has returned a non-boolean
// value; this allows for client-side error handling should something in
// the handler throw
args->ThrowError("Invalid conflict handler return type.");
}
}
return true;
}
bool AtomBundleMover::Move(mate::Arguments* args) {
// Path of the current bundle
NSString* bundlePath = [[NSBundle mainBundle] bundlePath];
// Skip if the application is already in the Applications folder
if (IsInApplicationsFolder(bundlePath))
return true;
NSFileManager* fileManager = [NSFileManager defaultManager];
NSString* diskImageDevice = ContainingDiskImageDevice(bundlePath);
NSString* applicationsDirectory = [[NSSearchPathForDirectoriesInDomains(
NSApplicationDirectory, NSLocalDomainMask, true) lastObject]
stringByResolvingSymlinksInPath];
NSString* bundleName = [bundlePath lastPathComponent];
NSString* destinationPath =
[applicationsDirectory stringByAppendingPathComponent:bundleName];
// Check if we can write to the applications directory
// and then make sure that if the app already exists we can overwrite it
bool needAuthorization =
![fileManager isWritableFileAtPath:applicationsDirectory] |
([fileManager fileExistsAtPath:destinationPath] &&
![fileManager isWritableFileAtPath:destinationPath]);
// Activate app -- work-around for focus issues related to "scary file from
// internet" OS dialog.
if (![NSApp isActive]) {
[NSApp activateIgnoringOtherApps:true];
}
// Move to applications folder
if (needAuthorization) {
bool authorizationCanceled;
if (!AuthorizedInstall(bundlePath, destinationPath,
&authorizationCanceled)) {
if (authorizationCanceled) {
// User rejected the authorization request
args->ThrowError("User rejected the authorization request");
return false;
} else {
args->ThrowError(
"Failed to copy to applications directory even with authorization");
return false;
}
}
} else {
// If a copy already exists in the Applications folder, put it in the Trash
if ([fileManager fileExistsAtPath:destinationPath]) {
// But first, make sure that it's not running
if (IsApplicationAtPathRunning(destinationPath)) {
// Check for callback handler and get user choice for open/quit
if (!ShouldContinueMove(BundlerMoverConflictType::EXISTS_AND_RUNNING,
args))
return false;
// Unless explicitly denied, give running app focus and terminate self
[[NSTask
launchedTaskWithLaunchPath:@"/usr/bin/open"
arguments:[NSArray
arrayWithObject:destinationPath]]
waitUntilExit];
electron::Browser::Get()->Quit();
return true;
} else {
// Check callback handler and get user choice for app trashing
if (!ShouldContinueMove(BundlerMoverConflictType::EXISTS, args))
return false;
// Unless explicitly denied, attempt to trash old app
if (!Trash([applicationsDirectory
stringByAppendingPathComponent:bundleName])) {
args->ThrowError("Failed to delete existing application");
return false;
}
}
}
if (!CopyBundle(bundlePath, destinationPath)) {
args->ThrowError(
"Failed to copy current bundle to the applications folder");
return false;
}
}
// Trash the original app. It's okay if this fails.
// NOTE: This final delete does not work if the source bundle is in a network
// mounted volume.
// Calling rm or file manager's delete method doesn't work either. It's
// unlikely to happen but it'd be great if someone could fix this.
if (diskImageDevice == nil && !DeleteOrTrash(bundlePath)) {
// Could not delete original but we just don't care
}
// Relaunch.
Relaunch(destinationPath);
// Launched from within a disk image? -- unmount (if no files are open after 5
// seconds, otherwise leave it mounted).
if (diskImageDevice) {
NSString* script = [NSString
stringWithFormat:@"(/bin/sleep 5 && /usr/bin/hdiutil detach %@) &",
ShellQuotedString(diskImageDevice)];
[NSTask launchedTaskWithLaunchPath:@"/bin/sh"
arguments:[NSArray arrayWithObjects:@"-c", script,
nil]];
}
electron::Browser::Get()->Quit();
return true;
}
bool AtomBundleMover::IsCurrentAppInApplicationsFolder() {
return IsInApplicationsFolder([[NSBundle mainBundle] bundlePath]);
}
bool AtomBundleMover::IsInApplicationsFolder(NSString* bundlePath) {
// Check all the normal Application directories
NSArray* applicationDirs = NSSearchPathForDirectoriesInDomains(
NSApplicationDirectory, NSAllDomainsMask, true);
for (NSString* appDir in applicationDirs) {
if ([bundlePath hasPrefix:appDir])
return true;
}
// Also, handle the case that the user has some other Application directory
// (perhaps on a separate data partition).
if ([[bundlePath pathComponents] containsObject:@"Applications"])
return true;
return false;
}
NSString* AtomBundleMover::ContainingDiskImageDevice(NSString* bundlePath) {
NSString* containingPath = [bundlePath stringByDeletingLastPathComponent];
struct statfs fs;
if (statfs([containingPath fileSystemRepresentation], &fs) ||
(fs.f_flags & MNT_ROOTFS))
return nil;
NSString* device = [[NSFileManager defaultManager]
stringWithFileSystemRepresentation:fs.f_mntfromname
length:strlen(fs.f_mntfromname)];
NSTask* hdiutil = [[[NSTask alloc] init] autorelease];
[hdiutil setLaunchPath:@"/usr/bin/hdiutil"];
[hdiutil setArguments:[NSArray arrayWithObjects:@"info", @"-plist", nil]];
[hdiutil setStandardOutput:[NSPipe pipe]];
[hdiutil launch];
[hdiutil waitUntilExit];
NSData* data =
[[[hdiutil standardOutput] fileHandleForReading] readDataToEndOfFile];
NSDictionary* info =
[NSPropertyListSerialization propertyListWithData:data
options:NSPropertyListImmutable
format:NULL
error:NULL];
if (![info isKindOfClass:[NSDictionary class]])
return nil;
NSArray* images = (NSArray*)[info objectForKey:@"images"];
if (![images isKindOfClass:[NSArray class]])
return nil;
for (NSDictionary* image in images) {
if (![image isKindOfClass:[NSDictionary class]])
return nil;
id systemEntities = [image objectForKey:@"system-entities"];
if (![systemEntities isKindOfClass:[NSArray class]])
return nil;
for (NSDictionary* systemEntity in systemEntities) {
if (![systemEntity isKindOfClass:[NSDictionary class]])
return nil;
NSString* devEntry = [systemEntity objectForKey:@"dev-entry"];
if (![devEntry isKindOfClass:[NSString class]])
return nil;
if ([devEntry isEqualToString:device])
return device;
}
}
return nil;
}
bool AtomBundleMover::AuthorizedInstall(NSString* srcPath,
NSString* dstPath,
bool* canceled) {
if (canceled)
*canceled = false;
// Make sure that the destination path is an app bundle. We're essentially
// running 'sudo rm -rf' so we really don't want to screw this up.
if (![[dstPath pathExtension] isEqualToString:@"app"])
return false;
// Do some more checks
if ([[dstPath stringByTrimmingCharactersInSet:[NSCharacterSet
whitespaceCharacterSet]]
length] == 0)
return false;
if ([[srcPath stringByTrimmingCharactersInSet:[NSCharacterSet
whitespaceCharacterSet]]
length] == 0)
return false;
int pid, status;
AuthorizationRef myAuthorizationRef;
// Get the authorization
OSStatus err =
AuthorizationCreate(NULL, kAuthorizationEmptyEnvironment,
kAuthorizationFlagDefaults, &myAuthorizationRef);
if (err != errAuthorizationSuccess)
return false;
AuthorizationItem myItems = {kAuthorizationRightExecute, 0, NULL, 0};
AuthorizationRights myRights = {1, &myItems};
AuthorizationFlags myFlags = (AuthorizationFlags)(
kAuthorizationFlagInteractionAllowed | kAuthorizationFlagExtendRights |
kAuthorizationFlagPreAuthorize);
err = AuthorizationCopyRights(myAuthorizationRef, &myRights, NULL, myFlags,
NULL);
if (err != errAuthorizationSuccess) {
if (err == errAuthorizationCanceled && canceled)
*canceled = true;
goto fail;
}
static OSStatus (*security_AuthorizationExecuteWithPrivileges)(
AuthorizationRef authorization, const char* pathToTool,
AuthorizationFlags options, char* const* arguments,
FILE** communicationsPipe) = NULL;
if (!security_AuthorizationExecuteWithPrivileges) {
// On 10.7, AuthorizationExecuteWithPrivileges is deprecated. We want to
// still use it since there's no good alternative (without requiring code
// signing). We'll look up the function through dyld and fail if it is no
// longer accessible. If Apple removes the function entirely this will fail
// gracefully. If they keep the function and throw some sort of exception,
// this won't fail gracefully, but that's a risk we'll have to take for now.
security_AuthorizationExecuteWithPrivileges = (OSStatus(*)(
AuthorizationRef, const char*, AuthorizationFlags, char* const*,
FILE**))dlsym(RTLD_DEFAULT, "AuthorizationExecuteWithPrivileges");
}
if (!security_AuthorizationExecuteWithPrivileges)
goto fail;
// Delete the destination
{
char rf[] = "-rf";
char* args[] = {rf, (char*)[dstPath fileSystemRepresentation], NULL};
err = security_AuthorizationExecuteWithPrivileges(
myAuthorizationRef, "/bin/rm", kAuthorizationFlagDefaults, args, NULL);
if (err != errAuthorizationSuccess)
goto fail;
// Wait until it's done
pid = wait(&status);
if (pid == -1 || !WIFEXITED(status))
goto fail; // We don't care about exit status as the destination most
// likely does not exist
}
// Copy
{
char pR[] = "-pR";
char* args[] = {pR, (char*)[srcPath fileSystemRepresentation],
(char*)[dstPath fileSystemRepresentation], NULL};
err = security_AuthorizationExecuteWithPrivileges(
myAuthorizationRef, "/bin/cp", kAuthorizationFlagDefaults, args, NULL);
if (err != errAuthorizationSuccess)
goto fail;
// Wait until it's done
pid = wait(&status);
if (pid == -1 || !WIFEXITED(status) || WEXITSTATUS(status))
goto fail;
}
AuthorizationFree(myAuthorizationRef, kAuthorizationFlagDefaults);
return true;
fail:
AuthorizationFree(myAuthorizationRef, kAuthorizationFlagDefaults);
return false;
}
bool AtomBundleMover::CopyBundle(NSString* srcPath, NSString* dstPath) {
NSFileManager* fileManager = [NSFileManager defaultManager];
NSError* error = nil;
return [fileManager copyItemAtPath:srcPath toPath:dstPath error:&error];
}
NSString* AtomBundleMover::ShellQuotedString(NSString* string) {
return [NSString
stringWithFormat:@"'%@'",
[string stringByReplacingOccurrencesOfString:@"'"
withString:@"'\\''"]];
}
void AtomBundleMover::Relaunch(NSString* destinationPath) {
// The shell script waits until the original app process terminates.
// This is done so that the relaunched app opens as the front-most app.
int pid = [[NSProcessInfo processInfo] processIdentifier];
// Command run just before running open /final/path
NSString* preOpenCmd = @"";
NSString* quotedDestinationPath = ShellQuotedString(destinationPath);
// Before we launch the new app, clear xattr:com.apple.quarantine to avoid
// duplicate "scary file from the internet" dialog.
preOpenCmd = [NSString
stringWithFormat:@"/usr/bin/xattr -d -r com.apple.quarantine %@",
quotedDestinationPath];
NSString* script =
[NSString stringWithFormat:
@"(while /bin/kill -0 %d >&/dev/null; do /bin/sleep 0.1; "
@"done; %@; /usr/bin/open %@) &",
pid, preOpenCmd, quotedDestinationPath];
[NSTask
launchedTaskWithLaunchPath:@"/bin/sh"
arguments:[NSArray arrayWithObjects:@"-c", script, nil]];
}
bool AtomBundleMover::IsApplicationAtPathRunning(NSString* bundlePath) {
bundlePath = [bundlePath stringByStandardizingPath];
for (NSRunningApplication* runningApplication in
[[NSWorkspace sharedWorkspace] runningApplications]) {
NSString* runningAppBundlePath =
[[[runningApplication bundleURL] path] stringByStandardizingPath];
if ([runningAppBundlePath isEqualToString:bundlePath]) {
return true;
}
}
return false;
}
bool AtomBundleMover::Trash(NSString* path) {
bool result = false;
if (floor(NSAppKitVersionNumber) >= NSAppKitVersionNumber10_8) {
result = [[NSFileManager defaultManager]
trashItemAtURL:[NSURL fileURLWithPath:path]
resultingItemURL:NULL
error:NULL];
}
if (!result) {
result = [[NSWorkspace sharedWorkspace]
performFileOperation:NSWorkspaceRecycleOperation
source:[path stringByDeletingLastPathComponent]
destination:@""
files:[NSArray arrayWithObject:[path lastPathComponent]]
tag:NULL];
}
// As a last resort try trashing with AppleScript.
// This allows us to trash the app in macOS Sierra even when the app is
// running inside an app translocation image.
if (!result) {
auto* code = R"str(
set theFile to POSIX file "%@"
tell application "Finder"
move theFile to trash
end tell
)str";
NSAppleScript* appleScript = [[[NSAppleScript alloc]
initWithSource:[NSString stringWithFormat:@(code), path]] autorelease];
NSDictionary* errorDict = nil;
NSAppleEventDescriptor* scriptResult =
[appleScript executeAndReturnError:&errorDict];
result = (scriptResult != nil);
}
return result;
}
bool AtomBundleMover::DeleteOrTrash(NSString* path) {
NSError* error;
if ([[NSFileManager defaultManager] removeItemAtPath:path error:&error]) {
return true;
} else {
return Trash(path);
}
}
} // namespace cocoa
} // namespace ui
} // namespace electron