| // Copyright 2022 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "components/headless/command_handler/headless_command_handler.h" |
| |
| #include <cstdint> |
| #include <iostream> |
| #include <string> |
| #include <string_view> |
| #include <utility> |
| #include <vector> |
| |
| #include "base/base64.h" |
| #include "base/command_line.h" |
| #include "base/containers/adapters.h" |
| #include "base/containers/fixed_flat_map.h" |
| #include "base/containers/span.h" |
| #include "base/files/file.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/json/json_writer.h" |
| #include "base/logging.h" |
| #include "base/no_destructor.h" |
| #include "base/path_service.h" |
| #include "base/strings/strcat.h" |
| #include "base/strings/string_number_conversions.h" |
| #include "base/strings/string_split.h" |
| #include "base/strings/string_util.h" |
| #include "base/task/sequenced_task_runner.h" |
| #include "base/task/task_traits.h" |
| #include "base/task/thread_pool.h" |
| #include "build/build_config.h" |
| #include "components/headless/command_handler/grit/headless_command_resources.h" |
| #include "components/headless/command_handler/headless_command_switches.h" |
| #include "content/public/browser/browser_task_traits.h" |
| #include "content/public/browser/browser_thread.h" |
| #include "content/public/browser/web_contents.h" |
| #include "content/public/browser/web_ui_data_source.h" |
| #include "content/public/common/content_switches.h" |
| #include "ui/base/resource/resource_bundle.h" |
| |
| namespace headless { |
| |
| namespace { |
| |
| // Default file name for screenshot. Can be overridden by "--screenshot" switch. |
| const char kDefaultScreenshotFileName[] = "screenshot.png"; |
| // Default file name for pdf. Can be overridden by "--print-to-pdf" switch. |
| const char kDefaultPDFFileName[] = "output.pdf"; |
| |
| const char kChromeHeadlessHost[] = "headless"; |
| const char kChromeHeadlessURL[] = "chrome://headless/"; |
| |
| const char kHeadlessCommandHtml[] = "headless_command.html"; |
| const char kHeadlessCommandJs[] = "headless_command.js"; |
| |
| // Specifies the initial window size: --window-size=w,h. Headless Chrome users |
| // historically use this to specify expected screenshot size. Originally defined |
| // in //chrome/common/chrome_switches.h which we cannot include from here. |
| const char kWindowSize[] = "window-size"; |
| |
| HeadlessCommandHandler::DoneCallback& GetGlobalDoneCallback() { |
| static base::NoDestructor<HeadlessCommandHandler::DoneCallback> done_callback; |
| return *done_callback; |
| } |
| |
| void EnsureHeadlessCommandResources() { |
| // Check if our resources are already loaded and bail out early. This happens |
| // when running Chrome with --headless switch and headless command resources |
| // have been merged into the Chrome's resource pack. |
| ui::ResourceBundle& bundle = ui::ResourceBundle::GetSharedInstance(); |
| if (!bundle.GetRawDataResource(IDR_HEADLESS_COMMAND_HTML).empty()) { |
| DCHECK(!bundle.GetRawDataResource(IDR_HEADLESS_COMMAND_JS).empty()); |
| return; |
| } |
| |
| // Check if we have headless command resource pack next to our binary and load |
| // it if so. Note that this code is expected to run early during the browser |
| // startup phase when file system access is still allowed. |
| base::FilePath resource_dir; |
| bool result = base::PathService::Get(base::DIR_ASSETS, &resource_dir); |
| DCHECK(result); |
| |
| base::FilePath resource_pack = |
| resource_dir.Append(FILE_PATH_LITERAL("headless_command_resources.pak")); |
| if (base::PathExists(resource_pack)) { |
| bundle.AddDataPackFromPath(resource_pack, ui::kScaleFactorNone); |
| } |
| } |
| |
| void CreateAndAddHeadlessHostDataSource( |
| content::BrowserContext* browser_context) { |
| EnsureHeadlessCommandResources(); |
| |
| content::WebUIDataSource* source = content::WebUIDataSource::CreateAndAdd( |
| browser_context, kChromeHeadlessHost); |
| DCHECK(source); |
| |
| source->AddResourcePath(kHeadlessCommandHtml, IDR_HEADLESS_COMMAND_HTML); |
| source->AddResourcePath(kHeadlessCommandJs, IDR_HEADLESS_COMMAND_JS); |
| } |
| |
| base::Value::Dict GetColorDictFromHexColor(uint32_t color, bool has_alpha) { |
| base::Value::Dict dict; |
| if (has_alpha) { |
| dict.Set("r", static_cast<int>((color & 0xff000000) >> 24)); |
| dict.Set("g", static_cast<int>((color & 0x00ff0000) >> 16)); |
| dict.Set("b", static_cast<int>((color & 0x0000ff00) >> 8)); |
| dict.Set("a", static_cast<int>((color & 0x000000ff))); |
| } else { |
| dict.Set("r", static_cast<int>((color & 0xff0000) >> 16)); |
| dict.Set("g", static_cast<int>((color & 0x00ff00) >> 8)); |
| dict.Set("b", static_cast<int>((color & 0x0000ff))); |
| } |
| |
| return dict; |
| } |
| |
| bool ParseWindowSize(const std::string& window_size, int* width, int* height) { |
| std::vector<std::string_view> width_and_height = base::SplitStringPiece( |
| window_size, ",x", base::KEEP_WHITESPACE, base::SPLIT_WANT_ALL); |
| if (width_and_height.size() != 2 || |
| !base::StringToInt(width_and_height[0], width) || |
| !base::StringToInt(width_and_height[1], height)) { |
| return false; |
| } |
| |
| return *width > 0 && *height > 0; |
| } |
| |
| bool GetCommandDictAndOutputPaths(base::Value::Dict* commands, |
| base::FilePath* pdf_file_path, |
| base::FilePath* screenshot_file_path) { |
| const base::CommandLine* command_line = |
| base::CommandLine::ForCurrentProcess(); |
| |
| // --dump-dom |
| if (command_line->HasSwitch(switches::kDumpDom)) { |
| commands->Set("dumpDom", true); |
| } |
| |
| // --print-to-pdf=[output path] |
| if (command_line->HasSwitch(switches::kPrintToPDF)) { |
| base::FilePath path = |
| command_line->GetSwitchValuePath(switches::kPrintToPDF); |
| if (path.empty()) { |
| path = base::FilePath().AppendASCII(kDefaultPDFFileName); |
| } |
| *pdf_file_path = path; |
| |
| base::Value::Dict params; |
| if (command_line->HasSwitch(switches::kNoPDFHeaderFooter)) { |
| params.Set("noHeaderFooter", true); |
| } |
| |
| if (command_line->HasSwitch(switches::kDisablePDFTagging)) { |
| params.Set("disablePDFTagging", true); |
| } |
| |
| if (command_line->HasSwitch(switches::kGeneratePDFDocumentOutline)) { |
| params.Set("generateDocumentOutline", true); |
| } |
| |
| commands->Set("printToPDF", std::move(params)); |
| } |
| |
| // --screenshot=[output path] |
| if (command_line->HasSwitch(switches::kScreenshot)) { |
| base::FilePath path = |
| command_line->GetSwitchValuePath(switches::kScreenshot); |
| if (path.empty()) { |
| path = base::FilePath().AppendASCII(kDefaultScreenshotFileName); |
| } |
| *screenshot_file_path = path; |
| |
| base::FilePath::StringType extension = |
| base::ToLowerASCII(path.FinalExtension()); |
| |
| static constexpr auto kImageFileTypes = |
| base::MakeFixedFlatMap<base::FilePath::StringViewType, const char*>({ |
| {FILE_PATH_LITERAL(".jpeg"), "jpeg"}, |
| {FILE_PATH_LITERAL(".jpg"), "jpeg"}, |
| {FILE_PATH_LITERAL(".png"), "png"}, |
| {FILE_PATH_LITERAL(".webp"), "webp"}, |
| }); |
| |
| auto it = kImageFileTypes.find(extension); |
| if (it == kImageFileTypes.cend()) { |
| LOG(ERROR) << "Unsupported screenshot image file type: " |
| << path.FinalExtension(); |
| return false; |
| } |
| |
| base::Value::Dict params; |
| params.Set("format", it->second); |
| |
| if (command_line->HasSwitch(kWindowSize)) { |
| int width, height; |
| if (ParseWindowSize(command_line->GetSwitchValueASCII(kWindowSize), |
| &width, &height)) { |
| params.Set("width", width); |
| params.Set("height", height); |
| } else { |
| LOG(ERROR) << "Invalid --" << kWindowSize << " specification ignored"; |
| } |
| } |
| |
| commands->Set("screenshot", std::move(params)); |
| } |
| |
| // --default-background-color=rrggbb[aa] |
| if (command_line->HasSwitch(switches::kDefaultBackgroundColor)) { |
| std::string hex_color = |
| command_line->GetSwitchValueASCII(switches::kDefaultBackgroundColor); |
| uint32_t color; |
| if (!(hex_color.length() == 6 || hex_color.length() == 8) || |
| !base::HexStringToUInt(hex_color, &color)) { |
| LOG(ERROR) |
| << "Expected a hex RGB or RGBA value for --default-background-color=" |
| << hex_color; |
| return false; |
| } |
| |
| commands->Set("defaultBackgroundColor", |
| GetColorDictFromHexColor(color, hex_color.length() == 8)); |
| } |
| |
| // virtual-time-budget=[ms] |
| if (command_line->HasSwitch(switches::kVirtualTimeBudget)) { |
| std::string budget_ms_str = |
| command_line->GetSwitchValueASCII(switches::kVirtualTimeBudget); |
| int budget_ms; |
| if (!base::StringToInt(budget_ms_str, &budget_ms)) { |
| LOG(ERROR) << "Expected an integer value for --virtual-time-budget=" |
| << budget_ms_str; |
| return false; |
| } |
| |
| commands->Set("virtualTimeBudget", budget_ms); |
| } |
| |
| // timeout=[ms] |
| if (command_line->HasSwitch(switches::kTimeout)) { |
| std::string timeout_ms_str = |
| command_line->GetSwitchValueASCII(switches::kTimeout); |
| int timeout_ms; |
| if (!base::StringToInt(timeout_ms_str, &timeout_ms)) { |
| LOG(ERROR) << "Expected an integer value for --timeout=" |
| << timeout_ms_str; |
| return false; |
| } |
| |
| commands->Set("timeout", timeout_ms); |
| } |
| |
| return true; |
| } |
| |
| bool WriteFileTask(base::FilePath file_path, std::string file_data) { |
| auto file_span = base::as_byte_span(file_data); |
| if (!base::WriteFile(file_path, file_span)) { |
| PLOG(ERROR) << "Failed to write file " << file_path; |
| return false; |
| } |
| |
| std::cerr << file_data.size() << " bytes written to file " << file_path |
| << std::endl; |
| return true; |
| } |
| |
| } // namespace |
| |
| HeadlessCommandHandler::HeadlessCommandHandler( |
| content::WebContents* web_contents, |
| GURL target_url, |
| DoneCallback done_callback, |
| scoped_refptr<base::SequencedTaskRunner> io_task_runner) |
| : target_url_(std::move(target_url)), |
| done_callback_(std::move(done_callback)), |
| io_task_runner_(std::move(io_task_runner)) { |
| DCHECK(web_contents); |
| DCHECK(io_task_runner_); |
| |
| content::WebContentsObserver::Observe(web_contents); |
| |
| // Load command execution harness resources and create URL data source |
| // for chrome://headless. |
| CreateAndAddHeadlessHostDataSource(web_contents->GetBrowserContext()); |
| |
| browser_devtools_client_.AttachToBrowser(); |
| devtools_client_.AttachToWebContents(web_contents); |
| } |
| |
| HeadlessCommandHandler::~HeadlessCommandHandler() = default; |
| |
| // static |
| GURL HeadlessCommandHandler::GetHandlerUrl() { |
| const std::string url = |
| base::StrCat({kChromeHeadlessURL, kHeadlessCommandHtml}); |
| return GURL(url); |
| } |
| |
| // static |
| bool HeadlessCommandHandler::HasHeadlessCommandSwitches( |
| const base::CommandLine& command_line) { |
| static const char* kCommandSwitches[] = { |
| switches::kDefaultBackgroundColor, |
| switches::kDumpDom, |
| switches::kPrintToPDF, |
| switches::kNoPDFHeaderFooter, |
| switches::kScreenshot, |
| switches::kTimeout, |
| switches::kVirtualTimeBudget, |
| }; |
| |
| for (const char* command_switch : kCommandSwitches) { |
| if (command_line.HasSwitch(command_switch)) { |
| return true; |
| } |
| } |
| |
| return false; |
| } |
| |
| // static |
| void HeadlessCommandHandler::ProcessCommands( |
| content::WebContents* web_contents, |
| GURL target_url, |
| DoneCallback done_callback, |
| scoped_refptr<base::SequencedTaskRunner> io_task_runner) { |
| if (!io_task_runner) { |
| io_task_runner = base::ThreadPool::CreateSequencedTaskRunner( |
| {base::MayBlock(), base::TaskPriority::USER_BLOCKING, |
| base::TaskShutdownBehavior::BLOCK_SHUTDOWN}); |
| } |
| |
| // Headless Command Handler instance will self delete when done. |
| new HeadlessCommandHandler(web_contents, std::move(target_url), |
| std::move(done_callback), io_task_runner); |
| } |
| |
| void HeadlessCommandHandler::DocumentOnLoadCompletedInPrimaryMainFrame() { |
| base::Value::Dict commands; |
| if (!GetCommandDictAndOutputPaths(&commands, &pdf_file_path_, |
| &screenshot_file_path_) || |
| commands.empty()) { |
| PostDone(); |
| return; |
| } |
| |
| commands.Set("targetUrl", target_url_.spec()); |
| |
| // Expose DevTools protocol to the target. |
| base::Value::Dict expose_params; |
| expose_params.Set("targetId", devtools_client_.GetTargetId()); |
| browser_devtools_client_.SendCommand("Target.exposeDevToolsProtocol", |
| std::move(expose_params)); |
| |
| // Set up Inspector domain. |
| devtools_client_.AddEventHandler( |
| "Inspector.targetCrashed", |
| base::BindRepeating(&HeadlessCommandHandler::OnTargetCrashed, |
| base::Unretained(this))); |
| devtools_client_.SendCommand("Inspector.enable"); |
| |
| std::string json_commands; |
| base::JSONWriter::Write(commands, &json_commands); |
| std::string script = "executeCommands(JSON.parse('" + json_commands + "'))"; |
| |
| base::Value::Dict params; |
| params.Set("expression", script); |
| params.Set("awaitPromise", true); |
| params.Set("returnByValue", true); |
| devtools_client_.SendCommand( |
| "Runtime.evaluate", std::move(params), |
| base::BindOnce(&HeadlessCommandHandler::OnCommandsResult, |
| base::Unretained(this))); |
| } |
| |
| void HeadlessCommandHandler::WebContentsDestroyed() { |
| LOG(ERROR) << "Unexpected renderer destruction."; |
| Done(); |
| } |
| |
| void HeadlessCommandHandler::OnTargetCrashed(const base::Value::Dict&) { |
| LOG(ERROR) << "Abnormal renderer termination."; |
| Done(); |
| } |
| |
| void HeadlessCommandHandler::OnCommandsResult(base::Value::Dict result) { |
| if (result.FindBoolByDottedPath("result.result.value.pageLoadTimedOut") |
| .value_or(false)) { |
| result_ = Result::kPageLoadTimeout; |
| LOG(ERROR) << "Page load timed out."; |
| } |
| |
| if (std::string* dom_dump = |
| result.FindStringByDottedPath("result.result.value.dumpDomResult")) { |
| std::cout << *dom_dump << std::endl; |
| } |
| |
| if (std::string* base64_data = result.FindStringByDottedPath( |
| "result.result.value.screenshotResult")) { |
| WriteFile(std::move(screenshot_file_path_), std::move(*base64_data)); |
| } |
| |
| if (std::string* base64_data = result.FindStringByDottedPath( |
| "result.result.value.printToPdfResult")) { |
| WriteFile(std::move(pdf_file_path_), std::move(*base64_data)); |
| } |
| |
| if (!write_file_tasks_in_flight_) { |
| PostDone(); |
| } |
| } |
| |
| void HeadlessCommandHandler::WriteFile(base::FilePath file_path, |
| std::string base64_file_data) { |
| std::string file_data; |
| CHECK(base::Base64Decode(base64_file_data, &file_data)); |
| |
| if (io_task_runner_->PostTaskAndReplyWithResult( |
| FROM_HERE, |
| base::BindOnce(&WriteFileTask, std::move(file_path), |
| std::move(file_data)), |
| base::BindOnce(&HeadlessCommandHandler::OnWriteFileDone, |
| base::Unretained(this)))) { |
| ++write_file_tasks_in_flight_; |
| } |
| } |
| |
| void HeadlessCommandHandler::OnWriteFileDone(bool success) { |
| DCHECK_GT(write_file_tasks_in_flight_, 0) << write_file_tasks_in_flight_; |
| |
| if (!success) { |
| result_ = Result::kWriteFileError; |
| } |
| |
| if (!--write_file_tasks_in_flight_) { |
| Done(); |
| } |
| } |
| |
| void HeadlessCommandHandler::PostDone() { |
| content::GetUIThreadTaskRunner({})->PostTask( |
| FROM_HERE, |
| base::BindOnce(&HeadlessCommandHandler::Done, base::Unretained(this))); |
| } |
| |
| void HeadlessCommandHandler::Done() { |
| devtools_client_.DetachClient(); |
| browser_devtools_client_.DetachClient(); |
| |
| Result result = result_; |
| DoneCallback done_callback(std::move(done_callback_)); |
| delete this; |
| std::move(done_callback).Run(result); |
| |
| if (GetGlobalDoneCallback()) { |
| std::move(GetGlobalDoneCallback()).Run(result); |
| } |
| } |
| |
| // static |
| void HeadlessCommandHandler::SetDoneCallbackForTesting( |
| DoneCallback done_callback) { |
| GetGlobalDoneCallback() = std::move(done_callback); |
| } |
| |
| } // namespace headless |