fix(renderer): derive popup policy from current input

This commit is contained in:
ldm0
2026-08-28 15:06:54 +08:00
committed by Donough Liu
parent ba64bba43d
commit 7f7a503b90
13 changed files with 977 additions and 134 deletions
+9 -5
View File
@@ -107,11 +107,15 @@ auto-attach reconciliation without duplicate sessions, stable Tab-to-Page
ownership across foreground changes, `Target.createTarget` foreground and
background behavior, Page visibility changes, screencast visibility
`true -> false -> true` across demotion and close-driven promotion, and a real
default discovery target that is not recreated after close. The complete
12-contract group passed three consecutive fresh Chromium processes and five
consecutive fresh Moli processes. Chromium source revision `a03603fe9af6` was
also inspected to explain the access-mode and auto-attach machinery; the
executable probe remains the behavioral authority.
default discovery target that is not recreated after close. It also drives a
real middle-button press/release through `Input.dispatchMouseEvent` and checks
that the trusted `auxclick` anchor default action leaves the source visible and
creates a hidden background Page. The original 12-contract matrix passed three
consecutive fresh Chromium processes and five consecutive fresh Moli processes;
the middle-click contract was then run unchanged against both engines.
Chromium source revision `a03603fe9af6` was also inspected to explain the
access-mode and auto-attach machinery; the executable probe remains the
behavioral authority.
## Current Coverage
@@ -106,6 +106,21 @@ def _raw_semantic_contracts() -> tuple[RawSemanticContract, ...]:
],
_noopener_popup_devtools_attribution,
),
RawSemanticContract(
"raw_cdp_contract_middle_click_background_popup",
"A real middle-button press/release dispatches an uncanceled auxclick whose anchor default action opens a background Page target while the source target stays foreground.",
"Chromium CurrentInputEvent, NavigationPolicyFromEvent, and HTMLAnchorElement auxclick default action",
[
"Target.createBrowserContext",
"Target.setDiscoverTargets",
"Target.createTarget",
"Target.attachToTarget x2",
"Runtime.evaluate",
"Input.dispatchMouseEvent x2",
"Target.getTargetInfo",
],
_middle_click_background_popup,
),
RawSemanticContract(
"raw_cdp_contract_target_multi_attach_independence",
"Each flattened attachment has a distinct session and detaching one session leaves the other attachment usable.",
@@ -1375,6 +1390,202 @@ async def _noopener_popup_devtools_attribution(
await client.websocket.close()
async def _middle_click_background_popup(
endpoint: str,
fixture: str,
) -> dict[str, Any]:
client = await connect_raw_cdp(endpoint)
browser_context_id: str | None = None
source_target_id: str | None = None
popup_target_id: str | None = None
stage = "create browser context"
try:
context_result, _ = await _raw_command(client, "Target.createBrowserContext")
browser_context_id = context_result["browserContextId"]
await _raw_command(client, "Target.setDiscoverTargets", {"discover": True})
source_url = f"{fixture}/plain?middle-click-source=1"
popup_url = f"{fixture}/plain?middle-click-popup=1"
stage = "create middle-click source target"
source_result, _ = await _raw_command(
client,
"Target.createTarget",
{"url": source_url, "browserContextId": browser_context_id},
)
source_target_id = source_result["targetId"]
attached, _ = await _raw_command(
client,
"Target.attachToTarget",
{"targetId": source_target_id, "flatten": True},
)
source_session_id = attached["sessionId"]
source_document: dict[str, Any] | None = None
async def source_document_loaded() -> bool:
nonlocal source_document
evaluation, _ = await _raw_command(
client,
"Runtime.evaluate",
{
"expression": "({url: location.href, readyState: document.readyState})",
"returnByValue": True,
},
session_id=source_session_id,
)
candidate = evaluation["result"].get("value")
source_document = candidate if isinstance(candidate, dict) else None
return source_document == {"url": source_url, "readyState": "complete"}
stage = "wait for middle-click source document"
await wait_until(source_document_loaded, "middle-click source document complete")
stage = "install middle-click anchor"
fixture_result, _ = await _raw_command(
client,
"Runtime.evaluate",
{
"expression": (
"(() => {"
"document.body.replaceChildren();"
"document.body.style.margin = '0';"
"const anchor = document.createElement('a');"
f"anchor.href = {json.dumps(popup_url)};"
"anchor.textContent = 'open';"
"anchor.style.cssText = 'position:fixed;left:0;top:0;width:120px;height:80px;display:block';"
"window.__middleAuxclickEvents = [];"
"anchor.addEventListener('auxclick', event => {"
"window.__middleAuxclickEvents.push(`${event.type}:${event.button}:${event.isTrusted}`);"
"});"
"document.body.append(anchor);"
"return anchor.getBoundingClientRect().toJSON();"
"})()"
),
"returnByValue": True,
},
session_id=source_session_id,
)
fixture_rect = fixture_result["result"].get("value")
_require(isinstance(fixture_rect, dict), "middle-click anchor has no layout rect")
stage = "dispatch real middle-button input"
await _raw_command(
client,
"Input.dispatchMouseEvent",
{
"type": "mousePressed",
"x": 20,
"y": 20,
"button": "middle",
"buttons": 4,
"clickCount": 1,
},
session_id=source_session_id,
)
_, release_seen = await _raw_command(
client,
"Input.dispatchMouseEvent",
{
"type": "mouseReleased",
"x": 20,
"y": 20,
"button": "middle",
"buttons": 0,
"clickCount": 1,
},
session_id=source_session_id,
)
auxclick_result, auxclick_seen = await _raw_command(
client,
"Runtime.evaluate",
{
"expression": "window.__middleAuxclickEvents.join('|')",
"returnByValue": True,
},
session_id=source_session_id,
)
assert_equal(
auxclick_result["result"].get("value"),
"auxclick:1:true",
"real middle-button auxclick",
)
stage = "wait for middle-click popup target"
popup_event = await _raw_wait_for_event(
client,
"Target.targetCreated",
lambda params: params.get("targetInfo", {}).get("browserContextId")
== browser_context_id
and params.get("targetInfo", {}).get("targetId") != source_target_id,
[*release_seen, *auxclick_seen],
)
popup_target_id = popup_event["params"]["targetInfo"]["targetId"]
popup_info: dict[str, Any] | None = None
async def popup_navigation_committed() -> bool:
nonlocal popup_info
result, _ = await _raw_command(
client,
"Target.getTargetInfo",
{"targetId": popup_target_id},
)
candidate = result.get("targetInfo")
popup_info = candidate if isinstance(candidate, dict) else None
return popup_info is not None and popup_info.get("url") == popup_url
stage = "wait for middle-click popup navigation"
await wait_until(popup_navigation_committed, "middle-click popup navigation")
popup_attach, _ = await _raw_command(
client,
"Target.attachToTarget",
{"targetId": popup_target_id, "flatten": True},
)
async def visibility(session_id: str) -> str | None:
result, _ = await _raw_command(
client,
"Runtime.evaluate",
{
"expression": "document.visibilityState",
"returnByValue": True,
},
session_id=session_id,
)
return result.get("result", {}).get("value")
source_visibility = await visibility(source_session_id)
popup_visibility = await visibility(popup_attach["sessionId"])
assert_equal(source_visibility, "visible", "middle-click source visibility")
assert_equal(popup_visibility, "hidden", "middle-click popup visibility")
return {
"auxclick": auxclick_result["result"].get("value"),
"popupUrl": popup_info.get("url") if popup_info is not None else None,
"sourceVisibility": source_visibility,
"popupVisibility": popup_visibility,
}
except Exception as error:
raise SmokeError(f"{stage}: {type(error).__name__}: {error}") from error
finally:
for target_id in (popup_target_id, source_target_id):
if target_id is not None:
await _ignore_raw_error(
client,
"Target.closeTarget",
{"targetId": target_id},
)
if browser_context_id is not None:
await _ignore_raw_error(
client,
"Target.disposeBrowserContext",
{"browserContextId": browser_context_id},
)
await client.websocket.close()
async def _target_close_lifecycle(
endpoint: str,
_fixture: str,
@@ -6,7 +6,7 @@ use crate::{
context_bootstrap::CHILD_BROWSING_CONTEXT_HANDLE_SLOT,
document_runtime::{DocumentPolicyContainer, DomHandle},
native_bridge::{
child_window_handle_from_marker_data,
InputNavigationPolicy, child_window_handle_from_marker_data,
element::{
SpecialBrowsingContextTarget, navigate_existing_browsing_context_target,
navigate_named_iframe_target,
@@ -221,6 +221,21 @@ pub(crate) fn window_open_callback<'s>(
let opener = (!suppress_opener).then_some(entered_window);
let opener_child_handle =
opener.and_then(|opener| window_open_receiver_child_handle(scope, opener));
let popup_disposition = match host
.current_input_event()
.map(crate::native_bridge::CurrentInputEvent::navigation_policy)
{
Some(InputNavigationPolicy::NewBackgroundSurface) => {
crate::RendererPopupDisposition::Background
}
Some(
InputNavigationPolicy::Current
| InputNavigationPolicy::Download
| InputNavigationPolicy::NewWindow
| InputNavigationPolicy::NewForegroundSurface,
)
| None => crate::RendererPopupDisposition::Foreground,
};
if popup_target_can_use_lightweight_window(&parsed.target_name, &url)
&& let Some(opened_popup) = host.open_lightweight_popup_window(
scope,
@@ -240,9 +255,6 @@ pub(crate) fn window_open_callback<'s>(
let window_open_event = opened_popup
.created_new_browsing_context
.then_some(window_open_event);
let popup_disposition = host
.current_input_popup_disposition()
.unwrap_or(crate::RendererPopupDisposition::Foreground);
host.record_pending_popup_activation(
RendererPendingPopupActivation::window(
root_document,
@@ -266,9 +278,6 @@ pub(crate) fn window_open_callback<'s>(
}
return;
}
let popup_disposition = host
.current_input_popup_disposition()
.unwrap_or(crate::RendererPopupDisposition::Foreground);
host.record_pending_popup_activation(
RendererPendingPopupActivation::window(
root_document,
@@ -267,7 +267,7 @@ impl JsContextHost {
timezone_override: None,
idle_override: None,
protocol_user_gesture_activation_depth: 0,
current_input_popup_disposition: None,
current_input_event: None,
webdriver_bidi_file_prompt_handler_stack: Vec::new(),
emulated_media: crate::protocol_types::EmulatedMediaOverrides::default(),
viewport_surface: None,
@@ -747,17 +747,15 @@ impl JsContextHost {
self.protocol_user_gesture_activation_depth > 0
}
pub(crate) fn replace_current_input_popup_disposition(
pub(crate) fn replace_current_input_event(
&mut self,
disposition: Option<crate::RendererPopupDisposition>,
) -> Option<crate::RendererPopupDisposition> {
std::mem::replace(&mut self.current_input_popup_disposition, disposition)
event: Option<crate::native_bridge::CurrentInputEvent>,
) -> Option<crate::native_bridge::CurrentInputEvent> {
std::mem::replace(&mut self.current_input_event, event)
}
pub(crate) fn current_input_popup_disposition(
&self,
) -> Option<crate::RendererPopupDisposition> {
self.current_input_popup_disposition
pub(crate) fn current_input_event(&self) -> Option<crate::native_bridge::CurrentInputEvent> {
self.current_input_event
}
pub(crate) fn begin_webdriver_bidi_file_prompt_handler(&mut self, handler: &str) {
@@ -869,7 +869,7 @@ pub(crate) struct JsContextHost {
timezone_override: Option<String>,
idle_override: Option<crate::protocol_types::EmulatedIdleOverride>,
protocol_user_gesture_activation_depth: usize,
current_input_popup_disposition: Option<crate::RendererPopupDisposition>,
current_input_event: Option<crate::native_bridge::CurrentInputEvent>,
webdriver_bidi_file_prompt_handler_stack: Vec<String>,
emulated_media: crate::protocol_types::EmulatedMediaOverrides,
viewport_surface: Option<crate::protocol_types::ViewportSurface>,
@@ -0,0 +1,278 @@
use std::{cell::RefCell, rc::Rc};
use super::JsContextHost;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct InputModifiers(u8);
impl InputModifiers {
const ALT: u8 = 1;
const CONTROL: u8 = 2;
const META: u8 = 4;
const SHIFT: u8 = 8;
const fn from_bits(bits: u8) -> Self {
Self(bits)
}
const fn alt(self) -> bool {
self.0 & Self::ALT != 0
}
const fn control(self) -> bool {
self.0 & Self::CONTROL != 0
}
const fn meta(self) -> bool {
self.0 & Self::META != 0
}
const fn shift(self) -> bool {
self.0 & Self::SHIFT != 0
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CurrentInputEventKind {
MouseUp { button: i32 },
EnterKey,
Other,
}
/// The navigation-relevant subset of the real platform input currently being
/// handled. DOM events are deliberately not stored here: synthetic events may
/// describe modifiers, but cannot manufacture the corresponding user intent.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct CurrentInputEvent {
kind: CurrentInputEventKind,
modifiers: InputModifiers,
}
impl CurrentInputEvent {
pub(crate) fn mouse(event_name: &str, button: i32, modifiers: u8) -> Self {
Self {
kind: if event_name == "mouseup" {
CurrentInputEventKind::MouseUp { button }
} else {
CurrentInputEventKind::Other
},
modifiers: InputModifiers::from_bits(modifiers),
}
}
pub(crate) fn keyboard(key: &str, modifiers: u8) -> Self {
Self {
kind: if key.eq_ignore_ascii_case("enter") {
CurrentInputEventKind::EnterKey
} else {
CurrentInputEventKind::Other
},
modifiers: InputModifiers::from_bits(modifiers),
}
}
pub(crate) fn navigation_policy(self) -> InputNavigationPolicy {
let button = match self.kind {
CurrentInputEventKind::MouseUp { button } => button,
CurrentInputEventKind::EnterKey => 0,
CurrentInputEventKind::Other => return InputNavigationPolicy::Current,
};
navigation_policy_from_modifiers(button, self.modifiers)
}
}
/// Chromium-shaped navigation intent before Moli folds window and tab chrome
/// into its foreground/background surface model.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum InputNavigationPolicy {
Current,
Download,
NewWindow,
NewForegroundSurface,
NewBackgroundSurface,
}
/// Computes a DOM event's requested policy, then applies Blink's synthetic
/// download and tab-under protections against the ambient real input.
pub(crate) fn navigation_policy_from_event(
button: i32,
modifiers: u8,
current_input: Option<CurrentInputEvent>,
) -> InputNavigationPolicy {
let event_policy =
navigation_policy_from_modifiers(button, InputModifiers::from_bits(modifiers));
let input_policy = current_input
.map(CurrentInputEvent::navigation_policy)
.unwrap_or(InputNavigationPolicy::Current);
match event_policy {
InputNavigationPolicy::Download if input_policy != InputNavigationPolicy::Download => {
InputNavigationPolicy::Current
}
InputNavigationPolicy::NewBackgroundSurface
if input_policy != InputNavigationPolicy::NewBackgroundSurface =>
{
InputNavigationPolicy::NewForegroundSurface
}
_ => event_policy,
}
}
fn navigation_policy_from_modifiers(
button: i32,
modifiers: InputModifiers,
) -> InputNavigationPolicy {
let platform_new_tab_modifier = if cfg!(target_os = "macos") {
modifiers.meta()
} else {
modifiers.control()
};
let requests_new_tab = button == 1 || platform_new_tab_modifier;
if !requests_new_tab && !modifiers.shift() && !modifiers.alt() {
InputNavigationPolicy::Current
} else if requests_new_tab {
if modifiers.shift() {
InputNavigationPolicy::NewForegroundSurface
} else {
InputNavigationPolicy::NewBackgroundSurface
}
} else if modifiers.shift() {
InputNavigationPolicy::NewWindow
} else {
InputNavigationPolicy::Download
}
}
/// Restores the ambient real input automatically across early returns,
/// nested input dispatch, and unwinding.
#[must_use = "dropping the scope immediately would discard the current input event"]
pub(crate) struct CurrentInputEventScope {
context_host: Rc<RefCell<JsContextHost>>,
previous: Option<CurrentInputEvent>,
installed: CurrentInputEvent,
}
impl CurrentInputEventScope {
pub(crate) fn enter(
context_host: Rc<RefCell<JsContextHost>>,
event: CurrentInputEvent,
) -> Self {
let previous = context_host
.borrow_mut()
.replace_current_input_event(Some(event));
Self {
context_host,
previous,
installed: event,
}
}
}
impl Drop for CurrentInputEventScope {
fn drop(&mut self) {
let replaced = self
.context_host
.borrow_mut()
.replace_current_input_event(self.previous);
assert_eq!(
replaced,
Some(self.installed),
"the current input event must remain scoped to its real input dispatch"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALT: u8 = 1;
const CONTROL: u8 = 2;
const META: u8 = 4;
const SHIFT: u8 = 8;
#[cfg(target_os = "macos")]
const PLATFORM_NEW_TAB_MODIFIER: u8 = META;
#[cfg(not(target_os = "macos"))]
const PLATFORM_NEW_TAB_MODIFIER: u8 = CONTROL;
#[cfg(target_os = "macos")]
const NON_PLATFORM_NEW_TAB_MODIFIER: u8 = CONTROL;
#[cfg(not(target_os = "macos"))]
const NON_PLATFORM_NEW_TAB_MODIFIER: u8 = META;
#[test]
fn modifier_policy_matches_chromium_surface_selection() {
assert_eq!(
navigation_policy_from_modifiers(0, InputModifiers::from_bits(0)),
InputNavigationPolicy::Current
);
assert_eq!(
navigation_policy_from_modifiers(
0,
InputModifiers::from_bits(NON_PLATFORM_NEW_TAB_MODIFIER),
),
InputNavigationPolicy::Current
);
assert_eq!(
navigation_policy_from_modifiers(1, InputModifiers::from_bits(0)),
InputNavigationPolicy::NewBackgroundSurface
);
assert_eq!(
navigation_policy_from_modifiers(
0,
InputModifiers::from_bits(PLATFORM_NEW_TAB_MODIFIER),
),
InputNavigationPolicy::NewBackgroundSurface
);
assert_eq!(
navigation_policy_from_modifiers(0, InputModifiers::from_bits(SHIFT)),
InputNavigationPolicy::NewWindow
);
assert_eq!(
navigation_policy_from_modifiers(1, InputModifiers::from_bits(SHIFT)),
InputNavigationPolicy::NewForegroundSurface
);
assert_eq!(
navigation_policy_from_modifiers(0, InputModifiers::from_bits(ALT)),
InputNavigationPolicy::Download
);
}
#[test]
fn synthesized_events_cannot_request_tab_unders_or_downloads() {
assert_eq!(
navigation_policy_from_event(1, 0, None),
InputNavigationPolicy::NewForegroundSurface
);
assert_eq!(
navigation_policy_from_event(0, PLATFORM_NEW_TAB_MODIFIER, None),
InputNavigationPolicy::NewForegroundSurface
);
assert_eq!(
navigation_policy_from_event(0, ALT, None),
InputNavigationPolicy::Current
);
}
#[test]
fn only_navigation_capable_real_input_supplies_an_input_policy() {
assert_eq!(
CurrentInputEvent::mouse("mousedown", 0, PLATFORM_NEW_TAB_MODIFIER).navigation_policy(),
InputNavigationPolicy::Current
);
assert_eq!(
CurrentInputEvent::mouse("mouseup", 0, PLATFORM_NEW_TAB_MODIFIER).navigation_policy(),
InputNavigationPolicy::NewBackgroundSurface
);
assert_eq!(
CurrentInputEvent::keyboard("Enter", PLATFORM_NEW_TAB_MODIFIER).navigation_policy(),
InputNavigationPolicy::NewBackgroundSurface
);
assert_eq!(
CurrentInputEvent::keyboard(" ", PLATFORM_NEW_TAB_MODIFIER).navigation_policy(),
InputNavigationPolicy::Current
);
}
}
@@ -125,7 +125,7 @@ pub(crate) use activation::{
pub(crate) use activation::{
activate_handle_via_click, activate_handle_via_click_with_detail_and_modifiers,
activate_handle_via_synthetic_click, dispatched_click_activation_target,
finish_legacy_activation_for_dispatched_click,
finish_legacy_activation_for_dispatched_click, perform_auxiliary_link_default_action,
perform_click_default_action_for_dispatched_event, perform_drop_default_action,
prepare_legacy_activation_for_dispatched_click, replace_contenteditable_selection,
scroll_to_url_fragment_or_top, select_contenteditable_contents,
@@ -13,6 +13,7 @@ use crate::{
document_runtime::{DomHandle, EventTargetHandle},
frame_owner_model::DocumentId,
native_bridge::context_host::ChildBrowsingContextBootstrap,
native_bridge::{CurrentInputEvent, InputNavigationPolicy, navigation_policy_from_event},
runtime::RendererDocumentLifecycleIdentity,
};
@@ -730,45 +731,8 @@ fn dispatch_click_event(
Some(dispatch_public_event(scope, runtime_ptr, handle, event))
}
fn click_handle_internal(
scope: &mut v8::PinScope<'_, '_>,
runtime_ptr: *mut JsContextHost,
handle: DomHandle,
x: f64,
y: f64,
button: i32,
buttons: i32,
click_detail: i32,
modifiers: u8,
user_initiated: bool,
) -> RendererInputDispatchOutcome {
let popup_disposition = current_click_popup_disposition(button, modifiers, user_initiated);
let previous_popup_disposition = unsafe { &mut *runtime_ptr }
.replace_current_input_popup_disposition(Some(popup_disposition));
let outcome = click_handle_internal_with_current_input(
scope,
runtime_ptr,
handle,
x,
y,
button,
buttons,
click_detail,
modifiers,
user_initiated,
);
let replaced_popup_disposition = unsafe { &mut *runtime_ptr }
.replace_current_input_popup_disposition(previous_popup_disposition);
assert_eq!(
replaced_popup_disposition,
Some(popup_disposition),
"the current click popup disposition must remain scoped to its activation"
);
outcome
}
#[allow(clippy::too_many_arguments)]
fn click_handle_internal_with_current_input(
fn click_handle_internal(
scope: &mut v8::PinScope<'_, '_>,
runtime_ptr: *mut JsContextHost,
handle: DomHandle,
@@ -1045,6 +1009,23 @@ fn click_activation_default_action_handle(
None
}
fn hyperlink_activation_default_action_handle(
runtime: &JsContextHost,
target: DomHandle,
) -> Option<DomHandle> {
let mut current = Some(target);
while let Some(handle) = current {
if is_disabled_form_control(runtime, handle) {
return None;
}
if element_is_anchor_with_href(runtime, handle) {
return Some(handle);
}
current = runtime.dom_host().parent_node(handle);
}
None
}
enum ClickActivationDefaultAction {
Element(DomHandle),
LabelControl(DomHandle),
@@ -1203,6 +1184,26 @@ pub(crate) fn perform_click_default_action_for_dispatched_event(
);
}
pub(crate) fn perform_auxiliary_link_default_action(
scope: &mut v8::PinScope<'_, '_>,
runtime_ptr: *mut JsContextHost,
target: DomHandle,
button: i32,
modifiers: u8,
pending_child_navigations_before_default: &[(DomHandle, ChildBrowsingContextBootstrap)],
) -> Option<RendererPendingDownloadActivation> {
let handle = hyperlink_activation_default_action_handle(unsafe { &*runtime_ptr }, target)?;
anchor_click_default_action(
scope,
runtime_ptr,
handle,
button,
modifiers,
true,
pending_child_navigations_before_default,
)
}
pub(crate) enum DispatchedClickLegacyActivation {
Checkbox {
handle: DomHandle,
@@ -1855,7 +1856,8 @@ fn anchor_click_default_action(
suggested_filename,
);
}
let navigation_policy = hyperlink_navigation_policy(button, modifiers, user_initiated);
let navigation_policy =
hyperlink_navigation_policy(button, modifiers, runtime.current_input_event());
if navigation_policy == HyperlinkNavigationPolicy::Download {
return anchor_download_activation(
scope,
@@ -2015,47 +2017,19 @@ enum HyperlinkNavigationPolicy {
fn hyperlink_navigation_policy(
button: i32,
modifiers: u8,
user_initiated: bool,
current_input: Option<CurrentInputEvent>,
) -> HyperlinkNavigationPolicy {
const ALT: u8 = 1;
const SHIFT: u8 = 8;
#[cfg(target_os = "macos")]
let platform_new_tab_modifier = 4;
#[cfg(not(target_os = "macos"))]
let platform_new_tab_modifier = 2;
let requests_new_tab = button == 1 || modifiers & platform_new_tab_modifier != 0;
let shift = modifiers & SHIFT != 0;
let alt = modifiers & ALT != 0;
if requests_new_tab {
let disposition = if shift || !user_initiated {
RendererPopupDisposition::Foreground
} else {
RendererPopupDisposition::Background
};
HyperlinkNavigationPolicy::Auxiliary(disposition)
} else if shift {
// The renderer target model has no separate window chrome. Preserve
// Chromium's selected-surface behavior by folding a new window into a
// foreground auxiliary target.
HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Foreground)
} else if alt && user_initiated {
HyperlinkNavigationPolicy::Download
} else {
HyperlinkNavigationPolicy::Current
}
}
fn current_click_popup_disposition(
button: i32,
modifiers: u8,
user_initiated: bool,
) -> RendererPopupDisposition {
match hyperlink_navigation_policy(button, modifiers, user_initiated) {
HyperlinkNavigationPolicy::Auxiliary(disposition) => disposition,
HyperlinkNavigationPolicy::Current | HyperlinkNavigationPolicy::Download => {
RendererPopupDisposition::Foreground
match navigation_policy_from_event(button, modifiers, current_input) {
InputNavigationPolicy::Current => HyperlinkNavigationPolicy::Current,
InputNavigationPolicy::Download => HyperlinkNavigationPolicy::Download,
InputNavigationPolicy::NewBackgroundSurface => {
HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Background)
}
InputNavigationPolicy::NewWindow | InputNavigationPolicy::NewForegroundSurface => {
// The renderer target model has no separate window chrome. Preserve
// Chromium's selected-surface behavior by folding a new window into a
// foreground auxiliary target.
HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Foreground)
}
}
}
@@ -2394,11 +2368,11 @@ mod hyperlink_popup_disposition_tests {
#[test]
fn ordinary_click_uses_the_declared_target() {
assert_eq!(
hyperlink_navigation_policy(0, 0, true),
hyperlink_navigation_policy(0, 0, None),
HyperlinkNavigationPolicy::Current
);
assert_eq!(
hyperlink_navigation_policy(0, NON_PLATFORM_NEW_TAB_MODIFIER, true),
hyperlink_navigation_policy(0, NON_PLATFORM_NEW_TAB_MODIFIER, None),
HyperlinkNavigationPolicy::Current
);
}
@@ -2406,19 +2380,35 @@ mod hyperlink_popup_disposition_tests {
#[test]
fn trusted_new_context_input_matches_chromium_surface_selection() {
assert_eq!(
hyperlink_navigation_policy(1, 0, true),
hyperlink_navigation_policy(1, 0, Some(CurrentInputEvent::mouse("mouseup", 1, 0)),),
HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Background)
);
assert_eq!(
hyperlink_navigation_policy(0, PLATFORM_NEW_TAB_MODIFIER, true),
hyperlink_navigation_policy(
0,
PLATFORM_NEW_TAB_MODIFIER,
Some(CurrentInputEvent::mouse(
"mouseup",
0,
PLATFORM_NEW_TAB_MODIFIER,
)),
),
HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Background)
);
assert_eq!(
hyperlink_navigation_policy(0, SHIFT, true),
hyperlink_navigation_policy(
0,
SHIFT,
Some(CurrentInputEvent::mouse("mouseup", 0, SHIFT)),
),
HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Foreground)
);
assert_eq!(
hyperlink_navigation_policy(1, SHIFT, true),
hyperlink_navigation_policy(
1,
SHIFT,
Some(CurrentInputEvent::mouse("mouseup", 1, SHIFT)),
),
HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Foreground)
);
}
@@ -2426,11 +2416,11 @@ mod hyperlink_popup_disposition_tests {
#[test]
fn synthetic_new_tab_input_cannot_create_a_background_tab_under() {
assert_eq!(
hyperlink_navigation_policy(1, 0, false),
hyperlink_navigation_policy(1, 0, None),
HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Foreground)
);
assert_eq!(
hyperlink_navigation_policy(0, PLATFORM_NEW_TAB_MODIFIER, false),
hyperlink_navigation_policy(0, PLATFORM_NEW_TAB_MODIFIER, None),
HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Foreground)
);
}
@@ -2438,11 +2428,11 @@ mod hyperlink_popup_disposition_tests {
#[test]
fn only_trusted_alt_click_requests_a_download() {
assert_eq!(
hyperlink_navigation_policy(0, ALT, true),
hyperlink_navigation_policy(0, ALT, Some(CurrentInputEvent::mouse("mouseup", 0, ALT)),),
HyperlinkNavigationPolicy::Download
);
assert_eq!(
hyperlink_navigation_policy(0, ALT, false),
hyperlink_navigation_policy(0, ALT, None),
HyperlinkNavigationPolicy::Current
);
}
@@ -7,7 +7,7 @@ pub(in crate::native_bridge) use default_action::navigate_form_target_browsing_c
pub(crate) use default_action::{
activate_handle_via_click, activate_handle_via_click_with_detail_and_modifiers,
activate_handle_via_synthetic_click, dispatched_click_activation_target,
finish_legacy_activation_for_dispatched_click,
finish_legacy_activation_for_dispatched_click, perform_auxiliary_link_default_action,
perform_click_default_action_for_dispatched_event, perform_drop_default_action,
prepare_legacy_activation_for_dispatched_click, replace_contenteditable_selection,
scroll_to_url_fragment_or_top, select_contenteditable_contents,
@@ -5,10 +5,14 @@ mod bridge;
mod child_window_surface;
mod collections;
mod context_host;
mod current_input;
pub(crate) use context_host::{
JsContextHost, JsContextHostPageTaskCapabilities, PendingScrollObservableEffects,
PostParseAutofocusAdmission, ServiceWorkerWindowOwner,
};
pub(crate) use current_input::{
CurrentInputEvent, CurrentInputEventScope, InputNavigationPolicy, navigation_policy_from_event,
};
pub(crate) mod document;
pub(crate) mod element;
mod helpers;
+110 -20
View File
@@ -1,4 +1,5 @@
use anyhow::Result;
use std::rc::Rc;
use super::input_dispatch_outcome;
use super::input_helpers::{
@@ -11,7 +12,6 @@ use super::inspector::{
use super::{ActiveDragSession, ActiveScrollbarDrag, ActiveTouchPoint, ScriptVm};
use crate::document_runtime::DomHandle;
use crate::dom::native::{Node, SelectedFile};
use crate::native_bridge::PointerCaptureDispatchEvent;
use crate::native_bridge::element::{
TouchEventPoint, activate_handle_via_click,
activate_handle_via_click_with_detail_and_modifiers, cache_input_files_from_selected_files,
@@ -22,13 +22,16 @@ use crate::native_bridge::element::{
construct_pointer_event_with_related_target_and_modifiers, construct_simple_event,
construct_touch_event, construct_touch_event_with_points, construct_wheel_event,
contenteditable_editing_host, dispatch_public_event, observable_input_hit_test,
observable_input_surface_hit_test, perform_drop_default_action,
perform_mouse_focus_default_action, perform_scrollbar_scroll_default_action,
perform_wheel_scroll_default_action, replace_contenteditable_selection,
replace_text_control_selection, select_contenteditable_contents,
text_control_set_selection_range_internal,
observable_input_surface_hit_test, perform_auxiliary_link_default_action,
perform_drop_default_action, perform_mouse_focus_default_action,
perform_scrollbar_scroll_default_action, perform_wheel_scroll_default_action,
replace_contenteditable_selection, replace_text_control_selection,
select_contenteditable_contents, text_control_set_selection_range_internal,
text_control_set_selection_range_with_direction_internal, text_control_value, update_focus,
};
use crate::native_bridge::{
CurrentInputEvent, CurrentInputEventScope, PointerCaptureDispatchEvent,
};
use crate::runtime::{
RendererDragData, RendererInputDispatchOutcome, RendererPointerEventProperties,
RendererTouchPoint,
@@ -72,6 +75,12 @@ fn can_suppress_compat_mouse_event(event_name: &str) -> bool {
const MOUSE_POINTER_ID: i32 = 1;
const TOUCH_POINTER_ID: i32 = 2;
struct PreparedMouseInputDispatch {
button: i32,
buttons: i32,
released_press: Option<PendingMousePress>,
}
fn touch_pointer_id(touch_id: i32) -> i32 {
TOUCH_POINTER_ID.saturating_add(touch_id.max(0))
}
@@ -362,19 +371,22 @@ impl ScriptVm {
pointer: RendererPointerEventProperties,
modifiers: u8,
) -> Result<RendererInputDispatchOutcome> {
let result = self
.dispatch_mouse_event_at_point_with_pointer_and_modifiers_without_checkpoint(
x,
y,
event_name,
button,
buttons,
click_count,
delta_x,
delta_y,
pointer,
modifiers,
);
let prepared = self.prepare_mouse_input_dispatch(event_name, button, buttons);
let _current_input_event = CurrentInputEventScope::enter(
Rc::clone(&self._context_host),
CurrentInputEvent::mouse(event_name, prepared.button, modifiers),
);
let result = self.dispatch_mouse_event_at_point_with_pointer_and_modifiers_body(
x,
y,
event_name,
prepared,
click_count,
delta_x,
delta_y,
pointer,
modifiers,
);
self.finish_input_event_dispatch_turn(result)
}
@@ -428,6 +440,30 @@ impl ScriptVm {
pointer: RendererPointerEventProperties,
modifiers: u8,
) -> Result<RendererInputDispatchOutcome> {
let prepared = self.prepare_mouse_input_dispatch(event_name, button, buttons);
let _current_input_event = CurrentInputEventScope::enter(
Rc::clone(&self._context_host),
CurrentInputEvent::mouse(event_name, prepared.button, modifiers),
);
self.dispatch_mouse_event_at_point_with_pointer_and_modifiers_body(
x,
y,
event_name,
prepared,
click_count,
delta_x,
delta_y,
pointer,
modifiers,
)
}
fn prepare_mouse_input_dispatch(
&mut self,
event_name: &str,
button: i32,
buttons: Option<i32>,
) -> PreparedMouseInputDispatch {
let previous_pressed_buttons = self.pressed_mouse_buttons;
let released_press = if event_name == "mouseup" {
self.pending_mouse_press.take()
@@ -474,6 +510,31 @@ impl ScriptVm {
}
}
let buttons = buttons.unwrap_or(self.pressed_mouse_buttons);
PreparedMouseInputDispatch {
button,
buttons,
released_press,
}
}
#[allow(clippy::too_many_arguments)]
fn dispatch_mouse_event_at_point_with_pointer_and_modifiers_body(
&mut self,
x: f64,
y: f64,
event_name: &str,
prepared: PreparedMouseInputDispatch,
click_count: i32,
delta_x: f64,
delta_y: f64,
pointer: RendererPointerEventProperties,
modifiers: u8,
) -> Result<RendererInputDispatchOutcome> {
let PreparedMouseInputDispatch {
button,
buttons,
released_press,
} = prepared;
if let Some(outcome) =
self.dispatch_active_scrollbar_mouse_event(x, y, event_name, button, buttons)?
@@ -967,6 +1028,11 @@ impl ScriptVm {
if event_name == "mouseup" {
suppress_compat_mouse_events = false;
}
let had_pending_top_level_navigation_before_event =
unsafe { &*runtime_ptr }.has_pending_location_navigation();
let pending_child_navigations_before_event = unsafe { &*runtime_ptr }
.pending_live_child_browsing_context_navigation_snapshot();
let mut pending_download = None;
if let Some(event) = construct_mouse_event_with_modifiers(
scope,
follow_up_event_name,
@@ -976,7 +1042,27 @@ impl ScriptVm {
buttons,
modifiers,
) {
let _ = dispatch_public_event(scope, runtime_ptr, handle, event);
let dispatched = dispatch_public_event(scope, runtime_ptr, handle, event);
if follow_up_event_name == "auxclick" && dispatched.allows_default() {
pending_download = perform_auxiliary_link_default_action(
scope,
runtime_ptr,
handle,
button,
modifiers,
&pending_child_navigations_before_event,
);
}
}
if follow_up_event_name == "auxclick" {
return Ok(RendererInputDispatchOutcome {
handled: true,
triggered_top_level_navigation:
!had_pending_top_level_navigation_before_event
&& unsafe { &*runtime_ptr }.has_pending_location_navigation(),
pending_download,
pending_file_chooser: None,
});
}
}
None => {}
@@ -1708,6 +1794,10 @@ impl ScriptVm {
auto_repeat: bool,
should_insert_text: bool,
) -> Result<RendererInputDispatchOutcome> {
let _current_input_event = CurrentInputEventScope::enter(
Rc::clone(&self._context_host),
CurrentInputEvent::keyboard(key, modifiers),
);
let handle = self
.document_runtime
.active_element_handle()
@@ -3727,7 +3727,61 @@ fn trusted_alt_click_downloads_an_anchor_without_a_download_attribute() {
}
#[test]
fn window_open_in_click_listener_uses_current_input_popup_disposition() {
fn alt_input_does_not_turn_window_open_into_a_download_or_background_popup() {
let mut vm = new_storage_test_vm("https://alt-window-open.test/path/index.html");
vm.eval(
r#"const html = document.createElement('html');
const body = document.createElement('body');
body.style.margin = '0';
const button = document.createElement('button');
button.style.cssText = 'display:block;width:100px;height:100px';
button.addEventListener('click', () => window.open('/target', '_blank'));
body.appendChild(button);
html.appendChild(body);
document.appendChild(html);
'installed'"#,
)
.expect("Alt window.open fixture should initialize");
vm.dispatch_mouse_event_at_point_with_pointer_and_modifiers(
20.0,
20.0,
"mousedown",
0,
None,
1,
0.0,
0.0,
crate::runtime::RendererPointerEventProperties::default(),
1,
)
.expect("Alt mousedown should dispatch");
let outcome = vm
.dispatch_mouse_event_at_point_with_pointer_and_modifiers(
20.0,
20.0,
"mouseup",
0,
None,
1,
0.0,
0.0,
crate::runtime::RendererPointerEventProperties::default(),
1,
)
.expect("Alt mouseup should dispatch");
assert!(outcome.pending_download.is_none());
let popups = vm.take_pending_popup_activations();
assert_eq!(popups.len(), 1);
assert_eq!(
popups[0].disposition(),
crate::RendererPopupDisposition::Foreground
);
}
#[test]
fn window_open_in_click_listener_uses_current_input_event() {
#[cfg(target_os = "macos")]
let (platform_modifier, synthetic_modifier) = (4, "metaKey: true");
#[cfg(not(target_os = "macos"))]
@@ -3821,6 +3875,211 @@ fn window_open_in_click_listener_uses_current_input_popup_disposition() {
);
}
#[test]
fn current_mouseup_input_covers_pointer_mouse_click_and_microtask_callbacks() {
#[cfg(target_os = "macos")]
let platform_modifier = 4;
#[cfg(not(target_os = "macos"))]
let platform_modifier = 2;
let mut vm = new_storage_test_vm("https://window-open-input-scope.test/path/index.html");
vm.eval(
r#"
(() => {
const html = document.createElement('html');
const body = document.createElement('body');
body.style.margin = '0';
const button = document.createElement('button');
button.style.cssText = 'display:block;width:100px;height:100px';
const nested = document.createElement('button');
nested.addEventListener('click', () => window.open('/nested-click', '_blank'));
button.addEventListener('pointerup', () => window.open('/pointerup', '_blank'));
button.addEventListener('mouseup', () => window.open('/mouseup', '_blank'));
button.addEventListener('click', () => {
window.open('/click', '_blank');
nested.click();
Promise.resolve().then(() => window.open('/microtask', '_blank'));
});
body.append(button, nested);
html.appendChild(body);
document.appendChild(html);
})()
"#,
)
.expect("current input scope fixture should initialize");
vm.dispatch_mouse_event_at_point_with_pointer_and_modifiers(
20.0,
20.0,
"mousedown",
0,
None,
1,
0.0,
0.0,
crate::runtime::RendererPointerEventProperties::default(),
platform_modifier,
)
.expect("new-tab-modified mousedown should dispatch");
vm.dispatch_mouse_event_at_point_with_pointer_and_modifiers(
20.0,
20.0,
"mouseup",
0,
None,
1,
0.0,
0.0,
crate::runtime::RendererPointerEventProperties::default(),
platform_modifier,
)
.expect("new-tab-modified mouseup should dispatch");
let popups = vm.take_pending_popup_activations();
assert_eq!(
popups.iter().map(|popup| popup.url()).collect::<Vec<_>>(),
vec![
"https://window-open-input-scope.test/pointerup",
"https://window-open-input-scope.test/mouseup",
"https://window-open-input-scope.test/click",
"https://window-open-input-scope.test/nested-click",
"https://window-open-input-scope.test/microtask",
]
);
assert!(
popups
.iter()
.all(|popup| { popup.disposition() == crate::RendererPopupDisposition::Background })
);
vm.eval("window.open('/after-input', '_blank'); 'done'")
.expect("post-input window.open should evaluate");
let after_input = vm.take_pending_popup_activations();
assert_eq!(after_input.len(), 1);
assert_eq!(
after_input[0].disposition(),
crate::RendererPopupDisposition::Foreground,
"the real input policy must be restored after its dispatch turn"
);
}
#[test]
fn middle_auxclick_runs_the_anchor_default_action_in_the_background() {
let mut vm = new_storage_test_vm("https://middle-auxclick.test/path/index.html");
vm.eval(
r#"const html = document.createElement('html');
const body = document.createElement('body');
body.style.margin = '0';
const link = document.createElement('a');
link.href = '/anchor-target';
link.style.cssText = 'display:block;width:100px;height:100px';
body.appendChild(link);
html.appendChild(body);
document.appendChild(html);
window.__auxclickEvents = [];
link.addEventListener('auxclick', event => {
__auxclickEvents.push(`${event.type}:${event.button}:${event.isTrusted}`);
if (event.button === 1) window.open('/listener-target', '_blank');
}); 'installed'"#,
)
.expect("auxclick listener should install");
vm.dispatch_mouse_event_at_point(20.0, 20.0, "mousedown", 1, None, 0.0, 0.0)
.expect("middle mousedown should dispatch");
let outcome = vm
.dispatch_mouse_event_at_point(20.0, 20.0, "mouseup", 1, None, 0.0, 0.0)
.expect("middle mouseup should dispatch");
assert!(outcome.handled);
assert!(!outcome.triggered_top_level_navigation);
assert_eq!(
vm.eval("__auxclickEvents.join('|')")
.expect("auxclick event log should evaluate"),
"auxclick:1:true"
);
let popups = vm.take_pending_popup_activations();
assert_eq!(
popups.iter().map(|popup| popup.url()).collect::<Vec<_>>(),
vec![
"https://middle-auxclick.test/listener-target",
"https://middle-auxclick.test/anchor-target",
]
);
assert!(
popups
.iter()
.all(|popup| { popup.disposition() == crate::RendererPopupDisposition::Background })
);
assert!(vm.take_pending_location_navigation_with_seed().is_none());
}
#[test]
fn canceling_middle_auxclick_suppresses_the_anchor_default_action() {
let mut vm = new_storage_test_vm("https://middle-auxclick-canceled.test/path/index.html");
vm.eval(
r#"const html = document.createElement('html');
const body = document.createElement('body');
body.style.margin = '0';
const link = document.createElement('a');
link.href = '/anchor-target';
link.style.cssText = 'display:block;width:100px;height:100px';
link.addEventListener('auxclick', event => event.preventDefault());
body.appendChild(link);
html.appendChild(body);
document.appendChild(html);
'installed'"#,
)
.expect("canceling auxclick listener should install");
vm.dispatch_mouse_event_at_point(20.0, 20.0, "mousedown", 1, None, 0.0, 0.0)
.expect("middle mousedown should dispatch");
vm.dispatch_mouse_event_at_point(20.0, 20.0, "mouseup", 1, None, 0.0, 0.0)
.expect("middle mouseup should dispatch");
assert!(vm.take_pending_popup_activations().is_empty());
assert!(vm.take_pending_location_navigation_with_seed().is_none());
}
#[test]
fn enter_key_window_open_inherits_the_real_input_modifiers() {
#[cfg(target_os = "macos")]
let platform_modifier = 4;
#[cfg(not(target_os = "macos"))]
let platform_modifier = 2;
let mut vm = new_storage_test_vm("https://enter-window-open.test/path/index.html");
vm.eval(
r#"const html = document.createElement('html');
const body = document.createElement('body');
const button = document.createElement('button');
body.appendChild(button);
html.appendChild(body);
document.appendChild(html);
button.addEventListener('click', () => window.open('/target', '_blank'));
button.focus();
'installed'"#,
)
.expect("Enter activation fixture should initialize");
vm.dispatch_key_event(
"keydown",
"Enter",
"Enter",
"",
platform_modifier,
false,
false,
)
.expect("new-tab-modified Enter should dispatch");
let popups = vm.take_pending_popup_activations();
assert_eq!(popups.len(), 1);
assert_eq!(
popups[0].disposition(),
crate::RendererPopupDisposition::Background
);
}
#[test]
fn keyboard_activation_click_preserves_modifiers() {
let mut vm = new_parsed_test_vm(