CVE-2026-91723
Overview
Background
- Web Install API
- the
navigator.install({manifest: manifest_url})renderer API that lets a page request installation of a web app described by a manifest URL, driving the browser-sideWebInstallFromManifestCommand. - `WebInstallFromManifestCommand`
- the web app command that converts a pre-parsed
blink::mojom::ManifestPtrinto aWebAppInstallInfo, downloads icons over the sharedabout:blankweb contents, and shows the install dialog before finalizing. - `content::Page` / `base::WeakPtr<content::Page>`
- a handle to the specific document that initiated the install; a cross-document navigation, destruction, or move to the back-forward cache invalidates it, which the command detects via
IsInitiatingPageGone(). - Install dialog origin
- the install dialog is anchored to the initiating
WebContentsand reads its live origin to render the “requesting origin” subtitle, so that origin must still belong to the page that actually made the call.
Root Cause Analysis
The manifest install flow is asynchronous: between the navigator.install() call and the moment the install dialog is displayed, the command acquires a SharedWebContentsLock, downloads icons in ManifestToWebAppInstallInfoJob, and upgrades to a SharedWebContentsWithAppLock — several suspension points. The install dialog is anchored to the initiating WebContents and reads its current origin for the “requesting origin” subtitle, and WebAppInstallFlowDialogDelegate dereferences that WebContents without a null check. If the initiating page navigates cross-document, is destroyed, or enters the back-forward cache during any of those async steps, the dialog would read the wrong (now-trusted) origin while still installing the attacker’s crafted app — a time-of-check/time-of-use origin-spoofing race.
The fix binds the flow to the initiating content::Page via a base::WeakPtr, observes PrimaryPageChanged on the WebContents, and re-validates IsInitiatingPageGone() (and the page_changed_before_start_ flag) at every continuation — StartWithLock, OnWebAppInstallInfoCreatedShowDialog, and OnAppLockAcquired — aborting with kCancelledDueToMainFrameNavigation before any dialog is shown. Because the guard runs before the sink (dialog display / WebContents deref) on every async edge, a page that changed identity can no longer reach the dialog.
WebContents’s live origin at dialog-display time even though many async hops separate the API call from that display; the fix pins the flow to the exact initiating content::Page and aborts the instant that page navigates, is destroyed, or is cached.Attack Path
- Initiate install from attacker page
An attacker-controlled page (with the
kWebAppInstallationfeature enabled) callsnavigator.install({manifest: manifest_url})for a crafted manifest that declares at least one icon, queuing aWebInstallFromManifestCommand. - Enter the async window
The command proceeds through
StartWithLockintoSharedWebContentsLockacquisition and icon download viaManifestToWebAppInstallInfoJob, opening a multi-step window before the dialog is shown. - Swap the page identity mid-flight
During that window the attacker navigates the initiating page cross-document to a trusted victim origin (or otherwise changes the primary page), so the
WebContentsnow reflects a different origin than the caller. - Dialog reads the spoofed origin
Without the guard,
OnWebAppInstallInfoCreatedShowDialog/OnAppLockAcquiredwould proceed and the dialog anchored to theWebContentswould display the victim origin as the requesting origin while installing the attacker’s app; the fix instead callsIsInitiatingPageGone()and aborts withkCancelledDueToMainFrameNavigation. - Possible crash on destroyed contents
If the initiating page/
WebContentsis torn down rather than navigated, the dialog delegate’s uncheckedWebContentsdereference could fault, giving a denial-of-service or use-after-free (inferred).
Impact Assessment
blink::features::kWebAppInstallation feature being enabled and the user acting on the dialog. No exploited-in-the-wild status or severity rating is stated in the advisory, and escalation of the unchecked WebContents dereference into a use-after-free or crash beyond the origin-spoof the patch demonstrates is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
Browserchrome/browser/ui/web_applications/web_app_dialog_utils.h |
modified | |
BrowserWindowInterfacechrome/browser/ui/web_applications/web_app_dialog_utils.h |
modified | |
Profilechrome/browser/ui/web_applications/web_app_dialog_utils.h |
modified | |
Pagechrome/browser/ui/web_applications/web_app_dialog_utils.h |
modified | |
WebContentschrome/browser/ui/web_applications/web_app_dialog_utils.h |
modified | |
ifchrome/browser/web_applications/commands/web_install_from_manifest_command.cc |
modified |
Files Changed
chrome/browser/web_applications/web_app_command_scheduler.htools/metrics/histograms/metadata/webapps/histograms.xmlchrome/browser/web_applications/commands/command_metrics.hchrome/browser/web_applications/test/fake_web_app_ui_manager.ccchrome/browser/web_applications/web_app_command_scheduler.ccchrome/browser/ui/web_applications/web_app_dialog_utils.ccchrome/browser/web_applications/commands/web_install_from_manifest_command.ccchrome/browser/ui/web_applications/web_app_dialog_utils.hchrome/browser/web_applications/web_install_service_impl.hchrome/browser/web_applications/BUILD.gnchrome/browser/web_applications/test/fake_web_app_ui_manager.hchrome/browser/web_applications/web_app_ui_manager.hchrome/browser/web_applications/commands/web_install_from_manifest_command.hchrome/browser/ui/web_applications/web_app_ui_manager_impl.ccchrome/browser/ui/web_applications/web_app_ui_manager_impl.hchrome/browser/web_applications/commands/command_metrics.ccchrome/browser/web_applications/web_install_service_impl.ccchrome/browser/web_applications/web_install_from_manifest_browsertest.cc
Audit Directions
- Origin read from a live `WebContents` after async hopsAudit every install/permission dialog that anchors to a
WebContentsand reads its origin at display time; each async continuation between the request and the prompt must re-validate that the initiating document is unchanged. - `WeakPtr` staleness across lock upgradesIn
WebAppCommandflows that acquire and upgrade locks (SharedWebContentsLock→SharedWebContentsWithAppLock), re-check page/contents liveness after eachUpgradeAndAcquireLockcallback rather than only at command start. - Navigation / bfcache during in-flight promptsFor any flow that can outlive a single document (cross-document navigation, tab close, back-forward cache entry), confirm the operation is pinned to a specific
content::Pageand observesPrimaryPageChanged, not merely to theWebContents.
Patch
From d00c374cf16977e6b110da69a5852475fe1166b0 Mon Sep 17 00:00:00 2001
From: Lia Hiscock <liahiscock@microsoft.com>
Date: Tue, 21 Jul 2026 23:32:26 -0700
Subject: [PATCH] [WebInstallAPI] 6/n: Implement WebInstallFromManifestCommand
Add the browser-side command that completes the API's manifest URL flow.
The command takes a pre-parsed manifest and:
- Converts ManifestPtr into WebAppInstallInfo and downloads icons via
the shared web contents in an about:blank context. See [1].
- Rejects manifests without icons, since the Web Install API only
installs crafted apps.
- Shows the install dialog anchored to the initiating web contents and,
on acceptance, finalizes the install.
Guard the flow against origin spoofing by tracking the initiating Page.
If the page is destroyed, navigated cross-document, or moved to the
back-forward cache during any async step, the install is aborted before
a dialog can display the wrong origin.
Wire the command through the scheduler (`InstallAppFromManifest`), the
UI manager (`TriggerInstallDialogForManifestInstall`), and
`CreateWebAppForManifestInstall`, and register a concurrent-install
guard in the service so a second in-flight call is rejected. Add the
`kInstallFromManifestUrl` install metric and its histogram variant.
[1] crbug.com/524520975 tracks reworking ImageDownloader to support
kOmit cookies.
Bug: 333795265, 474131239
Change-Id: Ib65ea431ec85b3766372ef5123b9ca8f8488ea7f
Include-Ci-Only-Tests: chromium.mac:mac15-x64-rel-tests|browser_tests
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8026191
Reviewed-by: Lu Huang <luhua@microsoft.com>
Reviewed-by: Daniel Murphy <dmurph@chromium.org>
Commit-Queue: Lia Hiscock <liahiscock@microsoft.com>
Cr-Commit-Position: refs/heads/main@{#1666043}
---
diff --git a/chrome/browser/ui/web_applications/web_app_dialog_utils.cc b/chrome/browser/ui/web_applications/web_app_dialog_utils.cc
index 4832ee93..7d56c01c 100644
--- a/chrome/browser/ui/web_applications/web_app_dialog_utils.cc
+++ b/chrome/browser/ui/web_applications/web_app_dialog_utils.cc
@@ -49,6 +49,7 @@
#include "components/webapps/browser/installable/ml_installability_promoter.h"
#include "components/webapps/browser/web_app_url_config.h"
#include "content/public/browser/navigation_entry.h"
+#include "third_party/blink/public/mojom/manifest/manifest.mojom.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/models/image_model.h"
#include "ui/base/resource/resource_bundle.h"
@@ -366,6 +367,28 @@
std::move(installed_callback));
}
+void CreateWebAppForManifestInstall(
+ content::WebContents* initiating_web_contents,
+ base::WeakPtr<content::Page> initiating_page,
+ std::unique_ptr<webapps::MlInstallOperationTracker> tracker,
+ blink::mojom::ManifestPtr manifest,
+ const GURL& manifest_url,
+ const GURL& requesting_page_url,
+ WebAppInstalledCallback installed_callback) {
+ auto* provider = WebAppProvider::GetForWebContents(initiating_web_contents);
+ CHECK(provider);
+
+ provider->scheduler().InstallAppFromManifest(
+ std::move(manifest), manifest_url, initiating_web_contents->GetWeakPtr(),
+ std::move(initiating_page), requesting_page_url,
+ base::BindOnce(&OnWebAppInstallShowInstallDialog,
+ WebAppInstallFlow::kInstallSite,
+ webapps::WebappInstallSource::WEB_INSTALL,
+ PwaInProductHelpState::kNotShown, std::move(tracker),
+ /*show_initiating_origin=*/true),
+ std::move(installed_callback));
+}
+
void ShowPwaInstallDialog(BrowserWindowInterface* bwi) {
CHECK(bwi);
diff --git a/chrome/browser/ui/web_applications/web_app_dialog_utils.h b/chrome/browser/ui/web_applications/web_app_dialog_utils.h
index fd7773a7..497e2fe9 100644
--- a/chrome/browser/ui/web_applications/web_app_dialog_utils.h
+++ b/chrome/browser/ui/web_applications/web_app_dialog_utils.h
@@ -9,12 +9,14 @@
#include "chrome/browser/ui/web_applications/web_app_dialogs.h"
#include "chrome/browser/web_applications/web_app_install_manager.h"
#include "components/webapps/common/web_app_id.h"
+#include "third_party/blink/public/mojom/manifest/manifest.mojom-forward.h"
class Browser;
class BrowserWindowInterface;
class Profile;
namespace content {
+class Page;
class WebContents;
}
@@ -64,6 +66,18 @@
const GURL& last_committed_url,
WebAppInstalledCallback installed_callback);
+// Starts the background install of a WebApp using a pre-parsed manifest,
+// initiated from a `navigator.install({manifest_url})` call from within
+// `initiating_web_contents`. Used for the Web Install API manifest_url flow.
+void CreateWebAppForManifestInstall(
+ content::WebContents* initiating_web_contents,
+ base::WeakPtr<content::Page> initiating_page,
+ std::unique_ptr<webapps::MlInstallOperationTracker> tracker,
+ blink::mojom::ManifestPtr manifest,
+ const GURL& manifest_url,
+ const GURL& requesting_page_url,
+ WebAppInstalledCallback installed_callback);
+
// Shows the PWA Install dialog for the active tab in the provided browser.
// Records PWAInstallIcon user metric and closes the PWA install IPH
// if it is showing.
diff --git a/chrome/browser/ui/web_applications/web_app_ui_manager_impl.cc b/chrome/browser/ui/web_applications/web_app_ui_manager_impl.cc
index 06334ec..3fc958e4 100644
--- a/chrome/browser/ui/web_applications/web_app_ui_manager_impl.cc
+++ b/chrome/browser/ui/web_applications/web_app_ui_manager_impl.cc
@@ -89,6 +89,7 @@
#include "extensions/browser/app_sorting.h"
#include "extensions/browser/extension_system.h"
#include "third_party/blink/public/mojom/manifest/manifest.mojom-shared.h"
+#include "third_party/blink/public/mojom/manifest/manifest.mojom.h"
#include "third_party/skia/include/core/SkBitmap.h"
#include "ui/base/page_transition_types.h"
#include "ui/gfx/native_ui_types.h"
@@ -531,6 +532,20 @@
last_committed_url, std::move(callback));
}
+void WebAppUiManagerImpl::TriggerInstallDialogForManifestInstall(
+ content::WebContents* initiating_web_contents,
+ base::WeakPtr<content::Page> initiating_page,
+ std::unique_ptr<webapps::MlInstallOperationTracker> tracker,
+ blink::mojom::ManifestPtr manifest,
+ const GURL& manifest_url,
+ const GURL& requesting_page_url,
+ InstallCallback callback) {
+ web_app::CreateWebAppForManifestInstall(
+ initiating_web_contents, std::move(initiating_page), std::move(tracker),
+ std::move(manifest), manifest_url, requesting_page_url,
+ std::move(callback));
+}
+
void WebAppUiManagerImpl::TriggerLaunchDialogForBackgroundInstall(
content::WebContents* initiating_web_contents,
const webapps::AppId& app_id,
diff --git a/chrome/browser/ui/web_applications/web_app_ui_manager_impl.h b/chrome/browser/ui/web_applications/web_app_ui_manager_impl.h
index ffe26b34..c3ad442 100644
--- a/chrome/browser/ui/web_applications/web_app_ui_manager_impl.h
+++ b/chrome/browser/ui/web_applications/web_app_ui_manager_impl.h
@@ -142,6 +142,7 @@
void TriggerInstallDialog(content::WebContents* web_contents,
webapps::WebappInstallSource source,
InstallCallback callback) override;
+ // TODO(crbug.com/520025525): Remove install_url code.
void TriggerInstallDialogForBackgroundInstall(
content::WebContents* initiating_web_contents,
std::unique_ptr<webapps::MlInstallOperationTracker> tracker,
@@ -149,6 +150,14 @@
const std::optional<GURL>& manifest_id,
const GURL& last_committed_url,
InstallCallback callback) override;
+ void TriggerInstallDialogForManifestInstall(
+ content::WebContents* initiating_web_contents,
+ base::WeakPtr<content::Page> initiating_page,
+ std::unique_ptr<webapps::MlInstallOperationTracker> tracker,
+ blink::mojom::ManifestPtr manifest,
+ const GURL& manifest_url,
+ const GURL& requesting_page_url,
+ InstallCallback callback) override;
void TriggerLaunchDialogForBackgroundInstall(
content::WebContents* initiating_web_contents,
const webapps::AppId& app_id,
diff --git a/chrome/browser/web_applications/commands/command_metrics.cc b/chrome/browser/web_applications/commands/command_metrics.cc
index 03d2893..134dca64 100644
--- a/chrome/browser/web_applications/commands/command_metrics.cc
+++ b/chrome/browser/web_applications/commands/command_metrics.cc
@@ -24,6 +24,8 @@
return ".InstallIsolatedWebApp";
case InstallCommand::kWebAppInstallFromUrl:
return ".WebInstallFromUrl";
+ case InstallCommand::kInstallFromManifestUrl:
+ return ".InstallFromManifestUrl";
}
}
diff --git a/chrome/browser/web_applications/commands/command_metrics.h b/chrome/browser/web_applications/commands/command_metrics.h
index e23ded9..54b4250 100644
--- a/chrome/browser/web_applications/commands/command_metrics.h
+++ b/chrome/browser/web_applications/commands/command_metrics.h
@@ -24,6 +24,8 @@
kInstallIsolatedWebApp,
// An installation triggered by the `navigator.install()` Web API.
kWebAppInstallFromUrl,
+ // An manifest-first installation triggered by the web install API.
+ kInstallFromManifestUrl,
};
// LINT.ThenChange(//tools/metrics/histograms/metadata/webapps/histograms.xml)
diff --git a/chrome/browser/web_applications/commands/web_install_from_manifest_command.cc b/chrome/browser/web_applications/commands/web_install_from_manifest_command.cc
new file mode 100644
index 0000000..023a3fb
--- /dev/null
+++ b/chrome/browser/web_applications/commands/web_install_from_manifest_command.cc
@@ -0,0 +1,318 @@
+// Copyright 2026 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/commands/web_install_from_manifest_command.h"
+
+#include <memory>
+
+#include "base/functional/bind.h"
+#include "base/metrics/histogram_functions.h"
+#include "chrome/browser/profiles/profile.h"
+#include "chrome/browser/web_applications/commands/command_metrics.h"
+#include "chrome/browser/web_applications/commands/web_app_command.h"
+#include "chrome/browser/web_applications/install_bounce_metric.h"
+#include "chrome/browser/web_applications/jobs/finalize_install_job.h"
+#include "chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job.h"
+#include "chrome/browser/web_applications/locks/shared_web_contents_lock.h"
+#include "chrome/browser/web_applications/locks/shared_web_contents_with_app_lock.h"
+#include "chrome/browser/web_applications/locks/web_app_lock_manager.h"
+#include "chrome/browser/web_applications/web_app_command_manager.h"
+#include "chrome/browser/web_applications/web_app_command_scheduler.h"
+#include "chrome/browser/web_applications/web_app_helpers.h"
+#include "chrome/browser/web_applications/web_app_install_info.h"
+#include "chrome/browser/web_applications/web_app_install_params.h"
+#include "chrome/browser/web_applications/web_app_install_utils.h"
+#include "chrome/browser/web_applications/web_app_provider.h"
+#include "chrome/browser/web_applications/web_app_registrar.h"
+#include "chrome/browser/web_applications/web_contents/web_app_data_retriever.h"
+#include "chrome/browser/web_applications/web_contents/web_contents_manager.h"
+#include "components/services/app_service/public/cpp/app_launch_util.h"
+#include "components/webapps/browser/install_result_code.h"
+#include "components/webapps/browser/installable/installable_metrics.h"
+#include "components/webapps/common/web_app_id.h"
+#include "content/public/browser/page.h"
+#include "content/public/browser/web_contents.h"
+#include "third_party/blink/public/mojom/manifest/manifest.mojom.h"
+
+namespace {
+constexpr webapps::WebappInstallSource kInstallSource =
+ webapps::WebappInstallSource::WEB_INSTALL;
+} // namespace
+
+namespace web_app {
+
+WebInstallFromManifestCommand::WebInstallFromManifestCommand(
+ Profile& profile,
+ blink::mojom::ManifestPtr manifest,
+ const GURL& manifest_url,
+ base::WeakPtr<content::WebContents> initiating_web_contents,
+ base::WeakPtr<content::Page> initiating_page,
+ const GURL& requesting_page_url,
+ WebAppInstallDialogCallback dialog_callback,
+ WebInstallFromManifestCommandCallback installed_callback)
+ : WebAppCommand<SharedWebContentsLock,
+ const webapps::AppId&,
+ webapps::InstallResultCode>(
+ "WebInstallFromManifestCommand",
+ SharedWebContentsLockDescription(),
+ base::BindOnce(
+ [](WebInstallFromManifestCommandCallback callback,
+ const webapps::AppId& app_id,
+ webapps::InstallResultCode code) {
+ RecordInstallMetrics(InstallCommand::kInstallFromManifestUrl,
+ WebAppType::kCraftedApp, code,
+ kInstallSource);
+ std::move(callback).Run(app_id, code);
+ },
+ std::move(installed_callback)),
+ /*args_for_shutdown=*/
+ std::make_tuple(webapps::AppId(),
+ webapps::InstallResultCode::
+ kCancelledOnWebAppProviderShuttingDown)),
+ profile_(profile),
+ manifest_(std::move(manifest)),
+ manifest_url_(manifest_url),
+ initiating_web_contents_(initiating_web_contents),
+ initiating_page_(std::move(initiating_page)),
+ requesting_page_url_(requesting_page_url),
+ dialog_callback_(std::move(dialog_callback)) {
+ if (initiating_web_contents_) {
+ Observe(initiating_web_contents_.get());
+ }
+ GetMutableDebugValue().Set("manifest_url_param", manifest_url_.spec());
+ GetMutableDebugValue().Set("installed_by", requesting_page_url_.spec());
+}
+
+WebInstallFromManifestCommand::~WebInstallFromManifestCommand() = default;
+
+content::WebContents* WebInstallFromManifestCommand::GetInstallingWebContents(
+ base::PassKey<WebAppCommandManager>) {
+ return initiating_web_contents_.get();
+}
+
+void WebInstallFromManifestCommand::StartWithLock(
+ std::unique_ptr<SharedWebContentsLock> lock) {
+ web_contents_lock_ = std::move(lock);
+
+ webapps::InstallableMetrics::TrackInstallEvent(kInstallSource);
+
+ // The dialog is anchored to the initiating page and reads its origin; bail
+ // out if the page went away or navigated to a new document while the shared
+ // web contents lock was being acquired (between being queued and getting a
+ // lock).
+ if (page_changed_before_start_ || IsInitiatingPageGone()) {
+ Abort(webapps::InstallResultCode::kCancelledDueToMainFrameNavigation);
+ return;
+ }
+
+ // The Web Install API only installs crafted apps, which must declare at least
+ // one icon in their manifest.
+ if (manifest_->icons.empty()) {
+ Abort(webapps::InstallResultCode::kNoValidIconsInManifest);
+ return;
+ }
+
+ data_retriever_ =
+ web_contents_lock_->web_contents_manager().CreateDataRetriever();
+
+ // Convert manifest to WebAppInstallInfo and download icons via the shared
+ // web contents (about:blank context — no user session cookies).
+ manifest_to_install_info_job_ =
+ ManifestToWebAppInstallInfoJob::CreateAndStart(
+ *manifest_, *data_retriever_.get(),
+ /*background_installation=*/true, kInstallSource,
+ web_contents_lock_->shared_web_contents().GetWeakPtr(),
+ [](IconUrlSizeSet& icon_url_size_set) {}, GetMutableDebugValue(),
+ base::BindOnce(&WebInstallFromManifestCommand::
+ OnWebAppInstallInfoCreatedShowDialog,
+ weak_ptr_factory_.GetWeakPtr()));
+}
+
+void WebInstallFromManifestCommand::PrimaryPageChanged(content::Page& page) {
+ // The primary page changed on the initiating WebContents. This means a
+ // cross-document navigation committed. The install context is no longer
+ // valid, so the install must not proceed.
+ if (!IsStarted()) {
+ // Command is queued but hasn't acquired its lock yet. Record the event so
+ // StartWithLock can abort safely.
+ page_changed_before_start_ = true;
+ return;
+ }
+ Abort(webapps::InstallResultCode::kCancelledDueToMainFrameNavigation);
+}
+
+void WebInstallFromManifestCommand::OnWebAppInstallInfoCreatedShowDialog(
+ std::unique_ptr<WebAppInstallInfo> install_info) {
+ CHECK(install_info);
+ // Critical origin-spoofing check: if the initiating page navigated to a new
+ // document while we were downloading icons, the dialog would read the wrong
+ // origin. Abort before showing it.
+ if (IsInitiatingPageGone()) {
+ Abort(webapps::InstallResultCode::kCancelledDueToMainFrameNavigation);
+ return;
+ }
+
+ web_app_info_ = std::move(install_info);
+ web_app_info_->manifest_url = manifest_url_;
+ // Required to display the dialog's "requesting origin" subtitle.
+ web_app_info_->installed_by = requesting_page_url_;
+
+ // Upgrade lock to include app lock for finalization.
+ std::optional<webapps::ManifestId> opt_manifest_id =
+ webapps::ManifestId::Create(manifest_->id);
+ // `manifest_->id` is guaranteed valid: it derives from `manifest_url_`
+ // which is validated as an HTTPS/localhost URL, and then used as the
+ // document_url during parsing.
+ CHECK(opt_manifest_id.has_value());
+
+ CHECK(!shared_web_contents_with_app_lock_);
+ shared_web_contents_with_app_lock_ =
+ std::make_unique<SharedWebContentsWithAppLock>();
+ command_manager()->lock_manager().UpgradeAndAcquireLock(
+ std::move(web_contents_lock_), *shared_web_contents_with_app_lock_,
+ {GenerateAppIdFromManifestId(*opt_manifest_id)},
+ base::BindOnce(&WebInstallFromManifestCommand::OnAppLockAcquired,
+ weak_ptr_factory_.GetWeakPtr()));
+}
+
+void WebInstallFromManifestCommand::OnAppLockAcquired() {
+ CHECK(shared_web_contents_with_app_lock_->IsGranted());
+
+ // The app lock is now held, but the initiating page may have gone away or
+ // navigated to a new document while acquiring it.
+ // `WebAppInstallFlowDialogDelegate` dereferences the WebContents without a
+ // null check and reads the page origin, so guard here.
+ if (IsInitiatingPageGone()) {
+ Abort(webapps::InstallResultCode::kCancelledDueToMainFrameNavigation);
+ return;
+ }
+ // Show the install dialog on the initiating page.
+ // TODO(crbug.com/415825168): Support detailed install dialog for background
... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/web_applications/test/fake_web_app_ui_manager.cc b/chrome/browser/web_applications/test/fake_web_app_ui_manager.cc
index a253503..4c344cb 100644
--- a/chrome/browser/web_applications/test/fake_web_app_ui_manager.cc
+++ b/chrome/browser/web_applications/test/fake_web_app_ui_manager.cc
@@ -220,6 +220,17 @@
NOTIMPLEMENTED();
}
+void FakeWebAppUiManager::TriggerInstallDialogForManifestInstall(
+ content::WebContents* initiating_web_contents,
+ base::WeakPtr<content::Page> initiating_page,
+ std::unique_ptr<webapps::MlInstallOperationTracker> tracker,
+ blink::mojom::ManifestPtr manifest,
+ const GURL& manifest_url,
+ const GURL& requesting_page_url,
+ InstallCallback callback) {
+ NOTIMPLEMENTED();
+}
+
void FakeWebAppUiManager::TriggerLaunchDialogForBackgroundInstall(
content::WebContents* initiating_web_contents,
const webapps::AppId& app_id,
diff --git a/chrome/browser/web_applications/test/fake_web_app_ui_manager.h b/chrome/browser/web_applications/test/fake_web_app_ui_manager.h
index 350e3379..e3970d1 100644
--- a/chrome/browser/web_applications/test/fake_web_app_ui_manager.h
+++ b/chrome/browser/web_applications/test/fake_web_app_ui_manager.h
@@ -134,6 +134,15 @@
const GURL& last_committed_url,
InstallCallback callback) override;
+ void TriggerInstallDialogForManifestInstall(
+ content::WebContents* initiating_web_contents,
+ base::WeakPtr<content::Page> initiating_page,
+ std::unique_ptr<webapps::MlInstallOperationTracker> tracker,
+ blink::mojom::ManifestPtr manifest,
+ const GURL& manifest_url,
+ const GURL& requesting_page_url,
+ InstallCallback callback) override;
+
void TriggerLaunchDialogForBackgroundInstall(
content::WebContents* initiating_web_contents,
const webapps::AppId& app_id,
diff --git a/chrome/browser/web_applications/web_install_from_manifest_browsertest.cc b/chrome/browser/web_applications/web_install_from_manifest_browsertest.cc
index 26ac074..2ca25cf7 100644
--- a/chrome/browser/web_applications/web_install_from_manifest_browsertest.cc
+++ b/chrome/browser/web_applications/web_install_from_manifest_browsertest.cc
@@ -2,28 +2,68 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+#include <deque>
+#include <memory>
+#include <optional>
+
#include "base/command_line.h"
+#include "base/containers/span.h"
+#include "base/files/file_util.h"
+#include "base/path_service.h"
+#include "base/strings/string_number_conversions.h"
+#include "base/strings/string_util.h"
+#include "base/test/bind.h"
+#include "base/test/metrics/histogram_tester.h"
+#include "base/test/run_until.h"
#include "base/test/scoped_feature_list.h"
+#include "base/test/test_future.h"
+#include "base/threading/thread_restrictions.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/browser.h"
+#include "chrome/browser/ui/tabs/tab_enums.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
+#include "chrome/browser/ui/web_applications/test/web_app_browsertest_util.h"
#include "chrome/browser/ui/web_applications/web_app_browsertest_base.h"
+#include "chrome/browser/ui/web_applications/web_app_dialogs.h"
+#include "chrome/browser/web_applications/model/app_installed_by.h"
+#include "chrome/browser/web_applications/test/web_app_test_observers.h"
+#include "chrome/browser/web_applications/web_app.h"
+#include "chrome/browser/web_applications/web_app_command_manager.h"
+#include "chrome/browser/web_applications/web_app_filter.h"
+#include "chrome/browser/web_applications/web_app_helpers.h"
+#include "chrome/browser/web_applications/web_app_provider.h"
+#include "chrome/browser/web_applications/web_app_registrar.h"
+#include "chrome/common/chrome_features.h"
+#include "chrome/common/chrome_paths.h"
#include "chrome/test/base/ui_test_utils.h"
#include "components/permissions/permission_request_manager.h"
#include "components/permissions/test/permission_request_observer.h"
+#include "components/url_formatter/elide_url.h"
+#include "components/webapps/browser/install_result_code.h"
+#include "components/webapps/common/web_app_id.h"
#include "content/public/browser/web_contents.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
+#include "net/test/embedded_test_server/controllable_http_response.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "net/test/embedded_test_server/http_request.h"
#include "net/test/embedded_test_server/http_response.h"
+#include "skia/ext/image_operations.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/features_generated.h"
+#include "third_party/blink/public/mojom/use_counter/metrics/webdx_feature.mojom.h"
+#include "ui/base/models/image_model.h"
+#include "ui/gfx/codec/png_codec.h"
+#include "ui/gfx/image/image_unittest_util.h"
#include "ui/views/bubble/bubble_dialog_delegate_view.h"
+#include "ui/views/controls/image_view.h"
+#include "ui/views/controls/label.h"
+#include "ui/views/interaction/element_tracker_views.h"
#include "ui/views/test/dialog_test.h"
#include "ui/views/test/widget_test.h"
#include "ui/views/widget/any_widget_observer.h"
#include "url/gurl.h"
+#include "url/origin.h"
#if BUILDFLAG(IS_CHROMEOS)
#include "ash/constants/ash_switches.h"
@@ -45,13 +85,45 @@
constexpr char kDataError[] = "DataError";
constexpr char kSecurityError[] = "SecurityError";
constexpr char kValidManifestNoId[] = "/banners/manifest.json";
+constexpr webapps::WebappInstallSource kInstallSource =
+ webapps::WebappInstallSource::WEB_INSTALL;
+constexpr apps::LaunchSource kLaunchSource =
+ apps::LaunchSource::kFromWebInstallApi;
+
+// Records whether a named widget is ever shown. Construct it before firing the
+// install and query `shown()` afterwards. Defaults to watching the simple
+// install dialog.
+class WebInstallDialogShownWatcher {
+ public:
+ explicit WebInstallDialogShownWatcher(
+ std::string widget_name = "WebAppSimpleInstallDialog")
+ : widget_name_(std::move(widget_name)) {
+ observer_.set_shown_callback(
+ base::BindLambdaForTesting([this](views::Widget* widget) {
+ if (widget->GetName() == widget_name_) {
+ shown_ = true;
+ }
+ }));
+ }
+
+ bool shown() const { return shown_; }
+
+ private:
+ const std::string widget_name_;
+ bool shown_ = false;
+ views::AnyWidgetObserver observer_{views::test::AnyWidgetTestPasskey{}};
+};
// Browser tests for the navigator.install({manifest: ...}) flow.
// These require a real renderer because manifest parsing uses the
// ManifestManager mojo interface via ParseManifestFromStringJob.
class WebInstallFromManifestBrowserTest : public WebAppBrowserTestBase {
public:
- WebInstallFromManifestBrowserTest() = default;
+ WebInstallFromManifestBrowserTest() {
+ scoped_feature_list_.InitWithFeatures(
+ {blink::features::kWebAppInstallation},
+ {features::kWebAppInstallDialog});
+ }
void SetUpOnMainThread() override {
// Register a handler for dynamic test-specific responses (e.g., invalid
@@ -61,6 +133,10 @@
&WebInstallFromManifestBrowserTest::HandleDynamicRequest,
base::Unretained(this)));
WebAppBrowserTestBase::SetUpOnMainThread();
+ // A second HTTPS origin, used to navigate the initiating page cross-origin
+ // mid-install.
+ secondary_server_.AddDefaultHandlers(GetChromeTestDataDir());
+ ASSERT_TRUE(secondary_server_.Start());
}
content::WebContents* web_contents() {
@@ -98,8 +174,25 @@
return content::ExecJs(wc, script);
}
- bool ResultExists() {
- return content::ExecJs(web_contents(), "webInstallResult");
+ // Calls navigator.install({manifest: manifest_url}) without resolving the
+ // returned promise. For tests that
+ // interrupt the flow (navigate away, close the tab, or inspect the dialog).
+ bool FireInstallFromManifestNoResolve(
+ const GURL& manifest_url,
+ content::WebContents* contents = nullptr) {
+ content::WebContents* wc = contents ? contents : web_contents();
+ const std::string script = content::JsReplace(
+ "navigator.install({manifest: $1})"
+ ".then(result => { webInstallResult = result; })"
+ ".catch(error => { webInstallError = error; });",
+ manifest_url.spec());
+ return content::ExecJs(wc, script,
+ content::EXECUTE_SCRIPT_NO_RESOLVE_PROMISES);
+ }
+
+ bool ResultExists(content::WebContents* contents = nullptr) {
+ content::WebContents* wc = contents ? contents : web_contents();
+ return content::ExecJs(wc, "webInstallResult");
}
bool ErrorExists(content::WebContents* contents = nullptr) {
@@ -131,6 +224,16 @@
->set_auto_response_for_test(response);
}
+ std::deque<AppInstalledBy> GetInstalledBy(const webapps::AppId& app_id) {
+ const WebApp* app = provider().registrar_unsafe().GetAppById(app_id);
+ CHECK(app);
+ return app->installed_by();
+ }
+
+ protected:
+ net::EmbeddedTestServer secondary_server_{
+ net::EmbeddedTestServer::TYPE_HTTPS};
+
private:
std::unique_ptr<net::test_server::HttpResponse> HandleDynamicRequest(
const net::test_server::HttpRequest& request) {
@@ -156,8 +259,7 @@
}
std::string dynamic_manifest_json_;
- base::test::ScopedFeatureList scoped_feature_list_{
- blink::features::kWebAppInstallation};
+ base::test::ScopedFeatureList scoped_feature_list_;
};
// Valid manifest with custom id, no id option provided.
@@ -165,20 +267,51 @@
ManifestOnly_Succeeds) {
NavigateToValidUrl();
SetPermissionResponse(/*permission_granted=*/true);
+ base::AutoReset<web_app::InstallDialogTestResponse> auto_accept_pwa =
+ web_app::SetPwaInstallationAutoRespondForTesting(
+ web_app::InstallDialogTestResponse::kAcceptAndLaunch);
+
GURL manifest_url =
embedded_https_test_server().GetURL("/banners/manifest_with_id.json");
permissions::PermissionRequestObserver observer(web_contents());
+ base::HistogramTester histograms;
ASSERT_TRUE(TryInstallFromManifest(manifest_url));
observer.Wait();
// The permission prompt must have been surfaced before granting.
EXPECT_TRUE(observer.request_shown());
- // TODO(liahiscock): validation passes, but install is not yet implemented.
- EXPECT_FALSE(ResultExists());
- EXPECT_TRUE(ErrorExists());
- EXPECT_EQ(GetErrorName(), kAbortError);
+ EXPECT_TRUE(ResultExists());
+ EXPECT_FALSE(ErrorExists());
+
+ // Verify the app is registered.
+ GURL manifest_id = embedded_https_test_server().GetURL("/some_id");
+ webapps::AppId app_id =
+ web_app::GenerateAppIdFromManifestId(webapps::ManifestId(manifest_id));
+ EXPECT_TRUE(provider().registrar_unsafe().AppMatches(
+ app_id, WebAppFilter::LaunchableFromInstallApi()));
+
+ // Verify the app is correctly attributed to the document that called
+ // navigator.install, i.e. the current committed URL.
+ std::deque<AppInstalledBy> installed_by = GetInstalledBy(app_id);
+ ASSERT_EQ(installed_by.size(), 1u);
+ EXPECT_FALSE(installed_by.front().install_api_call_time().is_null());
+ EXPECT_EQ(installed_by.front().requesting_url(),
+ web_contents()->GetLastCommittedURL());
+
+ test::CompletePageLoadForAllWebContents();
+ histograms.ExpectUniqueSample("WebApp.Install.Source.Success", kInstallSource,
+ 1);
+ histograms.ExpectUniqueSample(
+ "WebApp.InstallCommand.InstallFromManifestUrl.ResultCode",
+ webapps::InstallResultCode::kSuccessNewInstall, 1);
+ histograms.ExpectUniqueSample("WebApp.LaunchSource", kLaunchSource, 1);
+ histograms.ExpectUniqueSample("WebApp.NewCraftedAppInstalled.ByUser",
+ /*sample=*/true, 1);
+ histograms.ExpectBucketCount("Blink.UseCounter.WebDXFeatures",
+ blink::mojom::WebDXFeature::kNavigatorInstall,
+ 1);
}
// Valid manifest with custom id, matching id option.
@@ -186,6 +319,10 @@
ManifestAndId_Succeeds) {
NavigateToValidUrl();
SetPermissionResponse(/*permission_granted=*/true);
+ base::AutoReset<web_app::InstallDialogTestResponse> auto_accept_pwa =
+ web_app::SetPwaInstallationAutoRespondForTesting(
+ web_app::InstallDialogTestResponse::kAcceptAndLaunch);
+
GURL manifest_url =
embedded_https_test_server().GetURL("/banners/manifest_with_id.json");
// manifest_with_id.json has "id": "some_id", which resolves relative to
@@ -193,16 +330,42 @@
GURL manifest_id = embedded_https_test_server().GetURL("/some_id");
permissions::PermissionRequestObserver observer(web_contents());
+ base::HistogramTester histograms;
... (truncated)