1 /* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 #include <AppKit/AppKit.h>
7 #include <ApplicationServices/ApplicationServices.h>
8 #include <CoreFoundation/CoreFoundation.h>
9 #include <CoreServices/CoreServices.h>
10 #include <IOKit/IOKitLib.h>
13 #include <sys/mount.h>
14 #include <sys/param.h>
16 #include "MacRunFromDmgUtils.h"
17 #include "MacLaunchHelper.h"
19 #include "mozilla/ErrorResult.h"
20 #include "mozilla/glean/GleanMetrics.h"
21 #include "mozilla/intl/Localization.h"
22 #include "mozilla/Telemetry.h"
23 #include "nsCocoaFeatures.h"
24 #include "nsCocoaUtils.h"
25 #include "nsCommandLine.h"
26 #include "nsCommandLineServiceMac.h"
27 #include "nsILocalFileMac.h"
28 #include "nsIMacDockSupport.h"
29 #include "nsObjCExceptions.h"
33 # include "nsUpdateDriver.h"
36 // For IOKit docs, see:
37 // https://developer.apple.com/documentation/iokit
38 // https://developer.apple.com/library/archive/documentation/DeviceDrivers/Conceptual/IOKitFundamentals/
40 namespace mozilla::MacRunFromDmgUtils {
43 * Opens a dialog to ask the user whether the existing app in the Applications
44 * folder should be launched, or if the user wants to proceed with launching
45 * the app from the .dmg.
46 * Returns true if the dialog is successfully opened and the user chooses to
47 * launch the app from the Applications folder, otherwise returns false.
49 static bool AskUserIfWeShouldLaunchExistingInstall() {
50 NS_OBJC_BEGIN_TRY_BLOCK_RETURN;
52 // Try to get the localized strings:
53 nsTArray<nsCString> resIds = {
54 "branding/brand.ftl"_ns,
55 "toolkit/global/run-from-dmg.ftl"_ns,
57 RefPtr<intl::Localization> l10n = intl::Localization::Create(resIds, true);
60 nsAutoCString mozTitle, mozMessage, mozLaunchExisting, mozLaunchFromDMG;
61 l10n->FormatValueSync("prompt-to-launch-existing-app-title"_ns, {}, mozTitle,
66 l10n->FormatValueSync("prompt-to-launch-existing-app-message"_ns, {},
71 l10n->FormatValueSync("prompt-to-launch-existing-app-yes-button"_ns, {},
72 mozLaunchExisting, rv);
76 l10n->FormatValueSync("prompt-to-launch-existing-app-no-button"_ns, {},
77 mozLaunchFromDMG, rv);
82 NSString* title = [NSString
83 stringWithUTF8String:reinterpret_cast<const char*>(mozTitle.get())];
84 NSString* message = [NSString
85 stringWithUTF8String:reinterpret_cast<const char*>(mozMessage.get())];
86 NSString* launchExisting =
87 [NSString stringWithUTF8String:reinterpret_cast<const char*>(
88 mozLaunchExisting.get())];
89 NSString* launchFromDMG =
90 [NSString stringWithUTF8String:reinterpret_cast<const char*>(
91 mozLaunchFromDMG.get())];
93 NSAlert* alert = [[[NSAlert alloc] init] autorelease];
95 // Note that we don't set an icon since the app icon is used by default.
96 [alert setAlertStyle:NSAlertStyleInformational];
97 [alert setMessageText:title];
98 [alert setInformativeText:message];
99 // Note that if the user hits 'Enter' the "Install" button is activated,
100 // whereas if they hit 'Space' the "Don't Install" button is activated.
101 // That's standard behavior so probably desirable.
102 [alert addButtonWithTitle:launchExisting];
103 NSButton* launchFromDMGButton = [alert addButtonWithTitle:launchFromDMG];
104 // Since the "Don't Install" button doesn't have the title "Cancel" we need
105 // to map the Escape key to it manually:
106 [launchFromDMGButton setKeyEquivalent:@"\e"];
108 __block NSInteger result = -1;
109 dispatch_async(dispatch_get_main_queue(), ^{
110 result = [alert runModal];
114 // We need to call run on NSApp here for accessibility. See
115 // AskUserIfWeShouldInstall for a detailed explanation.
117 MOZ_ASSERT(result != -1);
119 return result == NSAlertFirstButtonReturn;
121 NS_OBJC_END_TRY_BLOCK_RETURN(false);
125 * Opens a dialog to ask the user whether the app should be installed to their
126 * Applications folder. Returns true if the dialog is successfully opened and
127 * the user accept, otherwise returns false.
129 static bool AskUserIfWeShouldInstall() {
130 NS_OBJC_BEGIN_TRY_BLOCK_RETURN;
132 // Try to get the localized strings:
133 nsTArray<nsCString> resIds = {
134 "branding/brand.ftl"_ns,
135 "toolkit/global/run-from-dmg.ftl"_ns,
137 RefPtr<intl::Localization> l10n = intl::Localization::Create(resIds, true);
140 nsAutoCString mozTitle, mozMessage, mozInstall, mozDontInstall;
141 l10n->FormatValueSync("prompt-to-install-title"_ns, {}, mozTitle, rv);
145 l10n->FormatValueSync("prompt-to-install-message"_ns, {}, mozMessage, rv);
149 l10n->FormatValueSync("prompt-to-install-yes-button"_ns, {}, mozInstall, rv);
153 l10n->FormatValueSync("prompt-to-install-no-button"_ns, {}, mozDontInstall,
159 NSString* title = [NSString
160 stringWithUTF8String:reinterpret_cast<const char*>(mozTitle.get())];
161 NSString* message = [NSString
162 stringWithUTF8String:reinterpret_cast<const char*>(mozMessage.get())];
163 NSString* install = [NSString
164 stringWithUTF8String:reinterpret_cast<const char*>(mozInstall.get())];
165 NSString* dontInstall = [NSString
166 stringWithUTF8String:reinterpret_cast<const char*>(mozDontInstall.get())];
168 NSAlert* alert = [[[NSAlert alloc] init] autorelease];
170 // Note that we don't set an icon since the app icon is used by default.
171 [alert setAlertStyle:NSAlertStyleInformational];
172 [alert setMessageText:title];
173 [alert setInformativeText:message];
174 // Note that if the user hits 'Enter' the "Install" button is activated,
175 // whereas if they hit 'Space' the "Don't Install" button is activated.
176 // That's standard behavior so probably desirable.
177 [alert addButtonWithTitle:install];
178 NSButton* dontInstallButton = [alert addButtonWithTitle:dontInstall];
179 // Since the "Don't Install" button doesn't have the title "Cancel" we need
180 // to map the Escape key to it manually:
181 [dontInstallButton setKeyEquivalent:@"\e"];
183 // We need to call run on NSApp to allow accessibility. We only run it
184 // for this specific alert which blocks the app's loop until the user
185 // responds, it then subsequently stops the app's loop.
187 // AskUserIfWeShouldInstall
189 // | ---> [NSApp run]
192 // | | | ----> [alert runModal]
193 // | | | | (User selects button)
194 // | | | <--------- done
196 // | | | -----> [NSApp stop:nil]
201 __block NSInteger result = -1;
202 dispatch_async(dispatch_get_main_queue(), ^{
203 result = [alert runModal];
208 MOZ_ASSERT(result != -1);
210 return result == NSAlertFirstButtonReturn;
212 NS_OBJC_END_TRY_BLOCK_RETURN(false);
215 static void ShowInstallFailedDialog() {
216 NS_OBJC_BEGIN_TRY_IGNORE_BLOCK;
218 // Try to get the localized strings:
219 nsTArray<nsCString> resIds = {
220 "branding/brand.ftl"_ns,
221 "toolkit/global/run-from-dmg.ftl"_ns,
223 RefPtr<intl::Localization> l10n = intl::Localization::Create(resIds, true);
226 nsAutoCString mozTitle, mozMessage;
227 l10n->FormatValueSync("install-failed-title"_ns, {}, mozTitle, rv);
231 l10n->FormatValueSync("install-failed-message"_ns, {}, mozMessage, rv);
236 NSString* title = [NSString
237 stringWithUTF8String:reinterpret_cast<const char*>(mozTitle.get())];
238 NSString* message = [NSString
239 stringWithUTF8String:reinterpret_cast<const char*>(mozMessage.get())];
241 NSAlert* alert = [[[NSAlert alloc] init] autorelease];
243 [alert setAlertStyle:NSAlertStyleWarning];
244 [alert setMessageText:title];
245 [alert setInformativeText:message];
247 __block NSInteger result = -1;
248 dispatch_async(dispatch_get_main_queue(), ^{
249 result = [alert runModal];
253 // We need to call run on NSApp here for accessibility. See
254 // AskUserIfWeShouldInstall for a detailed explanation.
256 MOZ_ASSERT(result != -1);
259 NS_OBJC_END_TRY_IGNORE_BLOCK;
263 * Helper to launch macOS tasks via NSTask.
265 static void LaunchTask(NSString* aPath, NSArray* aArguments) {
266 NSTask* task = [[NSTask alloc] init];
267 [task setExecutableURL:[NSURL fileURLWithPath:aPath]];
269 [task setArguments:aArguments];
271 [task launchAndReturnError:nil];
275 static void LaunchInstalledApp(NSString* aBundlePath) {
276 LaunchTask([[NSBundle bundleWithPath:aBundlePath] executablePath], nil);
279 static void RegisterAppWithLaunchServices(NSString* aBundlePath) {
280 NSArray* arguments = @[ @"-f", aBundlePath ];
281 LaunchTask(@"/System/Library/Frameworks/CoreServices.framework/Frameworks/"
282 @"LaunchServices.framework/Support/lsregister",
286 static void StripQuarantineBit(NSString* aBundlePath) {
287 NSArray* arguments = @[ @"-d", @"com.apple.quarantine", aBundlePath ];
288 LaunchTask(@"/usr/bin/xattr", arguments);
292 bool LaunchElevatedDmgInstall(NSString* aBundlePath, NSArray* aArguments) {
293 NSTask* task = [[NSTask alloc] init];
294 [task setExecutableURL:[NSURL fileURLWithPath:aBundlePath]];
296 [task setArguments:aArguments];
298 [task launchAndReturnError:nil];
300 bool didSucceed = InstallPrivilegedHelper();
301 [task waitUntilExit];
304 AbortElevatedUpdate();
311 // Note: both arguments are expected to contain the app name (to end with
313 static bool InstallFromPath(NSString* aBundlePath, NSString* aDestPath) {
314 bool installSuccessful = false;
315 NSFileManager* fileManager = [NSFileManager defaultManager];
316 if ([fileManager copyItemAtPath:aBundlePath toPath:aDestPath error:nil]) {
317 RegisterAppWithLaunchServices(aDestPath);
318 StripQuarantineBit(aDestPath);
319 installSuccessful = true;
323 // The installation may have been unsuccessful if the user did not have the
324 // rights to write to the Applications directory. Check for this situation and
325 // launch an elevated installation if necessary. Rather than creating a new,
326 // dedicated executable for this installation and incurring the
327 // added maintenance burden of yet another executable, we are using the
328 // updater binary. Since bug 394984 landed, the updater has the ability to
329 // install and launch itself as a Privileged Helper tool, which is what is
331 NSString* destDir = [aDestPath stringByDeletingLastPathComponent];
332 if (!installSuccessful && ![fileManager isWritableFileAtPath:destDir]) {
333 NSString* updaterBinPath = [NSString pathWithComponents:@[
334 aBundlePath, @"Contents", @"MacOS",
335 [NSString stringWithUTF8String:UPDATER_APP], @"Contents", @"MacOS",
336 [NSString stringWithUTF8String:UPDATER_BIN]
339 NSArray* arguments = @[ @"-dmgInstall", aBundlePath, aDestPath ];
340 LaunchElevatedDmgInstall(updaterBinPath, arguments);
341 installSuccessful = [fileManager fileExistsAtPath:aDestPath];
345 if (!installSuccessful) {
351 nsCOMPtr<nsIMacDockSupport> dockSupport =
352 do_GetService("@mozilla.org/widget/macdocksupport;1", &rv);
353 if (NS_SUCCEEDED(rv) && dockSupport) {
355 nsAutoString appPath, appToReplacePath;
356 nsCocoaUtils::GetStringForNSString(aDestPath, appPath);
357 nsCocoaUtils::GetStringForNSString(aBundlePath, appToReplacePath);
358 dockSupport->EnsureAppIsPinnedToDock(appPath, appToReplacePath, &isInDock);
364 bool IsAppRunningFromDmg() {
365 NS_OBJC_BEGIN_TRY_BLOCK_RETURN;
368 [[[NSBundle mainBundle] bundlePath] fileSystemRepresentation];
370 struct statfs statfsBuf;
371 if (statfs(path, &statfsBuf) != 0) {
375 // Optimization to minimize impact on startup time:
376 if (!(statfsBuf.f_flags & MNT_RDONLY)) {
380 // Get the "BSD device name" ("diskXsY", as found in /dev) of the filesystem
381 // our app is on so we can use IOKit to get its IOMedia object:
382 const char devDirPath[] = "/dev/";
383 const int devDirPathLength = strlen(devDirPath);
384 if (strncmp(statfsBuf.f_mntfromname, devDirPath, devDirPathLength) != 0) {
385 // This has been observed to happen under App Translocation. In this case,
386 // path is the translocated path, and f_mntfromname is the path under
387 // /Volumes. Do another stat on that path.
388 nsCString volumesPath(statfsBuf.f_mntfromname);
389 if (statfs(volumesPath.get(), &statfsBuf) != 0) {
393 if (strncmp(statfsBuf.f_mntfromname, devDirPath, devDirPathLength) != 0) {
394 // It still doesn't begin with /dev/, bail out.
398 const char* bsdDeviceName = statfsBuf.f_mntfromname + devDirPathLength;
400 // Get the IOMedia object:
401 // (Note: IOServiceGetMatchingServices takes ownership of serviceDict's ref.)
402 CFMutableDictionaryRef serviceDict =
403 IOBSDNameMatching(kIOMasterPortDefault, 0, bsdDeviceName);
408 IOServiceGetMatchingService(kIOMasterPortDefault, serviceDict);
409 if (!media || !IOObjectConformsTo(media, "IOMedia")) {
413 // Search the parent chain for a service implementing the disk image class
414 // (taking care to start with `media` itself):
415 io_service_t imageDrive = IO_OBJECT_NULL;
417 if (IORegistryEntryCreateIterator(
418 media, kIOServicePlane,
419 kIORegistryIterateRecursively | kIORegistryIterateParents,
420 &iter) != KERN_SUCCESS) {
421 IOObjectRelease(media);
424 const char* imageClass = nsCocoaFeatures::OnMontereyOrLater()
425 ? "AppleDiskImageDevice"
427 for (imageDrive = media; imageDrive; imageDrive = IOIteratorNext(iter)) {
428 if (IOObjectConformsTo(imageDrive, imageClass)) {
431 IOObjectRelease(imageDrive);
433 IOObjectRelease(iter);
436 IOObjectRelease(imageDrive);
441 NS_OBJC_END_TRY_BLOCK_RETURN(false);
444 bool MaybeInstallAndRelaunch() {
445 NS_OBJC_BEGIN_TRY_BLOCK_RETURN;
448 bool isFromDmg = IsAppRunningFromDmg();
449 bool isTranslocated = false;
451 NSString* bundlePath = [[NSBundle mainBundle] bundlePath];
452 if ([bundlePath containsString:@"/AppTranslocation/"]) {
453 isTranslocated = true;
457 if (!isFromDmg && !isTranslocated) {
461 // The Applications directory may not be at /Applications, although in
462 // practice we're unlikely to encounter since run-from-.dmg is really an
463 // issue with novice mac users. Still, look it up correctly:
464 NSArray* applicationsDirs = NSSearchPathForDirectoriesInDomains(
465 NSApplicationDirectory, NSLocalDomainMask, YES);
466 NSString* applicationsDir = applicationsDirs[0];
468 // Sanity check dir exists
469 NSFileManager* fileManager = [NSFileManager defaultManager];
471 if (![fileManager fileExistsAtPath:applicationsDir isDirectory:&isDir] ||
476 NSString* bundlePath = [[NSBundle mainBundle] bundlePath];
477 NSString* appName = [bundlePath lastPathComponent];
479 [applicationsDir stringByAppendingPathComponent:appName];
481 // If the app (an app of the same name) is already installed we can't really
482 // tell without asking if we're dealing with the edge case of an
483 // inexperienced user running from .dmg by mistake, or if we're dealing with
484 // a more sophisticated user intentionally running from .dmg.
485 if ([fileManager fileExistsAtPath:destPath]) {
486 if (AskUserIfWeShouldLaunchExistingInstall()) {
487 StripQuarantineBit(destPath);
488 LaunchInstalledApp(destPath);
494 if (!AskUserIfWeShouldInstall()) {
498 if (!InstallFromPath(bundlePath, destPath)) {
499 ShowInstallFailedDialog();
503 LaunchInstalledApp(destPath);
508 NS_OBJC_END_TRY_BLOCK_RETURN(false);
511 } // namespace mozilla::MacRunFromDmgUtils