chromium/chrome/browser/web_applications/os_integration/mac/app_shim_launch.mm

// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "chrome/browser/web_applications/os_integration/mac/app_shim_launch.h"

#import <Cocoa/Cocoa.h>

#include <string>
#include <vector>

#include "base/apple/bundle_locations.h"
#include "base/apple/foundation_util.h"
#include "base/check.h"
#include "base/command_line.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/functional/callback_helpers.h"
#import "base/mac/launch_application.h"
#include "base/process/process.h"
#include "base/run_loop.h"
#include "base/strings/sys_string_conversions.h"
#include "base/threading/scoped_blocking_call.h"
#include "base/version_info/version_info.h"
#import "chrome/browser/web_applications/os_integration/mac/app_shim_termination_observer.h"
#include "chrome/browser/web_applications/os_integration/mac/apps_folder_support.h"
#include "chrome/browser/web_applications/os_integration/mac/web_app_shortcut_creator.h"
#include "chrome/browser/web_applications/os_integration/mac/web_app_shortcut_mac.h"
#include "chrome/browser/web_applications/os_integration/web_app_shortcut.h"
#include "chrome/common/chrome_constants.h"
#import "chrome/common/mac/app_mode_common.h"
#include "components/variations/net/variations_command_line.h"
#include "content/public/browser/browser_thread.h"
#include "url/gurl.h"

namespace web_app {

namespace {

// TODO(crbug.com/41446873): Change all launch functions to take a single
// callback that returns a NSRunningApplication, rather than separate launch and
// termination callbacks.
void RunAppLaunchCallbacks(
    NSRunningApplication* app,
    base::OnceCallback<void(base::Process)> launch_callback,
    base::OnceClosure termination_callback) {
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
  DCHECK(app);

  // If the app doesn't have a valid pid, or if the application has been
  // terminated, then indicate failure in |launch_callback|.
  base::Process process(app.processIdentifier);
  if (!process.IsValid() || app.terminated) {
    LOG(ERROR) << "Application has already been terminated.";
    std::move(launch_callback).Run(base::Process());
    return;
  }

  // Otherwise, indicate successful launch, and watch for termination.
  // TODO(crbug.com/41446873): This watches for termination indefinitely,
  // but we only need to watch for termination until the app establishes a
  // (whereupon termination will be noticed by the mojo connection closing).
  std::move(launch_callback).Run(std::move(process));
  [AppShimTerminationObserver
      startObservingForRunningApplication:app
                             withCallback:std::move(termination_callback)];
}

base::CommandLine BuildCommandLineForShimLaunch() {
  base::CommandLine command_line(base::CommandLine::NO_PROGRAM);
  command_line.AppendSwitchASCII(
      app_mode::kLaunchedByChromeProcessId,
      base::NumberToString(base::GetCurrentProcId()));
  command_line.AppendSwitchPath(app_mode::kLaunchedByChromeBundlePath,
                                base::apple::MainBundlePath());

  // When running unbundled (e.g, when running browser_tests), the path
  // returned by base::apple::FrameworkBundlePath will not include the version.
  // Manually append it.
  // https://crbug.com/1286681
  const base::FilePath framework_bundle_path =
      base::apple::AmIBundled() ? base::apple::FrameworkBundlePath()
                                : base::apple::FrameworkBundlePath()
                                      .Append("Versions")
                                      .Append(version_info::GetVersionNumber());
  command_line.AppendSwitchPath(app_mode::kLaunchedByChromeFrameworkBundlePath,
                                framework_bundle_path);
  command_line.AppendSwitchPath(
      app_mode::kLaunchedByChromeFrameworkDylibPath,
      framework_bundle_path.Append(chrome::kFrameworkExecutableName));

  return command_line;
}

NSRunningApplication* FindRunningApplicationForBundleIdAndPath(
    const std::string& bundle_id,
    const base::FilePath& bundle_path) {
  NSArray<NSRunningApplication*>* apps = [NSRunningApplication
      runningApplicationsWithBundleIdentifier:base::SysUTF8ToNSString(
                                                  bundle_id)];
  for (NSRunningApplication* app in apps) {
    if (base::apple::NSURLToFilePath(app.bundleURL) == bundle_path) {
      return app;
    }
  }

  // Sometimes runningApplicationsWithBundleIdentifier incorrectly fails to
  // return all apps with the provided bundle id. So also scan over the full
  // list of running applications.
  apps = NSWorkspace.sharedWorkspace.runningApplications;
  for (NSRunningApplication* app in apps) {
    if (base::SysNSStringToUTF8(app.bundleIdentifier) == bundle_id &&
        base::apple::NSURLToFilePath(app.bundleURL) == bundle_path) {
      return app;
    }
  }

  return nil;
}

// Wrapper around base::mac::LaunchApplication that attempts to retry the launch
// once, if the initial launch fails. This helps reduce test flakiness on older
// Mac OS bots (Mac 11).
void LaunchApplicationWithRetry(const base::FilePath& app_bundle_path,
                                const base::CommandLine& command_line,
                                const std::vector<std::string>& url_specs,
                                base::mac::LaunchApplicationOptions options,
                                base::mac::LaunchApplicationCallback callback) {
  base::mac::LaunchApplication(
      app_bundle_path, command_line, url_specs, options,
      base::BindOnce(
          [](const base::FilePath& app_bundle_path,
             const base::CommandLine& command_line,
             const std::vector<std::string>& url_specs,
             base::mac::LaunchApplicationOptions options,
             base::mac::LaunchApplicationCallback callback,
             NSRunningApplication* app, NSError* error) {
            if (app) {
              std::move(callback).Run(app, nil);
              return;
            }

            if (@available(macOS 12.0, *)) {
              // In newer Mac OS versions this workaround isn't needed, and in
              // fact can itself cause flaky tests by launching the app twice
              // when only one launch is expected.
              std::move(callback).Run(app, error);
              return;
            }

            // Only retry for the one specific error code that seems to need
            // this. Like above, retrying in all cases can otherwise itself
            // cause flaky tests.
            if (error.domain == NSCocoaErrorDomain &&
                error.code == NSFileReadCorruptFileError) {
              LOG(ERROR) << "Failed to open application with path: "
                         << app_bundle_path << ", retrying in 100ms";
              // TODO(mek): Use "current" task runner?
              internals::GetShortcutIOTaskRunner()->PostDelayedTask(
                  FROM_HERE,
                  base::BindOnce(&base::mac::LaunchApplication, app_bundle_path,
                                 command_line, url_specs, options,
                                 std::move(callback)),
                  base::Milliseconds(100));
              return;
            }
            std::move(callback).Run(nil, error);
          },
          app_bundle_path, command_line, url_specs, options,
          std::move(callback)));
}

void LaunchTheFirstShimThatWorksOnFileThread(
    std::vector<base::FilePath> shim_paths,
    bool launched_after_rebuild,
    ShimLaunchMode launch_mode,
    const std::string& bundle_id,
    ShimLaunchedCallback launched_callback,
    ShimTerminatedCallback terminated_callback) {
  base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
                                                base::BlockingType::MAY_BLOCK);

