Bug 1867925 - Mark some storage-access-api tests as intermittent after wpt-sync....
[gecko.git] / toolkit / xre / MacRunFromDmgUtils.mm
blob78cf14ec6c3d1206f4b10c064db13a43e672e1b6
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>
11 #include <stdlib.h>
12 #include <string.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"
30 #include "prenv.h"
31 #include "nsString.h"
32 #ifdef MOZ_UPDATER
33 #  include "nsUpdateDriver.h"
34 #endif
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 {
42 /**
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.
48  */
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,
56   };
57   RefPtr<intl::Localization> l10n = intl::Localization::Create(resIds, true);
59   ErrorResult rv;
60   nsAutoCString mozTitle, mozMessage, mozLaunchExisting, mozLaunchFromDMG;
61   l10n->FormatValueSync("prompt-to-launch-existing-app-title"_ns, {}, mozTitle,
62                         rv);
63   if (rv.Failed()) {
64     return false;
65   }
66   l10n->FormatValueSync("prompt-to-launch-existing-app-message"_ns, {},
67                         mozMessage, rv);
68   if (rv.Failed()) {
69     return false;
70   }
71   l10n->FormatValueSync("prompt-to-launch-existing-app-yes-button"_ns, {},
72                         mozLaunchExisting, rv);
73   if (rv.Failed()) {
74     return false;
75   }
76   l10n->FormatValueSync("prompt-to-launch-existing-app-no-button"_ns, {},
77                         mozLaunchFromDMG, rv);
78   if (rv.Failed()) {
79     return false;
80   }
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];
111     [NSApp stop:nil];
112   });
114   // We need to call run on NSApp here for accessibility. See
115   // AskUserIfWeShouldInstall for a detailed explanation.
116   [NSApp run];
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.
128  */
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,
136   };
137   RefPtr<intl::Localization> l10n = intl::Localization::Create(resIds, true);
139   ErrorResult rv;
140   nsAutoCString mozTitle, mozMessage, mozInstall, mozDontInstall;
141   l10n->FormatValueSync("prompt-to-install-title"_ns, {}, mozTitle, rv);
142   if (rv.Failed()) {
143     return false;
144   }
145   l10n->FormatValueSync("prompt-to-install-message"_ns, {}, mozMessage, rv);
146   if (rv.Failed()) {
147     return false;
148   }
149   l10n->FormatValueSync("prompt-to-install-yes-button"_ns, {}, mozInstall, rv);
150   if (rv.Failed()) {
151     return false;
152   }
153   l10n->FormatValueSync("prompt-to-install-no-button"_ns, {}, mozDontInstall,
154                         rv);
155   if (rv.Failed()) {
156     return false;
157   }
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.
186   //
187   // AskUserIfWeShouldInstall
188   //          |
189   //          | ---> [NSApp run]
190   //          |         |
191   //          |         | ---> task
192   //          |         |       | ----> [alert runModal]
193   //          |         |       |               | (User selects button)
194   //          |         |       | <---------   done
195   //          |         |       |
196   //          |         |       | -----> [NSApp stop:nil]
197   //          |         |       | <-----
198   //          |         | <-----
199   //          | <-------
200   //        done
201   __block NSInteger result = -1;
202   dispatch_async(dispatch_get_main_queue(), ^{
203     result = [alert runModal];
204     [NSApp stop:nil];
205   });
207   [NSApp run];
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,
222   };
223   RefPtr<intl::Localization> l10n = intl::Localization::Create(resIds, true);
225   ErrorResult rv;
226   nsAutoCString mozTitle, mozMessage;
227   l10n->FormatValueSync("install-failed-title"_ns, {}, mozTitle, rv);
228   if (rv.Failed()) {
229     return;
230   }
231   l10n->FormatValueSync("install-failed-message"_ns, {}, mozMessage, rv);
232   if (rv.Failed()) {
233     return;
234   }
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];
250     [NSApp stop:nil];
251   });
253   // We need to call run on NSApp here for accessibility. See
254   // AskUserIfWeShouldInstall for a detailed explanation.
255   [NSApp run];
256   MOZ_ASSERT(result != -1);
257   (void)result;
259   NS_OBJC_END_TRY_IGNORE_BLOCK;
263  * Helper to launch macOS tasks via NSTask.
264  */
265 static void LaunchTask(NSString* aPath, NSArray* aArguments) {
266   NSTask* task = [[NSTask alloc] init];
267   [task setExecutableURL:[NSURL fileURLWithPath:aPath]];
268   if (aArguments) {
269     [task setArguments:aArguments];
270   }
271   [task launchAndReturnError:nil];
272   [task release];
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",
283              arguments);
286 static void StripQuarantineBit(NSString* aBundlePath) {
287   NSArray* arguments = @[ @"-d", @"com.apple.quarantine", aBundlePath ];
288   LaunchTask(@"/usr/bin/xattr", arguments);
291 #ifdef MOZ_UPDATER
292 bool LaunchElevatedDmgInstall(NSString* aBundlePath, NSArray* aArguments) {
293   NSTask* task = [[NSTask alloc] init];
294   [task setExecutableURL:[NSURL fileURLWithPath:aBundlePath]];
295   if (aArguments) {
296     [task setArguments:aArguments];
297   }
298   [task launchAndReturnError:nil];
300   bool didSucceed = InstallPrivilegedHelper();
301   [task waitUntilExit];
302   [task release];
303   if (!didSucceed) {
304     AbortElevatedUpdate();
305   }
307   return didSucceed;
309 #endif
311 // Note: both arguments are expected to contain the app name (to end with
312 // '.app').
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;
320   }
322 #ifdef MOZ_UPDATER
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
330   // necessary here.
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]
337     ]];
339     NSArray* arguments = @[ @"-dmgInstall", aBundlePath, aDestPath ];
340     LaunchElevatedDmgInstall(updaterBinPath, arguments);
341     installSuccessful = [fileManager fileExistsAtPath:aDestPath];
342   }
343 #endif
345   if (!installSuccessful) {
346     return false;
347   }
349   // Pin to dock:
350   nsresult rv;
351   nsCOMPtr<nsIMacDockSupport> dockSupport =
352       do_GetService("@mozilla.org/widget/macdocksupport;1", &rv);
353   if (NS_SUCCEEDED(rv) && dockSupport) {
354     bool isInDock;
355     nsAutoString appPath, appToReplacePath;
356     nsCocoaUtils::GetStringForNSString(aDestPath, appPath);
357     nsCocoaUtils::GetStringForNSString(aBundlePath, appToReplacePath);
358     dockSupport->EnsureAppIsPinnedToDock(appPath, appToReplacePath, &isInDock);
359   }
361   return true;
364 bool IsAppRunningFromDmg() {
365   NS_OBJC_BEGIN_TRY_BLOCK_RETURN;
367   const char* path =
368       [[[NSBundle mainBundle] bundlePath] fileSystemRepresentation];
370   struct statfs statfsBuf;
371   if (statfs(path, &statfsBuf) != 0) {
372     return false;
373   }
375   // Optimization to minimize impact on startup time:
376   if (!(statfsBuf.f_flags & MNT_RDONLY)) {
377     return false;
378   }
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) {
390       return false;
391     }
393     if (strncmp(statfsBuf.f_mntfromname, devDirPath, devDirPathLength) != 0) {
394       // It still doesn't begin with /dev/, bail out.
395       return false;
396     }
397   }
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);
404   if (!serviceDict) {
405     return false;
406   }
407   io_service_t media =
408       IOServiceGetMatchingService(kIOMasterPortDefault, serviceDict);
409   if (!media || !IOObjectConformsTo(media, "IOMedia")) {
410     return false;
411   }
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;
416   io_iterator_t iter;
417   if (IORegistryEntryCreateIterator(
418           media, kIOServicePlane,
419           kIORegistryIterateRecursively | kIORegistryIterateParents,
420           &iter) != KERN_SUCCESS) {
421     IOObjectRelease(media);
422     return false;
423   }
424   const char* imageClass = nsCocoaFeatures::OnMontereyOrLater()
425                                ? "AppleDiskImageDevice"
426                                : "IOHDIXHDDrive";
427   for (imageDrive = media; imageDrive; imageDrive = IOIteratorNext(iter)) {
428     if (IOObjectConformsTo(imageDrive, imageClass)) {
429       break;
430     }
431     IOObjectRelease(imageDrive);
432   }
433   IOObjectRelease(iter);
435   if (imageDrive) {
436     IOObjectRelease(imageDrive);
437     return true;
438   }
439   return false;
441   NS_OBJC_END_TRY_BLOCK_RETURN(false);
444 bool MaybeInstallAndRelaunch() {
445   NS_OBJC_BEGIN_TRY_BLOCK_RETURN;
447   @autoreleasepool {
448     bool isFromDmg = IsAppRunningFromDmg();
449     bool isTranslocated = false;
450     if (!isFromDmg) {
451       NSString* bundlePath = [[NSBundle mainBundle] bundlePath];
452       if ([bundlePath containsString:@"/AppTranslocation/"]) {
453         isTranslocated = true;
454       }
455     }
457     if (!isFromDmg && !isTranslocated) {
458       return false;
459     }
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];
470     BOOL isDir;
471     if (![fileManager fileExistsAtPath:applicationsDir isDirectory:&isDir] ||
472         !isDir) {
473       return false;
474     }
476     NSString* bundlePath = [[NSBundle mainBundle] bundlePath];
477     NSString* appName = [bundlePath lastPathComponent];
478     NSString* destPath =
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);
489         return true;
490       }
491       return false;
492     }
494     if (!AskUserIfWeShouldInstall()) {
495       return false;
496     }
498     if (!InstallFromPath(bundlePath, destPath)) {
499       ShowInstallFailedDialog();
500       return false;
501     }
503     LaunchInstalledApp(destPath);
505     return true;
506   }
508   NS_OBJC_END_TRY_BLOCK_RETURN(false);
511 }  // namespace mozilla::MacRunFromDmgUtils