530 lines
18 KiB
Rust
530 lines
18 KiB
Rust
//! Managed subscriptions and occurrence-specific propagation. A validated VM
|
|
//! function sequences calls, so all handlers share one execution budget.
|
|
use crate::{block, mount::Mount, ui_bridge::attach_desktop_type};
|
|
use endor_core::{
|
|
ContextNativeFunction, NativeCallable, NativeContext, NativeResult, ObjectPayload, ObjectRef,
|
|
Value, Vm, VmError, WeakObjectRef,
|
|
};
|
|
|
|
const MAX_DELIVERIES: usize = 10_000;
|
|
fn invalid(message: &'static str) -> VmError {
|
|
VmError::InvalidArgument(message)
|
|
}
|
|
|
|
pub fn validate_handler(
|
|
store: &endor_core::ObjectStore,
|
|
function: ObjectRef,
|
|
) -> Result<(), VmError> {
|
|
if !store.object(function)?.payload().is_function() {
|
|
return Err(invalid("event handler must be a Function"));
|
|
}
|
|
let arity = store
|
|
.call_arity(function)?
|
|
.ok_or(invalid("event handler requires positional metadata"))?;
|
|
if !arity.accepts(0) && !arity.accepts(1) {
|
|
return Err(invalid(
|
|
"event handler must accept zero arguments or one Event",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Event {
|
|
dispatch: Option<WeakObjectRef>,
|
|
group: usize,
|
|
stopped_at: Option<usize>,
|
|
immediate: bool,
|
|
prevented: bool,
|
|
}
|
|
impl ObjectPayload for Event {
|
|
fn reference_assignment_only(&self) -> bool {
|
|
true
|
|
}
|
|
fn as_any(&self) -> &dyn std::any::Any {
|
|
self
|
|
}
|
|
fn kind_name(&self) -> &'static str {
|
|
"tessera-event"
|
|
}
|
|
fn allows_attributes(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Connection {
|
|
pub handler: Option<ObjectRef>,
|
|
pub kind: String,
|
|
pub capture: bool,
|
|
}
|
|
impl ObjectPayload for Connection {
|
|
fn reference_assignment_only(&self) -> bool {
|
|
true
|
|
}
|
|
fn as_any(&self) -> &dyn std::any::Any {
|
|
self
|
|
}
|
|
fn kind_name(&self) -> &'static str {
|
|
"tessera-event-connection"
|
|
}
|
|
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
|
|
if let Some(handler) = self.handler {
|
|
visit(handler);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A token never retains its target or callback. The Block owns the connection.
|
|
#[derive(Debug)]
|
|
pub struct Subscription {
|
|
pub connection: WeakObjectRef,
|
|
}
|
|
impl ObjectPayload for Subscription {
|
|
fn reference_assignment_only(&self) -> bool {
|
|
true
|
|
}
|
|
fn as_any(&self) -> &dyn std::any::Any {
|
|
self
|
|
}
|
|
fn kind_name(&self) -> &'static str {
|
|
"tessera-subscription"
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum Method {
|
|
Stop,
|
|
StopImmediate,
|
|
Prevent,
|
|
Prevented,
|
|
Disconnect,
|
|
Connected,
|
|
}
|
|
impl NativeCallable for Method {
|
|
fn arity(&self) -> Option<endor_core::CallArity> {
|
|
Some(endor_core::CallArity::exact(1))
|
|
}
|
|
|
|
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
|
let [Value::Object(owner)] = args else {
|
|
return Err(invalid("event method receiver"));
|
|
};
|
|
let result = match self {
|
|
Self::Disconnect | Self::Connected => {
|
|
let weak = cx
|
|
.store()
|
|
.payload::<Subscription>(*owner, "Subscription")?
|
|
.connection;
|
|
let connection = cx.upgrade(weak)?;
|
|
let connected = if let Some(connection) = connection {
|
|
if cx
|
|
.store()
|
|
.object(connection)?
|
|
.payload()
|
|
.as_any()
|
|
.is::<crate::binding::Binding>()
|
|
{
|
|
let mut data = cx
|
|
.store()
|
|
.payload::<crate::binding::Binding>(connection, "Binding")?
|
|
.clone();
|
|
let connected = data.compute.is_some();
|
|
if matches!(self, Self::Disconnect) {
|
|
data.compute = None;
|
|
cx.replace_payload(connection, data)?;
|
|
}
|
|
connected
|
|
} else {
|
|
let mut data = cx
|
|
.store()
|
|
.payload::<Connection>(connection, "Connection")?
|
|
.clone();
|
|
let connected = data.handler.is_some();
|
|
if matches!(self, Self::Disconnect) {
|
|
data.handler = None;
|
|
cx.replace_payload(connection, data)?;
|
|
}
|
|
connected
|
|
}
|
|
} else {
|
|
false
|
|
};
|
|
if matches!(self, Self::Disconnect) {
|
|
Value::Nil
|
|
} else {
|
|
Value::Bool(connected)
|
|
}
|
|
}
|
|
_ => {
|
|
let mut event = cx.store().payload::<Event>(*owner, "Event")?.clone();
|
|
if matches!(self, Self::Prevented) {
|
|
Value::Bool(event.prevented)
|
|
} else {
|
|
match self {
|
|
Self::Stop | Self::StopImmediate => {
|
|
event.stopped_at = Some(event.group);
|
|
event.immediate |= matches!(self, Self::StopImmediate);
|
|
}
|
|
Self::Prevent => event.prevented = true,
|
|
_ => unreachable!(),
|
|
}
|
|
cx.replace_payload(*owner, event)?;
|
|
Value::Nil
|
|
}
|
|
}
|
|
};
|
|
Ok(NativeResult::Return(result))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct Constructor {
|
|
pub signature: Option<endor_core::CallSignature>,
|
|
}
|
|
impl NativeCallable for Constructor {
|
|
fn signature(&self) -> Option<endor_core::CallSignature> {
|
|
self.signature.clone()
|
|
}
|
|
fn arity(&self) -> Option<endor_core::CallArity> {
|
|
Some(endor_core::CallArity::range(2, usize::MAX))
|
|
}
|
|
|
|
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
|
let [Value::Object(definition), Value::Text(kind), values @ ..] = args else {
|
|
return Err(invalid("Event requires an event kind"));
|
|
};
|
|
let fields = cx
|
|
.store()
|
|
.payload::<endor_core::EndorTypeObject>(*definition, "Type")?
|
|
.fields()
|
|
.to_vec();
|
|
if fields.len() != values.len() {
|
|
return Err(invalid("incorrect Event field arguments"));
|
|
}
|
|
cx.store()
|
|
.check_class_field_arguments(*definition, values)?;
|
|
let event = new_event(cx, *definition, kind)?;
|
|
for (field, value) in fields.iter().zip(values) {
|
|
cx.set_attr(event, &field.name, value.clone())?;
|
|
}
|
|
Ok(NativeResult::Return(Value::Object(event)))
|
|
}
|
|
}
|
|
|
|
pub fn new_event(
|
|
cx: &mut NativeContext<'_>,
|
|
definition: ObjectRef,
|
|
kind: &str,
|
|
) -> Result<ObjectRef, VmError> {
|
|
if kind.is_empty() || kind.len() > 128 {
|
|
return Err(invalid("event kind must contain 1..128 UTF-8 bytes"));
|
|
}
|
|
let event = cx.allocate(Event::default())?;
|
|
cx.attach_type(event, definition)?;
|
|
cx.set_attr(event, "kind", Value::Text(kind.into()))?;
|
|
for name in ["target", "mount", "currentTarget", "currentMount", "hit"] {
|
|
cx.set_attr(event, name, Value::Nil)?;
|
|
}
|
|
cx.set_attr(event, "phase", Value::Text("none".into()))?;
|
|
Ok(event)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct TypedMethod {
|
|
method: Method,
|
|
signature: endor_core::CallSignature,
|
|
}
|
|
impl NativeCallable for TypedMethod {
|
|
fn signature(&self) -> Option<endor_core::CallSignature> {
|
|
Some(self.signature.clone())
|
|
}
|
|
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
|
self.method.invoke(cx, args)
|
|
}
|
|
}
|
|
|
|
pub fn install(
|
|
vm: &mut Vm,
|
|
roots: &mut Vec<ObjectRef>,
|
|
event_type: ObjectRef,
|
|
token_type: ObjectRef,
|
|
function_type: ObjectRef,
|
|
) -> Result<(), VmError> {
|
|
for (definition, name, method) in [
|
|
(event_type, "stopPropagation", Method::Stop),
|
|
(
|
|
event_type,
|
|
"stopImmediatePropagation",
|
|
Method::StopImmediate,
|
|
),
|
|
(event_type, "preventDefault", Method::Prevent),
|
|
(event_type, "isDefaultPrevented", Method::Prevented),
|
|
(token_type, "disconnect", Method::Disconnect),
|
|
(token_type, "isConnected", Method::Connected),
|
|
] {
|
|
let query = matches!(method, Method::Prevented | Method::Connected);
|
|
let function = vm.allocate(ContextNativeFunction::new(
|
|
name,
|
|
TypedMethod {
|
|
method,
|
|
signature: endor_core::CallSignature::exact(
|
|
vec![endor_core::AnnotationType::Nominal(definition)],
|
|
if query {
|
|
endor_core::AnnotationType::Bool
|
|
} else {
|
|
endor_core::AnnotationType::Unit
|
|
},
|
|
if query {
|
|
endor_core::CallEffect::ReadOnly
|
|
} else {
|
|
endor_core::CallEffect::Mutating
|
|
},
|
|
),
|
|
},
|
|
vec![],
|
|
))?;
|
|
roots.push(function);
|
|
vm.attach_type(function, function_type)?;
|
|
vm.define_method(definition, name, function)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum Handler {
|
|
Token(ObjectRef),
|
|
Legacy(ObjectRef),
|
|
ObjectMethod,
|
|
}
|
|
#[derive(Debug)]
|
|
struct Delivery {
|
|
event: ObjectRef,
|
|
owner: ObjectRef,
|
|
mount: Option<ObjectRef>,
|
|
group: usize,
|
|
phase: &'static str,
|
|
handler: Handler,
|
|
}
|
|
impl NativeCallable for Delivery {
|
|
fn arity(&self) -> Option<endor_core::CallArity> {
|
|
Some(endor_core::CallArity::exact(0))
|
|
}
|
|
|
|
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
|
let mut event = cx.store().payload::<Event>(self.event, "Event")?.clone();
|
|
if event.immediate || event.stopped_at.is_some_and(|group| group != self.group) {
|
|
return Ok(NativeResult::Return(Value::Nil));
|
|
}
|
|
let (function, mut arguments) = match self.handler {
|
|
Handler::Token(token) => {
|
|
let Some(handler) = cx
|
|
.store()
|
|
.payload::<Connection>(token, "Connection")?
|
|
.handler
|
|
else {
|
|
return Ok(NativeResult::Return(Value::Nil));
|
|
};
|
|
(handler, vec![])
|
|
}
|
|
Handler::Legacy(function) => (function, vec![]),
|
|
Handler::ObjectMethod => {
|
|
let Some(definition) = cx.store().type_of(self.owner)? else {
|
|
return Err(invalid("untyped event target"));
|
|
};
|
|
let function = cx
|
|
.store()
|
|
.lookup_method(definition, "onEvent")?
|
|
.ok_or(invalid("Block has no onEvent method"))?;
|
|
(function, vec![Value::Object(self.owner)])
|
|
}
|
|
};
|
|
event.group = self.group;
|
|
cx.replace_payload(self.event, event)?;
|
|
cx.set_attr(self.event, "phase", Value::Text(self.phase.into()))?;
|
|
cx.set_attr(self.event, "currentTarget", Value::Object(self.owner))?;
|
|
cx.set_attr(
|
|
self.event,
|
|
"currentMount",
|
|
self.mount.map(Value::Object).unwrap_or(Value::Nil),
|
|
)?;
|
|
if matches!(self.handler, Handler::ObjectMethod)
|
|
|| cx
|
|
.store()
|
|
.call_arity(function)?
|
|
.is_some_and(|arity| arity.accepts(1))
|
|
{
|
|
arguments.push(Value::Object(self.event));
|
|
}
|
|
Ok(NativeResult::Invoke {
|
|
function,
|
|
arguments,
|
|
})
|
|
}
|
|
}
|
|
#[derive(Debug)]
|
|
struct Finish(ObjectRef);
|
|
impl NativeCallable for Finish {
|
|
fn arity(&self) -> Option<endor_core::CallArity> {
|
|
Some(endor_core::CallArity::exact(0))
|
|
}
|
|
|
|
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
|
let mut event = cx.store().payload::<Event>(self.0, "Event")?.clone();
|
|
event.dispatch = None;
|
|
cx.replace_payload(self.0, event)?;
|
|
cx.set_attr(self.0, "phase", Value::Text("none".into()))?;
|
|
cx.set_attr(self.0, "currentTarget", Value::Nil)?;
|
|
cx.set_attr(self.0, "currentMount", Value::Nil)?;
|
|
Ok(NativeResult::Return(Value::Object(self.0)))
|
|
}
|
|
}
|
|
|
|
/// Snapshot the selected occurrence's route and listener order. Disconnection
|
|
/// before a listener's turn skips it; newly registered listeners wait for the
|
|
/// next dispatch. A code event without a Mount visits only its target object.
|
|
pub fn dispatch(
|
|
cx: &mut NativeContext<'_>,
|
|
owner: ObjectRef,
|
|
event_ref: ObjectRef,
|
|
mount: Option<ObjectRef>,
|
|
desktop: ObjectRef,
|
|
) -> Result<NativeResult, VmError> {
|
|
let mut event = cx.store().payload::<Event>(event_ref, "Event")?.clone();
|
|
if let Some(weak) = event.dispatch
|
|
&& cx.upgrade(weak)?.is_some()
|
|
{
|
|
return Err(invalid("Event is already being dispatched"));
|
|
}
|
|
let Value::Text(kind) = cx.store().get_attr(event_ref, "kind")? else {
|
|
return Err(invalid("Event kind must be text"));
|
|
};
|
|
if kind.is_empty() || kind.len() > 128 {
|
|
return Err(invalid("invalid event kind"));
|
|
}
|
|
let mut route = vec![(owner, mount)];
|
|
if let Some(mount) = mount {
|
|
let target = cx.store().payload::<Mount>(mount, "Mount")?.clone();
|
|
if !target.mounted || target.block != owner {
|
|
return Err(invalid("event context does not belong to this Block"));
|
|
}
|
|
let mut parent = target.parent;
|
|
let mut seen = std::collections::HashSet::from([mount]);
|
|
while let Some(weak) = parent {
|
|
let ancestor = cx
|
|
.upgrade(weak)?
|
|
.ok_or(invalid("event route has expired"))?;
|
|
if !seen.insert(ancestor) {
|
|
return Err(invalid("cyclic event route"));
|
|
}
|
|
let data = cx.store().payload::<Mount>(ancestor, "Mount")?;
|
|
if !data.mounted {
|
|
return Err(invalid("event route is detached"));
|
|
}
|
|
route.push((data.block, Some(ancestor)));
|
|
parent = data.parent;
|
|
}
|
|
if let Some(weak) = target.page {
|
|
let page = cx.upgrade(weak)?.ok_or(invalid("event Page has expired"))?;
|
|
route.push((page, None));
|
|
}
|
|
}
|
|
let guard = cx.new_object()?;
|
|
let mut calls = Vec::new();
|
|
let mut group = 0;
|
|
// Each occurrence is its own propagation group, even for shared Blocks.
|
|
let traversal = route
|
|
.iter()
|
|
.enumerate()
|
|
.rev()
|
|
.map(|(index, entry)| (index, entry, true))
|
|
.chain(
|
|
route
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, entry)| (index, entry, false)),
|
|
);
|
|
for (index, &(block, current_mount), capture) in traversal {
|
|
let (_, data) = block::resolve(cx.store(), block)?;
|
|
let mut handlers = Vec::new();
|
|
for token in &data.subscriptions {
|
|
let token_data = cx.store().payload::<Connection>(*token, "Connection")?;
|
|
if token_data.handler.is_some()
|
|
&& token_data.kind == kind
|
|
&& token_data.capture == capture
|
|
{
|
|
handlers.push(Handler::Token(*token));
|
|
}
|
|
}
|
|
if !capture {
|
|
handlers.insert(0, Handler::ObjectMethod);
|
|
if kind == "click"
|
|
&& let Some(handler) = data.click
|
|
{
|
|
handlers.push(Handler::Legacy(handler));
|
|
}
|
|
}
|
|
for handler in handlers {
|
|
if calls.len() >= MAX_DELIVERIES {
|
|
return Err(invalid("event delivery budget exceeded"));
|
|
}
|
|
let mut captures = vec![event_ref, guard, block];
|
|
if let Some(mount) = current_mount {
|
|
captures.push(mount);
|
|
}
|
|
match handler {
|
|
Handler::Token(token) | Handler::Legacy(token) => captures.push(token),
|
|
Handler::ObjectMethod => {}
|
|
}
|
|
let function = cx.allocate(ContextNativeFunction::new(
|
|
"event delivery",
|
|
Delivery {
|
|
event: event_ref,
|
|
owner: block,
|
|
mount: current_mount,
|
|
group,
|
|
phase: if index == 0 {
|
|
"target"
|
|
} else if capture {
|
|
"capture"
|
|
} else {
|
|
"bubble"
|
|
},
|
|
handler,
|
|
},
|
|
captures,
|
|
))?;
|
|
attach_desktop_type(cx, function, "Function", desktop)?;
|
|
calls.push(function);
|
|
}
|
|
// Capture and bubble at the target are one group: stopPropagation at
|
|
// target capture still permits other target listeners.
|
|
if index != 0 || !capture {
|
|
group += 1;
|
|
}
|
|
}
|
|
let finish = cx.allocate(ContextNativeFunction::new(
|
|
"event finish",
|
|
Finish(event_ref),
|
|
vec![event_ref, guard],
|
|
))?;
|
|
calls.push(finish);
|
|
let driver = crate::vm_sequence::build(cx, calls, "<event dispatch>", desktop)?;
|
|
event.dispatch = Some(cx.store().downgrade(guard)?);
|
|
event.group = 0;
|
|
event.stopped_at = None;
|
|
event.immediate = false;
|
|
event.prevented = false;
|
|
cx.replace_payload(event_ref, event)?;
|
|
cx.set_attr(event_ref, "target", Value::Object(owner))?;
|
|
cx.set_attr(
|
|
event_ref,
|
|
"mount",
|
|
mount.map(Value::Object).unwrap_or(Value::Nil),
|
|
)?;
|
|
Ok(NativeResult::Invoke {
|
|
function: driver,
|
|
arguments: vec![],
|
|
})
|
|
}
|