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};