Skip to main content

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(&registry.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}