// Copyright 2023 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. #include "base/mac/launch_application.h" #include #include "base/apple/bridging.h" #include "base/apple/foundation_util.h" #include "base/base_paths.h" #include "base/files/file_path.h" #include "base/files/file_util.h" #include "base/files/scoped_temp_dir.h" #include "base/functional/callback_helpers.h" #include "base/logging.h" #include "base/mac/mac_util.h" #include "base/path_service.h" #include "base/process/launch.h" #include "base/strings/string_util.h" #include "base/strings/sys_string_conversions.h" #include "base/task/bind_post_task.h" #include "base/task/thread_pool.h" #include "base/test/bind.h" #include "base/test/task_environment.h" #include "base/test/test_future.h" #include "base/threading/platform_thread.h" #include "base/types/expected.h" #include "base/uuid.h" #include "testing/gmock/include/gmock/gmock.h" #include "testing/gtest/include/gtest/gtest.h" #import "testing/gtest_mac.h" namespace base::mac { namespace { // Reads XML encoded property lists from `fifo_path`, calling `callback` for // each succesfully parsed dictionary. Loops indefinitely until the string // "" is read from `fifo_path`. void ReadLaunchEventsFromFifo( const FilePath& fifo_path, RepeatingCallback callback) { File f(fifo_path, File::FLAG_OPEN | File::FLAG_READ); std::string data; while (true) { char buf[4096]; int read_count = f.ReadAtCurrentPosNoBestEffort(buf, sizeof buf); if (read_count) { data += std::string(buf, read_count); // Assume that at any point the beginning of the data buffer is the start // of a plist. Search for the first end, and parse that substring. size_t end_of_plist; while ((end_of_plist = data.find("")) != std::string::npos) { std::string plist = data.substr(0, end_of_plist + 8); data = data.substr(plist.length()); NSDictionary* event = apple::ObjCCastStrict( SysUTF8ToNSString(TrimWhitespaceASCII(plist, TRIM_ALL)) .propertyList); callback.Run(event); } // No more plists found, check if the termination marker was send. if (data.find("") != std::string::npos) { break; } } else { // No data was read, wait for the file descriptor to become readable // again. fd_set fds; FD_ZERO(&fds); FD_SET(f.GetPlatformFile(), &fds); select(FD_SETSIZE, &fds, nullptr, nullptr, nullptr); } } } // This test harness creates an app bundle with a random bundle identifier to // avoid conflicts with concurrently running other tests. The binary in this app // bundle writes various events to a named pipe, allowing tests here to verify // that correct events were received by the app. class LaunchApplicationTest : public testing::Test { public: void SetUp() override { helper_bundle_id_ = SysUTF8ToNSString("org.chromium.LaunchApplicationTestHelper." + Uuid::GenerateRandomV4().AsLowercaseString()); FilePath data_root; ASSERT_TRUE(PathService::Get(DIR_OUT_TEST_DATA_ROOT, &data_root)); const FilePath helper_app_executable = data_root.AppendASCII("launch_application_test_helper"); // Put helper app inside home dir, as the default temp location gets special // treatment by launch services, effecting the behavior of some of these // tests. ASSERT_TRUE(temp_dir_.CreateUniqueTempDirUnderPath(base::GetHomeDir())); helper_app_bundle_path_ = temp_dir_.GetPath().AppendASCII("launch_application_test_helper.app"); const base::FilePath destination_contents_path = helper_app_bundle_path_.AppendASCII("Contents"); const base::FilePath destination_executable_path = destination_contents_path.AppendASCII("MacOS"); // First create the .app bundle directory structure. // Use NSFileManager so that the permissions can be set appropriately. The // base::CreateDirectory() routine forces mode 0700. NSError* error = nil; ASSERT_TRUE([NSFileManager.defaultManager createDirectoryAtURL:base::apple::FilePathToNSURL( destination_executable_path) withIntermediateDirectories:YES attributes:@{ NSFilePosixPermissions : @(0755) } error:&error]) << SysNSStringToUTF8(error.description); // Copy the executable file. helper_app_executable_path_ = destination_executable_path.Append(helper_app_executable.BaseName()); ASSERT_TRUE( base::CopyFile(helper_app_executable, helper_app_executable_path_)); // Write the PkgInfo file. constexpr char kPkgInfoData[] = "APPL????"; ASSERT_TRUE(base::WriteFile( destination_contents_path.AppendASCII("PkgInfo"), kPkgInfoData)); #if defined(ADDRESS_SANITIZER) const base::FilePath asan_library_path = data_root.AppendASCII("libclang_rt.asan_osx_dynamic.dylib"); ASSERT_TRUE(base::CopyFile( asan_library_path, destination_executable_path.Append(asan_library_path.BaseName()))); #endif // Generate the Plist file NSDictionary* plist = @{ @"CFBundleExecutable" : apple::FilePathToNSString(helper_app_executable.BaseName()), @"CFBundleIdentifier" : helper_bundle_id_, }; ASSERT_TRUE([plist writeToURL:apple::FilePathToNSURL( destination_contents_path.AppendASCII("Info.plist")) error:nil]); // Register the app with LaunchServices. LSRegisterURL(base::apple::FilePathToCFURL(helper_app_bundle_path_).get(), true); // Ensure app was registered with LaunchServices. Sometimes it takes a // little bit of time for this to happen, and some tests might fail if the // app wasn't registered yet. while (true) { NSArray* apps = nil; if (@available(macOS 12.0, *)) { apps = [[NSWorkspace sharedWorkspace] URLsForApplicationsWithBundleIdentifier:helper_bundle_id_]; } else { apps = apple::CFToNSOwnershipCast(LSCopyApplicationURLsForBundleIdentifier( apple::NSToCFPtrCast(helper_bundle_id_), /*outError=*/nullptr)); } if (apps.count > 0) { break; } PlatformThread::Sleep(Milliseconds(50)); } // Setup fifo to receive logs from the helper app. helper_app_fifo_path_ = temp_dir_.GetPath().AppendASCII("launch_application_test_helper.fifo"); ASSERT_EQ(0, mkfifo(helper_app_fifo_path_.value().c_str(), S_IWUSR | S_IRUSR | S_IWGRP | S_IWGRP)); // Create array to store received events in, and start listening for events. launch_events_ = [[NSMutableArray alloc] init]; base::ThreadPool::PostTask( FROM_HERE, {MayBlock()}, base::BindOnce( &ReadLaunchEventsFromFifo, helper_app_fifo_path_, BindPostTaskToCurrentDefault(BindRepeating( &LaunchApplicationTest::OnLaunchEvent, Unretained(this))))); } void TearDown() override { if (temp_dir_.IsValid()) { // Make sure fifo reading task stops reading/waiting. WriteFile(helper_app_fifo_path_, ""); // Make sure all apps that were launched by this test are terminated. NSArray* apps = NSWorkspace.sharedWorkspace.runningApplications; for (NSRunningApplication* app in apps) { if (temp_dir_.GetPath().IsParent( apple::NSURLToFilePath(app.bundleURL)) || [app.bundleIdentifier isEqualToString:helper_bundle_id_]) { [app forceTerminate]; } } // And make sure the temp dir was successfully deleted. EXPECT_TRUE(temp_dir_.Delete()); } } // Calls `LaunchApplication` with the given parameters, expecting the launch // to succeed. Returns the `NSRunningApplication*` the callback passed to // `LaunchApplication` was called with. NSRunningApplication* LaunchApplicationSyncExpectSuccess( const FilePath& app_bundle_path, const CommandLineArgs& command_line_args, const std::vector& url_specs, LaunchApplicationOptions options) { test::TestFuture result; LaunchApplication(app_bundle_path, command_line_args, url_specs, options, result.GetCallback()); EXPECT_FALSE(result.Get<1>()); EXPECT_TRUE(result.Get<0>()); return result.Get<0>(); } // Similar to the above method, except that this version expects the launch to // fail, returning the error. NSError* LaunchApplicationSyncExpectError( const FilePath& app_bundle_path, const CommandLineArgs& command_line_args, const std::vector& url_specs, LaunchApplicationOptions options) { test::TestFuture result; LaunchApplication(app_bundle_path, command_line_args, url_specs, options, result.GetCallback()); EXPECT_FALSE(result.Get<0>()); EXPECT_TRUE(result.Get<1>()); return result.Get<1>(); } // Waits for the total number of received launch events to reach at least // `expected_count`. void WaitForLaunchEvents(unsigned expected_count) { if (LaunchEventCount() >= expected_count) { return; } base::RunLoop loop; launch_event_callback_ = BindLambdaForTesting([&]() { if (LaunchEventCount() >= expected_count) { launch_event_callback_ = NullCallback(); loop.Quit(); } }); loop.Run(); } unsigned LaunchEventCount() { return launch_events_.count; } NSString* LaunchEventName(unsigned i) { if (i >= launch_events_.count) { return nil; } return apple::ObjCCastStrict(launch_events_[i][@"name"]); } NSDictionary* LaunchEventData(unsigned i) { if (i >= launch_events_.count) { return nil; } return apple::ObjCCastStrict(launch_events_[i][@"data"]); } protected: ScopedTempDir temp_dir_; NSString* helper_bundle_id_; FilePath helper_app_bundle_path_; FilePath helper_app_executable_path_; FilePath helper_app_fifo_path_; NSMutableArray* launch_events_; RepeatingClosure launch_event_callback_; test::TaskEnvironment task_environment_{ test::TaskEnvironment::MainThreadType::UI}; private: void OnLaunchEvent(NSDictionary* event) { NSLog(@"Event: %@", event); [launch_events_ addObject:event]; if (launch_event_callback_) { launch_event_callback_.Run(); } } }; TEST_F(LaunchApplicationTest, Basic) { std::vector command_line_args; NSRunningApplication* app = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {}, {}); ASSERT_TRUE(app); EXPECT_NSEQ(app.bundleIdentifier, helper_bundle_id_); EXPECT_EQ(apple::NSURLToFilePath(app.bundleURL), helper_app_bundle_path_); WaitForLaunchEvents(1); EXPECT_NSEQ(LaunchEventName(0), @"applicationDidFinishLaunching"); EXPECT_NSEQ(LaunchEventData(0)[@"activationPolicy"], @(NSApplicationActivationPolicyRegular)); EXPECT_EQ(app.activationPolicy, NSApplicationActivationPolicyRegular); EXPECT_NSEQ(LaunchEventData(0)[@"commandLine"], (@[ apple::FilePathToNSString(helper_app_executable_path_) ])); EXPECT_NSEQ(LaunchEventData(0)[@"processIdentifier"], @(app.processIdentifier)); } TEST_F(LaunchApplicationTest, BundleDoesntExist) { std::vector command_line_args; NSError* err = LaunchApplicationSyncExpectError( temp_dir_.GetPath().AppendASCII("notexists.app"), command_line_args, {}, {}); ASSERT_TRUE(err); err = LaunchApplicationSyncExpectError( temp_dir_.GetPath().AppendASCII("notexists.app"), command_line_args, {}, {.hidden_in_background = true}); ASSERT_TRUE(err); } TEST_F(LaunchApplicationTest, BundleCorrupt) { base::DeleteFile(helper_app_executable_path_); std::vector command_line_args; NSError* err = LaunchApplicationSyncExpectError(helper_app_bundle_path_, command_line_args, {}, {}); ASSERT_TRUE(err); err = LaunchApplicationSyncExpectError(helper_app_bundle_path_, command_line_args, {}, {.hidden_in_background = true}); ASSERT_TRUE(err); } TEST_F(LaunchApplicationTest, CommandLineArgs_StringVector) { std::vector command_line_args = {"--foo", "bar", "-v"}; NSRunningApplication* app = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {}, {}); ASSERT_TRUE(app); WaitForLaunchEvents(1); EXPECT_NSEQ(LaunchEventName(0), @"applicationDidFinishLaunching"); EXPECT_NSEQ(LaunchEventData(0)[@"commandLine"], (@[ apple::FilePathToNSString(helper_app_executable_path_), @"--foo", @"bar", @"-v" ])); } TEST_F(LaunchApplicationTest, CommandLineArgs_BaseCommandLine) { CommandLine command_line(CommandLine::NO_PROGRAM); command_line.AppendSwitchASCII("foo", "bar"); command_line.AppendSwitch("v"); command_line.AppendSwitchPath("path", FilePath("/tmp")); NSRunningApplication* app = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line, {}, {}); ASSERT_TRUE(app); WaitForLaunchEvents(1); EXPECT_NSEQ(LaunchEventName(0), @"applicationDidFinishLaunching"); EXPECT_NSEQ(LaunchEventData(0)[@"commandLine"], (@[ apple::FilePathToNSString(helper_app_executable_path_), @"--foo=bar", @"--v", @"--path=/tmp" ])); } TEST_F(LaunchApplicationTest, UrlSpecs) { std::vector command_line_args; std::vector urls = {"https://example.com", "x-chrome-launch://1"}; NSRunningApplication* app = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, urls, {}); ASSERT_TRUE(app); WaitForLaunchEvents(3); EXPECT_NSEQ(LaunchEventName(0), @"openURLs"); EXPECT_NSEQ(LaunchEventName(1), @"applicationDidFinishLaunching"); EXPECT_NSEQ(LaunchEventName(2), @"openURLs"); if (MacOSMajorVersion() == 11) { // macOS 11 (and only macOS 11) appears to sometimes trigger the openURLs // calls in reverse order. std::vector received_urls; for (NSString* url in apple::ObjCCastStrict( LaunchEventData(0)[@"urls"])) { received_urls.push_back(SysNSStringToUTF8(url)); } EXPECT_EQ(received_urls.size(), 1u); for (NSString* url in apple::ObjCCastStrict( LaunchEventData(2)[@"urls"])) { received_urls.push_back(SysNSStringToUTF8(url)); } EXPECT_THAT(received_urls, testing::UnorderedElementsAreArray(urls)); } else { EXPECT_NSEQ(LaunchEventData(0)[@"urls"], @[ @"https://example.com" ]); EXPECT_NSEQ(LaunchEventData(2)[@"urls"], @[ @"x-chrome-launch://1" ]); } } TEST_F(LaunchApplicationTest, CreateNewInstance) { std::vector command_line_args; NSRunningApplication* app1 = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {}, {.create_new_instance = false}); WaitForLaunchEvents(1); EXPECT_NSEQ(LaunchEventName(0), @"applicationDidFinishLaunching"); EXPECT_NSEQ(LaunchEventData(0)[@"processIdentifier"], @(app1.processIdentifier)); NSRunningApplication* app2 = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {"x-chrome-launch://0"}, {.create_new_instance = false}); EXPECT_NSEQ(app1, app2); EXPECT_EQ(app1.processIdentifier, app2.processIdentifier); WaitForLaunchEvents(2); EXPECT_NSEQ(LaunchEventName(1), @"openURLs"); EXPECT_NSEQ(LaunchEventData(1)[@"processIdentifier"], @(app2.processIdentifier)); NSRunningApplication* app3 = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {"x-chrome-launch://1"}, {.create_new_instance = true}); EXPECT_NSNE(app1, app3); EXPECT_NE(app1.processIdentifier, app3.processIdentifier); WaitForLaunchEvents(4); EXPECT_NSEQ(LaunchEventName(2), @"openURLs"); EXPECT_NSEQ(LaunchEventName(3), @"applicationDidFinishLaunching"); EXPECT_NSEQ(LaunchEventData(3)[@"processIdentifier"], @(app3.processIdentifier)); } TEST_F(LaunchApplicationTest, HiddenInBackground) { std::vector command_line_args = {"--test", "--foo"}; NSRunningApplication* app = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {}, {.hidden_in_background = true}); ASSERT_TRUE(app); EXPECT_NSEQ(app.bundleIdentifier, helper_bundle_id_); EXPECT_EQ(helper_app_bundle_path_, apple::NSURLToFilePath(app.bundleURL)); WaitForLaunchEvents(1); EXPECT_NSEQ(LaunchEventName(0), @"applicationDidFinishLaunching"); EXPECT_NSEQ(LaunchEventData(0)[@"activationPolicy"], @(NSApplicationActivationPolicyProhibited)); EXPECT_EQ(app.activationPolicy, NSApplicationActivationPolicyProhibited); EXPECT_NSEQ(LaunchEventData(0)[@"commandLine"], (@[ apple::FilePathToNSString(helper_app_executable_path_), @"--test", @"--foo" ])); EXPECT_NSEQ(LaunchEventData(0)[@"processIdentifier"], @(app.processIdentifier)); NSRunningApplication* app2 = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {}, {.create_new_instance = false, .hidden_in_background = true}); EXPECT_NSEQ(app, app2); EXPECT_EQ(app.processIdentifier, app2.processIdentifier); EXPECT_EQ(app.activationPolicy, NSApplicationActivationPolicyProhibited); EXPECT_EQ(app2.activationPolicy, NSApplicationActivationPolicyProhibited); // Launching without opening anything should not trigger any launch events. // Opening a URL in a new instance, should leave both instances in the // background. NSRunningApplication* app3 = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {"x-chrome-launch://2"}, {.create_new_instance = true, .hidden_in_background = true}); EXPECT_NSNE(app, app3); EXPECT_NE(app.processIdentifier, app3.processIdentifier); WaitForLaunchEvents(3); EXPECT_NSEQ(LaunchEventName(1), @"openURLs"); EXPECT_NSEQ(LaunchEventName(2), @"applicationDidFinishLaunching"); EXPECT_NSEQ(LaunchEventData(2)[@"processIdentifier"], @(app3.processIdentifier)); EXPECT_EQ(app.activationPolicy, NSApplicationActivationPolicyProhibited); EXPECT_EQ(app2.activationPolicy, NSApplicationActivationPolicyProhibited); EXPECT_EQ(app3.activationPolicy, NSApplicationActivationPolicyProhibited); } TEST_F(LaunchApplicationTest, HiddenInBackground_OpenUrlChangesActivationPolicy) { std::vector command_line_args = {"--test", "--foo"}; NSRunningApplication* app = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {}, {.hidden_in_background = true}); ASSERT_TRUE(app); EXPECT_NSEQ(app.bundleIdentifier, helper_bundle_id_); EXPECT_EQ(helper_app_bundle_path_, apple::NSURLToFilePath(app.bundleURL)); WaitForLaunchEvents(1); EXPECT_NSEQ(LaunchEventName(0), @"applicationDidFinishLaunching"); EXPECT_NSEQ(LaunchEventData(0)[@"activationPolicy"], @(NSApplicationActivationPolicyProhibited)); EXPECT_EQ(app.activationPolicy, NSApplicationActivationPolicyProhibited); EXPECT_NSEQ(LaunchEventData(0)[@"commandLine"], (@[ apple::FilePathToNSString(helper_app_executable_path_), @"--test", @"--foo" ])); EXPECT_NSEQ(LaunchEventData(0)[@"processIdentifier"], @(app.processIdentifier)); NSRunningApplication* app2 = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {"chrome://app-launch/0"}, {.create_new_instance = false, .hidden_in_background = true}); EXPECT_NSEQ(app, app2); EXPECT_EQ(app.processIdentifier, app2.processIdentifier); // Unexpected to me, but opening a URL seems to always change the activation // policy. EXPECT_EQ(app.activationPolicy, NSApplicationActivationPolicyRegular); EXPECT_EQ(app2.activationPolicy, NSApplicationActivationPolicyRegular); WaitForLaunchEvents(3); EXPECT_THAT( std::vector({SysNSStringToUTF8(LaunchEventName(1)), SysNSStringToUTF8(LaunchEventName(2))}), testing::UnorderedElementsAre("activationPolicyChanged", "openURLs")); } TEST_F(LaunchApplicationTest, HiddenInBackground_TransitionToForeground) { std::vector command_line_args; NSRunningApplication* app = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {"x-chrome-launch://1"}, {.hidden_in_background = true}); ASSERT_TRUE(app); WaitForLaunchEvents(2); EXPECT_NSEQ(LaunchEventName(0), @"openURLs"); EXPECT_NSEQ(LaunchEventName(1), @"applicationDidFinishLaunching"); EXPECT_NSEQ(LaunchEventData(1)[@"activationPolicy"], @(NSApplicationActivationPolicyProhibited)); EXPECT_EQ(app.activationPolicy, NSApplicationActivationPolicyProhibited); EXPECT_NSEQ(LaunchEventData(1)[@"processIdentifier"], @(app.processIdentifier)); // Second launch with hidden_in_background set to false should cause the first // app to switch activation policy. NSRunningApplication* app2 = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {}, {.hidden_in_background = false}); EXPECT_NSEQ(app, app2); WaitForLaunchEvents(3); EXPECT_NSEQ(LaunchEventName(2), @"activationPolicyChanged"); EXPECT_NSEQ(LaunchEventData(2)[@"activationPolicy"], @(NSApplicationActivationPolicyRegular)); EXPECT_EQ(app2.activationPolicy, NSApplicationActivationPolicyRegular); } TEST_F(LaunchApplicationTest, HiddenInBackground_AlreadyInForeground) { std::vector command_line_args; NSRunningApplication* app = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {"x-chrome-launch://1"}, {.hidden_in_background = false}); ASSERT_TRUE(app); WaitForLaunchEvents(2); EXPECT_NSEQ(LaunchEventName(0), @"openURLs"); EXPECT_NSEQ(LaunchEventName(1), @"applicationDidFinishLaunching"); EXPECT_NSEQ(LaunchEventData(1)[@"activationPolicy"], @(NSApplicationActivationPolicyRegular)); EXPECT_EQ(app.activationPolicy, NSApplicationActivationPolicyRegular); EXPECT_NSEQ(LaunchEventData(1)[@"processIdentifier"], @(app.processIdentifier)); // Second (and third) launch with hidden_in_background set to true should // reuse the existing app and keep it visible. NSRunningApplication* app2 = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {}, {.hidden_in_background = true}); EXPECT_NSEQ(app, app2); EXPECT_EQ(app2.activationPolicy, NSApplicationActivationPolicyRegular); NSRunningApplication* app3 = LaunchApplicationSyncExpectSuccess( helper_app_bundle_path_, command_line_args, {"x-chrome-launch://23"}, {.hidden_in_background = true}); EXPECT_NSEQ(app, app3); WaitForLaunchEvents(3); EXPECT_NSEQ(LaunchEventName(2), @"openURLs"); EXPECT_NSEQ(LaunchEventData(2)[@"processIdentifier"], @(app.processIdentifier)); EXPECT_EQ(app3.activationPolicy, NSApplicationActivationPolicyRegular); } } // namespace } // namespace base::mac