// 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.
#ifdef UNSAFE_BUFFERS_BUILD
// TODO(crbug.com/40285824): Remove this and convert code to safer constructs.
#pragma allow_unsafe_buffers
#endif
#import "chrome/browser/web_applications/os_integration/mac/web_app_shortcut_creator.h"
#import <Cocoa/Cocoa.h>
#include <stdint.h>
#include <algorithm>
#include <optional>
#include <string>
#include <vector>
#include "base/apple/bridging.h"
#include "base/apple/bundle_locations.h"
#include "base/apple/foundation_util.h"
#include "base/check.h"
#include "base/check_is_test.h"
#include "base/command_line.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/files/safe_base_name.h"
#include "base/files/scoped_temp_dir.h"
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/logging.h"
#include "base/mac/mac_util.h"
#include "base/metrics/histogram_functions.h"
#include "base/process/launch.h"
#include "base/strings/sys_string_conversions.h"
#include "base/strings/utf_string_conversions.h"
#include "base/synchronization/lock.h"
#include "base/version_info/version_info.h"
#include "chrome/browser/shortcuts/platform_util_mac.h"
#include "chrome/browser/web_applications/os_integration/mac/bundle_info_plist.h"
#include "chrome/browser/web_applications/os_integration/mac/icns_encoder.h"
#include "chrome/browser/web_applications/os_integration/mac/icon_utils.h"
#include "chrome/browser/web_applications/os_integration/mac/web_app_auto_login_util.h"
#include "chrome/browser/web_applications/os_integration/mac/web_app_shortcut_mac.h"
#include "chrome/browser/web_applications/os_integration/os_integration_test_override.h"
#import "chrome/common/mac/app_mode_common.h"
#include "content/public/browser/browser_thread.h"
#include "content/public/common/content_switches.h"
#if defined(COMPONENT_BUILD)
#include <mach-o/loader.h>
#include "base/base_paths.h"
#include "base/bits.h"
#include "base/path_service.h"
#endif
// <https://github.com/apple-oss-distributions/Security/blob/Security-60420.101.4/OSX/libsecurity_codesigning/lib/SecCodeSigner.h>
extern "C" {
extern const CFStringRef kSecCodeSignerFlags;
extern const CFStringRef kSecCodeSignerIdentity;
extern const CFStringRef kSecCodeSignerEntitlements;
const uint32_t kSecCodeMagicEntitlement = 0xfade7171;
typedef struct __SecCodeSigner* SecCodeSignerRef;
OSStatus SecCodeSignerCreate(CFDictionaryRef parameters,
SecCSFlags flags,
SecCodeSignerRef* signer);
OSStatus SecCodeSignerAddSignatureWithErrors(SecCodeSignerRef signer,
SecStaticCodeRef code,
SecCSFlags flags,
CFErrorRef* errors);
// Key used within CoreFoundation for loaded Info plists
extern const CFStringRef _kCFBundleNumericVersionKey;
} // extern "C"
namespace web_app {
BASE_FEATURE(kWebAppMaskableIconsOnMac,
"WebAppMaskableIconsOnMac",
base::FEATURE_ENABLED_BY_DEFAULT);
namespace {
// Result of creating app shortcut.
// These values are persisted to logs. Entries should not be renumbered and
// numeric values should never be reused.
enum class CreateShortcutResult {
kSuccess = 0,
kApplicationDirNotFound = 1,
// Obsolete: kFailToLocalizeApplication = 2,
// Obsolete: kFailToGetApplicationPaths = 3,
kFailToCreateTempDir = 4,
kStagingDirectoryNotExist = 5,
kFailToCreateExecutablePath = 6,
kFailToCopyExecutablePath = 7,
kFailToCopyPlist = 8,
kFailToWritePkgInfoFile = 9,
kFailToUpdatePlist = 10,
kFailToUpdateDisplayName = 11,
kFailToUpdateIcon = 12,
kFailToCreateParentDir = 13,
kFailToCopyApp = 14,
kFailToSign = 15,
kMaxValue = kFailToSign,
};
// Records the result of creating shortcut to UMA.
void RecordCreateShortcut(CreateShortcutResult result) {
base::UmaHistogramEnumeration("Apps.CreateShortcuts.Mac.Result2", result);
}
#if defined(COMPONENT_BUILD)
// Adds `new_rpath` to the paths the binary at `executable_path` will look at
// when loading shared libraries. Assumes there is enough room in the headers of
// the binary to fit the added path.
bool AddPathToRPath(const base::FilePath& executable_path,
const base::FilePath& new_rpath) {
rpath_command new_rpath_command;
new_rpath_command.cmd = LC_RPATH;
// Size is size of the command struct + size of the path + a null terminator,
// all rounded up to a multiple of 8 bytes.
new_rpath_command.cmdsize = base::bits::AlignUp<uint32_t>(
sizeof new_rpath_command + new_rpath.value().size() + 1, 8);
new_rpath_command.path.offset = sizeof new_rpath_command;
base::File executable_file(executable_path, base::File::FLAG_OPEN |
base::File::FLAG_WRITE |
base::File::FLAG_READ);
if (!executable_file.IsValid()) {
LOG(ERROR) << "Failed to open executable file at: " << executable_path
<< ", error: " << executable_file.error_details();
return false;
}
mach_header_64 header;
if (!executable_file.ReadAtCurrentPosAndCheck(
base::as_writable_bytes(base::span_from_ref(header))) ||
header.magic != MH_MAGIC_64 || header.filetype != MH_EXECUTE) {
LOG(ERROR) << "File at " << executable_path
<< " is not a valid Mach-O executable";
return false;
}
// Read existing load commands.
std::vector<uint8_t> commands(header.sizeofcmds);
if (!executable_file.ReadAtCurrentPosAndCheck(base::make_span(commands))) {
LOG(ERROR) << "Failed to read load commands from " << executable_path;
return false;
}
// Scan over the commands, finding the first LC_RPATH command. We'll insert
// our new command right after it.
auto commands_it = commands.begin();
for (unsigned i = 0; i < header.ncmds; ++i) {
load_command cmd;
if (commands.end() - commands_it < int{sizeof cmd}) {
LOG(ERROR) << "Reached end of commands before getting all commands";
return false;
}
memcpy(&cmd, &*commands_it, sizeof cmd);
if (commands.end() - commands_it < cmd.cmdsize) {
LOG(ERROR) << "Command ends past the end of the load commands";
return false;
}
commands_it += cmd.cmdsize;
if (cmd.cmd == LC_RPATH) {
// Insert the new command, padding the extra space with `0` bytes.
auto it = commands.insert(commands_it, new_rpath_command.cmdsize, 0);
memcpy(&*it, &new_rpath_command, sizeof new_rpath_command);
memcpy(&*it + sizeof new_rpath_command, new_rpath.value().data(),
new_rpath.value().size());
header.ncmds++;
header.sizeofcmds += new_rpath_command.cmdsize;
// Write the updated header and commands back to the file.
if (!executable_file.WriteAndCheck(0, base::byte_span_from_ref(header)) ||
!executable_file.WriteAndCheck(sizeof header,
base::make_span(commands))) {
LOG(ERROR) << "Failed to write updated load commands to "
<< executable_path;
return false;
}
executable_file.Close();
// And finally re-sign the resulting binary.
std::string codesign_output;
std::vector<std::string> codesign_argv = {"codesign", "--force", "--sign",
"-", executable_path.value()};
if (!base::GetAppOutputAndError(base::CommandLine(codesign_argv),
&codesign_output)) {
LOG(ERROR) << "Failed to sign executable at " << executable_path << ": "
<< codesign_output;
return false;
}
return true;
}
}
LOG(ERROR) << "Did not find any LC_RPATH commands in " << executable_path;
return false;
}
#endif
// Returns a reference to the static UpdateShortcuts lock.
// See https://crbug.com/1090548 for more info.
base::Lock& GetUpdateShortcutsLock() {
static base::NoDestructor<base::Lock> lock;
return *lock;
}
bool AppShimRevealDisabledForTest() {
// Disable app shim reveal in the Finder during tests, to avoid
// creating Finder windows that are never closed.
return base::CommandLine::ForCurrentProcess()->HasSwitch(
switches::kTestType) ||
OsIntegrationTestOverride::Get();
}
bool CopyStagingBundleToDestination(bool use_ad_hoc_signing_for_web_app_shims,
base::FilePath staging_path,
base::FilePath dst_app_path) {
if (!use_ad_hoc_signing_for_web_app_shims) {
return base::CopyDirectory(staging_path, dst_app_path, true);
}
// When using ad-hoc signing for web app shims, the final app shim must be
// written to disk by a separate helper tool. This helper tool is used
// so that binary authorization tools, such as Santa, can transitively trust
// app shims that it creates without trusting all files written by Chrome.
// This allows app shims to be trusted by the binary authorization tool
// despite having only ad-hoc code signatures.
base::FilePath web_app_shortcut_copier_path =
base::apple::FrameworkBundlePath().Append("Helpers").Append(
"web_app_shortcut_copier");
base::CommandLine command_line(web_app_shortcut_copier_path);
command_line.AppendArgPath(staging_path);
command_line.AppendArgPath(dst_app_path);
// Pass NSBundle's cached copy of the app's Info.plist data to the helper tool
// for use in dynamic signature validation. The data is validated against a
// hash recorded in the code signature before being used during requirement
// validation. NSBundle's cached copy is used to ensure that any changes to
// Info.plist on disk due to pending updates do not result in a version of the
// data being used that doesn't match the code signature of the running app.
NSMutableDictionary* info_plist_dictionary =
[base::apple::OuterBundle().infoDictionary mutableCopy];
// NSBundle inserts CFBundleNumericVersion into its in-memory copy of the info
// dictionary despite it not being present on disk. Remove it so that the
// serialized dictionary matches the Info.plist that was present at signing
// time.
info_plist_dictionary[base::apple::CFToNSPtrCast(
_kCFBundleNumericVersionKey)] = nil;
NS_VALID_UNTIL_END_OF_SCOPE NSData* info_plist_xml_data =
[NSPropertyListSerialization
dataWithPropertyList:info_plist_dictionary
format:NSPropertyListXMLFormat_v1_0
options:0
error:nullptr];
command_line.AppendArg(
std::string_view(static_cast<const char*>(info_plist_xml_data.bytes),
info_plist_xml_data.length));
// Synchronously wait for the copy to complete to match the semantics of
// `base::CopyDirectory`.
std::string command_output;
int exit_code;
if (base::GetAppOutputWithExitCode(command_line, &command_output,
&exit_code)) {
return !exit_code;
}
return false;
}
// Remove the leading . from the entries of |extensions|. Any items that do not
// have a leading . are removed.
std::set<std::string> GetFileHandlerExtensionsWithoutDot(
const std::set<std::string>& file_extensions) {
std::set<std::string> result;
for (const auto& file_extension : file_extensions) {
if (file_extension.length() <= 1 || file_extension[0] != '.') {
continue;
}
result.insert(file_extension.substr(1));
}
return result;
}
// Given the path to an app bundle, return the resources directory.
base::FilePath GetResourcesPath(const base::FilePath& app_path) {
return app_path.Append("Contents").Append("Resources");
}
// Given the path to an app bundle, return the URL of the Info.plist file.
NSURL* GetPlistURL(const base::FilePath& bundle_path) {
return base::apple::FilePathToNSURL(
bundle_path.Append("Contents").Append("Info.plist"));
}
bool HasExistingExtensionShimForDifferentProfile(
const base::FilePath& destination_directory,
const std::string& extension_id,
const base::FilePath& profile_dir) {
std::list<BundleInfoPlist> bundles_info =
BundleInfoPlist::GetAllInPath(destination_directory, /*recursive=*/false);
for (const auto& info : bundles_info) {
if (info.GetExtensionId() == extension_id &&
!info.IsForProfile(profile_dir)) {
return true;
}
}
return false;
}
base::FilePath GetMultiProfileAppDataDir(base::FilePath app_data_dir) {
// The kCrAppModeUserDataDirKey is expected to be a path in kWebAppDirname,
// and the true user data dir is extracted by going three directories up.
// For profile-agnostic apps, remove this reference to the profile name.
// TODO(crbug.com/40656955): Do not specify kCrAppModeUserDataDirKey
// if Chrome is using the default user data dir.
// Strip the app name directory.
base::FilePath app_name_dir = app_data_dir.BaseName();
app_data_dir = app_data_dir.DirName();
// Strip kWebAppDirname.
base::FilePath web_app_dir = app_data_dir.BaseName();
app_data_dir = app_data_dir.DirName();
// Strip the profile and replace it with kNewProfilePath.
app_data_dir = app_data_dir.DirName();
const std::string kNewProfilePath("-");
return app_data_dir.Append(kNewProfilePath)
.Append(web_app_dir)
.Append(app_name_dir);
}
NSData* AppShimEntitlements() {
// Entitlement data to disable library validation with the hardened runtime.
// The first 8 bytes of the entitlement data consists of two 32-bit values:
// a magic constant and the length of the data. They are populated below.
char entitlement_bytes[] =
R"xml(12345678<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
)xml";
// The magic constant and length are expected to be big endian.
uint32_t* entitlement_header = reinterpret_cast<uint32_t*>(entitlement_bytes);
entitlement_header[0] = CFSwapInt32HostToBig(kSecCodeMagicEntitlement);
entitlement_header[1] = CFSwapInt32HostToBig(sizeof(entitlement_bytes) - 1);
return [NSData dataWithBytes:static_cast<void*>(entitlement_bytes)
length:sizeof(entitlement_bytes) - 1];
}
} // namespace
WebAppShortcutCreator::WebAppShortcutCreator(
const base::FilePath& app_data_dir,
const base::FilePath& chrome_apps_dir,
const ShortcutInfo* shortcut_info,
bool use_ad_hoc_signing_for_web_app_shims)
: app_data_dir_(app_data_dir),
chrome_apps_dir_(chrome_apps_dir),
info_(shortcut_info),
use_ad_hoc_signing_for_web_app_shims_(
use_ad_hoc_signing_for_web_app_shims) {
DCHECK(shortcut_info);
}
WebAppShortcutCreator::~WebAppShortcutCreator() = default;
base::FilePath WebAppShortcutCreator::GetShortcutBasename() const {
// For profile-less shortcuts, use the fallback naming scheme to avoid change.
if (info_->profile_name.empty()) {
return GetFallbackBasename();
}
std::u16string title = info_->title;
std::optional<base::SafeBaseName> base_name =
shortcuts::SanitizeTitleForFileName(base::UTF16ToUTF8(info_->title));
if (!base_name.has_value()) {
return GetFallbackBasename();
}
return base_name->path().AddExtension(".app");
}
base::FilePath WebAppShortcutCreator::GetFallbackBasename() const {
std::string app_name;
// Check if there should be a separate shortcut made for different profiles.
// Such shortcuts will have a |profile_name| set on the ShortcutInfo,
// otherwise it will be empty.
if (!info_->profile_name.empty()) {
app_name += info_->profile_path.BaseName().value();
app_name += ' ';
}
app_name += info_->app_id;
return base::FilePath(app_name).ReplaceExtension("app");
}
base::FilePath WebAppShortcutCreator::GetApplicationsShortcutPath(
bool avoid_conflicts) const {
base::FilePath applications_dir = chrome_apps_dir_;
if (applications_dir.empty()) {
return base::FilePath();
}
if (!avoid_conflicts) {
return applications_dir.Append(GetShortcutBasename());
}
// Attempt to use the application's title for the file name.
base::FilePath path = base::GetUniquePathWithSuffixFormat(
applications_dir.Append(GetShortcutBasename()), " %d");
if (!path.empty()) {
return path;
}
// If all of those are taken, then use the combination of profile and
// extension id.
return applications_dir.Append(GetFallbackBasename());
}
std::vector<base::FilePath> WebAppShortcutCreator::GetAppBundlesById() const {
std::vector<base::FilePath> paths = GetAppBundlesByIdUnsorted();
// Sort the matches by preference.
base::FilePath default_path =
GetApplicationsShortcutPath(/*avoid_conflicts=*/false);
base::FilePath apps_dir = chrome_apps_dir_;
auto compare = [default_path, apps_dir](const base::FilePath& a,
const base::FilePath& b) {
if (a == b) {
return false;
}
// The default install path is preferred above all others.
if (a == default_path) {
return true;
}
if (b == default_path) {
return false;
}
// Paths in ~/Applications are preferred to paths not in ~/Applications.
bool a_in_apps_dir = apps_dir.IsParent(a);
bool b_in_apps_dir = apps_dir.IsParent(b);
if (a_in_apps_dir != b_in_apps_dir) {
return a_in_apps_dir > b_in_apps_dir;
}
return a < b;
};
std::sort(paths.begin(), paths.end(), compare);
return paths;
}
std::string WebAppShortcutCreator::GetAppBundleId() const {
return GetBundleIdentifierForShim(
info_->app_id, IsMultiProfile() ? base::FilePath() : info_->profile_path);
}
bool WebAppShortcutCreator::CreateShortcuts(
ShortcutCreationReason creation_reason,
ShortcutLocations creation_locations) {
DCHECK_NE(creation_locations.applications_menu_location,
APP_MENU_LOCATION_HIDDEN);
std::vector<base::FilePath> updated_app_paths;
if (!UpdateShortcuts(/*create_if_needed=*/true, &updated_app_paths)) {
return false;
}
if (creation_locations.in_startup) {
// Only add the first app to run at OS login.
WebAppAutoLoginUtil::GetInstance()->AddToLoginItems(updated_app_paths[0],
false);
}
if (creation_reason == SHORTCUT_CREATION_BY_USER) {
RevealAppShimInFinder(updated_app_paths[0]);
}
RecordCreateShortcut(CreateShortcutResult::kSuccess);
return true;
}
bool WebAppShortcutCreator::UpdateShortcuts(
bool create_if_needed,
std::vector<base::FilePath>* updated_paths) {
DCHECK(updated_paths && updated_paths->empty());
if (create_if_needed) {
const base::FilePath applications_dir = chrome_apps_dir_;
if (applications_dir.empty() ||
!base::DirectoryExists(applications_dir.DirName())) {
RecordCreateShortcut(CreateShortcutResult::kApplicationDirNotFound);
LOG(ERROR) << "Couldn't find an Applications directory to copy app to.";
return false;
}
}
// Acquire the UpdateShortcuts lock. This ensures only a single
// UpdateShortcuts call at a time will run at once past here. Not
// protecting against that can result in multiple CreateShortcutsAt()
// calls deleting and creating the app shim folder at once.
// See https://crbug.com/1090548 for more info.
base::AutoLock auto_lock(GetUpdateShortcutsLock());
// Get the list of paths to (re)create by bundle id (wherever it was moved
// or copied by the user).
std::vector<base::FilePath> app_paths = GetAppBundlesById();
// If that path does not exist, create a new entry in ~/Applications if
// requested.
if (app_paths.empty() && create_if_needed) {
app_paths.push_back(GetApplicationsShortcutPath(/*avoid_conflicts=*/true));
}
if (app_paths.empty()) {
// If `create_if_needed` is false, we've succesfully updated shortcuts if no
// shortcuts have been found.
return true;
}
CreateShortcutsAt(app_paths, updated_paths);
return updated_paths->size() == app_paths.size();
}
void WebAppShortcutCreator::RevealAppShimInFinder(
const base::FilePath& app_path) const {
auto closure = base::BindOnce(
[](const base::FilePath& app_path) {
// The Finder creates a new window each time the app shim is revealed.
// Skip revealing the app shim during testing to avoid an avalanche of
// new Finder windows.
if (AppShimRevealDisabledForTest()) {
return;
}
NSURL* path_url = base::apple::FilePathToNSURL(app_path);
[[NSWorkspace sharedWorkspace]
activateFileViewerSelectingURLs:@[ path_url ]];
},
app_path);
// Perform the call to NSWorkspace on the UI thread. Calling it on the IO
// thread appears to cause crashes.
// https://crbug.com/1067367
content::GetUIThreadTaskRunner({})->PostTask(FROM_HERE, std::move(closure));
}
std::vector<base::FilePath> WebAppShortcutCreator::GetAppBundlesByIdUnsorted()
const {
// Search using LaunchServices using the default bundle id.
const std::string bundle_id = GetBundleIdentifierForShim(
info_->app_id, IsMultiProfile() ? base::FilePath() : info_->profile_path);
auto bundle_infos =
BundleInfoPlist::SearchForBundlesById(bundle_id, chrome_apps_dir_);
// If in multi-profile mode, search using the profile-scoped bundle id, in
// case the user has an old shim hanging around.
if (bundle_infos.empty() && IsMultiProfile()) {
const std::string profile_scoped_bundle_id =
GetBundleIdentifierForShim(info_->app_id, info_->profile_path);
bundle_infos = BundleInfoPlist::SearchForBundlesById(
profile_scoped_bundle_id, chrome_apps_dir_);
}
std::vector<base::FilePath> bundle_paths;
for (const auto& bundle_info : bundle_infos) {
bundle_paths.push_back(bundle_info.bundle_path());
}
return bundle_paths;
}
bool WebAppShortcutCreator::IsMultiProfile() const {
return info_->is_multi_profile;
}
bool WebAppShortcutCreator::BuildShortcut(
const base::FilePath& staging_path) const {
if (!base::DirectoryExists(staging_path.DirName())) {
RecordCreateShortcut(CreateShortcutResult::kStagingDirectoryNotExist);
LOG(ERROR) << "Staging path directory does not exist: "
<< staging_path.DirName();
return false;
}
const base::FilePath framework_bundle_path =
base::apple::FrameworkBundlePath();
const base::FilePath executable_path =
framework_bundle_path.Append("Helpers").Append("app_mode_loader");
const base::FilePath plist_path =
framework_bundle_path.Append("Resources").Append("app_mode-Info.plist");
const base::FilePath destination_contents_path =
staging_path.Append("Contents");
const base::FilePath destination_executable_path =
destination_contents_path.Append("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;
if (![NSFileManager.defaultManager
createDirectoryAtURL:base::apple::FilePathToNSURL(
destination_executable_path)
withIntermediateDirectories:YES
attributes:@{
NSFilePosixPermissions : @(0755)
}
error:&error]) {
RecordCreateShortcut(CreateShortcutResult::kFailToCreateExecutablePath);
LOG(ERROR) << "Failed to create destination executable path: "
<< destination_executable_path
<< ", error=" << base::SysNSStringToUTF8([error description]);
return false;
}
// Copy the executable file.
if (!base::CopyFile(executable_path, destination_executable_path.Append(
executable_path.BaseName()))) {
RecordCreateShortcut(CreateShortcutResult::kFailToCopyExecutablePath);
LOG(ERROR) << "Failed to copy executable: " << executable_path;
return false;
}
#if defined(COMPONENT_BUILD)
// Test bots could have the build in a different path than where it was on a
// build bot. If this is the case in a component build, we'll need to fix the
// rpath of app_mode_loader to make sure it can still find its dynamic
// libraries.
base::FilePath rpath_to_add;
if (!base::PathService::Get(base::DIR_MODULE, &rpath_to_add)) {
LOG(ERROR) << "Failed to get module path";
return false;
}
if (!AddPathToRPath(
destination_executable_path.Append(executable_path.BaseName()),
rpath_to_add)) {
return false;
}
#endif
#if defined(ADDRESS_SANITIZER)
const base::FilePath asan_library_path =
framework_bundle_path.Append("Versions")
.Append("Current")
.Append("libclang_rt.asan_osx_dynamic.dylib");
if (!base::CopyFile(asan_library_path, destination_executable_path.Append(
asan_library_path.BaseName()))) {
LOG(ERROR) << "Failed to copy asan library: " << asan_library_path;
return false;
}
// The address sanitizer runtime must have a valid signature in order for the
// containing app bundle to be signed. On Apple Silicon the address sanitizer
// runtime library has a linker-generated ad-hoc code signature, but this is
// treated as equivalent to being unsigned when signing the containing app
// bundle.
std::string codesign_output;
std::vector<std::string> codesign_argv = {
"codesign", "--sign", "-",
destination_executable_path.Append(asan_library_path.BaseName()).value()};
CHECK(base::GetAppOutputAndError(base::CommandLine(codesign_argv),
&codesign_output))
<< "Failed to sign executable at "
<< destination_executable_path.Append(asan_library_path.BaseName())
.value()
<< ": " << codesign_output;
#endif
// Copy the Info.plist.
if (!base::CopyFile(plist_path,
destination_contents_path.Append("Info.plist"))) {
RecordCreateShortcut(CreateShortcutResult::kFailToCopyPlist);
LOG(ERROR) << "Failed to copy plist: " << plist_path;
return false;
}
// Write the PkgInfo file.
constexpr char kPkgInfoData[] = "APPL????";
if (!base::WriteFile(destination_contents_path.Append("PkgInfo"),
kPkgInfoData)) {
RecordCreateShortcut(CreateShortcutResult::kFailToWritePkgInfoFile);
LOG(ERROR) << "Failed to write PkgInfo file: " << destination_contents_path;
return false;
}
bool result = UpdatePlist(staging_path);
if (!result) {
RecordCreateShortcut(CreateShortcutResult::kFailToUpdatePlist);
return result;
}
result = UpdateDisplayName(staging_path);
if (!result) {
RecordCreateShortcut(CreateShortcutResult::kFailToUpdateDisplayName);
return result;
}
result = UpdateIcon(staging_path);
if (!result) {
RecordCreateShortcut(CreateShortcutResult::kFailToUpdateIcon);
}
result = UpdateSignature(staging_path);
if (!result) {
RecordCreateShortcut(CreateShortcutResult::kFailToSign);
}
return result;
}
void WebAppShortcutCreator::CreateShortcutsAt(
const std::vector<base::FilePath>& dst_app_paths,
std::vector<base::FilePath>* updated_paths) const {
DCHECK(updated_paths && updated_paths->empty());
DCHECK(!dst_app_paths.empty());
// CreateShortcutsAt() modifies the app shim on disk, first by deleting
// the destination app shim (if it exists), then by copying a new app shim
// from the source app to the destination. To ensure that process works,
// we must guarantee that no more than one CreateShortcutsAt() call will
// ever run at a time. We have an UpdateShortcuts lock for this purpose,
// so check that lock has been acquired on this thread before proceeding.
// See https://crbug.com/1090548 for more info.
GetUpdateShortcutsLock().AssertAcquired();
base::ScopedTempDir scoped_temp_dir;
if (!scoped_temp_dir.CreateUniqueTempDir()) {
RecordCreateShortcut(CreateShortcutResult::kFailToCreateTempDir);
return;
}
// Create the bundle in |staging_path|. Note that the staging path will be
// encoded in CFBundleName, and only .apps with that exact name will have
// their display name overridden by localization. To that end, use the base
// name from dst_app_paths.front(), to ensure that the Applications copy has
// its display name set appropriately.
base::FilePath staging_path =
scoped_temp_dir.GetPath().Append(dst_app_paths.front().BaseName());
if (!BuildShortcut(staging_path)) {
return;
}
// Copy to each destination in |dst_app_paths|.
for (const auto& dst_app_path : dst_app_paths) {
// Create the parent directory for the app.
base::FilePath dst_parent_dir = dst_app_path.DirName();
if (!base::CreateDirectory(dst_parent_dir)) {
RecordCreateShortcut(CreateShortcutResult::kFailToCreateParentDir);
LOG(ERROR) << "Creating directory " << dst_parent_dir.value()
<< " failed.";
continue;
}
// Delete any old copies that may exist.
base::DeletePathRecursively(dst_app_path);
// Copy the bundle to |dst_app_path|.
if (!CopyStagingBundleToDestination(UseAdHocSigningForWebAppShims(),
staging_path, dst_app_path)) {
RecordCreateShortcut(CreateShortcutResult::kFailToCopyApp);
LOG(ERROR) << "Copying app to dst dir: " << dst_parent_dir.value()
<< " failed";
continue;
}
// Remove the quarantine attribute from both the bundle and the executable.
base::mac::RemoveQuarantineAttribute(dst_app_path);
base::mac::RemoveQuarantineAttribute(dst_app_path.Append("Contents")
.Append("MacOS")
.Append("app_mode_loader"));
// LaunchServices will eventually detect the (updated) app, but explicitly
// calling LSRegisterURL ensures tests see the right state immediately.
LSRegisterURL(base::apple::FilePathToCFURL(dst_app_path).get(), true);
updated_paths->push_back(dst_app_path);
}
}
bool WebAppShortcutCreator::UpdateDisplayName(
const base::FilePath& app_path) const {
// Localization is used to display the app name (rather than the bundle
// filename). macOS searches for the best language in the order of preferred
// languages, but one of them must be found otherwise it will default to
// the filename.
NSString* language = NSLocale.preferredLanguages[0];
base::FilePath localized_dir = GetResourcesPath(app_path).Append(
base::SysNSStringToUTF8(language) + ".lproj");
if (!base::CreateDirectory(localized_dir)) {
return false;
}
// Colon is not a valid token in the display name, and although it will be
// shown correctly, the user has to remove it if they want to rename the
// app bundle. Therefore we just remove it. Note also that the OS will
// collapse multiple consecutive forward-slashes in the display name into one.
std::u16string title_normalized = info_->title;
base::RemoveChars(title_normalized, u":", &title_normalized);
NSString* bundle_name = base::SysUTF16ToNSString(info_->title);
NSString* display_name = base::SysUTF16ToNSString(title_normalized);
if (!IsMultiProfile() &&
HasExistingExtensionShimForDifferentProfile(
chrome_apps_dir_, info_->app_id, info_->profile_path)) {
display_name = [bundle_name
stringByAppendingString:base::SysUTF8ToNSString(
" (" + info_->profile_name + ")")];
}
NSDictionary* strings_plist = @{
base::apple::CFToNSPtrCast(kCFBundleNameKey) : bundle_name,
app_mode::kCFBundleDisplayNameKey : display_name
};
NSURL* localized_url =
base::apple::FilePathToNSURL(localized_dir.Append("InfoPlist.strings"));
return [strings_plist writeToURL:localized_url error:nil];
}
bool WebAppShortcutCreator::UpdatePlist(const base::FilePath& app_path) const {
NSString* app_id = base::SysUTF8ToNSString(info_->app_id);
NSString* extension_title = base::SysUTF16ToNSString(info_->title);
NSString* extension_url = base::SysUTF8ToNSString(info_->url.spec());
NSString* chrome_bundle_id =
base::SysUTF8ToNSString(base::apple::BaseBundleID());
NSDictionary* replacement_dict = @{
app_mode::kShortcutIdPlaceholder : app_id,
app_mode::kShortcutNamePlaceholder : extension_title,
app_mode::kShortcutURLPlaceholder : extension_url,
app_mode::kShortcutBrowserBundleIDPlaceholder : chrome_bundle_id
};
NSURL* plist_url = GetPlistURL(app_path);
NSMutableDictionary* plist =
[[NSMutableDictionary alloc] initWithContentsOfURL:plist_url error:nil];
NSArray* keys = plist.allKeys;
// 1. Fill in variables.
for (id key in keys) {
NSString* value = plist[key];
if (![value isKindOfClass:[NSString class]] || value.length < 2) {
continue;
}
// Remove leading and trailing '@'s.
NSString* variable =
[value substringWithRange:NSMakeRange(1, value.length - 2)];
NSString* substitution = replacement_dict[variable];
if (substitution) {
plist[key] = substitution;
}
}
// 2. Fill in other values.
plist[app_mode::kCrBundleVersionKey] =
base::SysUTF8ToNSString(version_info::GetVersionNumber());
plist[app_mode::kCFBundleShortVersionStringKey] =
base::SysUTF8ToNSString(info_->version_for_display);
if (IsMultiProfile()) {
plist[base::apple::CFToNSPtrCast(kCFBundleIdentifierKey)] =
base::SysUTF8ToNSString(GetBundleIdentifierForShim(info_->app_id));
base::FilePath data_dir = GetMultiProfileAppDataDir(app_data_dir_);
plist[app_mode::kCrAppModeUserDataDirKey] =
base::apple::FilePathToNSString(data_dir);
} else {
plist[base::apple::CFToNSPtrCast(kCFBundleIdentifierKey)] =
base::SysUTF8ToNSString(
GetBundleIdentifierForShim(info_->app_id, info_->profile_path));
plist[app_mode::kCrAppModeUserDataDirKey] =
base::apple::FilePathToNSString(app_data_dir_);
plist[app_mode::kCrAppModeProfileDirKey] =
base::apple::FilePathToNSString(info_->profile_path.BaseName());
plist[app_mode::kCrAppModeProfileNameKey] =
base::SysUTF8ToNSString(info_->profile_name);
}
plist[app_mode::kLSHasLocalizedDisplayNameKey] = @YES;
plist[app_mode::kNSHighResolutionCapableKey] = @YES;
plist[app_mode::kCrAppModeIsAdHocSignedKey] =
@(UseAdHocSigningForWebAppShims());
// 3. Fill in file handlers.
// The plist needs to contain file handlers for all profiles the app is
// installed in. `info_->file_handler_extensions` only contains information
// for the current profile, so combine that with the information from
// `info_->handlers_per_profile`.
auto file_handler_extensions =
GetFileHandlerExtensionsWithoutDot(info_->file_handler_extensions);
auto file_handler_mime_types = info_->file_handler_mime_types;
for (const auto& profile_handlers : info_->handlers_per_profile) {
if (profile_handlers.first == info_->profile_path) {
continue;
}
auto extensions = GetFileHandlerExtensionsWithoutDot(
profile_handlers.second.file_handler_extensions);
file_handler_extensions.insert(extensions.begin(), extensions.end());
file_handler_mime_types.insert(
profile_handlers.second.file_handler_mime_types.begin(),
profile_handlers.second.file_handler_mime_types.end());
}
if (!file_handler_extensions.empty() || !file_handler_mime_types.empty()) {
NSMutableArray* doc_types_value = [NSMutableArray array];
NSMutableDictionary* doc_types_dict = [NSMutableDictionary dictionary];
if (!file_handler_extensions.empty()) {
NSMutableArray* extensions = [NSMutableArray array];
for (const auto& file_extension : file_handler_extensions) {
[extensions addObject:base::SysUTF8ToNSString(file_extension)];
}
doc_types_dict[app_mode::kCFBundleTypeExtensionsKey] = extensions;
}
if (!file_handler_mime_types.empty()) {
NSMutableArray* mime_types = [NSMutableArray array];
for (const auto& mime_type : file_handler_mime_types) {
[mime_types addObject:base::SysUTF8ToNSString(mime_type)];
}
doc_types_dict[app_mode::kCFBundleTypeMIMETypesKey] = mime_types;
}
[doc_types_value addObject:doc_types_dict];
plist[app_mode::kCFBundleDocumentTypesKey] = doc_types_value;
}
// 4. Fill in protocol handlers
// Similarly to file handlers above, here too we need to combine handlers
// for the current profile with those for other profiles the app is installed
// in.
auto protocol_handlers = info_->protocol_handlers;
for (const auto& profile_handlers : info_->handlers_per_profile) {
if (profile_handlers.first == info_->profile_path) {
continue;
}
protocol_handlers.insert(profile_handlers.second.protocol_handlers.begin(),
profile_handlers.second.protocol_handlers.end());
}
if (!protocol_handlers.empty()) {
scoped_refptr<OsIntegrationTestOverride> os_override =
OsIntegrationTestOverride::Get();
if (os_override) {
CHECK_IS_TEST();
std::vector<std::string> protocol_handlers_vec;
protocol_handlers_vec.insert(protocol_handlers_vec.end(),
protocol_handlers.begin(),
protocol_handlers.end());
os_override->RegisterProtocolSchemes(info_->app_id,
std::move(protocol_handlers_vec));
}
NSMutableArray* handlers = [NSMutableArray array];
for (const auto& protocol_handler : protocol_handlers) {
[handlers addObject:base::SysUTF8ToNSString(protocol_handler)];
}
plist[app_mode::kCFBundleURLTypesKey] = @[ @{
app_mode::kCFBundleURLNameKey :
base::SysUTF8ToNSString(GetBundleIdentifierForShim(info_->app_id)),
app_mode::kCFBundleURLSchemesKey : handlers
} ];
}
// TODO(crbug.com/40807015): If we decide to rename app bundles on app title
// changes, instead of relying on localization, then this will need to change
// to use GetShortcutBaseName, most likely only for non-legacy-apps
// (in other words, revert to what the code looked like before on these
// lines). See also crbug.com/1021804.
base::FilePath app_name = app_path.BaseName().RemoveFinalExtension();
plist[base::apple::CFToNSPtrCast(kCFBundleNameKey)] =
base::apple::FilePathToNSString(app_name);
return [plist writeToURL:plist_url error:nil];
}
bool WebAppShortcutCreator::UpdateIcon(const base::FilePath& app_path) const {
if (info_->favicon.empty() && info_->favicon_maskable.empty()) {
return true;
}
IcnsEncoder icns_encoder;
bool has_valid_icons = false;
if (!info_->favicon_maskable.empty() &&
base::FeatureList::IsEnabled(kWebAppMaskableIconsOnMac)) {
for (gfx::ImageFamily::const_iterator it = info_->favicon_maskable.begin();
it != info_->favicon_maskable.end(); ++it) {
if (icns_encoder.AddImage(CreateAppleMaskedAppIcon(*it))) {
has_valid_icons = true;
}
}
}
if (!has_valid_icons) {
for (gfx::ImageFamily::const_iterator it = info_->favicon.begin();
it != info_->favicon.end(); ++it) {
if (icns_encoder.AddImage(*it)) {
has_valid_icons = true;
}
}
}
if (!has_valid_icons) {
return false;
}
base::FilePath resources_path = GetResourcesPath(app_path);
if (!base::CreateDirectory(resources_path)) {
return false;
}
return icns_encoder.WriteToFile(resources_path.Append("app.icns"));
}
bool WebAppShortcutCreator::UpdateSignature(
const base::FilePath& app_path) const {
if (!UseAdHocSigningForWebAppShims()) {
return true;
}
base::apple::ScopedCFTypeRef<CFURLRef> app_url =
base::apple::FilePathToCFURL(app_path);
base::apple::ScopedCFTypeRef<SecStaticCodeRef> app_code;
if (SecStaticCodeCreateWithPath(app_url.get(), kSecCSDefaultFlags,
app_code.InitializeInto()) != errSecSuccess) {
return false;
}
// Use the most restrictive flags possible. Library validation cannot be
// enabled as an adhoc binary's signing identity inherently does not match the
// signing identity of the non-system libraries that the app shim loads.
uint32_t code_signer_flags = kSecCodeSignatureRestrict |
kSecCodeSignatureForceKill |
kSecCodeSignatureRuntime;
auto* signer_params = @{
static_cast<id>(kSecCodeSignerFlags) : @(code_signer_flags),
static_cast<id>(kSecCodeSignerIdentity) : [NSNull null],
static_cast<id>(kSecCodeSignerEntitlements) : AppShimEntitlements(),
};
base::apple::ScopedCFTypeRef<SecCodeSignerRef> signer;
if (SecCodeSignerCreate(base::apple::NSToCFPtrCast(signer_params),
kSecCSDefaultFlags,
signer.InitializeInto()) != errSecSuccess) {
return false;
}
base::apple::ScopedCFTypeRef<CFErrorRef> errors;
if (SecCodeSignerAddSignatureWithErrors(
signer.get(), app_code.get(), kSecCSDefaultFlags,
errors.InitializeInto()) != errSecSuccess) {
LOG(ERROR) << "Failed to sign web app shim: " << errors.get();
return false;
}
base::apple::ScopedCFTypeRef<CFDictionaryRef> app_shim_info;
if (SecCodeCopySigningInformation(app_code.get(), kSecCSSigningInformation,
app_shim_info.InitializeInto()) !=
errSecSuccess) {
LOG(ERROR) << "Failed to copy signing information from web app shim";
return false;
}
CFDataRef cd_hash_data = base::apple::GetValueFromDictionary<CFDataRef>(
app_shim_info.get(), kSecCodeInfoUnique);
std::vector<uint8_t> cd_hash(
CFDataGetBytePtr(cd_hash_data),
CFDataGetBytePtr(cd_hash_data) + CFDataGetLength(cd_hash_data));
content::GetUIThreadTaskRunner()->PostTask(
FROM_HERE, base::BindOnce(&AppShimRegistry::SaveCdHashForApp,
base::Unretained(AppShimRegistry::Get()),
info_->app_id, std::move(cd_hash)));
return true;
}
// Return true if ad-hoc signing should be used for web app shims.
bool WebAppShortcutCreator::UseAdHocSigningForWebAppShims() const {
return use_ad_hoc_signing_for_web_app_shims_;
}
} // namespace web_app