Lumi
Extensions

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.

CostsFunctions
Nothing at allabout, license
One call from the run's budgetalert, settings, setting, profiles, open_window, hyper_key_enabled
accessibilityselection (the ⌘C fallback needs clipboard too)
clipboardclipboard_text, set_clipboard_text
applicationsopen_url (a web address needs network too)
networkfetch
configeverything 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);
MethodCalled forReceivesAnswers
run_commandA bound [[command]] pressedThe command's name; its params as a JSON object, variables already renderedAny string, which is not shown — call alert to tell the person something — or an error Lumi reports like a failed action
run_nodeA [[node]] reached in a flowThe node's name, params as a JSON object, and the incoming items as a JSON arrayThe outgoing items as a JSON array — empty ends the branch
run_uiA request from one of your windowsThe window's name, and whatever the page postedWhatever the page should get back — see Windows
on_lifecycleYour extension being installed, updated or uninstalledA LifecycleOk(()), 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:

EventWhenTypical use
InstalledRight after a first install — after your installer, if you ship one, has saved its answersOpen a Welcome window
Updated(from)Right after a new version replaces an installed oneSay what is new since from
UninstallingJust before your files are removedOpen 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 Uninstalling that fails or runs out of time is logged, and the uninstall goes ahead.
  • Installed and Updated run 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 — Uninstalling included.
  • Uninstalling is short and has no windows. The person is waiting on the Uninstall button, so it has five seconds, and your ui/ files are about to be deleted, so open_window is refused. Open a web page with open_url instead — which needs applications, plus network for 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 fetch and 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>;
}
FunctionAnswers
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.

On this page