From 6b010614e24d497b3976d5118c314d05b9e54afc Mon Sep 17 00:00:00 2001 From: Samuel Attard Date: Fri, 1 Sep 2017 00:37:12 +1000 Subject: [PATCH] Implement moveToApplicationsFolder (#10142) * Implement moveToApplicationsFolder * Fix tabs / spaces * Fix linting * Use Browser::Quit, instead of exit * Update documentation as per feedback * Fix spec --- atom/browser/api/atom_api_app.cc | 18 ++ atom/browser/api/atom_api_app.h | 5 + atom/browser/ui/cocoa/atom_bundle_mover.h | 42 +++ atom/browser/ui/cocoa/atom_bundle_mover.mm | 345 +++++++++++++++++++++ docs/api/app.md | 20 ++ filenames.gypi | 2 + spec/api-app-spec.js | 8 + 7 files changed, 440 insertions(+) create mode 100644 atom/browser/ui/cocoa/atom_bundle_mover.h create mode 100644 atom/browser/ui/cocoa/atom_bundle_mover.mm diff --git a/atom/browser/api/atom_api_app.cc b/atom/browser/api/atom_api_app.cc index 06d4dd8aaf5b..41098f82e444 100644 --- a/atom/browser/api/atom_api_app.cc +++ b/atom/browser/api/atom_api_app.cc @@ -54,6 +54,10 @@ #include "base/strings/utf_string_conversions.h" #endif +#if defined(OS_MACOSX) +#include "atom/browser/ui/cocoa/atom_bundle_mover.h" +#endif + using atom::Browser; namespace mate { @@ -1072,6 +1076,16 @@ void App::EnableMixedSandbox(mate::Arguments* args) { command_line->AppendSwitch(switches::kEnableMixedSandbox); } +#if defined(OS_MACOSX) +bool App::MoveToApplicationsFolder(mate::Arguments* args) { + return ui::cocoa::AtomBundleMover::Move(args); +} + +bool App::IsInApplicationsFolder() { + return ui::cocoa::AtomBundleMover::IsCurrentAppInApplicationsFolder(); +} +#endif + // static mate::Handle App::Create(v8::Isolate* isolate) { return mate::CreateHandle(isolate, new App(isolate)); @@ -1150,6 +1164,10 @@ void App::BuildPrototype( .SetMethod("getGPUFeatureStatus", &App::GetGPUFeatureStatus) .SetMethod("enableMixedSandbox", &App::EnableMixedSandbox) // TODO(juturu): Remove in 2.0, deprecate before then with warnings + #if defined(OS_MACOSX) + .SetMethod("moveToApplicationsFolder", &App::MoveToApplicationsFolder) + .SetMethod("isInApplicationsFolder", &App::IsInApplicationsFolder) + #endif .SetMethod("getAppMemoryInfo", &App::GetAppMetrics); } diff --git a/atom/browser/api/atom_api_app.h b/atom/browser/api/atom_api_app.h index 694a3e304730..ce5d108e9786 100644 --- a/atom/browser/api/atom_api_app.h +++ b/atom/browser/api/atom_api_app.h @@ -183,6 +183,11 @@ class App : public AtomBrowserClient::Delegate, v8::Local GetGPUFeatureStatus(v8::Isolate* isolate); void EnableMixedSandbox(mate::Arguments* args); +#if defined(OS_MACOSX) + bool MoveToApplicationsFolder(mate::Arguments* args); + bool IsInApplicationsFolder(); +#endif + #if defined(OS_WIN) // Get the current Jump List settings. v8::Local GetJumpListSettings(); diff --git a/atom/browser/ui/cocoa/atom_bundle_mover.h b/atom/browser/ui/cocoa/atom_bundle_mover.h new file mode 100644 index 000000000000..36613454588e --- /dev/null +++ b/atom/browser/ui/cocoa/atom_bundle_mover.h @@ -0,0 +1,42 @@ +// Copyright (c) 2017 GitHub, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ATOM_BROWSER_UI_COCOA_ATOM_BUNDLE_MOVER_H_ +#define ATOM_BROWSER_UI_COCOA_ATOM_BUNDLE_MOVER_H_ + +#include + +#include "native_mate/persistent_dictionary.h" + +namespace atom { + +namespace ui { + +namespace cocoa { + +class AtomBundleMover { + public: + static bool Move(mate::Arguments* args); + static bool IsCurrentAppInApplicationsFolder(); + + private: + static bool IsInApplicationsFolder(NSString* bundlePath); + static NSString* ContainingDiskImageDevice(NSString* bundlePath); + static void Relaunch(NSString* destinationPath); + static NSString* ShellQuotedString(NSString* string); + static bool CopyBundle(NSString* srcPath, NSString* dstPath); + static bool AuthorizedInstall(NSString* srcPath, NSString* dstPath, + bool* canceled); + static bool IsApplicationAtPathRunning(NSString* bundlePath); + static bool DeleteOrTrash(NSString* path); + static bool Trash(NSString* path); +}; + +} // namespace cocoa + +} // namespace ui + +} // namespace atom + +#endif // ATOM_BROWSER_UI_COCOA_ATOM_BUNDLE_MOVER_H_ diff --git a/atom/browser/ui/cocoa/atom_bundle_mover.mm b/atom/browser/ui/cocoa/atom_bundle_mover.mm new file mode 100644 index 000000000000..4a3072d53d5f --- /dev/null +++ b/atom/browser/ui/cocoa/atom_bundle_mover.mm @@ -0,0 +1,345 @@ +// 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 "atom/browser/ui/cocoa/atom_bundle_mover.h" + +#import +#import +#import +#import +#import +#import + +#import "atom/browser/browser.h" + +namespace atom { + +namespace ui { + +namespace cocoa { + +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)) { + // Give the running app focus and terminate myself + [[NSTask launchedTaskWithLaunchPath:@"/usr/bin/open" arguments:[NSArray arrayWithObject:destinationPath]] waitUntilExit]; + atom::Browser::Get()->Quit(); + return true; + } else { + 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]]; + } + + atom::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 = nil; + if (floor(NSAppKitVersionNumber) > NSAppKitVersionNumber10_5) { + info = [NSPropertyListSerialization propertyListWithData:data options:NSPropertyListImmutable format:NULL error:NULL]; + } else { + info = [NSPropertyListSerialization propertyListFromData:data mutabilityOption:NSPropertyListImmutable format:NULL errorDescription: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; + + if ([fileManager copyItemAtPath:srcPath toPath:dstPath error:&error]) { + return true; + } + else { + return false; + } +} + +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) { + NSAppleScript* appleScript = [[[NSAppleScript alloc] initWithSource: + [NSString stringWithFormat:@"\ + set theFile to POSIX file \"%@\" \n\ + tell application \"Finder\" \n\ + move theFile to trash \n\ + end tell", 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 atom \ No newline at end of file diff --git a/docs/api/app.md b/docs/api/app.md index 3ac9ca211b6d..0fed6e5dacd1 100644 --- a/docs/api/app.md +++ b/docs/api/app.md @@ -927,6 +927,26 @@ Enables mixed sandbox mode on the app. This method can only be called before app is ready. +### `app.isInApplicationsFolder()` _macOS_ + +Returns `Boolean` - Whether the application is currently running from the +systems Application folder. Use in combination with `app.moveToApplicationsFolder()` + +### `app.moveToApplicationsFolder()` _macOS_ + +Returns `Boolean` - Whether the move was successful. Please note that if +the move is successful your application will quit and relaunch. + +No confirmation dialog will be presented by default, if you wish to allow +the user to confirm the operation you may do so using the +[`dialog`](dialog.md) API. + +**NOTE:** This method throws errors if anything other than the user causes the +move to fail. For instance if the user cancels the authorization dialog this +method returns false. If we fail to perform the copy then this method will +throw an error. The message in the error should be informative and tell +you exactly what went wrong + ### `app.dock.bounce([type])` _macOS_ * `type` String (optional) - Can be `critical` or `informational`. The default is diff --git a/filenames.gypi b/filenames.gypi index e90fe322ba15..5b2e8c4feb87 100644 --- a/filenames.gypi +++ b/filenames.gypi @@ -292,6 +292,8 @@ 'atom/browser/ui/certificate_trust.h', 'atom/browser/ui/certificate_trust_mac.mm', 'atom/browser/ui/certificate_trust_win.cc', + 'atom/browser/ui/cocoa/atom_bundle_mover.h', + 'atom/browser/ui/cocoa/atom_bundle_mover.mm', 'atom/browser/ui/cocoa/atom_menu_controller.h', 'atom/browser/ui/cocoa/atom_menu_controller.mm', 'atom/browser/ui/cocoa/atom_touch_bar.h', diff --git a/spec/api-app-spec.js b/spec/api-app-spec.js index dc4844b1a613..df1eeccdae16 100644 --- a/spec/api-app-spec.js +++ b/spec/api-app-spec.js @@ -114,6 +114,14 @@ describe('app module', function () { }) }) + describe('app.isInApplicationsFolder()', function () { + it('should be false during tests', function () { + if (process.platform !== 'darwin') return + + assert.equal(app.isInApplicationsFolder(), false) + }) + }) + describe('app.exit(exitCode)', function () { var appProcess = null