Skip to main content

lumen_runtime/run/
mod.rs

1//! Launches a pure-markup Lumen app via `lumenc run <dir>`.
2//!
3//! Expects:
4//!
5//! ```text
6//! <dir>/
7//!   lumen.toml   # optional: app config
8//!   src/
9//!     main.lmn   # required: markup (with optional inline <script>)
10//!     main.css   # optional: stylesheet
11//! ```
12//!
13//! - Wires the default plugin stack: the registered layout engine, cosmic text, input, hover/press/drag/scroll primitives, the script host module for each language the app ships, optional MCP server.
14//! - Runs the registered window backend's event loop and returns when the window closes or a fatal error occurs.
15//! - Hot reload: a `notify` file watcher (inotify / FSEvents / ReadDirectoryChangesW) covers `src/main.lmn`, `src/main.css`, and every included / imported source; an fs event wakes the loop for one tick and the `hot_reload` system re-checks mtimes. On change it despawns the spawned root, re-parses, re-applies CSS, re-spawns, and reloads each script host's program in place. Parse errors keep the previous tree intact and log to stderr. `LUMEN_HOT_RELOAD_POLL=1` (or watcher init failure) falls back to the legacy 300 ms mtime poll.
16//! - Script commands: `SetText` updates the matching `LumenId`'s [`TextContent`]; other variants (`Print`, `AddClicks`, `SetString`) no-op here.
17
18use bevy_ecs::message::MessageReader;
19use bevy_ecs::prelude::*;
20use lumen_assets::AssetsPlugin;
21use lumen_capability::Preflight;
22use lumen_core::prelude::*;
23use lumen_core::window::{DEFAULT_CLEAR, WindowOptions};
24use lumen_core::window_backend::{WindowBackendEntry, WindowCorePlugin};
25use lumen_input::InputPlugin;
26use lumen_os_clipboard::ClipboardHost;
27use lumen_primitives::{
28    CheckboxPlugin, ControlsPlugin, DragPlugin, HoverTintPlugin, PressPlugin, ProgressPlugin,
29    RadioPlugin, ScrollPlugin, TabsPlugin, TooltipPlugin, TransitionPlugin, ValidationPlugin,
30};
31use lumen_script::ScriptCommand;
32// `ScriptSet` is how the host-neutral half orders against the hosts' own
33// systems: with several hosts installed, an edge naming one host's system
34// leaves the others outside the one-tick dirty window.
35use lumen_script::{ScriptCommandEvent, ScriptFn, ScriptSet};
36use lumen_text::{ShaperService, TextShaper};
37use lumen_text_cosmic::CosmicShaper;
38use std::path::{Path, PathBuf};
39use std::time::SystemTime;
40// `Duration` / `Instant` are only used by the (gated) hot-reload poll throttle.
41#[cfg(feature = "runtime-parse")]
42use std::time::{Duration, Instant};
43
44use crate::source_parser::SourceParser;
45use lumen_ir::layout_ir::{Attributes, Element};
46
47/// An embedder callback invoked on the fully-built [`App`] right before
48/// the window event loop starts. Every default plugin and system is
49/// already registered at that point, so a hook can insert resources and
50/// add systems ordered against the default stack's public systems (for
51/// example `.before(lumen_core::signals::apply_text_bindings)`).
52///
53/// The Rust SDK (`sdk/rust`, crate `lumenui`) uses it to wire Rust-closure
54/// event handlers and whole `bevy_ecs` systems into the tick without lumenc
55/// knowing about them.
56///
57/// Not `Send`: hooks are drained and invoked in [`build_app`] on the calling
58/// thread (see the loop near the end of that fn), never moved across threads.
59/// The bound is intentionally omitted so the SDK can defer `IntoScheduleConfigs`
60/// values - `system.chain()` / `system.run_if(..)` box into `!Send`
61/// `ScheduleConfigs`, which a `Send` hook could not capture.
62pub type AppHook = Box<dyn FnOnce(&mut App) + 'static>;
63
64/// One deferred [`Plugin`](lumen_core::app::Plugin) installation, held until
65/// [`build_app`] reaches the phase that runs it. `Send` so an embedder can
66/// build the options on one thread and run them on another; the plugin itself
67/// is moved into the closure.
68pub type PluginInstaller = Box<dyn FnOnce(&mut App) + Send>;
69
70/// The window-facing half of a built app: the resolved
71/// [`WindowOptions`] plus the render-side text shaper the build picked
72/// for it. Returned alongside the [`App`] by [`build_app`] and
73/// [`build_headless_app`].
74///
75/// The options are pure data that `lumen-core` owns, so any launch path
76/// and any window backend can name them. The shaper is a live backend
77/// object whose trait lives in `lumen-text`, which is why it sits beside
78/// the options instead of inside them; whichever launch path runs installs
79/// it into the render world as a [`ShaperService`] for the renderer to
80/// read.
81pub struct WindowSetup {
82    /// Size, title, clear color, chrome, and native menu bar.
83    pub options: WindowOptions,
84    /// Shaper used for render-side text. `None` skips text entirely.
85    pub text_shaper: Option<Box<dyn TextShaper>>,
86}
87
88/// Options for `lumenc run`.
89pub struct RunOptions {
90    /// Path to the app directory (must contain `src/main.lmn`).
91    pub dir: PathBuf,
92    /// Window title. Defaults to the directory name.
93    pub title: Option<String>,
94    /// Window size in logical pixels.
95    pub size: (u32, u32),
96    /// Background color when no CSS sets one.
97    pub clear: Color,
98    /// Watch source files and reload on change. On by default.
99    pub hot_reload: bool,
100    /// Plugins installed on the [`App`] before the markup is parsed and the
101    /// script hosts load. See
102    /// [`Self::with_plugin`].
103    pub plugins: Vec<PluginInstaller>,
104    /// Native functions exposed to the app's script in host-neutral terms.
105    /// Each one is registered into every host the app runs, marshalling
106    /// arguments and results through `lumen_script::ScriptValue`. This is what
107    /// the C-ABI's `lumen_app_expose` builds.
108    ///
109    /// candela resolves host calls through a declared block, so a candela
110    /// script reaches these as `native::<name>(...)` after declaring
111    /// `host "native" { any <name>(...); }`.
112    pub native_fns: Vec<ScriptFn>,
113    /// In-memory markup source. When `Some`, the runtime parses this
114    /// string instead of reading `<dir>/src/main.lmn` from disk, and hot
115    /// reload is disabled (there is no file to watch). Set by the Rust
116    /// SDK's `include_str!`-based embedding path; `dir` is still used
117    /// to resolve relative asset paths and `lumen.toml`.
118    pub markup: Option<String>,
119    /// In-memory stylesheet source. When `Some`, used instead of
120    /// `<dir>/src/main.css`. Independent of [`Self::markup`]; `None` falls
121    /// back to the on-disk lookup.
122    pub css: Option<String>,
123    /// Callbacks invoked on the fully-built [`App`] just before the
124    /// event loop starts. See [`AppHook`].
125    pub app_hooks: Vec<AppHook>,
126    /// Load a precompiled AOT artifact (`lumenc build`) instead of parsing
127    /// `<dir>/src/main.lmn` + `src/main.css` from source. When `Some`, the parser is
128    /// bypassed entirely (and hot reload is disabled); `dir` is still used to
129    /// resolve `lumen.toml`. Required for a runtime built without the
130    /// `runtime-parse` feature.
131    pub artifact: Option<PathBuf>,
132    /// Load a precompiled AOT artifact from in-memory bytes instead of a file
133    /// path. The link-not-embed launcher path: the compiler produces LMNA
134    /// bytes in-process and hands them across the C-ABI
135    /// (`lumen_app_new_from_lmna`) so the runtime never touches the parser or a
136    /// source file. When `Some`, it wins over [`Self::artifact`] and
137    /// [`Self::parser`]; `dir` is still used to resolve relative asset paths.
138    pub artifact_bytes: Option<Vec<u8>>,
139    /// True for a headless / bounded automation run (`--headless`, and the
140    /// FFI/test `run_app_headless` contract). Suppresses the long-lived
141    /// interactive daemons that only make sense for a windowed session: the
142    /// MCP introspection server (unless `[mcp] simulate = true`, which
143    /// automation drivers set) and the hot-reload file watcher. Off by
144    /// default so an ordinary `lumenc run` keeps both. See [`build_app`].
145    pub bounded: bool,
146    /// Serve the app's assets from a `.lpak` archive (`lumenc bundle`)
147    /// instead of the loose files under [`Self::dir`]. A path the markup
148    /// resolves under `dir` is looked up in the archive first and read
149    /// from disk only when the archive has no such entry, so an app can
150    /// ship one file next to its binary. `None` reads everything from
151    /// disk. Applied by [`run_app`] and [`build_headless_app`].
152    pub assets: Option<PathBuf>,
153    /// Injected markup/CSS front-end used for dev source-load and hot-reload
154    /// re-parse. `lumen-runtime` links no parser itself (it stays in the
155    /// compiler); the CLI / SDK / FFI dev paths populate this with a
156    /// [`SourceParser`] impl (`lumenc`'s `LumencParser`). `None` is valid for
157    /// the precompiled-artifact path ([`Self::artifact`]) and for a runtime
158    /// that only ever loads AOT artifacts; a from-source run with no parser
159    /// fails with [`RunError::ParserDisabled`].
160    pub parser: Option<Box<dyn SourceParser>>,
161    /// Injected compiler-plugin chain, mirroring [`Self::parser`]: the
162    /// runtime links no plugin loader; `lumenc` builds the chain from the
163    /// app's `[[plugins]]` declarations and hands it in here. `None` runs
164    /// the empty chain.
165    pub compiler_plugins: Option<std::sync::Arc<dyn crate::compiler_plugins::CompilerPlugins>>,
166    /// Injected `version`-source module resolutions, mirroring
167    /// [`Self::compiler_plugins`]: the runtime never resolves a `version`
168    /// entry of `[dependencies]` itself (no semver, no registry in its
169    /// graph); `lumenc` asks `lpm` to resolve each one and hands the outcome
170    /// in here for the module loader to consult before its own on-disk probe.
171    pub resolved_modules: crate::modules::ResolvedModules,
172    /// Script libraries the app depends on, as the name a script imports
173    /// under and the directory holding its sources. Filled in the same way
174    /// [`Self::resolved_modules`] is: the runtime resolves nothing, and
175    /// `lumenc` hands in what the registry answered.
176    pub import_roots: Vec<(String, std::path::PathBuf)>,
177    /// Start an offscreen renderer of this physical size while the app
178    /// builds: set by the rendered headless run, which picks the result up
179    /// from [`crate::run_headless`]'s side of the build.
180    pub(crate) offscreen_prestart: Option<(u32, u32)>,
181}
182
183impl RunOptions {
184    /// Built-in default window size - kept as a constant so the runtime
185    /// can detect that the caller didn't override it and let
186    /// `lumen.toml`'s `[window] size` win instead.
187    pub const DEFAULT_SIZE: (u32, u32) = (960, 720);
188
189    /// Construct with sensible defaults.
190    pub fn new(dir: impl Into<PathBuf>) -> Self {
191        Self {
192            dir: dir.into(),
193            title: None,
194            size: Self::DEFAULT_SIZE,
195            // Single source of truth for the fallback: see
196            // `lumen_core::window::DEFAULT_CLEAR`. `build_app` overrides this
197            // with the resolved `--lumen-window-bg` custom property when the
198            // app or its active skin defines one.
199            clear: DEFAULT_CLEAR,
200            hot_reload: true,
201            plugins: Vec::new(),
202            native_fns: Vec::new(),
203            markup: None,
204            css: None,
205            app_hooks: Vec::new(),
206            artifact: None,
207            artifact_bytes: None,
208            bounded: false,
209            assets: None,
210            parser: None,
211            compiler_plugins: None,
212            resolved_modules: crate::modules::ResolvedModules::default(),
213            import_roots: Vec::new(),
214            offscreen_prestart: None,
215        }
216    }
217
218    /// Builder: inject the markup/CSS front-end used for dev source-load and
219    /// hot-reload re-parse. See [`Self::parser`].
220    pub fn with_parser(mut self, parser: Box<dyn SourceParser>) -> Self {
221        self.parser = Some(parser);
222        self
223    }
224
225    /// Builder: inject the compiler-plugin chain run by the dev source-load
226    /// and hot-reload paths. See [`Self::compiler_plugins`].
227    pub fn with_compiler_plugins(
228        mut self,
229        plugins: std::sync::Arc<dyn crate::compiler_plugins::CompilerPlugins>,
230    ) -> Self {
231        self.compiler_plugins = Some(plugins);
232        self
233    }
234
235    /// Builder: load a precompiled AOT [`lumen_ir::artifact`] instead of parsing
236    /// source. See [`Self::artifact`].
237    pub fn with_artifact(mut self, path: impl Into<PathBuf>) -> Self {
238        self.artifact = Some(path.into());
239        self
240    }
241
242    /// Builder: load a precompiled AOT [`lumen_ir::artifact`] from in-memory
243    /// bytes instead of a file path. See [`Self::artifact_bytes`].
244    pub fn with_artifact_bytes(mut self, bytes: impl Into<Vec<u8>>) -> Self {
245        self.artifact_bytes = Some(bytes.into());
246        self
247    }
248
249    /// Builder: install a [`Plugin`](lumen_core::app::Plugin) on the app
250    /// before the markup is parsed and before the script hosts load.
251    ///
252    /// A plugin that brings an element registers its tag from `build`, which
253    /// this phase puts ahead of the parse that would otherwise refuse it. It
254    /// is also the phase a plugin that registers script functions
255    /// ([`ScriptFnAppExt::add_script_fn`](lumen_script::ScriptFnAppExt::add_script_fn))
256    /// belongs in: candela binds its host declarations when the program
257    /// compiles or the artifact loads, so a function registered later has
258    /// nothing to bind to. Contrast [`Self::with_app_hook`], which runs on the
259    /// fully-built app and is where systems ordered against the default stack
260    /// belong.
261    ///
262    /// Plugins install in the order they are added, after the runtime's own
263    /// built-in script functions and before [`Self::native_fns`]; a name
264    /// registered later shadows an earlier one.
265    pub fn with_plugin<P: lumen_core::app::Plugin + Send + 'static>(mut self, plugin: P) -> Self {
266        self.plugins.push(Box::new(move |app: &mut App| {
267            app.add_plugin(plugin);
268        }));
269        self
270    }
271
272    /// Builder: expose a native function to the app's script in host-neutral
273    /// terms. See [`Self::native_fns`].
274    pub fn with_native_fn(mut self, f: ScriptFn) -> Self {
275        self.native_fns.push(f);
276        self
277    }
278
279    /// Builder: parse this in-memory markup string instead of reading
280    /// `<dir>/src/main.lmn` from disk. Disables hot reload. See
281    /// [`Self::markup`].
282    pub fn with_markup(mut self, src: impl Into<String>) -> Self {
283        self.markup = Some(src.into());
284        self
285    }
286
287    /// Builder: apply this in-memory stylesheet instead of reading
288    /// `<dir>/src/main.css` from disk. See [`Self::css`].
289    pub fn with_css(mut self, src: impl Into<String>) -> Self {
290        self.css = Some(src.into());
291        self
292    }
293
294    /// Builder: install a callback invoked on the fully-built [`App`]
295    /// right before the event loop starts. See [`AppHook`].
296    pub fn with_app_hook<F>(mut self, f: F) -> Self
297    where
298        F: FnOnce(&mut App) + 'static,
299    {
300        self.app_hooks.push(Box::new(f));
301        self
302    }
303
304    /// Builder: serve assets from a `.lpak` archive. See [`Self::assets`].
305    pub fn with_assets(mut self, lpak: impl Into<PathBuf>) -> Self {
306        self.assets = Some(lpak.into());
307        self
308    }
309}
310
311/// Open the `.lpak` named by [`RunOptions::assets`] and hand it to the
312/// app's [`AssetServer`](lumen_assets::AssetServer), keyed on the app
313/// directory the archive was packed from.
314///
315/// Split from [`build_app`] so every entry point that hands an [`App`] to
316/// a caller installs the archive the same way. A missing or malformed
317/// archive fails the run rather than silently falling back to disk: the
318/// path was named explicitly, so reading different bytes than asked for
319/// is worse than stopping.
320fn install_assets(app: &mut App, lpak: Option<&Path>, dir: &Path) -> Result<(), RunError> {
321    let Some(lpak) = lpak else {
322        return Ok(());
323    };
324    let bundle = lumen_assets::LumenBundle::open(lpak)
325        .map_err(|e| RunError::Assets(lpak.to_path_buf(), e.to_string()))?;
326    let Some(mut server) = app.world.get_resource_mut::<lumen_assets::AssetServer>() else {
327        return Ok(());
328    };
329    // The paths the markup resolved are absolute (see `resolve_asset_paths`),
330    // so the root they are matched against has to be as well.
331    server.set_bundle_root(absolute_dir(dir));
332    server.register_bundle(bundle);
333    Ok(())
334}
335
336/// Errors raised while preparing the app.
337#[derive(Debug, thiserror::Error)]
338pub enum RunError {
339    /// Required input file missing or unreadable.
340    #[error("read {0}: {1}")]
341    Read(PathBuf, std::io::Error),
342    /// Markup failed to parse.
343    #[error("parse main.lmn: {0}")]
344    ParseHtml(String),
345    /// CSS failed to parse.
346    #[error("parse main.css: {0}")]
347    ParseCss(String),
348    /// CSS application failed.
349    #[error("apply CSS: {0}")]
350    ApplyCss(String),
351    /// A compiler plugin failed to load or one of its hooks failed. The
352    /// message names the plugin.
353    #[error("{0}")]
354    Plugin(String),
355    /// The window backend could not run the app, or the build carries
356    /// none.
357    #[error("window: {0}")]
358    Window(String),
359    /// The build carries no layout engine.
360    #[error("layout: {0}")]
361    LayoutEngine(String),
362    /// Headless mode failed to initialise (offscreen renderer or
363    /// signal-handler install). Raised only by
364    /// [`crate::run_headless::run_app_headless_rendered`].
365    #[error("headless: {0}")]
366    Headless(String),
367    /// `[render] backend` names a backend this build does not carry, or the
368    /// build carries none.
369    #[error("lumen.toml [render] backend: {0}")]
370    Render(String),
371    /// The app's script failed to compile. Raised by [`check_app`],
372    /// which compiles the combined `<script>` source with the exact
373    /// engine settings `lumenc run` loads with - a script that would
374    /// die at load fails the check instead of false-passing.
375    #[error("script: {0}")]
376    Script(String),
377    /// `lumen.toml` is invalid (parse / read error).
378    #[error("lumen.toml: {0}")]
379    Config(#[from] crate::config::ConfigError),
380    /// The app's code is at its root rather than under `src/`; carries the
381    /// migration hint. See [`crate::app_layout::FlatLayout`].
382    #[error("{0}")]
383    Layout(#[from] crate::app_layout::FlatLayout),
384    /// A `locale/*.ftl` catalogue could not be read or parsed, or its
385    /// filename is not a BCP-47 tag.
386    #[error("i18n: {0}")]
387    I18n(String),
388    /// A precompiled AOT artifact failed to read / decode.
389    #[error("artifact: {0}")]
390    Artifact(String),
391    /// The runtime was built without the `runtime-parse` feature (parser
392    /// removed) and asked to run an app from source rather than from a
393    /// precompiled artifact. Pass `--artifact <file>` (built via
394    /// `lumenc build`) or rebuild with `--features runtime-parse`.
395    #[error(
396        "this runtime was built without the markup parser (runtime-parse); \
397         run from a precompiled artifact (lumenc build) or rebuild with \
398         --features runtime-parse"
399    )]
400    ParserDisabled,
401    /// The `.lpak` archive named by [`RunOptions::assets`] could not be read.
402    #[error("read asset bundle {0}: {1}")]
403    Assets(PathBuf, String),
404}
405
406/// Read `<dir>/src/main.lmn` + optional `<dir>/src/main.css`, build a default
407/// `App`, spawn the parsed tree, and run it in the registered window
408/// backend.
409pub fn run_app(opts: RunOptions) -> Result<(), RunError> {
410    let (dir, assets) = (opts.dir.clone(), opts.assets.clone());
411    // An interactive launch runs the capabilities' preflights before any
412    // other work: a second instance of a single-instance app forwards its
413    // argv to the first from there and has nothing left to do. Headless runs
414    // and embeddings never come this way, so they never contend over what a
415    // preflight binds. A config that fails to read is reported by
416    // `build_app` moments later, on the same path.
417    let cfg = crate::config::LumenToml::load_or_default(&dir).unwrap_or_default();
418    let env = capability_env(&opts, &dir, &cfg);
419    if lumen_capability::preflight(&env) == Preflight::Exit {
420        return Ok(());
421    }
422    let (mut app, window) = build_app(opts)?;
423    install_assets(&mut app, assets.as_deref(), &dir)?;
424    // The render-side shaper is a render-world service, so the renderer -
425    // this one or a replacement - finds it in one place. Without one, text
426    // is skipped.
427    if let Some(shaper) = window.text_shaper {
428        app.render_world
429            .insert_non_send(ShaperService::from(shaper));
430    }
431    // Backend selection happens here, at the composition point: the window
432    // backend drives the renderers and the bridge through their traits and
433    // names none of them. Under `auto` every backend the build carries is
434    // handed over in priority order, and the window binds the first that
435    // can start.
436    let renderers = render_backends(&app, &cfg)?
437        .iter()
438        .map(|backend| (backend.renderer)())
439        .collect();
440    let backend = lumen_core::backends::registered::<WindowBackendEntry>(&app)
441        .preferred()
442        .ok_or_else(|| RunError::Window(lumen_core::backends::missing::<WindowBackendEntry>()))?;
443    let a11y = lumen_a11y_accesskit::bridge_factory();
444    (backend.backend)()
445        .run(app, window.options, renderers, Some(a11y))
446        .map_err(|e| RunError::Window(e.to_string()))
447}
448
449/// The render backends a launch of `app` tries, in order: the one
450/// `[render] backend` names, or every backend the build registered, highest
451/// priority first, under `auto`.
452pub(crate) fn render_backends(
453    app: &App,
454    cfg: &crate::config::LumenToml,
455) -> Result<Vec<lumen_core::render_backend::RenderBackend>, RunError> {
456    lumen_core::backends::registered::<lumen_core::render_backend::RenderBackend>(app)
457        .select(cfg.render.backend.backend_name())
458        .map_err(RunError::Render)
459}
460
461/// Build the full app WITHOUT opening a window, then drive `ticks`
462/// main-schedule ticks and return. Headless / CI entry point: same
463/// plugin stack, scripts, and reactive bindings as [`run_app`], but no
464/// windowing, input, or rendering - just [`App::tick`] in a loop.
465///
466/// `ticks == 0` builds-and-drops (validates the app loads). The window
467/// setup (title, size, text shaper) built alongside the app is
468/// discarded; headless ticks run the main schedule + extract + an empty
469/// render schedule (no renderer is installed off the windowed path), which
470/// is sufficient to exercise signal round-trips, script
471/// execution, and `<for>` / `<if>` reconciliation.
472pub fn run_app_headless(mut opts: RunOptions, ticks: u32) -> Result<(), RunError> {
473    // Headless / FFI contract: no interactive session, so gate off the MCP
474    // server + hot-reload watcher (see [`RunOptions::bounded`]).
475    opts.bounded = true;
476    let (mut app, _window) = build_headless_app(opts)?;
477    for _ in 0..ticks {
478        app.tick();
479    }
480    Ok(())
481}
482
483/// Shared headless plumbing: [`build_app`] plus the window-free half of
484/// every window backend, [`WindowCorePlugin`] (the window messages, the
485/// `RedrawScheduler`, and the extract pipeline). A window backend installs
486/// the same plugin when it runs; only the event loop and the renderer bind
487/// need a display. Installing it here gives every headless schedule the
488/// same resource/system set, so a system that reads window state doesn't
489/// fail validation. Used by [`run_app_headless`] (no renderer; FFI/test
490/// contract), [`crate::run_headless::run_app_headless_rendered`] (full
491/// offscreen-render mode), and the golden-image screenshot suite
492/// (`public/lumenc/tests/golden.rs`), which installs an offscreen renderer
493/// on top and reads the framebuffer back.
494pub fn build_headless_app(opts: RunOptions) -> Result<(App, WindowSetup), RunError> {
495    let (dir, assets) = (opts.dir.clone(), opts.assets.clone());
496    let (mut app, window) = build_app(opts)?;
497    install_assets(&mut app, assets.as_deref(), &dir)?;
498    app.add_plugin(WindowCorePlugin);
499    Ok((app, window))
500}
501
502/// Publish the markup tags this app's dependencies declare for the builds of
503/// `targets`, and the elements of `addons`, so the parser accepts them.
504///
505/// Called from every path that reads a config and then parses markup: the run
506/// build, `lumenc check`, and the AOT compile. It is deliberately independent
507/// of whether the modules are loadable here - `lumenc check` on a machine with
508/// no module beside it still has to accept the app's markup, because the
509/// declaration is the app's claim about itself.
510pub(crate) fn register_declared_tags(
511    cfg: &crate::config::LumenToml,
512    targets: &[lumen_modules::Target],
513    addons: &[lumen_ir::addon::Addon],
514) {
515    for target in targets {
516        lumen_modules::register_declared_tags(&cfg.dependencies_for(*target));
517    }
518    lumen_modules::register_addon_elements(addons);
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    /// A temp app dir carrying the given `lumen.toml` body.
526    fn app_dir(tag: &str, toml: &str) -> PathBuf {
527        let dir = std::env::temp_dir().join(format!(
528            "lumen-single-instance-gate-{tag}-{}",
529            std::process::id()
530        ));
531        let _ = std::fs::remove_dir_all(&dir);
532        std::fs::create_dir_all(&dir).expect("temp app dir");
533        std::fs::write(dir.join("lumen.toml"), toml).expect("write lumen.toml");
534        dir
535    }
536
537    /// The preflight `run_app` runs, as it runs it: the config read the same
538    /// tolerant way, the environment built from it.
539    fn preflight_for(dir: &Path) -> Preflight {
540        let opts = RunOptions::new(dir);
541        let cfg = crate::config::LumenToml::load_or_default(dir).unwrap_or_default();
542        lumen_capability::preflight(&capability_env(&opts, dir, &cfg))
543    }
544
545    #[test]
546    fn the_preflight_continues_when_single_instance_is_off() {
547        let dir = app_dir("off", "[app]\n");
548        assert_eq!(
549            preflight_for(&dir),
550            Preflight::Continue,
551            "no flag means the launch always continues"
552        );
553        let _ = std::fs::remove_dir_all(&dir);
554    }
555
556    #[test]
557    fn a_bad_config_reads_as_off_rather_than_panicking() {
558        // `run_app` reads its own copy of `lumen.toml` ahead of `build_app`'s
559        // load, on purpose (a secondary must exit before paying for anything
560        // else); a parse failure here must not be where that shows up first.
561        // `build_app` surfaces the real `RunError::Config` moments later on
562        // the primary path.
563        let dir = app_dir("bad-config", "not valid toml at all {{{");
564        assert_eq!(preflight_for(&dir), Preflight::Continue);
565        let _ = std::fs::remove_dir_all(&dir);
566    }
567
568    /// The config-driven half of single-instance: a first launch becomes
569    /// primary and continues, and a second launch for the SAME `[app] id` is
570    /// told to stop. The OS-level socket mechanics this rides on
571    /// (permissions, the platform directory, the primary/secondary bind
572    /// dance itself) are `lumen-os-lifecycle`'s own tests; this is only
573    /// about `[app] single_instance` reaching that crate's preflight through
574    /// the capability list the runtime links.
575    #[cfg(unix)]
576    #[test]
577    fn a_second_launch_for_the_same_app_is_told_to_stop() {
578        let id = format!("lumen-single-instance-gate-test-{}", std::process::id());
579        let dir = app_dir(
580            "dance",
581            &format!("[app]\nid = \"{id}\"\nsingle_instance = true\n"),
582        );
583
584        assert_eq!(
585            preflight_for(&dir),
586            Preflight::Continue,
587            "the first launch is the primary"
588        );
589        if preflight_for(&dir) == Preflight::Continue {
590            // The lifecycle preflight reports "primary" for the fail-closed
591            // path too, so this environment resolved no private per-user
592            // directory to lock under; `lumen-os-lifecycle`'s own tests
593            // cover that branch directly. Print and stop rather than fail:
594            // the same house pattern as a display a sandbox lacks.
595            eprintln!(
596                "skip: this environment offered no private per-user directory to bind the \
597                 single-instance socket under; nothing to assert about a second launch"
598            );
599            let _ = std::fs::remove_dir_all(&dir);
600            return;
601        }
602
603        // Best-effort cleanup of the socket the primary bound - matches
604        // `lumen-os-lifecycle`'s own tests, which face the same platform-
605        // specific location.
606        if let Some(rt) = std::env::var_os("XDG_RUNTIME_DIR") {
607            let _ = std::fs::remove_file(PathBuf::from(rt).join(format!("{id}.sock")));
608        }
609        let _ = std::fs::remove_dir_all(&dir);
610    }
611}
612
613// -- Submodules (mechanical carve of the former monolithic run.rs) ----------
614// Each submodule opens with `use super::*;`, inheriting this module's import
615// block and (via the private glob re-exports below) every sibling's items, so
616// intra-`run` references resolve unchanged.
617mod app_build;
618mod caret_scroll;
619mod check;
620mod hot_reload;
621mod i18n;
622mod introspection;
623mod loading;
624mod restyle;
625mod script_commands;
626mod script_systems;
627// `pub(crate)` so `crate::config`'s bundle capability inference can reuse the
628// shared source scan + marker helpers (Part B tree-shaking).
629pub(crate) mod subsystems;
630
631// Private glob re-exports: make every submodule item visible inside `run`
632// (and, transitively, to each submodule's `use super::*`). Behaviourally this
633// reconstructs the flat namespace the single-file module had.
634use caret_scroll::*;
635use check::*;
636use hot_reload::*;
637use i18n::*;
638use loading::*;
639use restyle::*;
640use script_commands::*;
641use script_systems::*;
642use subsystems::*;
643
644// Public re-exports: preserve every `crate::run::<name>` path that external
645// code (lib.rs re-exports, run_headless.rs, cli/build.rs, integration tests)
646// depended on when these items lived directly in run.rs.
647// `build_app` is public so the full-pipeline integration tests (which live in
648// `lumenc`, where the injected parser is the same crate instance) can build an
649// app window-free without the extra `WindowCorePlugin` that
650// `build_headless_app` layers on.
651pub use app_build::build_app;
652pub use check::CheckReport;
653// The app's catalogue directory, so a build that resolves translations
654// ahead of time reads them from where the runtime would.
655#[cfg(all(feature = "runtime-parse", feature = "modules"))]
656pub use check::module_surface;
657#[cfg(all(feature = "runtime-parse", feature = "modules"))]
658pub use check::script_exports;
659#[cfg(feature = "runtime-parse")]
660pub use check::{CompileDeps, ModuleSurface, check_app, compile_app, compile_app_with_skin};
661#[cfg(feature = "runtime-parse")]
662pub(crate) use hot_reload::HotReloadDriver;
663pub use i18n::locale_dir;
664pub use restyle::{
665    ColorSchemeIntent, ErrorBanner, ErrorBannerMarker, dismiss_error_banner_on_escape,
666    reconcile_error_banner,
667};