  // Avoid trying to launch known non-existent paths. This loop might
  // (technically) be O(n^2) but there will be too few paths for this to matter.
  while (!shim_paths.empty() && !base::PathExists(shim_paths.front())) {
    shim_paths.erase(shim_paths.begin());
  }
  if (shim_paths.empty()) {
    content::GetUIThreadTaskRunner({})->PostTask(
        FROM_HERE,
        base::BindOnce(std::move(launched_callback), base::Process()));
    return;
  }

  base::FilePath shim_path = shim_paths.front();
  shim_paths.erase(shim_paths.begin());

  base::CommandLine command_line = BuildCommandLineForShimLaunch();

  if (launched_after_rebuild) {
    command_line.AppendSwitch(app_mode::kLaunchedAfterRebuild);
  }

  // The shim must have the same feature parameter and field trial state as
  // Chrome, so pass this over the command line. This is not done as part of
  // `BuildcommandLineForShimLaunch`, as the other caller of that method is
  // to simulate a launch by the OS, which would not have these arguments.
  variations::VariationsCommandLine::GetForCurrentProcess().ApplyToCommandLine(
      command_line);

  LaunchApplicationWithRetry(
      shim_path, command_line, /*url_specs=*/{},
      {.activate = false,
       .hidden_in_background = launch_mode == ShimLaunchMode::kBackground},
      base::BindOnce(
          [](base::FilePath shim_path,
             std::vector<base::FilePath> remaining_shim_paths,
             bool launched_after_rebuild, ShimLaunchMode launch_mode,
             const std::string& bundle_id,
             ShimLaunchedCallback launched_callback,
             ShimTerminatedCallback terminated_callback,
             NSRunningApplication* app, NSError* error) {
            if (app) {
              RunAppLaunchCallbacks(app, std::move(launched_callback),
                                    std::move(terminated_callback));
              return;
            }

            LOG(ERROR) << "Failed to open application with path: " << shim_path;

            internals::GetShortcutIOTaskRunner()->PostTask(
                FROM_HERE,
                base::BindOnce(&LaunchTheFirstShimThatWorksOnFileThread,
                               remaining_shim_paths, launched_after_rebuild,
                               launch_mode, bundle_id,
                               std::move(launched_callback),
                               std::move(terminated_callback)));
          },
          shim_path, shim_paths, launched_after_rebuild, launch_mode, bundle_id,
          std::move(launched_callback), std::move(terminated_callback)));
}

void LaunchShimOnFileThread(LaunchShimUpdateBehavior update_behavior,
                            ShimLaunchMode launch_mode,
                            bool use_ad_hoc_signing_for_web_app_shims,
                            ShimLaunchedCallback launched_callback,
                            ShimTerminatedCallback terminated_callback,
                            std::unique_ptr<ShortcutInfo> shortcut_info) {
  base::ScopedBlockingCall scoped_blocking_call(FROM_HERE,
                                                base::BlockingType::MAY_BLOCK);

  // Recreate shims if requested, and populate |shim_paths| with the paths to
  // attempt to launch.
  bool launched_after_rebuild = false;
  std::vector<base::FilePath> shim_paths;
  bool shortcuts_updated = true;
  std::string bundle_id;

  {
    // Nested scope ensures that `shortcut_creator` is destroyed before the
    // `shortcut_info` it references.
    WebAppShortcutCreator shortcut_creator(
        internals::GetShortcutDataDir(*shortcut_info), GetChromeAppsFolder(),
        shortcut_info.get(), use_ad_hoc_signing_for_web_app_shims);

    // Recreate shims if requested, and populate |shim_paths| with the paths
    // to attempt to launch.
    switch (update_behavior) {
      case LaunchShimUpdateBehavior::kDoNotRecreate:
        // Attempt to locate the shim's path using LaunchServices.
        shim_paths = shortcut_creator.GetAppBundlesById();
        break;
      case LaunchShimUpdateBehavior::kRecreateIfInstalled:
        // Only attempt to launch shims that were updated.
        launched_after_rebuild = true;
        shortcuts_updated = shortcut_creator.UpdateShortcuts(
            /*create_if_needed=*/false, &shim_paths);
        break;
      case LaunchShimUpdateBehavior::kRecreateUnconditionally:
        // Likewise, only attempt to launch shims that were updated.
        launched_after_rebuild = true;
        shortcuts_updated = shortcut_creator.UpdateShortcuts(
            /*create_if_needed=*/true, &shim_paths);
        break;
    }
    LOG_IF(ERROR, !shortcuts_updated)
        << "Could not write shortcut for app shim.";

    bundle_id = shortcut_creator.GetAppBundleId();
  }

  // shortcut_info is no longer needed. Destroy it on the UI thread.
  content::GetUIThreadTaskRunner({})->PostTask(
      FROM_HERE, base::DoNothingWithBoundArgs(std::move(shortcut_info)));

  LaunchTheFirstShimThatWorksOnFileThread(
      shim_paths, launched_after_rebuild, launch_mode, bundle_id,
      std::move(launched_callback), std::move(terminated_callback));
}

}  // namespace

