Writing an extension
Add actions, flow nodes, settings and windows to Lumi with a Rust crate compiled to WebAssembly.
An extension is a small Rust crate, compiled to a WebAssembly component and
shipped beside a manifest.toml. It can give Lumi:
- Actions — commands a person binds to a shortcut, a leader menu step, a double-tap or an Fn combination, like any built-in action.
- Flow nodes — steps that appear in the Flow Editor's palette and run on the items that reach them.
- Settings — a form Lumi draws for it under Settings → Extensions.
- Windows — dialogs of its own, drawn from HTML files in the package.
It runs in Lumi's sandbox and reaches exactly what its manifest declares.
That is enforced by Lumi on every call the extension makes, not by review: an
extension that never declared network cannot reach the network whatever its
code tries.
The API is 0.2
An extension built against version 0.2 of the SDK installs on every Lumi
that speaks 0.2. When a later Lumi moves the interface to a new version, it
says so at install — rebuild against the new SDK, or update Lumi — rather
than loading an extension it cannot run. Coming from 0.1? Point the
dependency at 0.2, add on_lifecycle (returning Ok(()) is enough), and
rebuild — see Lifecycle.
What you need
-
A Rust toolchain with the WebAssembly component target:
rustup target add wasm32-wasip2 -
Lumi on the same Mac, to install and run what you build.
A first extension
Make the crate
cargo new --lib hello-lumi
cd hello-lumiAn extension is a library with no main — Lumi calls into it. In
Cargo.toml:
[lib]
crate-type = ["cdylib"]
[dependencies]
# Until the crate is on crates.io, it is taken from the store's repository.
# The tag names the interface version, so an update to the SDK is a choice
# you make.
lumi-extension-api = { git = "https://github.com/thiennguyen93/lumi-store", tag = "api-v0.2.0" }
serde_json = "1"Write the code
src/lib.rs implements one trait and registers it:
use lumi_extension_api as lumi;
struct Hello;
impl lumi::Guest for Hello {
fn run_command(name: String, _params: String) -> Result<String, String> {
match name.as_str() {
"greet" => {
let found = lumi::selection()?;
lumi::alert(&format!("You selected {} characters", found.text.chars().count()))?;
Ok("greeted".to_string())
}
other => Err(format!("no {other} command")),
}
}
fn run_node(name: String, _params: String, _items: String) -> Result<String, String> {
Err(format!("no {name} node"))
}
fn run_ui(window: String, _request: String) -> Result<String, String> {
Err(format!("no {window} window"))
}
fn on_lifecycle(_event: lumi::Lifecycle) -> Result<(), String> {
Ok(())
}
}
lumi::register!(Hello);Declare it
manifest.toml beside Cargo.toml says who the extension is, what it
contributes, and what it may reach:
[extension]
id = "dev.you.hello"
name = "Hello"
version = "0.1.0"
description = "Counts what you selected."
author = "You"
capabilities = ["accessibility"]
[[command]]
name = "greet"
label = "Count the selection"lumi::selection() needs accessibility, so the manifest asks for it. Leave
it out and the call is refused, by name, the first time it runs — see
Capabilities.
Build and package
cargo build --release --target wasm32-wasip2
mkdir -p package
cp manifest.toml package/
cp target/wasm32-wasip2/release/hello_lumi.wasm package/extension.wasm
tar -czf dev.you.hello.tar.gz -C package .A package is a gzipped tar holding manifest.toml and extension.wasm, and
optionally icon.svg and a ui/ directory — see
The package.
Install it
In Lumi, open Settings → Extensions and drag the .tar.gz onto the
list. Or press Install from file — the package icon at the end of the row
above the list, beside Store — and pick it. The review sheet lists what the extension declares; it says
not signed because only the store signs packages, which is the truth
about a file you built yourself.
Bind Count the selection to a shortcut like any other action, select some text anywhere, and press it.
How a run works
Every call into an extension — a command, a flow node, a request from one of its windows — is one run, and a run is bounded:
| Bound | Limit |
|---|---|
| Memory | 64 MiB, across every memory your component defines |
| Wall-clock time | 15 seconds, waiting included — a sleep counts |
| Calls into Lumi | 200 per run |
| Answer size | 4 MiB |
| Runs at once | 4 per extension |
The first two stop the run where it stands: a run that asks for more memory, or is still going at fifteen seconds, is ended and the person is told the extension was stopped. Lumi itself carries on — that is the point of the sandbox.
The next two are refusals rather than stops. Past the 200th call, every call
into Lumi answers an error instead of doing anything, and with ? that ends
your run on the spot. A call refused for a missing capability still counts
toward the 200. An answer over 4 MiB is replaced with an error saying how
large it was.
The last one is not per run: a fifth run started while four of yours are still going is refused straight away rather than queued, with a sentence saying the extension is busy. A window that fires requests in a loop is the usual way to meet it — send the next one when the last has answered.
A component may also define at most 4 memories, 16 tables and 32 instances.
Rust's wasm32-wasip2 toolchain builds one memory, two tables and three
instances, so this only matters to a component built some other way.
A flow node's run is ended when the person presses Stop on the flow, not only the steps after it.
A run starts from nothing each time: there is no state carried between runs in memory. What must last goes in your settings, which Lumi stores for you.