lumen_script/runtime.rs
1//! Host-generic script runtime.
2//!
3//! Everything in the scripting contract that is not compilation, value
4//! conversion, or invocation lives here, parameterized over
5//! `H: `[`ScriptHost`]: the 18-event dispatch surface with per-id handler
6//! routing, the derivation fixed-point driver, the store->mirror sync
7//! policy driver, timers, HTTP fetch plumbing, the load-failure banner
8//! protocol, and the [`ScriptPlugin`] that wires it all into the tick.
9//!
10//! Concrete hosts, each a runtime module (`lumen-candela` and the others
11//! under `std/`), provide engine + builtins + conversion and hand a built
12//! host to [`ScriptPlugin::new`].
13//!
14//! ## Ordering contract (7bfc0f2 - do not regress)
15//!
16//! ```text
17//! Systems:
18//! 1. control pushes (push_toggle/slider/textinput/scroll_to_signal)
19//! 2. sync_signals_into_host .after(all pushes) - type-preserving parse-back
20//! 3. tick_script .after(sync) + all event dispatchers
21//! 4. embedder's apply_script_commands .after(dispatchers) .before(derivations, readers)
22//! 5. apply_derivations .after(sync) - fixed point, <= 32 passes
23//! 6. dirty-gated binding readers
24//! A11ySync: clear_property_store_dirty
25//! ```
26//!
27//! Dirty flags live exactly one tick; every consumer of "what changed"
28//! must be ordered inside that window.
29//!
30//! ## Several hosts in one app
31//!
32//! An app can run more than one host at a time (one per script language it
33//! ships). Each host is its own `Resource`, so every `::<H>` system above is
34//! monomorphised once per active host. Embedders must therefore order against
35//! [`ScriptSet`] rather than against a concrete `system::<H>`: a `.before(
36//! apply_derivations::<Rhai>)` edge says nothing about the Lua host's
37//! derivation pass, and the dirty-window guarantees collapse for every host it
38//! does not name. The sets below hold the same systems for every active host,
39//! so one edge covers all of them.
40//!
41//! Hosts share the world's `PropertyStore`, so a signal written by one is read
42//! by the others on the same tick. Lifecycle and event callbacks (`on_start`,
43//! `on_ready`, `on_click`, `on_timer`, ...) are delivered to every active host;
44//! a host that does not define the handler ignores the call.
45
46use bevy_ecs::component::Mutable;
47use bevy_ecs::message::{Message, MessageReader, MessageRegistry, MessageWriter, Messages};
48use bevy_ecs::prelude::*;
49use lumen_core::net_capture::{self, NetEvent};
50use lumen_core::prelude::*;
51use lumen_core::time::Instant;
52use lumen_core::warn_line;
53use std::sync::Arc;
54
55use crate::PluginEvent;
56use crate::dnd;
57use crate::http::{
58 Credentials, DisabledHttpClient, HttpClient, HttpDispatch, HttpDone, HttpHook, HttpRequest,
59 HttpResponse, ThreadDispatch,
60};
61use crate::script_fn::ScriptFnRegistry;
62use crate::{CallFailure, CallOutcome, ScriptCommand, ScriptError, ScriptHost, ScriptValue};
63
64/// One [`ScriptCommand`] flowing through the ECS message bus so app
65/// systems can read it via `MessageReader<ScriptCommandEvent>`.
66#[derive(Message, Clone, Debug)]
67pub struct ScriptCommandEvent(pub ScriptCommand);
68
69/// Make sure the [`ScriptCommandEvent`] stream exists in `world`.
70///
71/// A script host registers it when it is installed, and an app without one
72/// still runs the appliers: the C ABI, the SDKs and the browser runtime write
73/// to the same stream, and a reader whose message type was never registered
74/// fails parameter validation, which ends the tick. Registering the same
75/// message twice would age its buffer twice a tick and drop a message a tick
76/// early, so every registration goes through here.
77pub fn register_script_commands(world: &mut World) {
78 if !world.contains_resource::<Messages<ScriptCommandEvent>>() {
79 MessageRegistry::register_message::<ScriptCommandEvent>(world);
80 }
81}
82
83/// Inserted by [`ScriptPlugin::build`] when the initial script failed to
84/// compile or evaluate. Embedders (lumenc's `run`) surface it
85/// prominently - e.g. through the in-window error banner - so a dead
86/// script never masquerades as a healthy app that just ignores clicks.
87/// Absent when the script loaded cleanly.
88#[derive(Resource, Debug, Clone)]
89pub struct ScriptLoadFailure(pub String);
90
91/// Wall-clock moment the script plugin was installed. Kept for embedders
92/// that want elapsed-since-install (the FFI surface reads it); the
93/// runtime itself is reactive-only and no longer computes a per-frame
94/// `t`.
95#[derive(Resource, Clone, Copy, Debug)]
96pub struct ScriptStartedAt(pub Instant);
97
98/// Per-call diagnostics prefix: `lumen-<lang>`, the name of the module that
99/// runs the language.
100pub(crate) fn prefix(lang: &str) -> String {
101 format!("lumen-{lang}")
102}
103
104// ---------------------------------------------------------------------
105// Ordering anchors
106// ---------------------------------------------------------------------
107
108/// Ordering anchors for the host-generic script systems.
109///
110/// Every `::<H>` system [`ScriptPlugin`] registers joins one of these sets, so
111/// an app running several hosts still has exactly one name to order against.
112/// Order against the set, never against `system::<H>`: with more than one host
113/// installed a concrete-system edge constrains that host alone and silently
114/// leaves the others outside the one-tick dirty window.
115///
116/// The sets are pairwise disjoint but for one nesting:
117/// [`ScriptSet::PluginEvents`] sits inside [`ScriptSet::Dispatch`]. Apart
118/// from that, `.after(one).before(another)` never closes a cycle.
119#[derive(bevy_ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
120pub enum ScriptSet {
121 /// [`sync_signals_into_host`]: store -> host mirror, per host.
122 SyncSignals,
123 /// A second [`sync_signals_into_host`] pass, late in the tick, installed
124 /// only when the app runs more than one host.
125 ///
126 /// A host updates its own mirror as its builtins run, so with one host the
127 /// early pass is enough. Across hosts it is not: a signal one host writes
128 /// reaches the store only when the embedder's command applier runs, which
129 /// is after the early pass, and the store's dirty flag is gone by the next
130 /// tick. Without this second pass the other hosts would never observe the
131 /// write at all. Ordered after the applier and before
132 /// [`ScriptSet::Derivations`], so a derivation in one language recomputes
133 /// on the same tick a dep written in another lands.
134 SyncSignalsLate,
135 /// [`tick_script`]: drain each host's command sink onto the message bus.
136 Tick,
137 /// [`apply_derivations`]: the per-host derived-signal fixed point.
138 Derivations,
139 /// Every event dispatcher that calls into a host
140 /// ([`dispatch_clicks_and_doubles`], [`dispatch_close_to_script`], the
141 /// toggle / slider / menu / dialog / DnD / text-input fanout, and
142 /// [`fire_plugin_events`], which delivers what the capabilities report).
143 ///
144 /// Every member runs after [`ScriptSet::SyncSignals`], which runs after the
145 /// control write-backs (`push_*_to_signal`). A handler therefore reads a
146 /// bound signal with this tick's toggle flip, slider move, or keystroke
147 /// already in it: `on_toggle` sees the new state, and a Save click on the
148 /// tick after typing reads the typed text.
149 Dispatch,
150 /// [`fire_due_timers`]: `on_timer` delivery, per host.
151 Timers,
152 /// [`fire_fetched_responses`]: `on_fetch` / `on_http` delivery, per host.
153 Fetch,
154 /// [`fire_plugin_events`]: delivery of the handler calls plugins and
155 /// capabilities pushed, per host. Nested inside [`ScriptSet::Dispatch`].
156 PluginEvents,
157 /// [`fire_on_ready`]: the once-per-mount `on_ready` dispatch, per host.
158 /// Registered by the embedder, not by [`ScriptPlugin`].
159 Ready,
160 /// [`fill_components`]: the use sites the build left for the script.
161 /// Registered by the embedder, not by [`ScriptPlugin`].
162 Fill,
163 /// The DOM propagation of raw input
164 /// ([`crate::dom_events::deliver_input_events`]), once for every host.
165 /// A `node.on("click", ...)` listener runs here, so this set produces
166 /// script commands and every applier of them orders after it.
167 DomInput,
168 /// The DOM propagation of derived state
169 /// ([`crate::dom_events::deliver_state_events`]), once for every host. Kept
170 /// apart from [`ScriptSet::DomInput`] because it runs at the far end
171 /// of the tick, after the text edits that script commands produce;
172 /// one set covering both could not be ordered against the appliers
173 /// without a cycle.
174 DomState,
175 /// [`fire_frame_callbacks`]: `on_frame(dt)` delivery, per host.
176 ///
177 /// Configured after [`ScriptSet::Dispatch`] so a callback sees the input
178 /// this tick carried, and before [`ScriptSet::Timers`] so a frame and a
179 /// timer coming due together always run in that order.
180 Frame,
181}
182
183/// Marker for the once-per-app half of [`ScriptPlugin::build`]: the shared
184/// registries, the message registration, and the non-generic drain systems.
185/// The second and later hosts skip that half, so a two-language app gets one
186/// [`TimerRegistry`] and one `drain_timer_commands`, not two of each.
187#[derive(Resource)]
188struct ScriptSharedInstalled;
189
190// ---------------------------------------------------------------------
191// Plugin
192// ---------------------------------------------------------------------
193
194/// Generic script plugin: loads `source` into the supplied host, fires
195/// `on_start` (re-stashing its commands into the sink so they flow
196/// through the first tick's normal drain), installs the host as a
197/// `Resource`, and registers the full dispatcher / derivation / timer /
198/// fetch system set.
199///
200/// Host construction (engine limits, builtin registration, embedder
201/// extensions) happens BEFORE this plugin: build the host, apply any
202/// host-specific extensions, then hand it over. A host module's own plugin is
203/// a thin wrapper doing exactly that.
204pub struct ScriptPlugin<H: ScriptHost + Resource<Mutability = Mutable>> {
205 host: H,
206 source: String,
207 uri: String,
208 source_map: lumen_ir::source_map::SourceMap,
209}
210
211impl<H: ScriptHost + Resource<Mutability = Mutable>> ScriptPlugin<H> {
212 /// Wrap a built host + script source. The source URI defaults to
213 /// `"<inline>"` (matches the historical compile-error shape).
214 pub fn new(host: H, source: impl Into<String>) -> Self {
215 Self {
216 host,
217 source: source.into(),
218 uri: "<inline>".to_string(),
219 source_map: lumen_ir::source_map::SourceMap::default(),
220 }
221 }
222
223 /// Override the source URI reported in compile errors.
224 pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
225 self.uri = uri.into();
226 self
227 }
228
229 /// Say where each piece of the source was read from, so a compile error
230 /// names the script file and line instead of the URI.
231 pub fn with_source_map(mut self, map: lumen_ir::source_map::SourceMap) -> Self {
232 self.source_map = map;
233 self
234 }
235}
236
237impl<H: ScriptHost + Resource<Mutability = Mutable>> Plugin for ScriptPlugin<H> {
238 fn build(mut self, app: &mut App) {
239 let lang = self.host.lang();
240 // Bind the app's registered functions before the program compiles.
241 // candela resolves every `host "..." { .. }` declaration while it
242 // compiles the source, so a function registered after the load has
243 // nothing left to bind to; Rhai and Lua would bind a name the compiled
244 // program already failed to resolve. Sealing after the drain turns a
245 // late registration into a warning instead of a silent miss.
246 if app.world.contains_resource::<ScriptFnRegistry>() {
247 let fns = app.world.resource::<ScriptFnRegistry>().fns().to_vec();
248 for f in &fns {
249 if let Err(e) = self.host.register_script_fn(f) {
250 warn_line!(
251 "{}: registering `{}` failed: {e}",
252 prefix(lang),
253 f.name.as_str()
254 );
255 }
256 }
257 // A plugin that ships sugar over its namespace hands it over here
258 // too, so the host can stage it ahead of the program.
259 let preludes: Vec<(String, String)> = app
260 .world
261 .resource::<ScriptFnRegistry>()
262 .preludes_for_lang(lang)
263 .iter()
264 .map(|p| (p.ns.clone(), p.source.clone()))
265 .collect();
266 for (ns, source) in &preludes {
267 self.host.add_prelude(ns, source);
268 }
269 app.world.resource_mut::<ScriptFnRegistry>().seal();
270 }
271 if let Err(e) = self
272 .host
273 .load(&self.source, &self.uri)
274 .map_err(|e| e.relocate(&self.uri, &self.source_map))
275 {
276 // Unmissable, multi-line stderr banner - a load failure kills
277 // every handler / signal / derivation while the window keeps
278 // rendering, which historically read as "the app ignores
279 // clicks" rather than "the script is dead".
280 warn_line!(
281 "\n\
282 ================================================================\n\
283 lumen-{lang}: SCRIPT LOAD FAILED\n\
284 \n\
285 {e}\n\
286 \n\
287 The window will still open, but every event handler, signal,\n\
288 and derivation in this script is DISABLED.\n\
289 Run `lumenc check <dir>` to reproduce this error in CI.\n\
290 ================================================================\n"
291 );
292 app.world.insert_resource(ScriptLoadFailure(e.to_string()));
293 }
294 // Fire on_start once now that the program is loaded. Its signal
295 // writes land in the store here, so the tree the app spawns next
296 // starts from them and the `<if>` / `<for>` pass the app runs before
297 // the first tick mounts what they open. Everything else it produced
298 // is re-stashed into the sink and drained on the first tick through
299 // the normal ScriptCommandEvent path (apply_script_commands is
300 // downstream of the message bus, not the sink).
301 let commands = match self.host.call("on_start", &[]) {
302 Ok(outcome) => outcome.commands,
303 Err(failure) => {
304 warn_line!("{}: on_start failed: {failure}", prefix(lang));
305 failure.commands
306 }
307 };
308 let rest = apply_signal_writes(&mut app.world, commands);
309 self.host.push_commands(rest);
310 app.world.insert_resource(self.host);
311 app.world.insert_resource(ScriptStartedAt(Instant::now()));
312 // -- Once per app, however many hosts are installed ---------------
313 if !app.world.contains_resource::<ScriptSharedInstalled>() {
314 app.world.insert_resource(ScriptSharedInstalled);
315 // Latch for the post-mount `on_ready` dispatch (see `fire_on_ready`).
316 app.world.insert_resource(OnReadyFired::default());
317 app.world.insert_resource(TimerRegistry::default());
318 app.world.insert_resource(DueTimers::default());
319 app.world.insert_resource(FrameHook::default());
320 app.world.insert_resource(DueFrame::default());
321 // The HTTP client is chosen at the composition point (the runtime
322 // installs `lumen-http-ureq`; an embedder installs its own), so
323 // only fall back to the disabled client when nothing put a
324 // registry in first.
325 if !app.world.contains_resource::<FetchRegistry>() {
326 app.world.insert_resource(FetchRegistry::default());
327 }
328 app.world.insert_resource(PendingFetchReplies::default());
329 // Events pushed from outside a script call: a module's worker,
330 // a portable plugin, a browser add-on's settled promise. Only
331 // the encoded form is native-only (a page cannot load a
332 // portable plugin), so the pipeline exists everywhere and the
333 // decoder stays out of the module a site downloads.
334 app.world.insert_resource(PendingPluginEvents::default());
335 register_plugin_event_message(&mut app.world);
336 lumen_core::plugin_events::init_plugin_events();
337 register_script_commands(&mut app.world);
338 // Toggle / slider dispatchers below read these messages. In
339 // production `lumen-primitives::ControlsPlugin` registers them
340 // first; we self-register defensively so tests that drive the
341 // script host without `ControlsPlugin` still bring up a valid
342 // schedule. Idempotent: already-initialised resources stay.
343 app.world
344 .init_resource::<bevy_ecs::message::Messages<lumen_primitives::ToggleChanged>>();
345 app.world
346 .init_resource::<bevy_ecs::message::Messages<lumen_primitives::SliderChanged>>();
347 // DnD dispatchers below read these. `lumen-os-dnd::DndPlugin`
348 // registers them in production; self-register defensively so a
349 // script host without DndPlugin still brings up a valid schedule.
350 app.world
351 .init_resource::<bevy_ecs::message::Messages<lumen_os_dnd::DropAccepted>>();
352 app.world
353 .init_resource::<bevy_ecs::message::Messages<lumen_os_dnd::DragStarted>>();
354 // `dispatch_text_input_to_script` reads the keyboard-edit queue.
355 // `lumen-input::InputPlugin` registers it in production;
356 // self-register so a script host without the input layer still
357 // brings up a valid schedule.
358 app.world.init_resource::<bevy_ecs::message::Messages<
359 lumen_core::text_events::TextEditApplied,
360 >>();
361 // Foundation property store: defensively init so bare tests that
362 // skip `App::new()`'s standard resources still bring up a valid
363 // schedule for the store consumers.
364 app.world
365 .init_resource::<lumen_core::property_store::PropertyStore>();
366 // The foundation typed-property bus drain is registered globally
367 // by `App::new()` in `TickStage::CommandDrain` (before `Systems`),
368 // so typed writes from host builtins are already committed to
369 // `PropertyStore` by the time `sync_signals_into_host` and
370 // `apply_derivations` read it. Idempotent init.
371 lumen_core::property_store::init_external_properties();
372 // Timer bookkeeping is host-neutral and runs once: it reschedules
373 // repeating timers and drops one-shots BEFORE any host fires, so a
374 // handler that cancels or re-arms the same name sees a clean slate,
375 // and every host is offered the same due list.
376 app.add_systems(
377 TickStage::Systems,
378 retire_due_timers
379 .after(ScriptSet::Tick)
380 .before(ScriptSet::Timers),
381 );
382 // The frame hook, on the same host-neutral shape as the timers:
383 // one decision before any host fires, one delivery set per host,
384 // one drain after it.
385 app.add_systems(
386 TickStage::Systems,
387 retire_frame_request
388 .after(ScriptSet::Tick)
389 .before(ScriptSet::Frame),
390 );
391 // After every set that calls into a host. A request is what
392 // raises the animation flag and wakes the loop, so one this
393 // system ran past does not arrive a tick late - it lets an idle
394 // app park with the request still in the buffer, and the
395 // animation never starts. `on_ready`, `on_timer`, `on_fetch`, a
396 // plugin event, and a DOM listener are all places a script starts
397 // one from, and none of them are ordered against this by
398 // anything else.
399 app.add_systems(
400 TickStage::Systems,
401 drain_frame_requests
402 .after(ScriptSet::Tick)
403 .after(ScriptSet::Dispatch)
404 .after(ScriptSet::Derivations)
405 .after(ScriptSet::Frame)
406 .after(ScriptSet::Timers)
407 .after(ScriptSet::Fetch)
408 .after(ScriptSet::PluginEvents)
409 .after(ScriptSet::Ready)
410 .after(ScriptSet::Fill)
411 .after(ScriptSet::DomInput)
412 .after(ScriptSet::DomState),
413 );
414 // HTTP replies land in a per-tick buffer rather than being taken
415 // straight off the channel, so every host is offered each reply
416 // instead of whichever host's system happened to run first.
417 app.add_systems(
418 TickStage::Systems,
419 collect_fetch_replies
420 .after(drain_fetch_commands)
421 .before(ScriptSet::Fetch),
422 );
423 app.add_systems(
424 TickStage::Systems,
425 clear_fetch_replies.after(ScriptSet::Fetch),
426 );
427 // The plugin-event pipeline mirrors the fetch one above: one
428 // host-neutral collect, one per-host delivery set, one clear.
429 app.add_systems(
430 TickStage::Systems,
431 collect_plugin_events.before(ScriptSet::PluginEvents),
432 );
433 app.add_systems(
434 TickStage::Systems,
435 clear_plugin_events.after(ScriptSet::PluginEvents),
436 );
437 app.add_systems(
438 TickStage::Systems,
439 drain_fetch_commands.after(ScriptSet::Tick),
440 );
441 // The clipboard builtins, answered by whichever clipboard backend
442 // the assembly installed. After every set that runs script code,
443 // so a command queued anywhere this tick is carried out this tick.
444 app.add_systems(
445 TickStage::Systems,
446 crate::clipboard::apply_clipboard_commands
447 .after(ScriptSet::Tick)
448 .after(ScriptSet::Dispatch)
449 .after(ScriptSet::DomInput)
450 .after(ScriptSet::Frame)
451 .after(ScriptSet::Fill),
452 );
453 // Must run after `fire_due_timers`: a repeating timer cancelled
454 // from inside its own `on_timer` emits a `CancelTimer` during the
455 // firing pass. Without this ordering the cancel could be drained a
456 // tick late - after `retire_due_timers` had already re-armed the
457 // timer and fired it one extra time. Draining right after the
458 // firing pass applies the cancel on the same tick, before the next
459 // re-fire. After every other set that calls into a host too, for
460 // the reason `drain_frame_requests` gives: a timer armed in a set
461 // this ran past is not registered until the next tick, and an
462 // idle loop never runs one to wake for it.
463 app.add_systems(
464 TickStage::Systems,
465 drain_timer_commands
466 .after(ScriptSet::Tick)
467 .after(ScriptSet::Dispatch)
468 .after(ScriptSet::Derivations)
469 .after(ScriptSet::Frame)
470 .after(ScriptSet::Timers)
471 .after(ScriptSet::Fetch)
472 .after(ScriptSet::PluginEvents)
473 .after(ScriptSet::Ready)
474 .after(ScriptSet::Fill)
475 .after(ScriptSet::DomInput)
476 .after(ScriptSet::DomState),
477 );
478 }
479 // -- Once per host -------------------------------------------------
480 // `.after(push_*)`: the two-way binding pushes (toggle flip,
481 // slider drag, keystroke mirror) write the store mid-Systems, and
482 // their dirty flags are cleared at end of tick. Unordered, this
483 // mirror sync can run before the push on the write tick - the
484 // host-local map then keeps the stale value, and since the key is
485 // never dirty again, `apply_derivations` (which reads dep values
486 // through the mirror) recomputes with the OLD value and the
487 // derived signal freezes. The widget garden's `toggle_status`
488 // label was the live repro.
489 app.add_systems(
490 TickStage::Systems,
491 sync_signals_into_host::<H>
492 .in_set(ScriptSet::SyncSignals)
493 .after(lumen_core::signals::push_toggle_to_signal)
494 .after(lumen_core::signals::push_slider_to_signal)
495 .after(lumen_core::signals::push_textinput_to_signal)
496 .after(lumen_core::signals::push_scroll_to_signal),
497 );
498 app.add_systems(
499 TickStage::Systems,
500 tick_script::<H>
501 .in_set(ScriptSet::Tick)
502 .after(ScriptSet::SyncSignals),
503 );
504 app.add_systems(
505 TickStage::Systems,
506 apply_derivations::<H>
507 .in_set(ScriptSet::Derivations)
508 .after(ScriptSet::SyncSignals),
509 );
510 // Between the input dispatch and the timers: a callback reads the
511 // input this tick carried, and a frame and a timer coming due
512 // together always run in that order.
513 app.add_systems(
514 TickStage::Systems,
515 fire_frame_callbacks::<H>
516 .in_set(ScriptSet::Frame)
517 .after(ScriptSet::Dispatch)
518 .before(ScriptSet::Timers),
519 );
520 app.add_systems(
521 TickStage::Systems,
522 fire_due_timers::<H>.in_set(ScriptSet::Timers),
523 );
524 app.add_systems(
525 TickStage::Systems,
526 fire_fetched_responses::<H>.in_set(ScriptSet::Fetch),
527 );
528 // Also a member of the dispatch set: a capability's event (a tray
529 // click, a hotkey, a resolved dialog) is input like a click, so every
530 // applier ordered after the dispatchers carries out the commands its
531 // handler queued on the tick it fired.
532 app.add_systems(
533 TickStage::Systems,
534 fire_plugin_events::<H>
535 .in_set(ScriptSet::PluginEvents)
536 .in_set(ScriptSet::Dispatch)
537 .after(ScriptSet::SyncSignals),
538 );
539 // Event dispatchers: forward Click / LongPress / DoubleClick to
540 // the script's `on_click(id)` / `on_long_press(id)` /
541 // `on_double_click(id)` functions with the entity's LumenId. Run
542 // after lumen-input's dispatch_clicks so the Click message is
543 // populated for this tick. Click + double-click are dispatched
544 // together so a double-click's `on_double_click` follows the
545 // `on_click` of the click that completed it.
546 app.add_systems(
547 TickStage::Systems,
548 dispatch_clicks_and_doubles::<H>
549 .in_set(ScriptSet::Dispatch)
550 .after(ScriptSet::SyncSignals)
551 .after(lumen_input::dispatch_clicks)
552 .after(lumen_primitives::press::detect_double_click),
553 );
554 app.add_systems(
555 TickStage::Systems,
556 (
557 dispatch_long_press_to_script::<H>,
558 dispatch_text_input_to_script::<H>,
559 dispatch_file_drops_to_script::<H>,
560 dnd::dispatch_drops_to_script::<H>,
561 dnd::dispatch_drag_start_to_script::<H>,
562 dispatch_menu_clicks_to_script::<H>,
563 dispatch_dialog_closes_to_script::<H>,
564 dispatch_toggle_to_script::<H>,
565 dispatch_slider_to_script::<H>,
566 dispatch_close_to_script::<H>,
567 )
568 .in_set(ScriptSet::Dispatch)
569 .after(ScriptSet::SyncSignals),
570 );
571 }
572}
573
574/// Apply the signal writes among `commands` to the world's store and answer
575/// the rest, in their order.
576///
577/// What `on_start` writes is the state the app starts in, so it goes in before
578/// the tree is spawned rather than on the first tick. A world with no store
579/// (a bare test app) takes none of them here, and they all travel on as
580/// commands.
581fn apply_signal_writes(world: &mut World, commands: Vec<ScriptCommand>) -> Vec<ScriptCommand> {
582 if !world.contains_resource::<lumen_core::property_store::PropertyStore>() {
583 return commands;
584 }
585 world.init_resource::<lumen_core::signals::ArraySignals>();
586 let mut rest = Vec::with_capacity(commands.len());
587 for command in commands {
588 match command {
589 ScriptCommand::SetSignal { name, value } => {
590 world
591 .resource_mut::<lumen_core::property_store::PropertyStore>()
592 .set_global_str(&name, value.as_str());
593 }
594 ScriptCommand::SetProperty { key, value } => {
595 world
596 .resource_mut::<lumen_core::property_store::PropertyStore>()
597 .set(key, value);
598 }
599 ScriptCommand::SetArray { name, items } => {
600 world
601 .resource_mut::<lumen_core::signals::ArraySignals>()
602 .set(name, items);
603 }
604 other => rest.push(other),
605 }
606 }
607 rest
608}
609
610/// Hot-reload orchestration entry: swap the loaded program on the live
611/// host resource via [`ScriptHost::replace`] (compile-first, atomic,
612/// full rollback on eval failure - the host owns the atomicity).
613/// Returns `None` when no host is installed (script-less app).
614pub fn reload_script<H: ScriptHost + Resource<Mutability = Mutable>>(
615 world: &mut World,
616 source: &str,
617 uri: &str,
618 map: &lumen_ir::source_map::SourceMap,
619) -> Option<Result<(), ScriptError>> {
620 world
621 .get_resource_mut::<H>()
622 .map(|mut host| host.replace(source, uri).map_err(|e| e.relocate(uri, map)))
623}
624
625// ---------------------------------------------------------------------
626// Core per-tick systems
627// ---------------------------------------------------------------------
628
629/// Per-tick driver that drains the host's command sink and forwards
630/// every [`ScriptCommand`] onto the [`ScriptCommandEvent`] message bus.
631/// On the first tick this also flushes the commands re-stashed by
632/// `on_start` (see [`ScriptPlugin::build`]).
633///
634/// `pub` so the embedder (lumenc) can order its command applier
635/// `.after(tick_script::<H>)` - required post dirty-gating so a signal
636/// write produced here lands in `PropertyStore` *and* is observed by the
637/// pull-binding readers within the same tick, before
638/// `clear_property_store_dirty` empties the dirty queue in `A11ySync`.
639pub fn tick_script<H: ScriptHost + Resource<Mutability = Mutable>>(
640 mut host: ResMut<H>,
641 mut events: MessageWriter<ScriptCommandEvent>,
642) {
643 for c in host.drain_commands() {
644 events.write(ScriptCommandEvent(c));
645 }
646}
647
648/// Fill every use site the build could not finish by calling the function it
649/// names and putting the node that call returns in the marker's place.
650///
651/// A component element names a script function. Where instantiating the block
652/// that function returns is the same as calling it, the build already put the
653/// block in the tree and no marker was left. Where the function has to run,
654/// because it works a value out or picks between blocks, the build left one
655/// element carrying [`PendingFill`] and this is what runs it.
656///
657/// The call's own commands go out first, so the subtree it built exists by the
658/// time the replacement names it; the reserved token the host minted resolves
659/// inside the same batch. The marker is dropped on the way, so a filled site
660/// fills once.
661///
662/// Every tick rather than once: a subtree spawned while the app runs can carry
663/// a marker of its own.
664///
665/// Only the host that compiles markup blocks runs this, because that is the
666/// only host a marker can name a function in. A function it does not declare
667/// is a program that failed to load or an artifact that lost it: reported once
668/// and the marker dropped, rather than retried against a program that will
669/// never have it. Loud, because what it leaves behind is an empty element,
670/// which is exactly what a reader would not notice.
671pub fn fill_components<H: ScriptHost + Resource<Mutability = Mutable>>(
672 mut host: ResMut<H>,
673 pending: Query<(Entity, &lumen_core::components::PendingFill)>,
674 // Function names already complained about, so a tree that keeps spawning
675 // the same unfillable use site says it once. One host fills, so this
676 // needs no sharing.
677 mut reported: Local<std::collections::HashSet<String>>,
678 mut commands: Commands,
679 mut events: MessageWriter<ScriptCommandEvent>,
680) {
681 if !H::FILLS_COMPONENTS {
682 return;
683 }
684 for (entity, fill) in &pending {
685 let args: Vec<ScriptValue> = fill
686 .args
687 .iter()
688 .map(|value| ScriptValue::Str(value.clone()))
689 .collect();
690 let outcome = match host.call(&fill.function, &args) {
691 Ok(outcome) => outcome,
692 Err(failure) => {
693 warn_line!(
694 "{}: component {} failed: {failure}",
695 prefix(host.lang()),
696 fill.function
697 );
698 for c in failure.commands {
699 events.write(ScriptCommandEvent(c));
700 }
701 commands
702 .entity(entity)
703 .remove::<lumen_core::components::PendingFill>();
704 continue;
705 }
706 };
707 if !outcome.found {
708 if reported.insert(fill.function.clone()) {
709 warn_line!(
710 "{}: component {} is named in the tree but the loaded program does not \
711 declare it; nothing was built there",
712 prefix(host.lang()),
713 fill.function
714 );
715 }
716 commands
717 .entity(entity)
718 .remove::<lumen_core::components::PendingFill>();
719 continue;
720 }
721 for c in outcome.commands {
722 events.write(ScriptCommandEvent(c));
723 }
724 if let Some(new) = outcome.ret.as_ref().and_then(node_of) {
725 events.write(ScriptCommandEvent(ScriptCommand::ReplaceWith {
726 old: lumen_core::node::NodeHandle::new(entity).pack(),
727 new,
728 }));
729 } else {
730 warn_line!(
731 "{}: component {} returned no node; a component returns one lmn! block",
732 prefix(host.lang()),
733 fill.function
734 );
735 }
736 commands
737 .entity(entity)
738 .remove::<lumen_core::components::PendingFill>();
739 }
740}
741
742/// The packed node a component's return value names, if it names one.
743///
744/// A host that cannot carry a 64-bit handle returns the interned id instead,
745/// which is what the node side-table resolves; the result may be a reserved
746/// spawn token, which the applier maps inside the batch it arrived in.
747fn node_of(value: &ScriptValue) -> Option<u64> {
748 match value {
749 ScriptValue::I64(id) => lumen_core::node::resolve_node_raw(i32::try_from(*id).ok()?),
750 _ => None,
751 }
752}
753
754/// Latch guarding [`fire_on_ready`]: holds the [`ScriptHost::lang`] of every
755/// host that has already dispatched `on_ready`, so each active host fires
756/// once per mount. Hot reload clears the set after respawning the tree, so a
757/// script that builds DOM in `on_ready` rebuilds it on the fresh mount.
758#[derive(Resource, Default)]
759pub struct OnReadyFired(pub std::collections::HashSet<&'static str>);
760
761/// Dispatch the script's optional `on_ready()` once per mount, on the first
762/// tick after the DOM index is published.
763///
764/// `on_start` runs at app-construction time, before any tick, when no element
765/// is queryable yet: a `node_get_by_id` there returns 0. That forced DOM apps
766/// to defer their first tree build behind a `set_timeout("boot", 0)` timer.
767/// `on_ready` closes that gap: it runs after the first `build_dom_index`
768/// publish, so a query inside it observes the mounted static tree and the app
769/// can build its initial DOM directly. A script without `on_ready` is a no-op
770/// (the host reports the missing handler as not-found), so existing `on_start`
771/// apps are unaffected. Commands `on_ready` emits flow onto the event bus like
772/// any handler's, and this system is ordered before the DOM-command collector
773/// so those mutations apply on the same first tick.
774pub fn fire_on_ready<H: ScriptHost + Resource<Mutability = Mutable>>(
775 mut host: ResMut<H>,
776 mut fired: ResMut<OnReadyFired>,
777 mut events: MessageWriter<ScriptCommandEvent>,
778) {
779 if !fired.0.insert(host.lang()) {
780 return;
781 }
782 let lang = host.lang();
783 if let Err(e) = forward_call(host.call("on_ready", &[]), &mut events) {
784 warn_line!("{}: on_ready failed: {e}", prefix(lang));
785 }
786}
787
788/// Mirror the ECS-side global [`lumen_core::property_store::PropertyStore`]
789/// cells into the host's rich-typed local mirror at the start of every
790/// tick, walking only the keys dirtied this tick. Without this, the
791/// script's signal reads see stale values when another system (a `<for>`
792/// reconciler, a two-way bind push, ...) wrote to the property store
793/// between the previous tick and this one.
794///
795/// Strings only - boolean / numeric / colour cells stay typed in the
796/// store. Per-key overwrite policy (type-preserving parse-back) is
797/// pinned by [`ScriptHost::mirror_sync_str`].
798pub fn sync_signals_into_host<H: ScriptHost + Resource<Mutability = Mutable>>(
799 mut host: ResMut<H>,
800 store: Res<lumen_core::property_store::PropertyStore>,
801) {
802 // Idle-tick fast path: nothing was written to the store this tick,
803 // so the host-local mirror is already current. The dirty queue is
804 // only cleared in A11ySync (after this system runs at the top of
805 // Systems), so an empty queue means no global cell changed.
806 if store.dirty_peek().is_empty() {
807 return;
808 }
809 for key in store.dirty_peek() {
810 let lumen_core::property_store::PropertyKey::Global(name) = key else {
811 continue;
812 };
813 let Some(lumen_core::property_store::PropertyValue::Str(v)) = store.get(key) else {
814 continue;
815 };
816 host.mirror_sync_str(name.as_ref(), v.as_ref());
817 }
818}
819
820/// Re-evaluate every computed signal whose deps changed this tick.
821///
822/// Reads the per-tick `PropertyStore::dirty_global_names` set together with
823/// the arrays `ArraySignals::changed` names, snapshots every derivation
824/// whose declared deps intersect that set (plus the
825/// pending-initial set), evaluates each via
826/// [`ScriptHost::eval_derivation`], and commits the result **directly
827/// into the store** (marking it dirty) so binding readers ordered
828/// `.after` this system observe the fresh derived value on the same tick
829/// the dep changed.
830///
831/// Cascades (derived-of-derived) resolve within the tick: after each
832/// evaluation wave, names whose stored value actually changed form the
833/// dirty set for the next wave, looping to a fixed point (bounded by 32
834/// passes; a self-referential cycle logs and stops). Pending-initial
835/// names all run in wave 1 regardless of dirt; an erroring derivation
836/// stays pending and retries next tick. Equal writes don't propagate
837/// (`set_global_str` skips the dirty push).
838///
839/// A panicking evaluation is contained the same way: caught, reported
840/// through the same failed-derivation log line an `Err` gets, and left
841/// pending so it retries next tick. See the `catch_unwind` call below.
842///
843/// Each write is also mirrored as a [`ScriptCommand::SetSignal`] event so
844/// bare-plugin embedders that drain [`ScriptCommandEvent`] keep observing
845/// derived values; under lumenc that replay is a next-tick equal-value
846/// no-op.
847pub fn apply_derivations<H: ScriptHost + Resource<Mutability = Mutable>>(
848 mut host: ResMut<H>,
849 mut store: ResMut<lumen_core::property_store::PropertyStore>,
850 arrays: Option<Res<lumen_core::signals::ArraySignals>>,
851 mut out: MessageWriter<ScriptCommandEvent>,
852) {
853 /// Upper bound on in-tick cascade waves. Real dependency chains are
854 /// shallow (2-3); the bound only exists to break derivation cycles.
855 const MAX_DERIVATION_PASSES: usize = 32;
856 // Dirty-first: bail before touching the registry when nothing this
857 // derivation could depend on changed. Only then snapshot the
858 // matching subset (still outside any closure invocation so the host
859 // holds no locks across re-entrant builtins).
860 let mut pending = host.pending_initial();
861 // An array signal is a dependency like any other: a write that changed
862 // its contents is recorded beside the scalar dirty queue, not in it.
863 let mut dirty: std::collections::HashSet<String> = store
864 .dirty_global_names()
865 .chain(arrays.iter().flat_map(|a| a.changed()))
866 .map(str::to_string)
867 .collect();
868 if dirty.is_empty() && pending.is_empty() {
869 return;
870 }
871 let mut evaluated: Vec<String> = Vec::new();
872 let mut pass = 0;
873 loop {
874 let dirty_refs: std::collections::HashSet<&str> =
875 dirty.iter().map(String::as_str).collect();
876 let derivations = host.derivations_matching(&dirty_refs, &pending);
877 if derivations.is_empty() {
878 break;
879 }
880 if pass == MAX_DERIVATION_PASSES {
881 warn_line!(
882 "{}: derivation cascade exceeded {MAX_DERIVATION_PASSES} \
883 passes (cyclic derive()?); giving up for this tick",
884 prefix(host.lang())
885 );
886 break;
887 }
888 pass += 1;
889 // Names whose stored value actually changed this wave - the
890 // dirty set for the next wave (derived-of-derived).
891 let mut wrote: std::collections::HashSet<String> = std::collections::HashSet::new();
892 for (name, deps, closure) in derivations {
893 let needs_initial = pending.contains(&name);
894 // A panic out of the script engine (a VM assertion tripped by
895 // corrupted interpreter state, say) must not unwind through the
896 // tick and take the process with it. Every host's `call` /
897 // `call_closure` is expected to turn its own panics into a
898 // `ScriptError` before returning, which the candela hosts do;
899 // this is the backstop for a panic that escapes anyway -
900 // one containment point, feeding the same failed-derivation
901 // path below rather than a second reporting mechanism. Neither
902 // `store` nor the property-store dirty set is touched before
903 // this call returns, so a caught panic leaves them exactly as
904 // they were before this derivation ran.
905 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
906 host.eval_derivation(&closure, &deps, &name)
907 }))
908 .unwrap_or_else(|payload| {
909 Err(ScriptError::Runtime(format!(
910 "derivation panicked: {}",
911 panic_payload_text(payload.as_ref())
912 )))
913 });
914 match outcome {
915 Ok(text) => {
916 if store.set_global_str(&name, text.as_str()) {
917 wrote.insert(name.clone());
918 }
919 out.write(ScriptCommandEvent(ScriptCommand::SetSignal {
920 name: name.clone(),
921 value: text,
922 }));
923 if needs_initial {
924 evaluated.push(name);
925 }
926 }
927 Err(e) => {
928 warn_line!("{}: derive '{name}' failed: {e}", prefix(host.lang()));
929 }
930 }
931 }
932 // The initial-evaluation set is fully consumed by the first
933 // wave (every pending name matches unconditionally); later
934 // waves are dirty-driven only. Erroring derivations stay in
935 // pending-initial (not `evaluated`) and retry next tick.
936 pending.clear();
937 dirty = wrote;
938 if dirty.is_empty() {
939 break;
940 }
941 }
942 if !evaluated.is_empty() {
943 host.clear_pending(&evaluated);
944 }
945}
946
947/// Best-effort text for a caught panic payload.
948///
949/// `Any` only carries the payload types `panic!` produces: `&'static str`
950/// for a literal message, `String` for a formatted one. Any other payload (a
951/// `panic_any` caller reaching in with something else) reports as unknown
952/// rather than failing to build a message.
953///
954/// Public so a host writing its own containment reports a panic the same way
955/// this backstop does.
956pub fn panic_payload_text(payload: &(dyn std::any::Any + Send)) -> String {
957 payload
958 .downcast_ref::<&str>()
959 .map(|s| s.to_string())
960 .or_else(|| payload.downcast_ref::<String>().cloned())
961 .unwrap_or_else(|| "non-string panic payload".to_owned())
962}
963
964// ---------------------------------------------------------------------
965// Timers
966// ---------------------------------------------------------------------
967
968/// One active timer.
969#[derive(Clone, Debug)]
970struct ActiveTimer {
971 /// Wall-clock moment the next fire is due.
972 fire_at: Instant,
973 /// Reschedule interval (`None` = one-shot).
974 repeat_every: Option<std::time::Duration>,
975}
976
977/// Active timers keyed by name. Names are unique - setting a timer with
978/// the same name replaces the previous one (matches set_interval /
979/// set_timeout semantics in browsers).
980#[derive(Resource, Default)]
981pub struct TimerRegistry {
982 timers: std::collections::HashMap<String, ActiveTimer>,
983}
984
985/// Read `SetTimer` / `CancelTimer` commands emitted by the script's
986/// builtins this tick and update the [`TimerRegistry`] accordingly, then
987/// ask the loop to wake at the earliest pending deadline. Without that
988/// request an idle loop parks on events alone and a timer armed from a
989/// click fires only when some unrelated input ticks the app.
990pub fn drain_timer_commands(
991 mut events: MessageReader<ScriptCommandEvent>,
992 mut timers: ResMut<TimerRegistry>,
993 wake: Option<Res<lumen_core::tick::WakeDeadline>>,
994) {
995 let now = Instant::now();
996 for ev in events.read() {
997 match &ev.0 {
998 ScriptCommand::SetTimer {
999 name,
1000 millis,
1001 repeat,
1002 } => {
1003 let dur = std::time::Duration::from_millis(*millis);
1004 timers.timers.insert(
1005 name.clone(),
1006 ActiveTimer {
1007 fire_at: now + dur,
1008 repeat_every: if *repeat { Some(dur) } else { None },
1009 },
1010 );
1011 }
1012 ScriptCommand::CancelTimer { name } => {
1013 timers.timers.remove(name);
1014 }
1015 _ => {}
1016 }
1017 }
1018 if let Some(wake) = wake
1019 && let Some(next) = timers.timers.values().map(|t| t.fire_at).min()
1020 {
1021 wake.request(next);
1022 }
1023}
1024
1025/// Names of the timers due this tick, in sorted order. Rewritten every tick by
1026/// [`retire_due_timers`] and read by [`fire_due_timers`] on each active host.
1027#[derive(Resource, Default)]
1028pub struct DueTimers(pub Vec<String>);
1029
1030/// Collect the timers whose deadline has passed, reschedule the repeating ones,
1031/// and drop the one-shots. Host-neutral and registered once, so each active
1032/// host is offered the same due list instead of the first one to run taking it.
1033///
1034/// Reschedule / remove happens BEFORE any host fires, so a handler that calls
1035/// `cancel_timer` or `set_interval` on the same name sees a clean slate. Due
1036/// timers are sorted by name for determinism.
1037pub fn retire_due_timers(mut timers: ResMut<TimerRegistry>, mut due_out: ResMut<DueTimers>) {
1038 let now = Instant::now();
1039 let mut due: Vec<String> = timers
1040 .timers
1041 .iter()
1042 .filter(|(_, t)| t.fire_at <= now)
1043 .map(|(name, _)| name.clone())
1044 .collect();
1045 due.sort();
1046 for name in &due {
1047 let next = timers
1048 .timers
1049 .get(name)
1050 .and_then(|t| t.repeat_every.map(|d| now + d));
1051 match next {
1052 Some(fire_at) => {
1053 if let Some(t) = timers.timers.get_mut(name) {
1054 t.fire_at = fire_at;
1055 }
1056 }
1057 None => {
1058 timers.timers.remove(name);
1059 }
1060 }
1061 }
1062 due_out.0 = due;
1063}
1064
1065/// Fire `on_timer(name)` for every timer [`retire_due_timers`] collected this
1066/// tick. A host that defines no matching handler ignores the call, so in an app
1067/// running several languages the timer reaches whichever host declared it.
1068pub fn fire_due_timers<H: ScriptHost + Resource<Mutability = Mutable>>(
1069 mut host: ResMut<H>,
1070 due: Res<DueTimers>,
1071 mut out: MessageWriter<ScriptCommandEvent>,
1072) {
1073 for name in &due.0 {
1074 if let Err(e) = route_event(&mut *host, "timer", "on_timer", name, &mut out) {
1075 warn_line!("{}: on_timer({name}) failed: {e}", prefix(host.lang()));
1076 }
1077 }
1078}
1079
1080// ---------------------------------------------------------------------
1081// Frame callbacks
1082// ---------------------------------------------------------------------
1083
1084/// The longest step `on_frame` is ever handed, in seconds.
1085///
1086/// A tick can take arbitrarily long - a slow first frame, a stalled thread,
1087/// a machine that went to sleep - and an animation integrating the real
1088/// elapsed time would jump across the whole gap. The cap turns the worst
1089/// case into a slow frame instead of a teleport, at the cost of an animation
1090/// running slightly behind wall-clock after a stall, which is the trade every
1091/// game loop makes.
1092pub const MAX_FRAME_DT: f64 = 0.100;
1093
1094/// The state behind `request_frame()` / `on_frame(dt)`.
1095///
1096/// One request buys one callback. Nothing here schedules a second one, so an
1097/// app that stops asking stops being ticked for it and the loop parks; that
1098/// is what makes an idle animation free.
1099#[derive(Resource, Default)]
1100pub struct FrameHook {
1101 /// A `request_frame()` arrived and has not been served yet.
1102 requested: bool,
1103 /// A callback ran on the previous tick, so the next one continues a loop
1104 /// rather than starting one. It is what decides whether `dt` is elapsed
1105 /// time or zero.
1106 running: bool,
1107}
1108
1109/// The step handed to `on_frame` this tick, or `None` when no frame is due.
1110/// Rewritten every tick by [`retire_frame_request`] and read by
1111/// [`fire_frame_callbacks`] on each active host.
1112#[derive(Resource, Default)]
1113pub struct DueFrame(pub Option<f64>);
1114
1115/// Decide whether a frame callback is due this tick and what step it gets.
1116///
1117/// Host-neutral and registered once, so every active host is offered the same
1118/// frame instead of the first one to run taking it. The request is consumed
1119/// here, before any host fires, so a callback that asks for another is asking
1120/// for the next tick and cannot re-enter this one.
1121///
1122/// The first frame of a loop gets `0.0`: no callback ran before it, so there
1123/// is no interval to report and a made-up one would move an animation before
1124/// any time passed. Every frame after that gets the tick's own elapsed time,
1125/// capped at [`MAX_FRAME_DT`].
1126pub fn retire_frame_request(
1127 mut hook: ResMut<FrameHook>,
1128 tick: Res<lumen_core::tick::Tick>,
1129 mut due: ResMut<DueFrame>,
1130) {
1131 let serve = std::mem::take(&mut hook.requested);
1132 due.0 = serve.then(|| {
1133 if hook.running {
1134 tick.dt.as_secs_f64().min(MAX_FRAME_DT)
1135 } else {
1136 0.0
1137 }
1138 });
1139 hook.running = serve;
1140}
1141
1142/// Call `on_frame(dt)` on this host when [`retire_frame_request`] found a
1143/// frame due. A host that declares no `on_frame` is silent, so in an app
1144/// running several languages the callback reaches whichever host declared it.
1145pub fn fire_frame_callbacks<H: ScriptHost + Resource<Mutability = Mutable>>(
1146 mut host: ResMut<H>,
1147 due: Res<DueFrame>,
1148 mut out: MessageWriter<ScriptCommandEvent>,
1149) {
1150 let Some(dt) = due.0 else {
1151 return;
1152 };
1153 let lang = host.lang();
1154 if let Err(e) = forward_call(host.call("on_frame", &[ScriptValue::F64(dt)]), &mut out) {
1155 warn_line!("{}: on_frame failed: {e}", prefix(lang));
1156 }
1157}
1158
1159/// Read the `RequestFrame` commands emitted this tick and arm the hook.
1160///
1161/// Registered after every set that calls into a host, because a request is
1162/// only worth anything on the tick it was made: arming is what raises the
1163/// animation flag and wakes the loop, so a request this system ran past does
1164/// not merely arrive late, it lets an idle app park and never arrive at all.
1165/// A request made from inside `on_frame` therefore arms the next tick rather
1166/// than re-entering this one, which is what keeps a loop at one callback per
1167/// tick.
1168///
1169/// Both the animation flag and the waker are optional so a bare test can
1170/// drive these systems without a window.
1171pub fn drain_frame_requests(
1172 mut events: MessageReader<ScriptCommandEvent>,
1173 mut hook: ResMut<FrameHook>,
1174 animations: Option<Res<lumen_core::render_world::AnimationsActive>>,
1175 waker: Option<Res<lumen_core::app::EventLoopWaker>>,
1176) {
1177 // Counted rather than short-circuited: the reader's cursor advances as it
1178 // yields, so stopping at the first request would leave the rest of this
1179 // tick's commands unread, and the next tick would find a stale request
1180 // sitting in the buffer and serve a frame nobody asked for.
1181 let requested = events
1182 .read()
1183 .filter(|ev| matches!(ev.0, ScriptCommand::RequestFrame))
1184 .count();
1185 if requested == 0 {
1186 return;
1187 }
1188 hook.requested = true;
1189 if let Some(animations) = animations.as_deref() {
1190 animations.request();
1191 }
1192 if let Some(waker) = waker.as_deref() {
1193 waker.wake();
1194 }
1195}
1196
1197// ---------------------------------------------------------------------
1198// HTTP fetch
1199// ---------------------------------------------------------------------
1200
1201/// HTTP plumbing: start requests away from the world thread, marshal the
1202/// completed reply back onto the ECS/UI thread, and surface it to the script.
1203///
1204/// Both `fetch(url, tag)` (simple sugar) and `http(#{...})` (general form)
1205/// flow through this single registry, dispatcher, and completion channel -
1206/// there is exactly one async delivery mechanism. The worker only ever *sends*
1207/// the outcome down the channel; [`collect_fetch_replies`], running on the
1208/// world thread, moves it into [`PendingFetchReplies`], and
1209/// [`fire_fetched_responses`] is the only place a signal / handler is touched.
1210/// That worker->UI-thread hand-off mirrors Slint's `invoke_from_event_loop`
1211/// marshalling.
1212///
1213/// The registry holds the [`HttpDispatch`] every request runs on, which in turn
1214/// holds the [`HttpClient`]. A default registry dispatches
1215/// [`DisabledHttpClient`] onto a worker thread, so a build with no client
1216/// installed answers each request with the "no HTTP client" error instead of
1217/// hanging or silently dropping it. The runtime installs the client Lumen ships
1218/// (`lumen-http-ureq`) with [`FetchRegistry::with_client`] before the script
1219/// plugin builds; an embedder swaps in its own the same way, and a platform
1220/// with no thread to block swaps the dispatcher itself with
1221/// [`FetchRegistry::with_dispatch`].
1222#[derive(Resource)]
1223pub struct FetchRegistry {
1224 sender: crossbeam_channel::Sender<HttpOutcome>,
1225 receiver: crossbeam_channel::Receiver<HttpOutcome>,
1226 dispatch: Arc<dyn HttpDispatch>,
1227}
1228
1229impl Default for FetchRegistry {
1230 fn default() -> Self {
1231 Self::with_client(Arc::new(DisabledHttpClient))
1232 }
1233}
1234
1235impl FetchRegistry {
1236 /// Build a registry whose requests run on `client`, one worker thread per
1237 /// request.
1238 ///
1239 /// Insert it before the script plugin builds; the plugin only installs its
1240 /// own default when no registry is present.
1241 pub fn with_client(client: Arc<dyn HttpClient>) -> Self {
1242 Self::with_dispatch(Arc::new(ThreadDispatch::new(client)))
1243 }
1244
1245 /// Build a registry whose requests run on `dispatch`, for a platform where
1246 /// a blocked worker thread is not how a request completes.
1247 pub fn with_dispatch(dispatch: Arc<dyn HttpDispatch>) -> Self {
1248 let (tx, rx) = crossbeam_channel::unbounded();
1249 Self {
1250 sender: tx,
1251 receiver: rx,
1252 dispatch,
1253 }
1254 }
1255}
1256
1257/// The [`HttpHook`]s every script request passes through, in the order they
1258/// were added.
1259///
1260/// Separate from [`FetchRegistry`] so that swapping the client leaves the
1261/// hooks in place: a plugin adds its hook here from `Plugin::build`, before or
1262/// after whichever registry the app ends up with.
1263#[derive(Resource, Default, Clone)]
1264pub struct HttpHooks(Vec<Arc<dyn HttpHook>>);
1265
1266impl HttpHooks {
1267 /// Run `hook` on every request from now on, after the hooks already here.
1268 pub fn add(&mut self, hook: Arc<dyn HttpHook>) {
1269 self.0.push(hook);
1270 }
1271
1272 /// True when no hook is registered.
1273 pub fn is_empty(&self) -> bool {
1274 self.0.is_empty()
1275 }
1276}
1277
1278/// How a completed request should be delivered back to the script.
1279#[derive(Clone, Copy)]
1280enum DeliveryStyle {
1281 /// `fetch()` sugar: fire `on_fetch(tag, body)` /
1282 /// `on_fetch_error(tag, msg)`, treating a non-2xx status as an error
1283 /// (preserves the historical `fetch()` contract).
1284 Fetch,
1285 /// `http()`: fire `on_http(tag, response)` with a structured map;
1286 /// a non-2xx status is a *completed* reply, not an error.
1287 Http,
1288}
1289
1290struct HttpOutcome {
1291 tag: String,
1292 style: DeliveryStyle,
1293 /// `Ok` = the request completed and a reply (any status) came back.
1294 /// `Err` = transport failure (DNS, connect, timeout, bad method/url).
1295 result: Result<HttpResponse, String>,
1296}
1297
1298/// Read `Fetch` / `Http` commands the script emitted this tick and hand each
1299/// to the registry's dispatcher. Other variants are no-ops here (they
1300/// flow to apply_script_commands and timer drains separately).
1301///
1302/// Each reply wakes the event loop once it is queued, so an app that went idle
1303/// while the request was in flight runs the tick that delivers it. The waker is
1304/// optional so a bare test can drive this without a loop to wake.
1305pub fn drain_fetch_commands(
1306 mut events: MessageReader<ScriptCommandEvent>,
1307 fetcher: Res<FetchRegistry>,
1308 hooks: Option<Res<HttpHooks>>,
1309 waker: Option<Res<lumen_core::app::EventLoopWaker>>,
1310) {
1311 for ev in events.read() {
1312 let (mut req, tag, style) = match &ev.0 {
1313 ScriptCommand::Fetch { url, tag } => (
1314 HttpRequest {
1315 method: "GET".to_string(),
1316 url: url.clone(),
1317 headers: Vec::new(),
1318 body: None,
1319 timeout_ms: None,
1320 credentials: Credentials::default(),
1321 },
1322 tag.clone(),
1323 DeliveryStyle::Fetch,
1324 ),
1325 ScriptCommand::Http {
1326 method,
1327 url,
1328 headers,
1329 body,
1330 timeout_ms,
1331 credentials,
1332 tag,
1333 } => (
1334 HttpRequest {
1335 method: method.clone(),
1336 url: url.clone(),
1337 headers: headers.clone(),
1338 body: body.clone(),
1339 timeout_ms: *timeout_ms,
1340 credentials: *credentials,
1341 },
1342 tag.clone(),
1343 DeliveryStyle::Http,
1344 ),
1345 _ => continue,
1346 };
1347 // Dev-tooling capture (no-op unless the devtools sink is installed):
1348 // report the dispatch so the Network tab shows the in-flight request
1349 // before its reply lands.
1350 net_capture::record(NetEvent::Started {
1351 tag: tag.clone(),
1352 method: req.method.clone(),
1353 url: req.url.clone(),
1354 });
1355 let hooks = hooks.as_deref().filter(|h| !h.is_empty()).cloned();
1356 if let Some(hooks) = &hooks {
1357 for hook in &hooks.0 {
1358 hook.before_send(&mut req);
1359 }
1360 }
1361 let tx = fetcher.sender.clone();
1362 let wake = waker.as_deref().cloned();
1363 let label = tag.clone();
1364 let sent = hooks.map(|hooks| (hooks, req.clone()));
1365 let done: HttpDone = Box::new(move |result| {
1366 if let (Some((hooks, sent)), Ok(response)) = (&sent, &result) {
1367 for hook in &hooks.0 {
1368 hook.on_reply(sent, response);
1369 }
1370 }
1371 // Queue first, then wake: the tick the wake runs must find the
1372 // reply on the channel.
1373 let _ = tx.send(HttpOutcome { tag, style, result });
1374 if let Some(wake) = &wake {
1375 wake.wake();
1376 }
1377 });
1378 fetcher
1379 .dispatch
1380 .dispatch(&label, req, MAX_HTTP_BODY_BYTES, done);
1381 }
1382}
1383
1384/// Hard cap on the response body a client may buffer into memory, in bytes
1385/// (16 MiB).
1386///
1387/// A huge or open-ended (chunked / streaming) endpoint must not be able to
1388/// OOM the per-request worker: reads past this bound abort with an error
1389/// that is surfaced to the script as the reply's `error` field. Callers who
1390/// legitimately need larger payloads should stream, not `fetch`. The cap is
1391/// policy, so it lives here and is passed to every
1392/// [`HttpClient::send`](crate::http::HttpClient::send) call rather than being
1393/// each client's business.
1394const MAX_HTTP_BODY_BYTES: u64 = 16 * 1024 * 1024;
1395
1396/// Build the structured `on_http` response map from a completed request.
1397/// Shape is `web`-`fetch`-like / Qt `QNetworkReply`-like:
1398/// `#{ ok, status, headers, body, error }`. Header names are lowercased
1399/// (HTTP header names are case-insensitive; lowercasing gives scripts a
1400/// stable key to index by).
1401fn http_response_to_value(result: &Result<HttpResponse, String>) -> ScriptValue {
1402 use std::collections::HashMap;
1403 let mut map: HashMap<String, ScriptValue> = HashMap::new();
1404 match result {
1405 Ok(resp) => {
1406 let ok = (200..300).contains(&resp.status);
1407 let mut headers: HashMap<String, ScriptValue> = HashMap::new();
1408 for (k, v) in &resp.headers {
1409 headers.insert(k.to_ascii_lowercase(), ScriptValue::Str(v.clone()));
1410 }
1411 map.insert("ok".to_string(), ScriptValue::Bool(ok));
1412 map.insert("status".to_string(), ScriptValue::I64(resp.status as i64));
1413 map.insert("headers".to_string(), ScriptValue::Map(headers));
1414 map.insert("body".to_string(), ScriptValue::Str(resp.body.clone()));
1415 map.insert("error".to_string(), ScriptValue::Str(String::new()));
1416 }
1417 Err(err) => {
1418 // Transport failure (DNS, connect, timeout): still a
1419 // structured reply the script can branch on, never a panic.
1420 map.insert("ok".to_string(), ScriptValue::Bool(false));
1421 map.insert("status".to_string(), ScriptValue::I64(0));
1422 map.insert("headers".to_string(), ScriptValue::Map(HashMap::new()));
1423 map.insert("body".to_string(), ScriptValue::Str(String::new()));
1424 map.insert("error".to_string(), ScriptValue::Str(err.clone()));
1425 }
1426 }
1427 ScriptValue::Map(map)
1428}
1429
1430/// HTTP replies that finished this tick, waiting to be delivered to every
1431/// active host. Filled by [`collect_fetch_replies`] and emptied by
1432/// [`clear_fetch_replies`].
1433#[derive(Resource, Default)]
1434pub struct PendingFetchReplies(Vec<HttpOutcome>);
1435
1436/// Move finished HTTP replies (marshalled back from the worker threads) off the
1437/// channel into [`PendingFetchReplies`], and record each for the devtools
1438/// network pane. Host-neutral and registered once, so a reply is offered to
1439/// every active host rather than taken by whichever ran first.
1440pub fn collect_fetch_replies(
1441 fetcher: Res<FetchRegistry>,
1442 mut pending: ResMut<PendingFetchReplies>,
1443) {
1444 while let Ok(outcome) = fetcher.receiver.try_recv() {
1445 // Dev-tooling capture (no-op unless the devtools sink is installed):
1446 // pair the reply with its in-flight request by `tag`.
1447 let (ok, status, error) = match &outcome.result {
1448 Ok(resp) => (true, resp.status, String::new()),
1449 Err(err) => (false, 0u16, err.clone()),
1450 };
1451 net_capture::record(NetEvent::Completed {
1452 tag: outcome.tag.clone(),
1453 ok,
1454 status,
1455 error,
1456 });
1457 pending.0.push(outcome);
1458 }
1459}
1460
1461/// Drop this tick's delivered HTTP replies. Runs after every host's
1462/// [`fire_fetched_responses`].
1463pub fn clear_fetch_replies(mut pending: ResMut<PendingFetchReplies>) {
1464 pending.0.clear();
1465}
1466
1467/// Invoke the script's completion handler once for each reply
1468/// [`collect_fetch_replies`] gathered. This is the only place a network reply
1469/// crosses into script/signal land - the worker never touches the world,
1470/// mirroring Slint's `invoke_from_event_loop`.
1471///
1472/// `fetch()` replies preserve the historical contract: `on_fetch(tag,
1473/// body)` on 2xx, `on_fetch_error(tag, msg)` on transport failure or
1474/// non-2xx. `http()` replies fire `on_http(tag, response)` with the
1475/// structured map for every completed request.
1476pub fn fire_fetched_responses<H: ScriptHost + Resource<Mutability = Mutable>>(
1477 mut host: ResMut<H>,
1478 pending: Res<PendingFetchReplies>,
1479 mut out: MessageWriter<ScriptCommandEvent>,
1480) {
1481 for outcome in &pending.0 {
1482 match outcome.style {
1483 DeliveryStyle::Fetch => {
1484 let (event_name, fallback_fn, payload) = match &outcome.result {
1485 Ok(resp) if (200..300).contains(&resp.status) => {
1486 ("fetch", "on_fetch", resp.body.clone())
1487 }
1488 Ok(resp) => (
1489 "fetch_error",
1490 "on_fetch_error",
1491 format!("HTTP status {}", resp.status),
1492 ),
1493 Err(err) => ("fetch_error", "on_fetch_error", err.clone()),
1494 };
1495 if let Err(e) = route_event_two_args(
1496 &mut *host,
1497 event_name,
1498 fallback_fn,
1499 &outcome.tag,
1500 &payload,
1501 &mut out,
1502 ) {
1503 warn_line!(
1504 "{}: {fallback_fn}({}) failed: {e}",
1505 prefix(host.lang()),
1506 outcome.tag
1507 );
1508 }
1509 }
1510 DeliveryStyle::Http => {
1511 let response = http_response_to_value(&outcome.result);
1512 if let Err(e) = route_event_key_value(
1513 &mut *host,
1514 "http",
1515 "on_http",
1516 &outcome.tag,
1517 response,
1518 &mut out,
1519 ) {
1520 warn_line!(
1521 "{}: on_http({}) failed: {e}",
1522 prefix(host.lang()),
1523 outcome.tag
1524 );
1525 }
1526 }
1527 }
1528 }
1529}
1530
1531// ---------------------------------------------------------------------
1532// Plugin events
1533// ---------------------------------------------------------------------
1534
1535/// Handler calls pushed from outside a script call and delivered this tick,
1536/// mirroring [`PendingFetchReplies`]: filled by [`collect_plugin_events`],
1537/// offered to every active host by [`fire_plugin_events`], and emptied by
1538/// [`clear_plugin_events`].
1539#[derive(Resource, Default)]
1540pub struct PendingPluginEvents(Vec<PluginEvent>);
1541
1542/// Register [`PluginEvent`] as a message, so a main-thread system can write
1543/// one through `MessageWriter<PluginEvent>`. Idempotent: [`ScriptPlugin`]
1544/// calls it, and so does a capability that writes events, whichever installs
1545/// first.
1546pub fn register_plugin_event_message(world: &mut World) {
1547 if !world.contains_resource::<Messages<PluginEvent>>() {
1548 MessageRegistry::register_message::<PluginEvent>(world);
1549 }
1550}
1551
1552/// Move the events plugins and capabilities pushed into
1553/// [`PendingPluginEvents`]. Host-neutral and registered once, so a handler
1554/// call is offered to every active host rather than taken by whichever ran
1555/// first; a [`PluginEvent::Commands`] batch goes straight onto the command
1556/// bus here instead, so it applies once however many hosts run.
1557///
1558/// Events come from two places. The cross-thread bus
1559/// ([`lumen_core::plugin_events`]) carries what other threads pushed: an
1560/// event pushed from the engine's own address space arrives as the value, and
1561/// one a portable plugin pushed arrives encoded and is decoded here. A page
1562/// loads no portable plugin, so the browser build carries no decoder. Then
1563/// the [`PluginEvent`] messages main-thread systems wrote, in the order they
1564/// wrote them; a writer ordered before this system is delivered on its own
1565/// tick.
1566pub fn collect_plugin_events(
1567 mut pending: ResMut<PendingPluginEvents>,
1568 mut written: MessageReader<PluginEvent>,
1569 mut out: MessageWriter<ScriptCommandEvent>,
1570) {
1571 let from_bus = lumen_core::plugin_events::drain_plugin_events()
1572 .into_iter()
1573 .filter_map(script_event);
1574 for event in from_bus.chain(written.read().cloned()) {
1575 match event {
1576 PluginEvent::Commands(commands) => {
1577 for command in commands {
1578 out.write(ScriptCommandEvent(command));
1579 }
1580 }
1581 event => pending.0.push(event),
1582 }
1583 }
1584}
1585
1586/// Take one event off the cross-thread bus back as the [`PluginEvent`] it was
1587/// pushed as, or say why it is not one.
1588fn script_event(queued: lumen_core::plugin_events::QueuedEvent) -> Option<PluginEvent> {
1589 use lumen_core::plugin_events::QueuedEvent;
1590
1591 match queued {
1592 QueuedEvent::Value(value) => match value.downcast::<PluginEvent>() {
1593 Ok(event) => Some(*event),
1594 Err(_) => {
1595 warn_line!("lumen-script: an event on the bus is not a script event");
1596 None
1597 }
1598 },
1599 #[cfg(not(target_arch = "wasm32"))]
1600 QueuedEvent::Bytes(bytes) => match lumen_plugin_abi::codec::decode::<PluginEvent>(&bytes) {
1601 Ok(event) => Some(event),
1602 Err(e) => {
1603 warn_line!("lumen-script: a plugin event did not decode: {e}");
1604 None
1605 }
1606 },
1607 #[cfg(target_arch = "wasm32")]
1608 QueuedEvent::Bytes(_) => {
1609 warn_line!("lumen-script: an encoded plugin event reached a page");
1610 None
1611 }
1612 }
1613}
1614
1615/// Drop this tick's delivered plugin events. Runs after every host's
1616/// [`fire_plugin_events`].
1617pub fn clear_plugin_events(mut pending: ResMut<PendingPluginEvents>) {
1618 pending.0.clear();
1619}
1620
1621/// Invoke the script's handler once for each call [`collect_plugin_events`]
1622/// gathered. Routing matches the fetch pipeline exactly: a per-key
1623/// `on(event, key, fn)` registration wins, else the event's fallback, and the
1624/// key rides as the handler's first argument ahead of the event's own.
1625pub fn fire_plugin_events<H: ScriptHost + Resource<Mutability = Mutable>>(
1626 mut host: ResMut<H>,
1627 pending: Res<PendingPluginEvents>,
1628 mut out: MessageWriter<ScriptCommandEvent>,
1629) {
1630 for event in &pending.0 {
1631 let PluginEvent::Call {
1632 event,
1633 key,
1634 fallback,
1635 args,
1636 } = event
1637 else {
1638 // Commands never reach the buffer; `collect_plugin_events` put
1639 // them on the bus already.
1640 continue;
1641 };
1642 let target = resolve_handler(&*host, event, key, fallback);
1643 if target.is_empty() {
1644 continue;
1645 }
1646 let mut call_args = Vec::with_capacity(args.len() + 1);
1647 call_args.push(ScriptValue::Str(key.clone()));
1648 call_args.extend(args.iter().cloned());
1649 let lang = host.lang();
1650 if let Err(e) = forward_call(host.call(&target, &call_args), &mut out) {
1651 warn_line!("{}: {target}({key}) failed: {e}", prefix(lang));
1652 }
1653 }
1654}
1655
1656// ---------------------------------------------------------------------
1657// Event routing helpers
1658// ---------------------------------------------------------------------
1659
1660/// Push a [`CallOutcome`]'s commands onto the message bus.
1661fn forward_outcome(outcome: CallOutcome, out: &mut MessageWriter<ScriptCommandEvent>) {
1662 for c in outcome.commands {
1663 out.write(ScriptCommandEvent(c));
1664 }
1665}
1666
1667/// Push a call's commands onto the message bus, whether it returned or
1668/// raised, and hand back its error. A handler that raises keeps what it did
1669/// before the error, as a browser keeps the DOM changes a throwing listener
1670/// made.
1671fn forward_call(
1672 result: Result<CallOutcome, CallFailure>,
1673 out: &mut MessageWriter<ScriptCommandEvent>,
1674) -> Result<(), ScriptError> {
1675 match result {
1676 Ok(outcome) => {
1677 forward_outcome(outcome, out);
1678 Ok(())
1679 }
1680 Err(failure) => {
1681 for c in failure.commands {
1682 out.write(ScriptCommandEvent(c));
1683 }
1684 Err(failure.error)
1685 }
1686 }
1687}
1688
1689/// Resolve the actual function name to call: per-id handler (registered
1690/// via `on(event, key, fn_name)`) if present, else the supplied
1691/// fallback. `key` doubles as the "id_str" for entity-keyed events and
1692/// the "tag/name" for fetch/timer events.
1693fn resolve_handler<H: ScriptHost>(host: &H, event_name: &str, key: &str, fallback: &str) -> String {
1694 host.handler_for(event_name, key)
1695 .unwrap_or_else(|| fallback.to_string())
1696}
1697
1698/// Dispatch a single-arg event through the host's per-id handler
1699/// registry. If `on(event, key, fn_name)` registered a specific handler
1700/// for `(event_name, key)`, call that fn name with `key` as the single
1701/// arg. Otherwise fall through to the global `<fallback_fn>(key)`.
1702fn route_event<H: ScriptHost + Resource<Mutability = Mutable>>(
1703 host: &mut H,
1704 event_name: &str,
1705 fallback_fn: &str,
1706 id_str: &str,
1707 out: &mut MessageWriter<ScriptCommandEvent>,
1708) -> Result<(), ScriptError> {
1709 let target = resolve_handler(&*host, event_name, id_str, fallback_fn);
1710 forward_call(
1711 host.call(&target, &[ScriptValue::Str(id_str.to_string())]),
1712 out,
1713 )
1714}
1715
1716/// Two-arg variant of [`route_event`]: per-id handler lookup keyed by
1717/// the first arg, falling back to the global handler. Used for
1718/// `on_text_input(id, text)`, `on_file_dropped(id, path)`,
1719/// `on_fetch(tag, body)` / `on_fetch_error(tag, msg)`.
1720pub(crate) fn route_event_two_args<H: ScriptHost + Resource<Mutability = Mutable>>(
1721 host: &mut H,
1722 event_name: &str,
1723 fallback_fn: &str,
1724 key: &str,
1725 arg2: &str,
1726 out: &mut MessageWriter<ScriptCommandEvent>,
1727) -> Result<(), ScriptError> {
1728 let target = resolve_handler(&*host, event_name, key, fallback_fn);
1729 forward_call(
1730 host.call(
1731 &target,
1732 &[
1733 ScriptValue::Str(key.to_string()),
1734 ScriptValue::Str(arg2.to_string()),
1735 ],
1736 ),
1737 out,
1738 )
1739}
1740
1741/// Variant of [`route_event_two_args`] whose second argument is an
1742/// arbitrary structured [`ScriptValue`] rather than a string. Used for
1743/// `on_http(tag, response)`, where `response` is a
1744/// `#{ ok, status, headers, body, error }` map. Per-id handler lookup is
1745/// keyed by `key` (the request tag), falling back to the global handler.
1746fn route_event_key_value<H: ScriptHost + Resource<Mutability = Mutable>>(
1747 host: &mut H,
1748 event_name: &str,
1749 fallback_fn: &str,
1750 key: &str,
1751 value: ScriptValue,
1752 out: &mut MessageWriter<ScriptCommandEvent>,
1753) -> Result<(), ScriptError> {
1754 let target = resolve_handler(&*host, event_name, key, fallback_fn);
1755 forward_call(
1756 host.call(&target, &[ScriptValue::Str(key.to_string()), value]),
1757 out,
1758 )
1759}
1760
1761/// Per-id-handler-aware variant for `(id: String, value: bool)` events;
1762/// currently `on_toggle(id, checked)`.
1763fn route_event_id_bool<H: ScriptHost + Resource<Mutability = Mutable>>(
1764 host: &mut H,
1765 event_name: &str,
1766 fallback_fn: &str,
1767 id_str: &str,
1768 value: bool,
1769 out: &mut MessageWriter<ScriptCommandEvent>,
1770) -> Result<(), ScriptError> {
1771 let target = resolve_handler(&*host, event_name, id_str, fallback_fn);
1772 forward_call(
1773 host.call(
1774 &target,
1775 &[
1776 ScriptValue::Str(id_str.to_string()),
1777 ScriptValue::Bool(value),
1778 ],
1779 ),
1780 out,
1781 )
1782}
1783
1784/// Per-id-handler-aware variant for `(id: String, value: f64)` events;
1785/// currently `on_slider(id, value)`.
1786fn route_event_id_f64<H: ScriptHost + Resource<Mutability = Mutable>>(
1787 host: &mut H,
1788 event_name: &str,
1789 fallback_fn: &str,
1790 id_str: &str,
1791 value: f64,
1792 out: &mut MessageWriter<ScriptCommandEvent>,
1793) -> Result<(), ScriptError> {
1794 let target = resolve_handler(&*host, event_name, id_str, fallback_fn);
1795 forward_call(
1796 host.call(
1797 &target,
1798 &[
1799 ScriptValue::Str(id_str.to_string()),
1800 ScriptValue::F64(value),
1801 ],
1802 ),
1803 out,
1804 )
1805}
1806
1807// ---------------------------------------------------------------------
1808// Event dispatchers
1809// ---------------------------------------------------------------------
1810
1811/// Collect `items` into a `Vec` preserving first-seen order and dropping
1812/// duplicates. Used to give double-click dispatch a deterministic order
1813/// (message order) instead of the arbitrary iteration order of a `HashSet`.
1814fn collect_ordered_unique(items: impl IntoIterator<Item = Entity>) -> Vec<Entity> {
1815 let mut out: Vec<Entity> = Vec::new();
1816 for e in items {
1817 if !out.contains(&e) {
1818 out.push(e);
1819 }
1820 }
1821 out
1822}
1823
1824/// Forward this tick's `ClickEvent` / `DoubleClickEvent` messages to the
1825/// script's `on_click(id)` / `on_double_click(id)` handlers.
1826///
1827/// Every click reaches `on_click`, the second of a quick pair included, and a
1828/// double-click reaches `on_double_click` in addition, after the click that
1829/// completed it: the order a browser fires `click` and `dblclick` in.
1830///
1831/// Exposed so the embedder can order same-tick consumers against it -
1832/// the reactive-binding readers run `.after` this system so a signal a
1833/// handler writes here is committed and reflected by `bind="..."` on the
1834/// very tick the click fired.
1835pub fn dispatch_clicks_and_doubles<H: ScriptHost + Resource<Mutability = Mutable>>(
1836 mut host: ResMut<H>,
1837 mut clicks: MessageReader<ClickEvent>,
1838 mut doubles: MessageReader<DoubleClickEvent>,
1839 ids: Query<&LumenId>,
1840 mut out: MessageWriter<ScriptCommandEvent>,
1841) {
1842 // Ordered, deduped list in first-seen message order. Iterating a
1843 // `HashSet` directly gives a nondeterministic dispatch order when two
1844 // entities are double-clicked in one tick, which violates the
1845 // deterministic-dispatch invariant (the click path below is already
1846 // message-ordered).
1847 let double_order: Vec<Entity> = collect_ordered_unique(doubles.read().map(|ev| ev.entity));
1848
1849 for click in clicks.read() {
1850 let id_str = ids.get(click.entity).map(|i| i.0.as_str()).unwrap_or("");
1851 if let Err(e) = route_event(&mut *host, "click", "on_click", id_str, &mut out) {
1852 warn_line!("{}: on_click failed: {e}", prefix(host.lang()));
1853 }
1854 }
1855 for entity in double_order {
1856 let id_str = ids.get(entity).map(|i| i.0.as_str()).unwrap_or("");
1857 if let Err(e) = route_event(
1858 &mut *host,
1859 "double_click",
1860 "on_double_click",
1861 id_str,
1862 &mut out,
1863 ) {
1864 warn_line!("{}: on_double_click failed: {e}", prefix(host.lang()));
1865 }
1866 }
1867}
1868
1869/// Forward the window backend's [`lumen_core::input::CloseRequest`] to
1870/// the script's `on_close()` lifecycle hook - *before* the backend tears
1871/// anything down, so scripts get a last chance to persist state.
1872///
1873/// Close veto: an `on_close` that returns `false` keeps the window open
1874/// (a fresh `CloseRequest { vetoed: true }` is written for the backend's
1875/// post-tick veto check). Any other return value, or no `on_close`
1876/// function at all, lets the close proceed.
1877///
1878/// Public so embedders can order their `ScriptCommandEvent` consumers
1879/// `.after` this dispatcher: on a committed close the veto tick is the
1880/// app's *last* tick, so commands `on_close` emits must be applied in
1881/// the same tick or they are silently dropped at exit.
1882pub fn dispatch_close_to_script<H: ScriptHost + Resource<Mutability = Mutable>>(
1883 mut host: ResMut<H>,
1884 mut msgs: ResMut<bevy_ecs::message::Messages<lumen_core::input::CloseRequest>>,
1885 mut cursor: Local<bevy_ecs::message::MessageCursor<lumen_core::input::CloseRequest>>,
1886 mut out: MessageWriter<ScriptCommandEvent>,
1887) {
1888 // Veto responses (`vetoed: true`) circulate on the same bus; only
1889 // genuine backend-emitted requests trigger the hook.
1890 let requests = cursor.read(&msgs).filter(|ev| !ev.vetoed).count();
1891 let mut veto = false;
1892 for _ in 0..requests {
1893 match host.call("on_close", &[]) {
1894 Ok(outcome) => {
1895 if matches!(&outcome.ret, Some(ScriptValue::Bool(false))) {
1896 veto = true;
1897 }
1898 forward_outcome(outcome, &mut out);
1899 }
1900 Err(failure) => {
1901 warn_line!("{}: on_close failed: {failure}", prefix(host.lang()));
1902 for c in failure.commands {
1903 out.write(ScriptCommandEvent(c));
1904 }
1905 }
1906 }
1907 }
1908 if veto {
1909 msgs.write(lumen_core::input::CloseRequest { vetoed: true });
1910 }
1911}
1912
1913/// Forward [`lumen_primitives::ToggleChanged`] as `on_toggle(id,
1914/// checked)` (bool), with per-id handler routing via
1915/// `on("toggle", "<id>", "<fn>")`.
1916pub fn dispatch_toggle_to_script<H: ScriptHost + Resource<Mutability = Mutable>>(
1917 mut host: ResMut<H>,
1918 mut events: MessageReader<lumen_primitives::ToggleChanged>,
1919 ids: Query<&LumenId>,
1920 mut out: MessageWriter<ScriptCommandEvent>,
1921) {
1922 for ev in events.read() {
1923 let id_str = ids.get(ev.entity).map(|i| i.0.as_str()).unwrap_or("");
1924 if let Err(e) = route_event_id_bool(
1925 &mut *host,
1926 "toggle",
1927 "on_toggle",
1928 id_str,
1929 ev.checked,
1930 &mut out,
1931 ) {
1932 warn_line!("{}: on_toggle failed: {e}", prefix(host.lang()));
1933 }
1934 }
1935}
1936
1937/// Forward [`lumen_primitives::SliderChanged`] as `on_slider(id, value)`
1938/// (f64), with per-id handler routing via `on("slider", "<id>", "<fn>")`.
1939pub fn dispatch_slider_to_script<H: ScriptHost + Resource<Mutability = Mutable>>(
1940 mut host: ResMut<H>,
1941 mut events: MessageReader<lumen_primitives::SliderChanged>,
1942 ids: Query<&LumenId>,
1943 mut out: MessageWriter<ScriptCommandEvent>,
1944) {
1945 for ev in events.read() {
1946 let id_str = ids.get(ev.entity).map(|i| i.0.as_str()).unwrap_or("");
1947 if let Err(e) = route_event_id_f64(
1948 &mut *host,
1949 "slider",
1950 "on_slider",
1951 id_str,
1952 ev.value as f64,
1953 &mut out,
1954 ) {
1955 warn_line!("{}: on_slider failed: {e}", prefix(host.lang()));
1956 }
1957 }
1958}
1959
1960/// Forward [`LongPressEvent`] as `on_long_press(id)`.
1961pub fn dispatch_long_press_to_script<H: ScriptHost + Resource<Mutability = Mutable>>(
1962 mut host: ResMut<H>,
1963 mut events: MessageReader<LongPressEvent>,
1964 ids: Query<&LumenId>,
1965 mut out: MessageWriter<ScriptCommandEvent>,
1966) {
1967 for ev in events.read() {
1968 let id_str = ids.get(ev.entity).map(|i| i.0.as_str()).unwrap_or("");
1969 if let Err(e) = route_event(&mut *host, "long_press", "on_long_press", id_str, &mut out) {
1970 warn_line!("{}: on_long_press failed: {e}", prefix(host.lang()));
1971 }
1972 }
1973}
1974
1975/// Forward [`FileDropped`] as `on_file_dropped(id, path)`.
1976pub fn dispatch_file_drops_to_script<H: ScriptHost + Resource<Mutability = Mutable>>(
1977 mut host: ResMut<H>,
1978 mut events: MessageReader<FileDropped>,
1979 ids: Query<&LumenId>,
1980 mut out: MessageWriter<ScriptCommandEvent>,
1981) {
1982 for ev in events.read() {
1983 let id_str = ids.get(ev.entity).map(|i| i.0.as_str()).unwrap_or("");
1984 let path_str = ev.path.to_string_lossy().to_string();
1985 if let Err(e) = route_event_two_args(
1986 &mut *host,
1987 "file_dropped",
1988 "on_file_dropped",
1989 id_str,
1990 &path_str,
1991 &mut out,
1992 ) {
1993 warn_line!("{}: on_file_dropped failed: {e}", prefix(host.lang()));
1994 }
1995 }
1996}
1997
1998/// Forward [`lumen_core::input::MenuClicked`] to the script as
1999/// `on_menu(id)`, with the per-id `on("menu", id, fn)` router applying
2000/// first.
2001pub fn dispatch_menu_clicks_to_script<H: ScriptHost + Resource<Mutability = Mutable>>(
2002 mut host: ResMut<H>,
2003 mut events: MessageReader<lumen_core::input::MenuClicked>,
2004 mut out: MessageWriter<ScriptCommandEvent>,
2005) {
2006 for ev in events.read() {
2007 if let Err(e) = route_event(&mut *host, "menu", "on_menu", &ev.id, &mut out) {
2008 warn_line!("{}: on_menu failed: {e}", prefix(host.lang()));
2009 }
2010 }
2011}
2012
2013/// Forward [`lumen_core::input::DialogClosed`] (W5 dialog contract) to
2014/// the script: `accepted = true` routes as `on_dialog_accepted(id)` /
2015/// `on("dialog_accepted", id, fn)`, `accepted = false` as
2016/// `on_dialog_rejected(id)` / `on("dialog_rejected", id, fn)`. The
2017/// emitter guarantees exactly one message per open->close cycle.
2018pub fn dispatch_dialog_closes_to_script<H: ScriptHost + Resource<Mutability = Mutable>>(
2019 mut host: ResMut<H>,
2020 mut events: MessageReader<lumen_core::input::DialogClosed>,
2021 mut out: MessageWriter<ScriptCommandEvent>,
2022) {
2023 for ev in events.read() {
2024 let (event_name, fallback) = if ev.accepted {
2025 ("dialog_accepted", "on_dialog_accepted")
2026 } else {
2027 ("dialog_rejected", "on_dialog_rejected")
2028 };
2029 if let Err(e) = route_event(&mut *host, event_name, fallback, &ev.id, &mut out) {
2030 warn_line!("{}: {fallback} failed: {e}", prefix(host.lang()));
2031 }
2032 }
2033}
2034
2035/// Forward text changes as `on_text_input(id, text)`.
2036///
2037/// Fires on every edit that changes the text, which is what a live preview
2038/// needs, and once more when the field is committed with Enter. An entity
2039/// gets at most one call per tick: an IME commit both mutates the buffer
2040/// and raises [`TextInputCommitted`], and the script should see that as one
2041/// edit, not two.
2042pub fn dispatch_text_input_to_script<H: ScriptHost + Resource<Mutability = Mutable>>(
2043 mut host: ResMut<H>,
2044 mut edits: MessageReader<lumen_core::text_events::TextEditApplied>,
2045 mut commits: MessageReader<TextInputCommitted>,
2046 ids: Query<&LumenId>,
2047 fields: Query<crate::dom_events::FieldText>,
2048 mut out: MessageWriter<ScriptCommandEvent>,
2049) {
2050 use lumen_core::text_events::AppliedKind;
2051
2052 let mut fired: Vec<Entity> = Vec::new();
2053 let fire =
2054 |host: &mut H, out: &mut MessageWriter<ScriptCommandEvent>, e: Entity, text: &str| {
2055 let id_str = ids.get(e).map(|i| i.0.as_str()).unwrap_or("");
2056 if let Err(err) =
2057 route_event_two_args(host, "text_input", "on_text_input", id_str, text, out)
2058 {
2059 warn_line!("{}: on_text_input failed: {err}", prefix(host.lang()));
2060 }
2061 };
2062
2063 for ev in edits.read() {
2064 // A pure caret move is not a text change.
2065 if matches!(ev.kind, AppliedKind::CursorMove) || fired.contains(&ev.entity) {
2066 continue;
2067 }
2068 let Some(text) = fields
2069 .get(ev.entity)
2070 .ok()
2071 .and_then(crate::dom_events::field_text)
2072 else {
2073 continue;
2074 };
2075 fired.push(ev.entity);
2076 fire(&mut host, &mut out, ev.entity, &text);
2077 }
2078 for ev in commits.read() {
2079 if fired.contains(&ev.entity) {
2080 continue;
2081 }
2082 fired.push(ev.entity);
2083 fire(&mut host, &mut out, ev.entity, &ev.text);
2084 }
2085}
2086
2087#[cfg(test)]
2088mod dispatch_tests {
2089 use super::*;
2090
2091 /// Double-click dispatch order is deterministic: the collected targets
2092 /// follow message (insertion) order, not `HashSet` iteration order, and
2093 /// duplicates collapse to their first occurrence.
2094 #[test]
2095 fn double_click_targets_are_message_ordered_and_deduped() {
2096 let mut world = World::new();
2097 let a = world.spawn_empty().id();
2098 let b = world.spawn_empty().id();
2099 let c = world.spawn_empty().id();
2100
2101 // Deliberately out of id order, with a repeat.
2102 let order = collect_ordered_unique([c, a, b, a, c]);
2103 assert_eq!(order, vec![c, a, b], "first-seen order, deduped");
2104
2105 // Stable across repeated runs regardless of entity id values.
2106 for _ in 0..8 {
2107 assert_eq!(collect_ordered_unique([b, c, a]), vec![b, c, a]);
2108 }
2109 }
2110}
2111
2112#[cfg(test)]
2113mod http_tests {
2114 use super::*;
2115 use crate::ScriptValue;
2116
2117 /// The structured-map builder is pure and must expose exactly the
2118 /// `web`-`fetch`-like fields the script branches on.
2119 #[test]
2120 fn response_map_success_shape() {
2121 let resp = HttpResponse {
2122 status: 201,
2123 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2124 body: "{\"ok\":true}".to_string(),
2125 };
2126 let ScriptValue::Map(m) = http_response_to_value(&Ok(resp)) else {
2127 panic!("expected a map");
2128 };
2129 assert_eq!(m.get("ok"), Some(&ScriptValue::Bool(true)));
2130 assert_eq!(m.get("status"), Some(&ScriptValue::I64(201)));
2131 assert_eq!(
2132 m.get("body"),
2133 Some(&ScriptValue::Str("{\"ok\":true}".to_string()))
2134 );
2135 assert_eq!(m.get("error"), Some(&ScriptValue::Str(String::new())));
2136 // Header names are lowercased for stable indexing.
2137 let ScriptValue::Map(h) = m.get("headers").unwrap() else {
2138 panic!("headers not a map");
2139 };
2140 assert_eq!(
2141 h.get("content-type"),
2142 Some(&ScriptValue::Str("application/json".to_string()))
2143 );
2144 }
2145
2146 /// A non-2xx is a completed reply (`ok=false`, real status), not an
2147 /// error - the script can branch on it.
2148 #[test]
2149 fn response_map_non_2xx_is_not_error() {
2150 let resp = HttpResponse {
2151 status: 404,
2152 headers: vec![],
2153 body: "nope".to_string(),
2154 };
2155 let ScriptValue::Map(m) = http_response_to_value(&Ok(resp)) else {
2156 panic!("expected a map");
2157 };
2158 assert_eq!(m.get("ok"), Some(&ScriptValue::Bool(false)));
2159 assert_eq!(m.get("status"), Some(&ScriptValue::I64(404)));
2160 assert_eq!(m.get("error"), Some(&ScriptValue::Str(String::new())));
2161 }
2162
2163 /// A transport failure surfaces as structured data (`status=0`,
2164 /// populated `error`) rather than a panic.
2165 #[test]
2166 fn response_map_transport_error_shape() {
2167 let ScriptValue::Map(m) = http_response_to_value(&Err("dns boom".to_string())) else {
2168 panic!("expected a map");
2169 };
2170 assert_eq!(m.get("ok"), Some(&ScriptValue::Bool(false)));
2171 assert_eq!(m.get("status"), Some(&ScriptValue::I64(0)));
2172 assert_eq!(
2173 m.get("error"),
2174 Some(&ScriptValue::Str("dns boom".to_string()))
2175 );
2176 }
2177
2178 /// The registry is the seam: whatever [`HttpClient`] it holds is the one
2179 /// a queued `fetch()` runs on, and its reply reaches
2180 /// [`PendingFetchReplies`] unchanged. Drives the real systems with a
2181 /// recording client, so no socket is opened.
2182 #[test]
2183 fn requests_run_on_the_installed_client() {
2184 use std::sync::Mutex;
2185
2186 #[derive(Default)]
2187 struct RecordingClient {
2188 seen: Mutex<Vec<HttpRequest>>,
2189 }
2190
2191 impl HttpClient for RecordingClient {
2192 fn send(&self, request: &HttpRequest, body_limit: u64) -> Result<HttpResponse, String> {
2193 self.seen.lock().unwrap().push(request.clone());
2194 Ok(HttpResponse {
2195 status: 200,
2196 headers: vec![("X-Cap".to_string(), body_limit.to_string())],
2197 body: format!("{} {}", request.method, request.url),
2198 })
2199 }
2200 }
2201
2202 let client = Arc::new(RecordingClient::default());
2203 let mut world = World::new();
2204 world.insert_resource(FetchRegistry::with_client(client.clone()));
2205 world.insert_resource(PendingFetchReplies::default());
2206 MessageRegistry::register_message::<ScriptCommandEvent>(&mut world);
2207 world.write_message(ScriptCommandEvent(ScriptCommand::Fetch {
2208 url: "http://example.invalid/thing".to_string(),
2209 tag: "t".to_string(),
2210 }));
2211
2212 let mut schedule = Schedule::default();
2213 schedule.add_systems(drain_fetch_commands);
2214 schedule.run(&mut world);
2215
2216 // The worker thread is the only asynchrony here, so block on the
2217 // registry channel until its reply is queued rather than spinning a
2218 // bounded number of times: on a loaded machine a spin runs out before
2219 // the worker is scheduled, and the asserts below then read a world
2220 // nothing has landed in yet. `Select::ready` waits for the message
2221 // without taking it, leaving it for the collector. It also orders the
2222 // client's own bookkeeping, since the worker sends only after
2223 // `HttpClient::send` returns.
2224 {
2225 let registry = world.resource::<FetchRegistry>();
2226 let mut ready = crossbeam_channel::Select::new();
2227 ready.recv(®istry.receiver);
2228 ready.ready();
2229 }
2230
2231 let mut collect = Schedule::default();
2232 collect.add_systems(collect_fetch_replies);
2233 collect.run(&mut world);
2234
2235 let seen = client.seen.lock().unwrap();
2236 assert_eq!(seen.len(), 1, "exactly one request reached the client");
2237 assert_eq!(seen[0].method, "GET");
2238 assert_eq!(seen[0].url, "http://example.invalid/thing");
2239
2240 let pending = world.resource::<PendingFetchReplies>();
2241 assert_eq!(pending.0.len(), 1, "the reply reached the world thread");
2242 let reply = pending.0[0].result.as_ref().expect("client returned Ok");
2243 assert_eq!(reply.status, 200);
2244 assert_eq!(reply.body, "GET http://example.invalid/thing");
2245 // The body cap is policy the runtime passes down, not a client default.
2246 assert_eq!(
2247 reply.headers[0].1,
2248 MAX_HTTP_BODY_BYTES.to_string(),
2249 "the client was handed the runtime's body cap"
2250 );
2251 }
2252
2253 /// An `http()` call's whole request, the credentials mode included,
2254 /// reaches the dispatcher as the script wrote it.
2255 #[test]
2256 fn an_http_request_reaches_the_dispatcher_whole() {
2257 use std::sync::Mutex;
2258
2259 #[derive(Default)]
2260 struct RecordingDispatch {
2261 seen: Mutex<Vec<HttpRequest>>,
2262 }
2263
2264 impl HttpDispatch for RecordingDispatch {
2265 fn dispatch(&self, _label: &str, request: HttpRequest, _limit: u64, done: HttpDone) {
2266 self.seen.lock().unwrap().push(request);
2267 done(Err("recorded".to_string()));
2268 }
2269 }
2270
2271 let dispatch = Arc::new(RecordingDispatch::default());
2272 let mut world = World::new();
2273 world.insert_resource(FetchRegistry::with_dispatch(dispatch.clone()));
2274 MessageRegistry::register_message::<ScriptCommandEvent>(&mut world);
2275 world.write_message(ScriptCommandEvent(ScriptCommand::Http {
2276 method: "POST".to_string(),
2277 url: "https://example.invalid/api".to_string(),
2278 headers: vec![("x-token".to_string(), "t".to_string())],
2279 body: Some("{}".to_string()),
2280 timeout_ms: Some(250),
2281 credentials: Credentials::Include,
2282 tag: "save".to_string(),
2283 }));
2284
2285 let mut schedule = Schedule::default();
2286 schedule.add_systems(drain_fetch_commands);
2287 schedule.run(&mut world);
2288
2289 let seen = dispatch.seen.lock().unwrap();
2290 assert_eq!(seen.len(), 1);
2291 let request = &seen[0];
2292 assert_eq!(request.method, "POST");
2293 assert_eq!(request.url, "https://example.invalid/api");
2294 assert_eq!(request.headers, [("x-token".to_string(), "t".to_string())]);
2295 assert_eq!(request.body.as_deref(), Some("{}"));
2296 assert_eq!(request.timeout_ms, Some(250));
2297 assert_eq!(request.credentials, Credentials::Include);
2298 }
2299
2300 /// Something on the plugin bus that is not a script event, or bytes that
2301 /// do not decode as one, is dropped with a warning rather than taking the
2302 /// tick down.
2303 #[test]
2304 fn an_event_the_script_layer_cannot_read_is_dropped() {
2305 assert!(lumen_core::plugin_events::push_plugin_value(Box::new(7_u8)));
2306 assert!(lumen_core::plugin_events::push_plugin_event(vec![0xff; 3]));
2307
2308 let mut world = World::new();
2309 world.insert_resource(PendingPluginEvents::default());
2310 MessageRegistry::register_message::<ScriptCommandEvent>(&mut world);
2311 register_plugin_event_message(&mut world);
2312 let mut schedule = Schedule::default();
2313 schedule.add_systems(collect_plugin_events);
2314 schedule.run(&mut world);
2315
2316 assert!(world.resource::<PendingPluginEvents>().0.is_empty());
2317 let written = world.resource::<Messages<ScriptCommandEvent>>();
2318 assert!(written.is_empty(), "nothing reached the command bus");
2319 }
2320
2321 /// A main-thread system's events are collected on the tick it wrote them,
2322 /// in the order it wrote them, and a command batch among them goes
2323 /// straight to the command bus.
2324 #[test]
2325 fn events_a_system_writes_are_collected_in_order() {
2326 let mut world = World::new();
2327 world.insert_resource(PendingPluginEvents::default());
2328 MessageRegistry::register_message::<ScriptCommandEvent>(&mut world);
2329 register_plugin_event_message(&mut world);
2330 // A second registration is harmless, so a capability and the script
2331 // plugin can both ask for it.
2332 register_plugin_event_message(&mut world);
2333 let call = |event: &str| PluginEvent::Call {
2334 event: event.to_string(),
2335 key: "talk".to_string(),
2336 fallback: format!("on_{event}"),
2337 args: Vec::new(),
2338 };
2339 world.write_message(call("hotkey"));
2340 world.write_message(PluginEvent::Commands(vec![ScriptCommand::Print(
2341 "from a capability".to_string(),
2342 )]));
2343 world.write_message(call("hotkey_release"));
2344
2345 let mut schedule = Schedule::default();
2346 schedule.add_systems(collect_plugin_events);
2347 schedule.run(&mut world);
2348
2349 // The bus is process-global and another test may have left an event
2350 // on it, so only this test's two calls are looked at.
2351 let names: Vec<&str> = world
2352 .resource::<PendingPluginEvents>()
2353 .0
2354 .iter()
2355 .filter_map(|event| match event {
2356 PluginEvent::Call { event, key, .. } if key == "talk" => Some(event.as_str()),
2357 _ => None,
2358 })
2359 .collect();
2360 assert_eq!(names, ["hotkey", "hotkey_release"]);
2361 let commands = world.resource::<Messages<ScriptCommandEvent>>();
2362 assert!(
2363 commands
2364 .iter_current_update_messages()
2365 .any(|c| matches!(&c.0, ScriptCommand::Print(line) if line == "from a capability")),
2366 "the command batch reached the command bus"
2367 );
2368 }
2369
2370 /// A build with no client installed answers every request with the
2371 /// rebuild hint, so `fetch()` never silently does nothing.
2372 #[test]
2373 fn default_registry_reports_the_missing_client() {
2374 let err = DisabledHttpClient
2375 .send(&HttpRequest::default(), MAX_HTTP_BODY_BYTES)
2376 .expect_err("the disabled client always errors");
2377 assert!(err.contains("http-fetch"), "error names the feature: {err}");
2378 }
2379}
2380
2381#[cfg(test)]
2382mod derivation_panic_tests {
2383 use super::*;
2384 use crate::dom_events::text_event_tests::NoHost;
2385 use std::collections::HashSet;
2386 use std::sync::Arc;
2387 use std::sync::atomic::{AtomicUsize, Ordering};
2388
2389 /// A host whose one derivation panics on every evaluation, standing in
2390 /// for the corrupted-VM-state panic issue #204 describes. Wraps the
2391 /// crate's existing "no host" test double (`NoHost`) instead of
2392 /// reimplementing its unimplemented stubs a second time: every required
2393 /// method `apply_derivations` doesn't reach forwards to it, and the
2394 /// trait defaults cover the rest. `derivations_matching` and
2395 /// `pending_initial` are real overrides (this test needs an actual
2396 /// pending derivation), and `eval_derivation` is the panic under test.
2397 #[derive(Resource)]
2398 struct PanickingDerivationHost {
2399 inner: NoHost,
2400 eval_calls: Arc<AtomicUsize>,
2401 }
2402
2403 impl ScriptHost for PanickingDerivationHost {
2404 type Closure = ();
2405
2406 fn compile_check(&self, source: &str, uri: &str) -> Result<(), ScriptError> {
2407 self.inner.compile_check(source, uri)
2408 }
2409 fn load(&mut self, source: &str, uri: &str) -> Result<(), ScriptError> {
2410 self.inner.load(source, uri)
2411 }
2412 fn replace(&mut self, source: &str, uri: &str) -> Result<(), ScriptError> {
2413 self.inner.replace(source, uri)
2414 }
2415 fn reset(&mut self) {
2416 self.inner.reset()
2417 }
2418 fn call(
2419 &mut self,
2420 fn_name: &str,
2421 args: &[ScriptValue],
2422 ) -> Result<CallOutcome, CallFailure> {
2423 self.inner.call(fn_name, args)
2424 }
2425 fn call_closure(
2426 &mut self,
2427 closure: &Self::Closure,
2428 args: &[ScriptValue],
2429 ) -> Result<ScriptValue, ScriptError> {
2430 self.inner.call_closure(closure, args)
2431 }
2432 fn eval_derivation(
2433 &mut self,
2434 _closure: &Self::Closure,
2435 _deps: &[String],
2436 _name: &str,
2437 ) -> Result<String, ScriptError> {
2438 self.eval_calls.fetch_add(1, Ordering::SeqCst);
2439 panic!("simulated VM index-out-of-bounds");
2440 }
2441 fn derivations_matching(
2442 &self,
2443 _dirty: &HashSet<&str>,
2444 pending: &HashSet<String>,
2445 ) -> Vec<(String, Vec<String>, Self::Closure)> {
2446 pending
2447 .iter()
2448 .map(|name| (name.clone(), Vec::new(), ()))
2449 .collect()
2450 }
2451 fn pending_initial(&self) -> HashSet<String> {
2452 [String::from("doubled")].into_iter().collect()
2453 }
2454 fn register_script_fn(&mut self, f: &crate::ScriptFn) -> Result<(), ScriptError> {
2455 self.inner.register_script_fn(f)
2456 }
2457 fn lang(&self) -> &'static str {
2458 self.inner.lang()
2459 }
2460 }
2461
2462 /// Issue #204: a derivation whose evaluation panics (a corrupted VM
2463 /// tripping an internal assertion, in production) must not unwind past
2464 /// `apply_derivations` and take the process with it. Before the
2465 /// `catch_unwind` this test guards, `schedule.run` below unwinds out of
2466 /// the test itself; after it, the system returns normally, the panic is
2467 /// reported through the same log line a failed (`Err`) derivation gets,
2468 /// and the property store is left exactly as it was.
2469 #[test]
2470 fn a_panicking_derivation_does_not_unwind_past_apply_derivations() {
2471 let mut world = World::new();
2472 MessageRegistry::register_message::<ScriptCommandEvent>(&mut world);
2473 world.insert_resource(lumen_core::property_store::PropertyStore::default());
2474 let eval_calls = Arc::new(AtomicUsize::new(0));
2475 world.insert_resource(PanickingDerivationHost {
2476 inner: NoHost,
2477 eval_calls: eval_calls.clone(),
2478 });
2479
2480 let mut schedule = Schedule::default();
2481 schedule.add_systems(apply_derivations::<PanickingDerivationHost>);
2482
2483 // A panic escaping `apply_derivations` would unwind out of this
2484 // call and fail the test with the raw panic message rather than an
2485 // assertion - the containment under test is that it does not.
2486 schedule.run(&mut world);
2487
2488 assert_eq!(
2489 eval_calls.load(Ordering::SeqCst),
2490 1,
2491 "the derivation ran (and panicked) exactly once"
2492 );
2493 let store = world.resource::<lumen_core::property_store::PropertyStore>();
2494 assert!(
2495 store.get_global_str("doubled").is_none(),
2496 "a caught panic must not leave a half-written store entry"
2497 );
2498
2499 // The panicking derivation stays pending (never reaches
2500 // `evaluated`), so the next tick retries it rather than disabling
2501 // it permanently.
2502 schedule.run(&mut world);
2503 assert_eq!(
2504 eval_calls.load(Ordering::SeqCst),
2505 2,
2506 "the derivation is retried on the next tick, not silently dropped"
2507 );
2508 }
2509
2510 /// `panic_payload_text` reads a caught panic's payload, and `Any` only
2511 /// ever carries one of three shapes: `panic!("literal")` boxes a
2512 /// `&'static str`, `panic!("{}", x)` boxes a `String`, and
2513 /// `panic_any(v)` can box anything else. All three are real, so all
2514 /// three get their own case rather than trusting the `&str` arm alone.
2515 #[test]
2516 fn panic_payload_text_reads_all_three_payload_shapes() {
2517 use std::panic::{AssertUnwindSafe, catch_unwind};
2518
2519 let str_payload = catch_unwind(AssertUnwindSafe(|| panic!("literal"))).unwrap_err();
2520 assert_eq!(panic_payload_text(str_payload.as_ref()), "literal");
2521
2522 let string_payload =
2523 catch_unwind(AssertUnwindSafe(|| panic!("{} {}", "formatted", 1))).unwrap_err();
2524 assert_eq!(panic_payload_text(string_payload.as_ref()), "formatted 1");
2525
2526 let other_payload =
2527 catch_unwind(AssertUnwindSafe(|| std::panic::panic_any(42u32))).unwrap_err();
2528 assert_eq!(
2529 panic_payload_text(other_payload.as_ref()),
2530 "non-string panic payload"
2531 );
2532 }
2533}
2534
2535#[cfg(test)]
2536mod frame_hook_tests {
2537 use super::*;
2538 use crate::dom_events::text_event_tests::NoHost;
2539 use bevy_ecs::message::Messages;
2540 use bevy_ecs::system::RunSystemOnce;
2541 use std::sync::Arc;
2542 use std::sync::atomic::{AtomicBool, Ordering};
2543
2544 /// A host that records every `on_frame` step it is handed, and can ask
2545 /// for another frame from inside the callback.
2546 ///
2547 /// `LANG` exists to mint a second host type from one implementation: two
2548 /// hosts is the case where a frame has to reach both, and two hand-rolled
2549 /// `ScriptHost` impls would be the same delegation twice over. The
2550 /// required methods the suite does not reach forward to `NoHost`, the
2551 /// crate's existing stub, rather than being written out again here.
2552 #[derive(Resource)]
2553 struct Host<const LANG: usize> {
2554 inner: NoHost,
2555 /// Whether this host declares `on_frame` at all.
2556 declares: bool,
2557 /// Whether the call raises instead of answering.
2558 fails: bool,
2559 /// Whether each callback asks for another frame.
2560 re_request: Arc<AtomicBool>,
2561 /// Every step handed to `on_frame`, in order.
2562 seen: Arc<std::sync::Mutex<Vec<f64>>>,
2563 /// Every function name `call` was asked for.
2564 calls: Arc<std::sync::Mutex<Vec<String>>>,
2565 }
2566
2567 /// The host a single-host case drives.
2568 type FrameHost = Host<0>;
2569 /// The second host, for the case where a frame has to reach every one.
2570 type OtherHost = Host<1>;
2571
2572 impl<const LANG: usize> Host<LANG> {
2573 fn new(declares: bool) -> Self {
2574 Self {
2575 inner: NoHost,
2576 declares,
2577 fails: false,
2578 re_request: Arc::new(AtomicBool::new(false)),
2579 seen: Arc::new(std::sync::Mutex::new(Vec::new())),
2580 calls: Arc::new(std::sync::Mutex::new(Vec::new())),
2581 }
2582 }
2583
2584 /// A host whose handler raises.
2585 fn failing() -> Self {
2586 Self {
2587 fails: true,
2588 ..Self::new(true)
2589 }
2590 }
2591 }
2592
2593 impl<const LANG: usize> ScriptHost for Host<LANG> {
2594 type Closure = ();
2595
2596 fn call(
2597 &mut self,
2598 fn_name: &str,
2599 args: &[ScriptValue],
2600 ) -> Result<CallOutcome, CallFailure> {
2601 if let Ok(mut calls) = self.calls.lock() {
2602 calls.push(fn_name.to_string());
2603 }
2604 if self.fails {
2605 return Err(CallFailure {
2606 error: ScriptError::Runtime("the handler raised".to_string()),
2607 commands: Vec::new(),
2608 });
2609 }
2610 if !self.declares {
2611 // What a host answers for a handler the script never wrote.
2612 return Ok(CallOutcome {
2613 commands: Vec::new(),
2614 ret: None,
2615 found: false,
2616 });
2617 }
2618 if let Some(ScriptValue::F64(dt)) = args.first()
2619 && let Ok(mut seen) = self.seen.lock()
2620 {
2621 seen.push(*dt);
2622 }
2623 let commands = if self.re_request.load(Ordering::SeqCst) {
2624 vec![ScriptCommand::RequestFrame]
2625 } else {
2626 Vec::new()
2627 };
2628 Ok(CallOutcome {
2629 commands,
2630 ret: None,
2631 found: true,
2632 })
2633 }
2634
2635 fn lang(&self) -> &'static str {
2636 match LANG {
2637 0 => "test",
2638 _ => "other",
2639 }
2640 }
2641
2642 // The required methods the frame hook never reaches forward to
2643 // `NoHost` rather than repeating its bodies.
2644 fn compile_check(&self, source: &str, uri: &str) -> Result<(), ScriptError> {
2645 self.inner.compile_check(source, uri)
2646 }
2647 fn load(&mut self, source: &str, uri: &str) -> Result<(), ScriptError> {
2648 self.inner.load(source, uri)
2649 }
2650 fn replace(&mut self, source: &str, uri: &str) -> Result<(), ScriptError> {
2651 self.inner.replace(source, uri)
2652 }
2653 fn reset(&mut self) {
2654 self.inner.reset()
2655 }
2656 fn call_closure(
2657 &mut self,
2658 closure: &Self::Closure,
2659 args: &[ScriptValue],
2660 ) -> Result<ScriptValue, ScriptError> {
2661 self.inner.call_closure(closure, args)
2662 }
2663 fn register_script_fn(&mut self, f: &crate::ScriptFn) -> Result<(), ScriptError> {
2664 self.inner.register_script_fn(f)
2665 }
2666 }
2667
2668 /// A world with the hook's resources and the command bus.
2669 fn world_with(host: FrameHost) -> World {
2670 let mut world = World::new();
2671 world.init_resource::<Messages<ScriptCommandEvent>>();
2672 world.init_resource::<lumen_core::tick::Tick>();
2673 world.insert_resource(FrameHook::default());
2674 world.insert_resource(DueFrame::default());
2675 world.insert_resource(host);
2676 world
2677 }
2678
2679 /// One tick's worth of the hook: decide, deliver, drain. `run_system_once`
2680 /// builds fresh reader state every call, so the bus is cleared afterwards
2681 /// to keep this tick's commands out of the next one.
2682 fn drive(world: &mut World) {
2683 world
2684 .run_system_once(retire_frame_request)
2685 .expect("retire ran");
2686 world
2687 .run_system_once(fire_frame_callbacks::<FrameHost>)
2688 .expect("fire ran");
2689 world
2690 .run_system_once(drain_frame_requests)
2691 .expect("drain ran");
2692 world.resource_mut::<Messages<ScriptCommandEvent>>().clear();
2693 }
2694
2695 /// Queue the command the builtin emits, as a script's call would.
2696 fn request(world: &mut World) {
2697 world
2698 .resource_mut::<Messages<ScriptCommandEvent>>()
2699 .write(ScriptCommandEvent(ScriptCommand::RequestFrame));
2700 world
2701 .run_system_once(drain_frame_requests)
2702 .expect("drain ran");
2703 world.resource_mut::<Messages<ScriptCommandEvent>>().clear();
2704 }
2705
2706 #[test]
2707 fn one_request_buys_exactly_one_callback() {
2708 let mut world = world_with(FrameHost::new(true));
2709 let seen = Arc::clone(&world.resource::<FrameHost>().seen);
2710 request(&mut world);
2711
2712 drive(&mut world);
2713 assert_eq!(seen.lock().unwrap().len(), 1);
2714 drive(&mut world);
2715 assert_eq!(
2716 seen.lock().unwrap().len(),
2717 1,
2718 "a served request must not schedule another"
2719 );
2720 assert!(
2721 world.resource::<DueFrame>().0.is_none(),
2722 "no frame is due once the request was served"
2723 );
2724 }
2725
2726 #[test]
2727 fn a_callback_that_asks_again_keeps_the_loop_running() {
2728 let mut world = world_with(FrameHost::new(true));
2729 let host = world.resource::<FrameHost>();
2730 let (seen, re_request) = (Arc::clone(&host.seen), Arc::clone(&host.re_request));
2731 re_request.store(true, Ordering::SeqCst);
2732 request(&mut world);
2733
2734 for _ in 0..3 {
2735 drive(&mut world);
2736 }
2737 assert_eq!(seen.lock().unwrap().len(), 3);
2738
2739 // Stop asking and the loop parks after the frame already armed.
2740 re_request.store(false, Ordering::SeqCst);
2741 drive(&mut world);
2742 drive(&mut world);
2743 assert_eq!(
2744 seen.lock().unwrap().len(),
2745 4,
2746 "the loop stops when it stops"
2747 );
2748 }
2749
2750 #[test]
2751 fn no_request_is_no_frame_and_no_call() {
2752 let mut world = world_with(FrameHost::new(true));
2753 let calls = Arc::clone(&world.resource::<FrameHost>().calls);
2754 drive(&mut world);
2755 assert!(world.resource::<DueFrame>().0.is_none());
2756 assert!(calls.lock().unwrap().is_empty());
2757 }
2758
2759 #[test]
2760 fn the_first_frame_of_a_loop_reports_no_elapsed_time() {
2761 let mut world = world_with(FrameHost::new(true));
2762 let seen = Arc::clone(&world.resource::<FrameHost>().seen);
2763 world.resource_mut::<lumen_core::tick::Tick>().dt = std::time::Duration::from_millis(16);
2764 request(&mut world);
2765 drive(&mut world);
2766 assert_eq!(seen.lock().unwrap().as_slice(), [0.0]);
2767 }
2768
2769 #[test]
2770 fn a_stalled_tick_is_capped_rather_than_reported() {
2771 let mut world = world_with(FrameHost::new(true));
2772 let host = world.resource::<FrameHost>();
2773 let (seen, re_request) = (Arc::clone(&host.seen), Arc::clone(&host.re_request));
2774 re_request.store(true, Ordering::SeqCst);
2775 world.resource_mut::<lumen_core::tick::Tick>().dt = std::time::Duration::from_secs(30);
2776 request(&mut world);
2777 drive(&mut world);
2778 drive(&mut world);
2779 assert_eq!(seen.lock().unwrap().as_slice(), [0.0, MAX_FRAME_DT]);
2780 }
2781
2782 #[test]
2783 fn a_host_that_declares_no_handler_is_silent() {
2784 let mut world = world_with(FrameHost::new(false));
2785 let host = world.resource::<FrameHost>();
2786 let (seen, calls) = (Arc::clone(&host.seen), Arc::clone(&host.calls));
2787 request(&mut world);
2788 drive(&mut world);
2789 assert_eq!(calls.lock().unwrap().as_slice(), ["on_frame"]);
2790 assert!(seen.lock().unwrap().is_empty());
2791 }
2792
2793 #[test]
2794 fn every_active_host_is_offered_the_same_frame() {
2795 let mut world = world_with(FrameHost::new(true));
2796 world.insert_resource(OtherHost::new(true));
2797 let first = Arc::clone(&world.resource::<FrameHost>().seen);
2798 let second = Arc::clone(&world.resource::<OtherHost>().seen);
2799 request(&mut world);
2800
2801 world
2802 .run_system_once(retire_frame_request)
2803 .expect("retire ran");
2804 world
2805 .run_system_once(fire_frame_callbacks::<FrameHost>)
2806 .expect("first host ran");
2807 world
2808 .run_system_once(fire_frame_callbacks::<OtherHost>)
2809 .expect("second host ran");
2810
2811 assert_eq!(first.lock().unwrap().len(), 1);
2812 assert_eq!(second.lock().unwrap().len(), 1);
2813 }
2814
2815 #[test]
2816 fn two_requests_in_one_tick_buy_one_frame() {
2817 // A reader's cursor lives with the system across ticks, and it
2818 // advances per message yielded. Stopping at the first request would
2819 // leave the rest of this tick's commands unread, and the next tick
2820 // would find the second request still sitting there and arm a frame
2821 // the script asked for once.
2822 //
2823 // A `Schedule` rather than `run_system_once`, because the whole point
2824 // is state that survives the tick: `run_system_once` builds a fresh
2825 // reader every call and could not observe this either way.
2826 let mut world = world_with(FrameHost::new(true));
2827 let mut drain = bevy_ecs::schedule::Schedule::default();
2828 drain.add_systems(drain_frame_requests);
2829
2830 {
2831 let mut bus = world.resource_mut::<Messages<ScriptCommandEvent>>();
2832 bus.write(ScriptCommandEvent(ScriptCommand::RequestFrame));
2833 bus.write(ScriptCommandEvent(ScriptCommand::Print("between".into())));
2834 bus.write(ScriptCommandEvent(ScriptCommand::RequestFrame));
2835 }
2836 drain.run(&mut world);
2837 assert!(world.resource::<FrameHook>().requested, "the ask arrived");
2838
2839 // Serve it, then take the next tick with nothing written at all.
2840 world
2841 .run_system_once(retire_frame_request)
2842 .expect("retire ran");
2843 assert!(world.resource::<DueFrame>().0.is_some());
2844 world
2845 .resource_mut::<Messages<ScriptCommandEvent>>()
2846 .update();
2847 drain.run(&mut world);
2848
2849 assert!(
2850 !world.resource::<FrameHook>().requested,
2851 "both requests were the same ask; the second must not arm a tick \
2852 nobody asked for"
2853 );
2854 }
2855
2856 #[test]
2857 fn arming_raises_the_animation_flag_and_wakes_the_loop() {
2858 // A request is only worth anything if the tick it asks for happens.
2859 // On an idle app nothing else is going to schedule one, so these two
2860 // are the whole mechanism.
2861 let mut world = world_with(FrameHost::new(true));
2862 let woken = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2863 let counter = Arc::clone(&woken);
2864 world.insert_resource(lumen_core::app::EventLoopWaker(Arc::new(move || {
2865 counter.fetch_add(1, Ordering::SeqCst);
2866 })));
2867 world.init_resource::<lumen_core::render_world::AnimationsActive>();
2868
2869 request(&mut world);
2870
2871 assert!(
2872 world
2873 .resource::<lumen_core::render_world::AnimationsActive>()
2874 .get(),
2875 "a pending frame keeps the driver awake"
2876 );
2877 assert_eq!(woken.load(Ordering::SeqCst), 1, "and wakes it if it parked");
2878 }
2879
2880 #[test]
2881 fn a_handler_that_raises_leaves_the_app_running() {
2882 // The callback is the app's code, so it can fail; the frame that
2883 // carried it must not take the tick down with it.
2884 let mut world = world_with(FrameHost::failing());
2885 let calls = Arc::clone(&world.resource::<FrameHost>().calls);
2886 request(&mut world);
2887 drive(&mut world);
2888
2889 assert_eq!(calls.lock().unwrap().as_slice(), ["on_frame"]);
2890
2891 // The next tick finds nothing due: the frame was served, failure and
2892 // all, and a handler that raised asked for no other.
2893 drive(&mut world);
2894 assert!(world.resource::<DueFrame>().0.is_none());
2895 assert_eq!(calls.lock().unwrap().len(), 1);
2896 }
2897
2898 #[test]
2899 fn the_builtin_queues_one_request() {
2900 let f = crate::builtin_script_fns()
2901 .into_iter()
2902 .find(|f| f.name == "request_frame")
2903 .expect("the builtin table carries request_frame");
2904 let (result, commands) = f.invoke(&[]);
2905 assert_eq!(result, Ok(ScriptValue::Unit));
2906 assert_eq!(commands.len(), 1);
2907 assert!(matches!(commands[0], ScriptCommand::RequestFrame));
2908 }
2909}