gecko-dev/toolkit/xre/MacRunFromDmgUtils.mm

539 строки
18 KiB
Plaintext

/* -*- 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, *)) {
setenv("MOZ_INSTALLED_AND_RELAUNCHED_FROM_DMG", "1", 1);
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;
}
bool triedElevatedInstall = false;
#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];
triedElevatedInstall = true;
}
#endif
if (!installSuccessful) {
if (!triedElevatedInstall) {
glean::startup::run_from_dmg_install_outcome.Get("non_privileged_install_failed"_ns)
.Set(true);
} else {
glean::startup::run_from_dmg_install_outcome.Get("privileged_install_failed"_ns).Set(true);
}
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;
}
}
Telemetry::ScalarSet(Telemetry::ScalarID::STARTUP_IS_RUN_FROM_DMG, isFromDmg);
Telemetry::ScalarSet(Telemetry::ScalarID::STARTUP_IS_RUN_FROM_APP_TRANSLOCATED_LOCATION,
isTranslocated);
if (!isFromDmg && !isTranslocated) {
if (getenv("MOZ_INSTALLED_AND_RELAUNCHED_FROM_DMG")) {
unsetenv("MOZ_INSTALLED_AND_RELAUNCHED_FROM_DMG");
glean::startup::run_from_dmg_install_outcome.Get("installed_and_relaunched"_ns).Set(true);
}
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) {
glean::startup::run_from_dmg_install_outcome.Get("root_applications_dir_missing"_ns)
.Set(true);
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);
glean::startup::run_from_dmg_install_outcome.Get("user_accepted_launch_existing"_ns)
.Set(true);
return true;
}
glean::startup::run_from_dmg_install_outcome.Get("user_declined_launch_existing"_ns)
.Set(true);
return false;
}
if (!AskUserIfWeShouldInstall()) {
glean::startup::run_from_dmg_install_outcome.Get("user_declined_install_prompt"_ns).Set(true);
return false;
}
if (!InstallFromPath(bundlePath, destPath)) {
ShowInstallFailedDialog();
return false;
}
LaunchInstalledApp(destPath);
return true;
}
NS_OBJC_END_TRY_BLOCK_RETURN(false);
}
} // namespace mozilla::MacRunFromDmgUtils