diff options
Diffstat (limited to 'toolkit/xre/MacRunFromDmgUtils.mm')
-rw-r--r-- | toolkit/xre/MacRunFromDmgUtils.mm | 513 |
1 files changed, 513 insertions, 0 deletions
diff --git a/toolkit/xre/MacRunFromDmgUtils.mm b/toolkit/xre/MacRunFromDmgUtils.mm new file mode 100644 index 0000000000..38381b5c48 --- /dev/null +++ b/toolkit/xre/MacRunFromDmgUtils.mm @@ -0,0 +1,513 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include <AppKit/AppKit.h> +#include <ApplicationServices/ApplicationServices.h> +#include <CoreFoundation/CoreFoundation.h> +#include <CoreServices/CoreServices.h> +#include <IOKit/IOKitLib.h> +#include <stdlib.h> +#include <string.h> +#include <sys/mount.h> +#include <sys/param.h> + +#include "MacRunFromDmgUtils.h" +#include "MacLaunchHelper.h" + +#include "mozilla/ErrorResult.h" +#include "mozilla/glean/GleanMetrics.h" +#include "mozilla/intl/Localization.h" +#include "mozilla/Telemetry.h" +#include "nsCocoaFeatures.h" +#include "nsCocoaUtils.h" +#include "nsCommandLine.h" +#include "nsCommandLineServiceMac.h" +#include "nsILocalFileMac.h" +#include "nsIMacDockSupport.h" +#include "nsObjCExceptions.h" +#include "prenv.h" +#include "nsString.h" +#ifdef MOZ_UPDATER +# include "nsUpdateDriver.h" +#endif +#include "SDKDeclarations.h" + +// For IOKit docs, see: +// https://developer.apple.com/documentation/iokit +// https://developer.apple.com/library/archive/documentation/DeviceDrivers/Conceptual/IOKitFundamentals/ + +namespace mozilla::MacRunFromDmgUtils { + +/** + * Opens a dialog to ask the user whether the existing app in the Applications + * folder should be launched, or if the user wants to proceed with launching + * the app from the .dmg. + * Returns true if the dialog is successfully opened and the user chooses to + * launch the app from the Applications folder, otherwise returns false. + */ +static bool AskUserIfWeShouldLaunchExistingInstall() { + NS_OBJC_BEGIN_TRY_BLOCK_RETURN; + + // Try to get the localized strings: + nsTArray<nsCString> resIds = { + "branding/brand.ftl"_ns, + "toolkit/global/run-from-dmg.ftl"_ns, + }; + RefPtr<intl::Localization> l10n = intl::Localization::Create(resIds, true); + + ErrorResult rv; + nsAutoCString mozTitle, mozMessage, mozLaunchExisting, mozLaunchFromDMG; + l10n->FormatValueSync("prompt-to-launch-existing-app-title"_ns, {}, mozTitle, rv); + if (rv.Failed()) { + return false; + } + l10n->FormatValueSync("prompt-to-launch-existing-app-message"_ns, {}, mozMessage, rv); + if (rv.Failed()) { + return false; + } + l10n->FormatValueSync("prompt-to-launch-existing-app-yes-button"_ns, {}, mozLaunchExisting, rv); + if (rv.Failed()) { + return false; + } + l10n->FormatValueSync("prompt-to-launch-existing-app-no-button"_ns, {}, mozLaunchFromDMG, rv); + if (rv.Failed()) { + return false; + } + + NSString* title = [NSString stringWithUTF8String:reinterpret_cast<const char*>(mozTitle.get())]; + NSString* message = + [NSString stringWithUTF8String:reinterpret_cast<const char*>(mozMessage.get())]; + NSString* launchExisting = + [NSString stringWithUTF8String:reinterpret_cast<const char*>(mozLaunchExisting.get())]; + NSString* launchFromDMG = + [NSString stringWithUTF8String:reinterpret_cast<const char*>(mozLaunchFromDMG.get())]; + + NSAlert* alert = [[[NSAlert alloc] init] autorelease]; + + // Note that we don't set an icon since the app icon is used by default. + [alert setAlertStyle:NSAlertStyleInformational]; + [alert setMessageText:title]; + [alert setInformativeText:message]; + // Note that if the user hits 'Enter' the "Install" button is activated, + // whereas if they hit 'Space' the "Don't Install" button is activated. + // That's standard behavior so probably desirable. + [alert addButtonWithTitle:launchExisting]; + NSButton* launchFromDMGButton = [alert addButtonWithTitle:launchFromDMG]; + // Since the "Don't Install" button doesn't have the title "Cancel" we need + // to map the Escape key to it manually: + [launchFromDMGButton setKeyEquivalent:@"\e"]; + + __block NSInteger result = -1; + dispatch_async(dispatch_get_main_queue(), ^{ + result = [alert runModal]; + [NSApp stop:nil]; + }); + + // We need to call run on NSApp here for accessibility. See + // AskUserIfWeShouldInstall for a detailed explanation. + [NSApp run]; + MOZ_ASSERT(result != -1); + + return result == NSAlertFirstButtonReturn; + + NS_OBJC_END_TRY_BLOCK_RETURN(false); +} + +/** + * Opens a dialog to ask the user whether the app should be installed to their + * Applications folder. Returns true if the dialog is successfully opened and + * the user accept, otherwise returns false. + */ +static bool AskUserIfWeShouldInstall() { + NS_OBJC_BEGIN_TRY_BLOCK_RETURN; + + // Try to get the localized strings: + nsTArray<nsCString> resIds = { + "branding/brand.ftl"_ns, + "toolkit/global/run-from-dmg.ftl"_ns, + }; + RefPtr<intl::Localization> l10n = intl::Localization::Create(resIds, true); + + ErrorResult rv; + nsAutoCString mozTitle, mozMessage, mozInstall, mozDontInstall; + l10n->FormatValueSync("prompt-to-install-title"_ns, {}, mozTitle, rv); + if (rv.Failed()) { + return false; + } + l10n->FormatValueSync("prompt-to-install-message"_ns, {}, mozMessage, rv); + if (rv.Failed()) { + return false; + } + l10n->FormatValueSync("prompt-to-install-yes-button"_ns, {}, mozInstall, rv); + if (rv.Failed()) { + return false; + } + l10n->FormatValueSync("prompt-to-install-no-button"_ns, {}, mozDontInstall, rv); + if (rv.Failed()) { + return false; + } + + NSString* title = [NSString stringWithUTF8String:reinterpret_cast<const char*>(mozTitle.get())]; + NSString* message = + [NSString stringWithUTF8String:reinterpret_cast<const char*>(mozMessage.get())]; + NSString* install = + [NSString stringWithUTF8String:reinterpret_cast<const char*>(mozInstall.get())]; + NSString* dontInstall = + [NSString stringWithUTF8String:reinterpret_cast<const char*>(mozDontInstall.get())]; + + NSAlert* alert = [[[NSAlert alloc] init] autorelease]; + + // Note that we don't set an icon since the app icon is used by default. + [alert setAlertStyle:NSAlertStyleInformational]; + [alert setMessageText:title]; + [alert setInformativeText:message]; + // Note that if the user hits 'Enter' the "Install" button is activated, + // whereas if they hit 'Space' the "Don't Install" button is activated. + // That's standard behavior so probably desirable. + [alert addButtonWithTitle:install]; + NSButton* dontInstallButton = [alert addButtonWithTitle:dontInstall]; + // Since the "Don't Install" button doesn't have the title "Cancel" we need + // to map the Escape key to it manually: + [dontInstallButton setKeyEquivalent:@"\e"]; + + // We need to call run on NSApp to allow accessibility. We only run it + // for this specific alert which blocks the app's loop until the user + // responds, it then subsequently stops the app's loop. + // + // AskUserIfWeShouldInstall + // | + // | ---> [NSApp run] + // | | + // | | -----> task + // | | | -----------> [alert runModal] + // | | | | (User selects button) + // | | | <--------------- done + // | | | + // | | | -----------> [NSApp stop:nil] + // | | | <----------- + // | | <-------- + // | <------- + // done + __block NSInteger result = -1; + dispatch_async(dispatch_get_main_queue(), ^{ + result = [alert runModal]; + [NSApp stop:nil]; + }); + + [NSApp run]; + MOZ_ASSERT(result != -1); + + return result == NSAlertFirstButtonReturn; + + NS_OBJC_END_TRY_BLOCK_RETURN(false); +} + +static void ShowInstallFailedDialog() { + NS_OBJC_BEGIN_TRY_IGNORE_BLOCK; + + // Try to get the localized strings: + nsTArray<nsCString> resIds = { + "branding/brand.ftl"_ns, + "toolkit/global/run-from-dmg.ftl"_ns, + }; + RefPtr<intl::Localization> l10n = intl::Localization::Create(resIds, true); + + ErrorResult rv; + nsAutoCString mozTitle, mozMessage; + l10n->FormatValueSync("install-failed-title"_ns, {}, mozTitle, rv); + if (rv.Failed()) { + return; + } + l10n->FormatValueSync("install-failed-message"_ns, {}, mozMessage, rv); + if (rv.Failed()) { + return; + } + + NSString* title = [NSString stringWithUTF8String:reinterpret_cast<const char*>(mozTitle.get())]; + NSString* message = + [NSString stringWithUTF8String:reinterpret_cast<const char*>(mozMessage.get())]; + + NSAlert* alert = [[[NSAlert alloc] init] autorelease]; + + [alert setAlertStyle:NSAlertStyleWarning]; + [alert setMessageText:title]; + [alert setInformativeText:message]; + + __block NSInteger result = -1; + dispatch_async(dispatch_get_main_queue(), ^{ + result = [alert runModal]; + [NSApp stop:nil]; + }); + + // We need to call run on NSApp here for accessibility. See + // AskUserIfWeShouldInstall for a detailed explanation. + [NSApp run]; + MOZ_ASSERT(result != -1); + (void)result; + + NS_OBJC_END_TRY_IGNORE_BLOCK; +} + +/** + * Helper to launch macOS tasks via NSTask. + */ +static void LaunchTask(NSString* aPath, NSArray* aArguments) { + if (@available(macOS 10.13, *)) { + NSTask* task = [[NSTask alloc] init]; + [task setExecutableURL:[NSURL fileURLWithPath:aPath]]; + if (aArguments) { + [task setArguments:aArguments]; + } + [task launchAndReturnError:nil]; + [task release]; + } else { + NSArray* arguments = aArguments; + if (!arguments) { + arguments = @[]; + } + [NSTask launchedTaskWithLaunchPath:aPath arguments:arguments]; + } +} + +static void LaunchInstalledApp(NSString* aBundlePath) { + LaunchTask([[NSBundle bundleWithPath:aBundlePath] executablePath], nil); +} + +static void RegisterAppWithLaunchServices(NSString* aBundlePath) { + NSArray* arguments = @[ @"-f", aBundlePath ]; + LaunchTask(@"/System/Library/Frameworks/CoreServices.framework/Frameworks/" + @"LaunchServices.framework/Support/lsregister", + arguments); +} + +static void StripQuarantineBit(NSString* aBundlePath) { + NSArray* arguments = @[ @"-d", @"com.apple.quarantine", aBundlePath ]; + LaunchTask(@"/usr/bin/xattr", arguments); +} + +#ifdef MOZ_UPDATER +bool LaunchElevatedDmgInstall(NSString* aBundlePath, NSArray* aArguments) { + NSTask* task; + if (@available(macOS 10.13, *)) { + task = [[NSTask alloc] init]; + [task setExecutableURL:[NSURL fileURLWithPath:aBundlePath]]; + if (aArguments) { + [task setArguments:aArguments]; + } + [task launchAndReturnError:nil]; + } else { + NSArray* arguments = aArguments; + if (!arguments) { + arguments = @[]; + } + task = [NSTask launchedTaskWithLaunchPath:aBundlePath arguments:arguments]; + } + + bool didSucceed = InstallPrivilegedHelper(); + [task waitUntilExit]; + if (@available(macOS 10.13, *)) { + [task release]; + } + if (!didSucceed) { + AbortElevatedUpdate(); + } + + return didSucceed; +} +#endif + +// Note: both arguments are expected to contain the app name (to end with +// '.app'). +static bool InstallFromPath(NSString* aBundlePath, NSString* aDestPath) { + bool installSuccessful = false; + NSFileManager* fileManager = [NSFileManager defaultManager]; + if ([fileManager copyItemAtPath:aBundlePath toPath:aDestPath error:nil]) { + RegisterAppWithLaunchServices(aDestPath); + StripQuarantineBit(aDestPath); + installSuccessful = true; + } + +#ifdef MOZ_UPDATER + // The installation may have been unsuccessful if the user did not have the + // rights to write to the Applications directory. Check for this situation and + // launch an elevated installation if necessary. Rather than creating a new, + // dedicated executable for this installation and incurring the + // added maintenance burden of yet another executable, we are using the + // updater binary. Since bug 394984 landed, the updater has the ability to + // install and launch itself as a Privileged Helper tool, which is what is + // necessary here. + NSString* destDir = [aDestPath stringByDeletingLastPathComponent]; + if (!installSuccessful && ![fileManager isWritableFileAtPath:destDir]) { + NSString* updaterBinPath = [NSString pathWithComponents:@[ + aBundlePath, @"Contents", @"MacOS", [NSString stringWithUTF8String:UPDATER_APP], @"Contents", + @"MacOS", [NSString stringWithUTF8String:UPDATER_BIN] + ]]; + + NSArray* arguments = @[ @"-dmgInstall", aBundlePath, aDestPath ]; + LaunchElevatedDmgInstall(updaterBinPath, arguments); + installSuccessful = [fileManager fileExistsAtPath:aDestPath]; + } +#endif + + if (!installSuccessful) { + return false; + } + + // Pin to dock: + nsresult rv; + nsCOMPtr<nsIMacDockSupport> dockSupport = + do_GetService("@mozilla.org/widget/macdocksupport;1", &rv); + if (NS_SUCCEEDED(rv) && dockSupport) { + bool isInDock; + nsAutoString appPath, appToReplacePath; + nsCocoaUtils::GetStringForNSString(aDestPath, appPath); + nsCocoaUtils::GetStringForNSString(aBundlePath, appToReplacePath); + dockSupport->EnsureAppIsPinnedToDock(appPath, appToReplacePath, &isInDock); + } + + return true; +} + +bool IsAppRunningFromDmg() { + NS_OBJC_BEGIN_TRY_BLOCK_RETURN; + + const char* path = [[[NSBundle mainBundle] bundlePath] fileSystemRepresentation]; + + struct statfs statfsBuf; + if (statfs(path, &statfsBuf) != 0) { + return false; + } + + // Optimization to minimize impact on startup time: + if (!(statfsBuf.f_flags & MNT_RDONLY)) { + return false; + } + + // Get the "BSD device name" ("diskXsY", as found in /dev) of the filesystem + // our app is on so we can use IOKit to get its IOMedia object: + const char devDirPath[] = "/dev/"; + const int devDirPathLength = strlen(devDirPath); + if (strncmp(statfsBuf.f_mntfromname, devDirPath, devDirPathLength) != 0) { + // This has been observed to happen under App Translocation. In this case, + // path is the translocated path, and f_mntfromname is the path under + // /Volumes. Do another stat on that path. + nsCString volumesPath(statfsBuf.f_mntfromname); + if (statfs(volumesPath.get(), &statfsBuf) != 0) { + return false; + } + + if (strncmp(statfsBuf.f_mntfromname, devDirPath, devDirPathLength) != 0) { + // It still doesn't begin with /dev/, bail out. + return false; + } + } + const char* bsdDeviceName = statfsBuf.f_mntfromname + devDirPathLength; + + // Get the IOMedia object: + // (Note: IOServiceGetMatchingServices takes ownership of serviceDict's ref.) + CFMutableDictionaryRef serviceDict = IOBSDNameMatching(kIOMasterPortDefault, 0, bsdDeviceName); + if (!serviceDict) { + return false; + } + io_service_t media = IOServiceGetMatchingService(kIOMasterPortDefault, serviceDict); + if (!media || !IOObjectConformsTo(media, "IOMedia")) { + return false; + } + + // Search the parent chain for a service implementing the disk image class + // (taking care to start with `media` itself): + io_service_t imageDrive = IO_OBJECT_NULL; + io_iterator_t iter; + if (IORegistryEntryCreateIterator(media, kIOServicePlane, + kIORegistryIterateRecursively | kIORegistryIterateParents, + &iter) != KERN_SUCCESS) { + IOObjectRelease(media); + return false; + } + const char* imageClass = + nsCocoaFeatures::OnMontereyOrLater() ? "AppleDiskImageDevice" : "IOHDIXHDDrive"; + for (imageDrive = media; imageDrive; imageDrive = IOIteratorNext(iter)) { + if (IOObjectConformsTo(imageDrive, imageClass)) { + break; + } + IOObjectRelease(imageDrive); + } + IOObjectRelease(iter); + + if (imageDrive) { + IOObjectRelease(imageDrive); + return true; + } + return false; + + NS_OBJC_END_TRY_BLOCK_RETURN(false); +} + +bool MaybeInstallAndRelaunch() { + NS_OBJC_BEGIN_TRY_BLOCK_RETURN; + + @autoreleasepool { + bool isFromDmg = IsAppRunningFromDmg(); + bool isTranslocated = false; + if (!isFromDmg) { + NSString* bundlePath = [[NSBundle mainBundle] bundlePath]; + if ([bundlePath containsString:@"/AppTranslocation/"]) { + isTranslocated = true; + } + } + + if (!isFromDmg && !isTranslocated) { + return false; + } + + // The Applications directory may not be at /Applications, although in + // practice we're unlikely to encounter since run-from-.dmg is really an + // issue with novice mac users. Still, look it up correctly: + NSArray* applicationsDirs = + NSSearchPathForDirectoriesInDomains(NSApplicationDirectory, NSLocalDomainMask, YES); + NSString* applicationsDir = applicationsDirs[0]; + + // Sanity check dir exists + NSFileManager* fileManager = [NSFileManager defaultManager]; + BOOL isDir; + if (![fileManager fileExistsAtPath:applicationsDir isDirectory:&isDir] || !isDir) { + return false; + } + + NSString* bundlePath = [[NSBundle mainBundle] bundlePath]; + NSString* appName = [bundlePath lastPathComponent]; + NSString* destPath = [applicationsDir stringByAppendingPathComponent:appName]; + + // If the app (an app of the same name) is already installed we can't really + // tell without asking if we're dealing with the edge case of an + // inexperienced user running from .dmg by mistake, or if we're dealing with + // a more sophisticated user intentionally running from .dmg. + if ([fileManager fileExistsAtPath:destPath]) { + if (AskUserIfWeShouldLaunchExistingInstall()) { + StripQuarantineBit(destPath); + LaunchInstalledApp(destPath); + return true; + } + return false; + } + + if (!AskUserIfWeShouldInstall()) { + return false; + } + + if (!InstallFromPath(bundlePath, destPath)) { + ShowInstallFailedDialog(); + return false; + } + + LaunchInstalledApp(destPath); + + return true; + } + + NS_OBJC_END_TRY_BLOCK_RETURN(false); +} + +} // namespace mozilla::MacRunFromDmgUtils |