Skip to content

Packaging an app

During development lumenc run parses your markup and CSS on every launch. That is what makes hot reload work, and it is the wrong thing to ship: a released app should do the parsing once, before anyone downloads it.

This page covers going from an app directory to something you can hand someone. The short answer is lumenc package; the rest of the page is the pieces underneath it and when you would reach for them on their own. It describes a markup app throughout, and the last section covers what differs for an app written against one of the SDKs.

Package the app

lumenc package myapp

This writes myapp/dist/myapp/: a folder holding your app as an executable, the Lumen runtime library beside it, lumen.toml, and your app's files at the same relative paths your markup names them by. Copy that folder to another machine, run the executable, and the app starts. Nobody needs Lumen installed, and nobody needs a compiler.

The Lumen runtime library sits in the folder next to the executable. On Linux and macOS the shared engine and the Rust standard library it was built against travel beside it, and any runtime modules the app declares are staged into a modules/ subfolder: path and bundled modules are copied from where the declaration points, and a version module comes from the registry, so the shipped folder carries the exact library lumen.lock pins. The script host the app runs on is staged there too: lumen-candela, which runs the bytecode the package compiled the app's candela scripts to, with no compiler in the folder. A candela package needs no staging: its scripts compiled into the executable with the app's own. All of it belongs to the app; keep the folder together when you move it.

The engine is the app's own

The engine in the folder carries only the optional subsystems the app uses. Of the tray, notifications, file dialogs, hotkeys, the HTTP client, the renderers, the developer tools and the rest, a package keeps the ones the app's sources call into and leaves the others out, and [capabilities] names any it should keep or leave out regardless. The summary line lists both sets:

lumenc package: wrote myapp/dist/myapp/myapp for linux-x86_64 (5 app files
beside it, engine linked for the app with 4 capabilities (layout-taffy,
window-winit, render-cpu, render-gpu) and without 11 capabilities (...))

The engine is linked for the app on the machine doing the packaging, from the release's engine kit: the recorded link of the toolchain's own engine, which is replayed with only the app's subsystems on it and without the exports nothing in the folder uses. No compiler is involved, and the link is quick. It needs a linker, the same one --static needs: a C toolchain on Linux, plus the development files of GTK 3 that the engine links against, the Xcode Command Line Tools on macOS, and the Visual Studio Build Tools with the "Desktop development with C++" workload on Windows. The first package for a platform downloads its engine kit and caches it.

When the engine cannot be linked here, the folder carries the toolchain's full engine and the package says why in one line: no kit was published or could be downloaded, this lumenc is a build from source rather than the release the kit belongs to, the package is for another platform, or the link itself failed (the line quotes the linker). An app whose [capabilities] turns a subsystem off is stopped instead, since the full engine could not leave it out.

A subsystem the package leaves out is gone from the folder. If the app's sources call one of its builtins while [capabilities] turns it off, the package step warns, and the running app prints a warning the first time the script makes that call:

lumen: warning: `tray_icon` does nothing here: this package was built without
os-tray. Name it in lumen.toml [capabilities] to package it in.

A Windows package stays one library plus the executable, with any portable plugins the app declares in modules/ and its candela packages compiled in; the script hosts its scripts run on are linked into the library. Runtime modules do not load beside it, because there is no shared engine there for one to load into, so an app that declares one of the modules that ship with the toolchain is packaged with --static instead, which compiles them in. A runtime module from a path or version source cannot be packaged for Windows either way; a portable plugin from either source can.

Choose the name and the destination yourself:

lumenc package myapp build/Notes --name Notes

To hand the result to someone rather than run it, ask for an archive as well:

lumenc package myapp --zip

That writes myapp/dist/myapp.zip beside the folder, holding the folder itself, so unpacking it gives the same directory back rather than scattering an executable and its libraries.

