feat(navigator): expose native Scheduling capability shim

This commit is contained in:
ldm0
2026-09-29 16:41:49 +08:00
parent fcc8d56976
commit a536fa3767
11 changed files with 217 additions and 4 deletions
@@ -6,6 +6,7 @@ mod media_capabilities;
mod media_devices;
mod navigator;
mod navigator_subobjects;
mod scheduling;
mod screen;
mod visual_viewport;
mod window_state;
@@ -30,6 +30,7 @@ use super::media_capabilities::{
};
use super::media_devices::{build_media_devices_object, install_media_devices_template_bindings};
use super::navigator_subobjects::{NavigatorSubobject, ensure_navigator_subobject};
use super::scheduling::{build_scheduling_object, install_scheduling_template_bindings};
use crate::document_runtime::DomHandle;
use crate::native_bridge::OwnerDispatchScope;
use crate::util::{
@@ -74,6 +75,7 @@ const NAVIGATOR_RUNTIME_DATA_KEYS: &[&str] = &[
"storageBuckets",
"geolocation",
"mediaCapabilities",
"scheduling",
];
const WORKER_NAVIGATOR_INSTALLED_SLOT: &str = "__moliWorkerNavigatorInstalled";
const WORKER_NAVIGATOR_MATERIALIZING_SLOT: &str = "__moliWorkerNavigatorMaterializing";
@@ -193,6 +195,8 @@ struct NavigatorRuntimeDataPrototypeDeclaration {
geolocation: (),
#[webapi(accessor_property, getter = navigator_runtime_data_getter_callback, data = callback_data_index_value(scope, 32))]
media_capabilities: (),
#[webapi(accessor_property, getter = navigator_runtime_data_getter_callback, data = callback_data_index_value(scope, 33))]
scheduling: (),
#[webapi(accessor_property, getter = navigator_cookie_enabled_getter_callback)]
cookie_enabled: (),
}
@@ -591,6 +595,9 @@ struct WindowNavigatorBackingDeclaration<'scope, 'profile> {
#[webapi(data_property, enumerable)]
media_capabilities: v8::Local<'scope, v8::Value>,
#[webapi(data_property, enumerable)]
scheduling: v8::Local<'scope, v8::Value>,
}
#[derive(WebApiObject)]
@@ -896,6 +903,7 @@ pub(in crate::context_bootstrap) fn install_navigator_template_bindings<'s>(
install_geolocation_template_bindings(scope, template, interface_name);
install_navigator_collection_template_bindings(scope, template, interface_name);
install_media_capabilities_template_bindings(scope, template, interface_name);
install_scheduling_template_bindings(scope, template, interface_name);
let prototype = template.prototype_template(scope);
match interface_name {
"MediaDevices" => install_media_devices_template_bindings(scope, template),
@@ -1106,6 +1114,7 @@ pub(super) fn build_lazy_navigator_subobject_in_current_realm<'s>(
|| navigator_geolocation_secure_context_available(scope, owner_child, owner_popup);
build_media_capabilities_object(scope, secure_context, worker)?.into()
}
NavigatorSubobject::Scheduling => build_scheduling_object(scope)?.into(),
};
Ok(value)
}
@@ -1279,6 +1288,7 @@ fn build_window_navigator_backing_for_owner<'s>(
storage_buckets: v8::undefined(scope).into(),
geolocation: v8::undefined(scope).into(),
media_capabilities: v8::undefined(scope).into(),
scheduling: v8::undefined(scope).into(),
}
.bind(scope)
.map_err(|error| anyhow!("failed to bind Navigator backing object: {error}"))
@@ -22,11 +22,12 @@ pub(super) enum NavigatorSubobject {
StorageBuckets,
Geolocation,
MediaCapabilities,
Scheduling,
}
impl NavigatorSubobject {
#[cfg(test)]
pub(super) const ALL: [Self; 16] = [
pub(super) const ALL: [Self; 17] = [
Self::Languages,
Self::MimeTypes,
Self::Plugins,
@@ -43,6 +44,7 @@ impl NavigatorSubobject {
Self::StorageBuckets,
Self::Geolocation,
Self::MediaCapabilities,
Self::Scheduling,
];
pub(super) fn from_key(key: &str) -> Option<Self> {
@@ -63,6 +65,7 @@ impl NavigatorSubobject {
"storageBuckets" => Some(Self::StorageBuckets),
"geolocation" => Some(Self::Geolocation),
"mediaCapabilities" => Some(Self::MediaCapabilities),
"scheduling" => Some(Self::Scheduling),
_ => None,
}
}
@@ -85,6 +88,7 @@ impl NavigatorSubobject {
Self::StorageBuckets => "storageBuckets",
Self::Geolocation => "geolocation",
Self::MediaCapabilities => "mediaCapabilities",
Self::Scheduling => "scheduling",
}
}
}
@@ -0,0 +1,67 @@
use anyhow::{Result, anyhow};
use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject};
use crate::{util::throw_type_error, web_api_interfaces, webidl};
#[derive(WebApiObject)]
#[webapi(interface = web_api_interfaces::Scheduling)]
struct SchedulingObjectDeclaration {}
#[derive(Default, WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::Scheduling, enumerable)]
struct SchedulingPrototypeDeclaration {
#[webapi(method, length = 0, callback = is_input_pending_callback)]
is_input_pending: (),
}
#[derive(webidl::WebIdlDictionary)]
#[webidl(prefix = "IsInputPendingOptions")]
struct IsInputPendingOptions {
#[webidl(name = "includeContinuous", default = false)]
_include_continuous: bool,
}
pub(super) fn install_scheduling_template_bindings<'s>(
scope: &mut v8::PinScope<'s, '_, ()>,
template: v8::Local<'s, v8::FunctionTemplate>,
interface_name: &str,
) {
if interface_name == "Scheduling" {
SchedulingPrototypeDeclaration::initialize_prototype_template(
scope,
template.prototype_template(scope),
);
}
}
pub(super) fn build_scheduling_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
) -> Result<v8::Local<'s, v8::Object>> {
SchedulingObjectDeclaration::new()
.bind(scope)
.map_err(|error| anyhow!("failed to bind Scheduling object: {error}"))
}
fn is_input_pending_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s, v8::Value>,
) {
if !web_api_interfaces::Scheduling::is_instance(scope, args.this()) {
throw_type_error(scope, "Illegal invocation");
return;
}
// Even the idle compatibility profile must perform dictionary conversion:
// includeContinuous getters and their exceptions are observable WebIDL.
if let Err(error) = webidl::parse_dictionary::<IsInputPendingOptions>(
scope,
args.get(0),
webidl::Context::argument("Scheduling.isInputPending", 1),
) {
webidl::throw_error(scope, &error);
return;
}
// Capability shim: pending-input prediction is not connected to the owner
// queue yet. Report the idle state, not a measurement of queued CDP input.
rv.set_bool(false);
}
@@ -544,6 +544,10 @@ const CONSTRUCTOR_SPECS_BEFORE_STREAMS: &[ConstructorSpec] = &[
interface: web_api_interfaces::MediaCapabilities::DESCRIPTOR,
kind: ConstructorKind::Illegal,
},
ConstructorSpec {
interface: web_api_interfaces::Scheduling::DESCRIPTOR,
kind: ConstructorKind::Illegal,
},
ConstructorSpec {
interface: web_api_interfaces::Touch::DESCRIPTOR,
kind: ConstructorKind::Touch,
@@ -42,6 +42,7 @@ mod platform_identity;
mod pointer_lock;
mod popup_hyperlinks;
mod promise_rejection;
mod scheduling;
mod security_policy;
mod service_worker_drain;
mod session_description;
@@ -0,0 +1,48 @@
use super::*;
#[test]
fn navigator_scheduling_exposes_native_idle_capability_contract() {
for url in ["https://scheduling.test/", "http://scheduling.test/"] {
let mut vm = new_storage_test_vm(url);
assert_eq!(
vm.eval(include_str!(
"../../../../tests/fixtures/navigator-scheduling.js"
))
.expect("Scheduling idle and WebIDL contract should hold"),
"window-ok"
);
}
}
#[test]
fn navigator_scheduling_materializes_in_its_navigator_realm() {
let mut vm = new_storage_test_vm("https://scheduling-realm.test/");
vm.eval(
r#"
const frame = document.createElement('iframe');
(document.body || document.documentElement || document).appendChild(frame);
globalThis.__schedulingFrame = frame;
"#,
)
.expect("Scheduling child setup");
materialize_single_child_default_realm_for_test(&mut vm, "Scheduling child realm");
assert_eq!(
vm.eval(
r#"
(() => {
const child = __schedulingFrame.contentWindow;
const getter = Object.getOwnPropertyDescriptor(Navigator.prototype, 'scheduling').get;
const scheduling = getter.call(child.navigator);
return JSON.stringify({
ownRealm: Object.getPrototypeOf(scheduling) === child.Scheduling.prototype,
stable: scheduling === child.navigator.scheduling,
separate: scheduling !== navigator.scheduling,
idle: Scheduling.prototype.isInputPending.call(scheduling)
});
})()
"#,
)
.expect("borrowed Scheduling getter should use the Navigator realm"),
r#"{"ownRealm":true,"stable":true,"separate":true,"idle":false}"#
);
}
@@ -621,6 +621,7 @@ fn navigator_scalar_access_does_not_materialize_unused_same_object_children() {
"Geolocation",
"GeolocationPositionError",
"MediaCapabilities",
"Scheduling",
"Clipboard",
"ClipboardItem",
] {
@@ -663,7 +664,8 @@ fn navigator_scalar_access_does_not_materialize_unused_same_object_children() {
"mediaCapabilities",
navigator.mediaCapabilities,
navigator.mediaCapabilities
]
],
["scheduling", navigator.scheduling, navigator.scheduling]
];
return JSON.stringify({
same: pairs.every(([, first, second]) => first === second),
@@ -685,13 +687,15 @@ fn navigator_scalar_access_does_not_materialize_unused_same_object_children() {
Geolocation.prototype,
mediaCapabilitiesRealm:
Object.getPrototypeOf(navigator.mediaCapabilities) ===
MediaCapabilities.prototype
MediaCapabilities.prototype,
schedulingRealm:
Object.getPrototypeOf(navigator.scheduling) === Scheduling.prototype
});
})()
"#,
)
.expect("Navigator lazy subobjects should materialize"),
r#"{"same":true,"languagesFrozen":true,"permissionsRealm":true,"mediaDevicesRealm":true,"storageRealm":true,"clipboardRealm":true,"geolocationRealm":true,"mediaCapabilitiesRealm":true}"#
r#"{"same":true,"languagesFrozen":true,"permissionsRealm":true,"mediaDevicesRealm":true,"storageRealm":true,"clipboardRealm":true,"geolocationRealm":true,"mediaCapabilitiesRealm":true,"schedulingRealm":true}"#
);
assert_eq!(
default_navigator_subobjects(&mut vm),
@@ -712,10 +716,12 @@ fn navigator_scalar_access_does_not_materialize_unused_same_object_children() {
"storageBuckets",
"geolocation",
"mediaCapabilities",
"scheduling",
]
);
assert_eq!(constructor_materialization_count(&mut vm, "Geolocation"), 1);
assert_eq!(constructor_materialization_count(&mut vm, "Clipboard"), 1);
assert_eq!(constructor_materialization_count(&mut vm, "Scheduling"), 1);
assert_eq!(
constructor_materialization_count(&mut vm, "ClipboardItem"),
0,
@@ -416,6 +416,7 @@ interfaces! {
SVGTransform;
SVGTransformList;
SVGUseElement: SVGGraphicsElement;
Scheduling;
Screen;
ScreenOrientation: EventTarget;
SecurityPolicyViolationEvent: Event;
@@ -1,5 +1,22 @@
use super::*;
#[tokio::test]
async fn worker_navigator_does_not_expose_window_scheduling() {
ensure_v8();
let mut handle = spawn_worker(
format!(
"try {{ postMessage({}); }} catch (error) {{ postMessage(String(error)); }} close();",
include_str!("../../../../tests/fixtures/navigator-scheduling.js")
),
"https://scheduling-worker.test/worker.js".into(),
);
let message = timeout(TIMEOUT, handle.recv())
.await
.expect("worker Scheduling exposure probe should settle")
.expect("worker should return the exposure result");
assert_eq!(expect_post_json(message), r#""worker-ok""#);
}
#[tokio::test]
async fn worker_compression_streams_roundtrip_all_formats() {
ensure_v8();
+54
View File
@@ -0,0 +1,54 @@
// Idle/capability contract only; this does not test queued-input reporting.
(() => {
const assert = (condition, message) => { if (!condition) throw new Error(message); };
if (typeof window === 'undefined') {
assert(typeof Scheduling === 'undefined', 'Scheduling must not be exposed in workers');
assert(!('scheduling' in navigator), 'WorkerNavigator must not expose scheduling');
return 'worker-ok';
}
const scheduling = navigator.scheduling;
assert(typeof Scheduling === 'function', 'Scheduling interface');
assert(scheduling instanceof Scheduling, 'Scheduling brand');
assert(Object.getPrototypeOf(scheduling) === Scheduling.prototype, 'Scheduling prototype');
assert(Object.prototype.toString.call(scheduling) === '[object Scheduling]', 'Scheduling tag');
assert(navigator.scheduling === scheduling, 'cached per-Navigator object');
assert(Object.getOwnPropertyNames(scheduling).length === 0, 'no own implementation fields');
assert(!Object.hasOwn(navigator, 'scheduling'), 'Navigator prototype accessor');
const descriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, 'scheduling');
assert(descriptor.enumerable && descriptor.configurable && !descriptor.set, 'readonly accessor flags');
assert(descriptor.get.call(navigator) === scheduling, 'borrowed getter');
const method = Scheduling.prototype.isInputPending;
const methodDescriptor = Object.getOwnPropertyDescriptor(Scheduling.prototype, 'isInputPending');
assert(methodDescriptor.enumerable && methodDescriptor.configurable && methodDescriptor.writable, 'method flags');
assert(method.length === 0 && method.name === 'isInputPending', 'method signature');
assert(Function.prototype.toString.call(method).includes('[native code]'), 'native method');
const throwsTypeError = (callback, message) => {
let caught;
try { callback(); } catch (error) { caught = error; }
assert(caught instanceof TypeError, message);
};
throwsTypeError(() => new Scheduling(), 'illegal constructor');
throwsTypeError(() => Scheduling(), 'illegal function call');
for (const receiver of [null, {}, Scheduling.prototype, Object.create(Scheduling.prototype), new Proxy(scheduling, {})]) {
throwsTypeError(() => method.call(receiver), 'illegal method receiver');
}
throwsTypeError(() => descriptor.get.call({}), 'illegal Navigator receiver');
for (const options of [undefined, null, {}, {includeContinuous:false}, {includeContinuous:true}, () => {}]) {
assert(method.call(scheduling, options) === false, 'idle result');
}
for (const options of [1, true, 'x', Symbol('x')]) {
throwsTypeError(() => method.call(scheduling, options), 'dictionary conversion');
}
const accesses = [];
const options = new Proxy({}, {get(_target, key) { accesses.push(key); return {valueOf() { throw new Error('no coercion'); }}; }});
assert(method.call(scheduling, options) === false, 'truthy includeContinuous');
assert(accesses.join(',') === 'includeContinuous', 'only read the declared dictionary member');
const sentinel = new Error('options getter');
let caught;
try { method.call(scheduling, {get includeContinuous() { throw sentinel; }}); } catch (error) { caught = error; }
assert(caught === sentinel, 'preserve dictionary getter exception');
let reads = 0;
throwsTypeError(() => method.call({}, {get includeContinuous() { reads++; }}), 'brand precedes conversion');
assert(reads === 0, 'invalid receiver must not read options');
return 'window-ok';
})()