void LaunchShim(LaunchShimUpdateBehavior update_behavior,
                ShimLaunchMode launch_mode,
                ShimLaunchedCallback launched_callback,
                ShimTerminatedCallback terminated_callback,
                std::unique_ptr<ShortcutInfo> shortcut_info) {
  if (AppShimCreationAndLaunchDisabledForTest() || !shortcut_info) {
    content::GetUIThreadTaskRunner({})->PostTask(
        FROM_HERE,
        base::BindOnce(std::move(launched_callback), base::Process()));
    return;
  }

  internals::PostAsyncShortcutIOTask(
      base::BindOnce(&LaunchShimOnFileThread, update_behavior, launch_mode,
                     UseAdHocSigningForWebAppShims(),
                     std::move(launched_callback),
                     std::move(terminated_callback)),
      std::move(shortcut_info));
}

void LaunchShimForTesting(const base::FilePath& shim_path,  // IN-TEST
                          const std::vector<GURL>& urls,
                          ShimLaunchedCallback launched_callback,
                          ShimTerminatedCallback terminated_callback,
                          const base::FilePath& chromium_path) {
  base::CommandLine command_line = BuildCommandLineForShimLaunch();
  command_line.AppendSwitch(app_mode::kLaunchedForTest);
  command_line.AppendSwitch(app_mode::kIsNormalLaunch);
  command_line.AppendSwitchPath(app_mode::kLaunchChromeForTest, chromium_path);

  std::vector<std::string> url_specs;
  url_specs.reserve(urls.size());
  for (const GURL& url : urls) {
    url_specs.push_back(url.spec());
  }

  LaunchApplicationWithRetry(
      shim_path, command_line, url_specs, {.activate = false},
      base::BindOnce(
          [](const base::FilePath& shim_path,
             ShimLaunchedCallback launched_callback,
             ShimTerminatedCallback terminated_callback,
             NSRunningApplication* app, NSError* error) {
            if (error) {
              LOG(ERROR) << "Failed to open application with path: "
                         << shim_path;

              std::move(launched_callback).Run(base::Process());
              return;
            }
            RunAppLaunchCallbacks(app, std::move(launched_callback),
                                  std::move(terminated_callback));
          },
          shim_path, std::move(launched_callback),
          std::move(terminated_callback)));
}

void WaitForShimToQuitForTesting(const base::FilePath& shim_path,  // IN-TEST
                                 const std::string& app_id,
                                 bool terminate_shim) {
  std::string bundle_id = GetBundleIdentifierForShim(app_id);
  NSRunningApplication* matching_app =
      FindRunningApplicationForBundleIdAndPath(bundle_id, shim_path);
  if (!matching_app) {
    LOG(ERROR) << "No matching applications found for app_id " << app_id
               << " and path " << shim_path;
    return;
  }

  if (terminate_shim) {
    [matching_app terminate];
  }

  base::RunLoop loop;
  [AppShimTerminationObserver
      startObservingForRunningApplication:matching_app
                             withCallback:loop.QuitClosure()];
  loop.Run();
}

}  // namespace web_app