The markup, the stylesheet, and the scripts are compiled into the executable, so src/ stays behind; a multi-page app compiles every page in. Everything else in the app directory travels: images, fonts, sounds, translation catalogues, data files. Dotfiles and a target/ directory are left behind, and so are the output folder itself and the app's dist/ folder, where lumenc writes packages and sites by default, wherever this package is written.

The candela standard library travels too, in a libs/ folder beside the executable. Scripts read it as they compile, so import "std/..." and the array methods resolve in the folder the same way they do under lumenc run.

LICENSE, NOTICE and THIRD-PARTY-LICENSES travel beside the executable as well. A packaged app ships the Lumen engine and the libraries the engine is built from, and several of those ask that their license text reach whoever receives the binary. Keep the three files in the folder when you hand it on.

Packaging a markup app needs no Rust toolchain. It copies a prebuilt launcher and appends your compiled app to the copy. On macOS it links the app in instead, because a signed Mach-O executable cannot carry trailing data, and that step needs the Xcode Command Line Tools (xcode-select --install).

Packaging for another platform

lumenc package myapp --target windows-x86_64

The targets are linux-x86_64, linux-aarch64, macos-x86_64, macos-aarch64, windows-x86_64, and windows-aarch64. For a markup app packaging is file assembly rather than compilation, so any host can produce any of them; an SDK app is compiled, and what that needs is below.

The runtime library and the launcher for the other platform come from a published Lumen release: the one you installed, or the newest one if this lumenc was not installed from a release. They are downloaded once, kept in a cache keyed by that release, and checked against the checksums published with it. If you already have those two files, point at them instead and nothing is downloaded:

lumenc package myapp --target windows-x86_64 --lib-dir /path/to/files

A macOS package built from another platform ships the compiled app as a file beside the executable rather than inside it, since linking it in needs a macOS linker. It runs the same way.

An app declaring [dependencies] cross-packages. A bundled module comes from the target's module archive, downloaded from the same release as the toolchain files, verified, and cached beside them; a version module comes from the registry, resolved for the platform being packaged rather than for yours, so the folder carries that platform's build. A path module cannot cross-package - a local library is built for one platform - so package that one on a machine of the target platform instead.

One self-contained executable

lumenc package myapp --static

This writes the same folder with one difference: the app is a single executable. The engine is inside it, and so is every runtime module the app declares and the candela host its scripts run on, so there is no runtime library beside it and no modules/ subfolder. It carries no Rhai or Lua host, so --static refuses an app with a script in either language. The engine inside is the app's own, chosen the way a folder package's is: of the optional subsystems the kit offers (tray, notifications, dialogs, hotkeys, the HTTP client and the rest), the executable carries the ones the app's sources show it uses, and [capabilities] names any it should carry or leave out regardless. The renderer follows [render] backend: cpu or gpu links that one renderer and none of the other, which is the way to keep a small app small, and auto links both so the app starts on a machine without a GPU. The candela standard library still travels in libs/, because scripts read it off disk as they compile whatever links the engine, and the three license files travel beside the executable as they do in a folder package. Copy the executable, libs/, the license files, and the app's files, and that is the whole app.

Reach for it when you want one file to hand over rather than a folder to keep together, and on Windows when your app declares a module that ships with the toolchain, which is the only shape that loads one there. Portable plugins are not linked in; they travel beside the executable in modules/, since they load into it the way they load into any other host.

