SDK reference
Every function in lumi-extension-api, what it answers, and what it costs.
lumi-extension-api is the crate your extension depends on. Import it under a
short name and everything below is one path away:
use lumi_extension_api as lumi;What each call costs
Every function below exists whether or not your manifest declares what it needs. An undeclared one is refused when it is called, with an error that names the capability — so the person who sees it is you, with the manifest open.
| Costs | Functions |
|---|---|
| Nothing at all | about, license |
| One call from the run's budget | alert, settings, setting, profiles, open_window, hyper_key_enabled |
accessibility | selection (the ⌘C fallback needs clipboard too) |
clipboard | clipboard_text, set_clipboard_text |
applications | open_url (a web address needs network too) |
network | fetch |
config | everything in lumi::config |
Every call into Lumi except about and license spends from one allowance of
200 per run, so a loop of alerts runs dry long before it can flood the
screen. The call is spent before its capability is checked, so a refused call
counts too. Past the allowance every call answers an error — and settings(),
which has no error to give, answers an empty object, so read your settings
early in a run rather than after a long loop.
Your extension
Guest and register!
The trait you implement and the macro that wires it up. Call register! once,
at the crate root, on the type that implements Guest.
impl lumi::Guest for MyExtension {
fn run_command(name: String, params: String) -> Result<String, String>;
fn run_node(name: String, params: String, items: String) -> Result<String, String>;
fn run_ui(window: String, request: String) -> Result<String, String>;
fn on_lifecycle(event: lumi::Lifecycle) -> Result<(), String>;
}
lumi::register!(MyExtension);| Method | Called for | Receives | Answers |
|---|---|---|---|
run_command | A bound [[command]] pressed | The command's name; its params as a JSON object, variables already rendered | Any string, which is not shown — call alert to tell the person something — or an error Lumi reports like a failed action |
run_node | A [[node]] reached in a flow | The node's name, params as a JSON object, and the incoming items as a JSON array | The outgoing items as a JSON array — empty ends the branch |
run_ui | A request from one of your windows | The window's name, and whatever the page posted | Whatever the page should get back — see Windows |
on_lifecycle | Your extension being installed, updated or uninstalled | A Lifecycle | Ok(()), or an error that is only written to Lumi's log |
Answer the names you do not know with an error rather than a panic. A panic is reported to the person as the extension having crashed on an error of its own, with no more detail than that; the backtrace goes to Lumi's log. An error's text is cut to 64 KiB.
on_lifecycle(event)
pub enum Lifecycle {
Installed,
Updated(String), // the version that was replaced
Uninstalling,
}Three moments in your extension's life, each told to it once:
| Event | When | Typical use |
|---|---|---|
Installed | Right after a first install — after your installer, if you ship one, has saved its answers | Open a Welcome window |
Updated(from) | Right after a new version replaces an installed one | Say what is new since from |
Uninstalling | Just before your files are removed | Open a feedback page in the browser |
Nothing to do is the ordinary answer:
fn on_lifecycle(_event: lumi::Lifecycle) -> Result<(), String> {
Ok(())
}The rules, all of which follow from nobody having pressed anything:
- Only while switched on. A switched-off extension is not told it was updated or uninstalled — off means none of its code runs.
- Nobody sees the answer. An error is written to Lumi's log, never shown.
- It cannot stop anything. An
Uninstallingthat fails or runs out of time is logged, and the uninstall goes ahead. InstalledandUpdatedrun after the install has finished, on their own, with the same allowance and deadline as a command.- It takes one of your four runs at once. A hook that arrives while four
runs of yours are going is skipped and logged —
Uninstallingincluded. Uninstallingis short and has no windows. The person is waiting on the Uninstall button, so it has five seconds, and yourui/files are about to be deleted, soopen_windowis refused. Open a web page withopen_urlinstead — which needsapplications, plusnetworkfor a web address.
fn on_lifecycle(event: lumi::Lifecycle) -> Result<(), String> {
match event {
lumi::Lifecycle::Installed => lumi::open_window("welcome"),
lumi::Lifecycle::Updated(from) => lumi::alert(&format!("Updated from {from}")),
lumi::Lifecycle::Uninstalling => lumi::open_url("https://example.dev/why-uninstall"),
}
}A Welcome window is an ordinary [[window]]:
declare it, and open it from Installed.
settings() and setting(name)
pub fn settings() -> serde_json::Value
pub fn setting(name: &str) -> Option<String>Your [[settings]] values as the person saved them, one key per field, with
defaults filled in for anything untouched. A field declared
scope = "profile" holds the live profile's value. setting is the
shortcut for one string field.
Settings are fixed for the length of a run: a save in Lumi while your code is running reaches the next run.
open_window(name)
pub fn open_window(name: &str) -> Result<(), String>Opens — or brings forward — one of the windows your manifest declares. A name the manifest does not declare is refused. See Windows.
It is refused from run_node too: a flow runs in the background, and a
dialog out of a flow nobody just pressed is not something a flow may do yet.
And it is refused from Uninstalling, since your ui/ files are about to
go.
Talking to the person
alert(text)
pub fn alert(text: &str) -> Result<(), String>Draws a line of text over whatever is in front, in the alert style the person chose in Lumi. It takes no focus and fades on its own — the way an extension answers somebody who pressed a key with no Lumi window open. Text over 64 KiB is refused with an error rather than cut.
The desk
selection()
pub fn selection() -> Result<Found, String>
pub struct Found {
pub text: String,
pub how: String, // "accessibility", "copy" or "nothing"
}The text selected in the frontmost application. Needs accessibility.
Lumi asks the application through Accessibility first. If you also declared
clipboard, an application that exposes nothing is asked again with a ⌘C,
and the pasteboard is put back afterwards; how says which one answered.
Empty text with how == "nothing" is the ordinary miss — nothing selected —
not an error.
clipboard_text() and set_clipboard_text(text)
pub fn clipboard_text() -> Result<Option<String>, String>
pub fn set_clipboard_text(text: &str) -> Result<(), String>The pasteboard's plain text, or None when it holds no text — an image, a
file and an empty board are one answer. Writing replaces what was there, and
text over 4 MiB is refused. Both need clipboard.
open_url(url)
pub fn open_url(url: &str) -> Result<(), String>Hands a URL to whichever application handles it. Web and mail addresses only:
https://, http:// and mailto:, in any case. Needs applications, and a
web address needs network too.
The network
fetch(request)
pub fn fetch(request: &Request) -> Result<Response, String>
pub struct Request {
pub method: String, // empty means GET
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Option<String>,
}
pub struct Response {
pub status: u16,
pub ok: bool, // status in 200..300
pub url: String, // the final URL, after redirects
pub headers: Vec<(String, String)>,
pub body: String,
}One HTTP request, with a ten-second timeout. Needs network. The method is
upper-cased, and empty means GET.
- Redirects are followed, five at most.
- The body of a response is at most 5 MiB; a larger one is an error, not a truncated body.
- Local addresses — loopback, the local network, link-local — are refused
unless the person has switched on Let flows reach this network under
Settings → General → Advanced, whatever the request asks for. The address is
resolved once and judged, and every redirect is judged again, so a
redirect cannot reach what the first address could not. The switch is
named for flows because it is the same one: an extension's
fetchand a flow's script reach the network through the same door.
Profiles
profiles()
pub fn profiles() -> Result<Profiles, String>
pub struct Profiles {
pub active: String, // the live profile's id, always one of `profiles`
pub profiles: Vec<Profile>, // never empty
}
pub struct Profile {
pub id: String, // stable across renames — key by this
pub name: String, // what the person calls it — show this
}
impl Profiles {
pub fn active_profile(&self) -> Option<&Profile>;
}A person's profiles are whole sets of shortcuts, snippets and flows, one live at a time. Your extension is installed once for the whole Mac, so this is how it tells the sets apart.
One call answers both halves, so the list and the live id are always from the
same moment. Ask whenever you need it rather than holding on to an answer:
the person can switch at any time and nothing tells your extension that they
did. To keep a value per profile, declare the setting
scope = "profile".
let book = lumi::profiles()?;
let here = book.active_profile().map(|p| p.name.as_str()).unwrap_or("?");
lumi::alert(&format!("{here} — one of {} profiles", book.profiles.len()))?;Lumi itself
about()
pub fn about() -> About
pub struct About {
pub version: String, // "1.22.0"
pub homepage: String, // "https://lumikeys.app"
pub author: String,
}Which Lumi is running. Check version before relying on something a newer
Lumi added — compare the numbers rather than matching the string.
license()
pub fn license() -> Edition
pub enum Edition {
Free, // no licence on this Mac
Pro, // Pro is on
Inactive, // a licence that is not granting Pro right now
}Whether this Mac has Lumi Pro. The edition is the whole of what an extension learns about the licence — never the key, the seat, or who bought it. Ask each time: the person can buy, renew or lapse while your extension is installed.
Word Pro, never Free
Lumi never labels a free copy — a badge is a reward, and a label on a free copy is an advertisement. Anything your extension draws inside Lumi should follow the same rule: say "Pro" when it is, and nothing otherwise.
hyper_key_enabled()
pub fn hyper_key_enabled() -> Result<bool, String>Whether Lumi's Hyper key is switched on in the live profile. While it is, Caps Lock belongs to Lumi — remapped and never delivered as itself — so a page that listens for keys should not wait for one. Needs no capability; it spends one call from the budget.
It is the switch alone. The modifiers the Hyper key adds and what tapping it
does are in config::hyper_key(), which needs
config. Ask when you need it: the person can flip it, or switch profile, at
any time.
Added in Lumi 1.23.0. An extension that calls it should say
min-lumi-version = "1.23.0" in its manifest: on an older Lumi it cannot
start.
Reading the person's setup
Everything under lumi::config needs the config capability, reads the live
profile, and is read-only.
pub mod config {
pub fn settings() -> Result<serde_json::Value, String>;
pub fn shortcuts() -> Result<serde_json::Value, String>;
pub fn snippets() -> Result<serde_json::Value, String>;
pub fn hyper_key() -> Result<serde_json::Value, String>;
pub fn double_tap() -> Result<serde_json::Value, String>;
pub fn fn_key() -> Result<serde_json::Value, String>;
}| Function | Answers |
|---|---|
settings() | Lumi's general settings: appearance, startAtLogin, showProfileInMenuBar, appShortcuts, alert, leader, arrange |
shortcuts() | { "enabled": …, "bindings": [...] } — the Shortcuts pane's switch and every row: ordinary combinations, double-taps, Fn combinations, leader menus and their steps |
snippets() | Expansion's switch, its options, and every snippet under items |
hyper_key() | Whether the Caps Lock Hyper key is on, the modifiers it holds, and what a tap on its own does |
double_tap() | Its switch and timing, plus the rows it arms under bindings |
fn_key() | Its switch, plus the rows it arms under bindings |
let shortcuts = lumi::config::shortcuts()?;
let rows = shortcuts["bindings"].as_array().map(Vec::len).unwrap_or(0);
lumi::alert(&format!("{rows} shortcuts in this profile"))?;Another extension's inputs are not shown. A row that runs a command of
some other extension arrives with its action.params left out: those are what
the person typed into that extension's form, and may be a token or an account
it asked for. You still see the row, which extension and which command it
runs. Rows that run your own commands keep their params.
The shape is Lumi's own. Each answer uses the same camelCase field names and values Lumi saves the person's settings with, so a field you read today keeps its name in the next Lumi. New fields may appear — read defensively, never exhaustively. Rows written by a newer Lumi that this one cannot read are left out rather than guessed at.
It is content, and the review sheet says so. Snippets are where people
keep addresses and canned replies. The person installing sees "reads your
shortcuts, snippets and Lumi settings" — beside "sends data over the network"
if you declared that too — so ask for config only when the extension really
reads it.
There is no write. To change what a keystroke does, offer a command or a node and let the person bind it. Reading is for fitting around what is already there: suggesting a combination nobody holds, or not doing work a snippet already does.
Ask when you need it. The person can edit a row or switch profile at any moment. While Lumi's own settings cannot be read, every call here is refused with a sentence rather than answered with empty defaults that would look like a person with nothing set up.