It needs a linker on the machine doing the packaging: a C toolchain on Linux (build-essential, base-devel, or your distribution's equivalent), the Xcode Command Line Tools on macOS, and the Visual Studio Build Tools with the "Desktop development with C++" workload on Windows. The first --static package for a platform also downloads that platform's link kit from the release channel and caches it, which takes a moment and happens once.

Three things it will not do, each with an exit code of 2 and a message saying so:

  • Package an app written against an SDK. Those bring their own executable from their own toolchain.
  • Package for a platform other than the one you are on. The link runs through the tools installed here.
  • Link a runtime module from a path or version source. Only the modules that ship with the toolchain (bundled = true) are in the kit; on Linux and macOS, package without --static and the library is staged beside the executable instead. Candela packages and portable plugins from either source package as usual.

What a packaged app does at startup

The executable reads the app compiled into it, opens the runtime library sitting next to it, and runs; a --static executable skips the opening and runs what it already carries. Its own directory is the app directory: relative paths in your markup, and lumen.toml, resolve against it, so the folder works wherever it is copied.

Pass --headless --ticks N to a packaged app to run it window-free for a fixed number of ticks, which is how you smoke-test a package in CI.

Compile without packaging

lumenc build runs the same compile step and writes the result to a file:

lumenc build myapp myapp.lmna

It parses src/main.lmn and the stylesheet, runs the whole cascade, splices includes and imports, records which language each part of the script is in and which runtime module runs it, and writes one artifact. A candela script is compiled to bytecode, and only the bytecode goes in: a shipped app runs it on the bytecode host, with no compiler and no source. A Rhai or Lua script goes in as source. Run it back with:

lumenc run myapp --artifact myapp.lmna

The app directory is still needed here: it supplies lumen.toml and the files your markup refers to. What the artifact replaces is the parse, not the directory.

Reach for build when you want the compiled app on its own: to embed it in a host application through the C ABI, to measure startup without the parser, or to check that an app compiles at all. Reach for package when you want something to ship.

Two things follow from compiling ahead of time, whichever command you use:

  • Startup skips the parse and the cascade.
  • Hot reload is off. There is no source being watched, so edits need a rebuild.

Everything else behaves the same. Colours and metrics stay reachable through CSS variables and design tokens, because the artifact carries the cascaded stylesheet rather than freezing resolved values into the tree.

lumenc check is the fast way to confirm an app parses before you compile it.

Limits of the compiled form

An app that reaches for a file no part of the app directory holds keeps pointing at the absolute path it was given, which will not exist on anyone else's machine. Keep what your app needs inside the app directory.

A multi-page app compiles whole: every page goes into the executable along with the page names navigation resolves against, so a packaged app routes without carrying any .lmn files. Adding a page then means rebuilding, since there are no page files left to reload.

Archive the app's files

lumenc bundle myapp myapp.lpak

This packs every regular file under the app directory into one .lpak archive, skipping dotfiles, target/, and src/; an archive holds the app's assets, and the code is compiled rather than read by name. Use it when you want those files as a single addressable blob rather than a folder, for instance to serve them from one file or to keep a build output tidy.

Run against the archive with --assets:

lumenc run myapp --assets myapp.lpak

Every image, icon, and sound the markup names is then read out of the archive. Lookups are keyed by the path relative to the app directory, the same path you write in the markup, so nothing in the app changes. A file the archive does not carry still comes from disk, which lets you keep one loose file for a quick edit without repacking. Fonts are the exception: they load through the system font database and are read from disk even when the archive carries them.

Pair it with an artifact to run an app as two files plus lumen.toml:

lumenc run myapp --artifact myapp.lmna --assets myapp.lpak

Rebuild the archive whenever an asset changes. A missing or corrupt archive stops the run rather than quietly falling back to the directory.

Trim the runtime

An app that makes no network calls and opens no file dialogs does not need the code for either. lumenc package already leaves such subsystems out of the engine it ships; lumenc bundle --static instead compiles a runtime library from Lumen's source with only the app's subsystems in it.

lumenc bundle --static myapp out/

It prints the capability set it resolved, builds the trimmed runtime, and copies the library into out/ with the source tree's LICENSE, NOTICE and THIRD-PARTY-LICENSES beside it. Put that library in a package in place of the one lumenc package copied, and the packaged app opens the trimmed build instead.

Detection is deliberately cautious: a subsystem is dropped only on a clear signal that the app never uses it, and anything ambiguous is kept. Override either direction in lumen.toml:

[capabilities]
http-fetch = false
async = false

See the capabilities table for what each key covers.

This command compiles the runtime from Lumen's source, so it needs a copy of that source tree; point LUMEN_WORKSPACE_DIR at it. Without one, the command still prints the resolved capability set and then stops.

Build steps your app needs

Some apps need something built before they can run: a C library the script loads, a generated data file, a downloaded asset. Declare those as hooks in lumen.toml and Lumen runs them for you.

[[hooks]]
when    = "prebuild"
os      = "linux"
run     = "cc -shared -fPIC -O2 -o libmd.so md.c"
inputs  = ["md.c"]
outputs = ["libmd.so"]

A prebuild hook runs before lumenc run, build, bundle, and package. A prerun hook runs only before lumenc run, after every prebuild hook. lumenc check never runs hooks, so a check stays free of side effects. A candela script that loads a C library a hook builds is still checked: the dylib block's own declarations are what the calls are checked against, and the check opens no library. The check reference lists the forms that do need the library built first.

Listing inputs and outputs makes the hook skippable: when the outputs are already newer than the inputs, the command does not run again. Leave either list out and the hook runs every time.

Hooks run in declaration order with the app directory as their working directory, and a failing hook stops the command. Give a hook an os when it only makes sense on one platform, as in the example above; declare one entry per platform to cover them all.

A hook is a shell command that a lumen.toml asks for, so treat an app from someone else the way you would treat a project with a build script. Pass --no-hooks to any of run, build, bundle, or package to skip them.

The full key list is in the lumen.toml reference.

Apps written against an SDK

An app authored with the Rust, C++, or Python SDK is a program in that language, not a markup directory, and it is built by that language's own toolchain. lumenc run, lumenc build, and lumenc package all detect one from its contents, or from [app] kind in lumen.toml, and hand the build to cargo, cmake, or the interpreter.

lumenc package then assembles the same folder around whatever that build produced, so packaging one is the same command:

lumenc package myapp

Every kind produces an executable, and the runtime library goes beside it, the same as for a markup app. How the executable is produced is what differs:

  • Rust. cargo build --release runs, and the binary it reports is copied in under your app's name. On Linux and macOS a Rust app links the engine rather than compiling a copy into itself, so the executable is small and the engine travels beside it, out of the same build. On Windows the runtime is inside the executable and nothing travels with it. That engine is whatever the app's own cargo build linked, so [capabilities] does not trim it, and a package of a Rust app with the table set says so.
  • C++. CMake configures and builds, and the executable from the build tree is copied in. If the project builds more than one executable, the most recent one is packaged and the others are named on the way past; give the app its own directory to keep that unambiguous.
  • Python. The app is frozen into an executable with PyInstaller, which bundles the interpreter and the app's modules into one file. Install it first (pip install pyinstaller).

Unlike a markup app, an SDK app reads its markup, stylesheet, and scripts at run time, so those files travel with it. What stays behind is the source it was compiled from and the build tree that compile left.

A C++ or Python app gets an engine linked for it the way a markup app does, with one difference: its program can drive any subsystem through the C ABI, and the source scan cannot read it, so the engine keeps every subsystem an app's code could call. Only the development ones and the renderer [render] backend does not pick are left out, and [capabilities] decides the rest.

Cross-packaging an SDK app

--target works here too, and the SDK's own toolchain does the compiling:

lumenc package myapp --target linux-aarch64

For a Rust app the target triple is passed to cargo, so rustup target add aarch64-unknown-linux-gnu is what makes it work. For a C++ app, set CMAKE_TOOLCHAIN_FILE to a toolchain file for that platform; there is nothing Lumen can supply in its place, so packaging says so rather than building this machine's binary under another platform's name. A Python app is frozen against the interpreter doing the freezing and can only be packaged for the platform you are on.

A Rust app needs nothing from the release channel: the engine it links comes out of its own cargo build, so rustup target add is the whole requirement. The other kinds open the C library, and that one is fetched for the platform you asked for.

Every flag on every command here is in the CLI reference.