Lumi
Extensions

Windows

Dialogs of an extension's own, drawn from HTML in the package, and the bridge back to its code.

An extension may draw its own dialogs — a richer settings screen, a picker, anything a form cannot say. Ship static files under ui/ in the package and declare each window in the manifest.

Opening one

Two ways, both only for windows the manifest declares:

  • From code — lumi::open_window("settings"), usually from a command, so a shortcut can summon the dialog.
  • From Lumi — the extension's About page under Settings → Extensions has a button for every declared window, unless you ship an About page of your own.

The declaration is the grant. The review sheet lists every window, so what was reviewed is everything that can ever open. A flow node cannot open one — see open_window.

A window opens at its path with ?window=<name> on the address, so one page can serve several windows. Opening a window that is already up brings it forward, unminimised, without reloading it. Closing it destroys it, and the next open builds it again from the manifest installed at that moment.

What the page is

Any static bundle. Plain HTML and JavaScript need no build step, which keeps the file that is reviewed the file that runs. A framework works the same way — with Vite and React, for example:

npm create vite@latest my-ui -- --template react
# set `base: "./"` in vite.config.js, then
npm run build
cp -R dist/* ../ui/

Ship production builds only. The page's content security policy is

default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
font-src 'self' data:; frame-ancestors 'none'

so scripts run only from your own files: no inline <script>, no eval — which development bundles rely on. Inline styles, and data: images and fonts, are allowed. A file larger than 8 MB is not served.

Building for the store

The store packs ui/ from your reviewed source as it stands and has no build step yet, so a submission carrying a built, minified bundle is refused at review — it is a binary that was never reviewed. For the store, ship ui/ as files you wrote by hand. A framework build suits a package you install yourself.

Treat the page as stateless — storage in the page is not guaranteed to last. State that matters belongs in your settings, or on your side of run_ui.

What the page can reach

Nothing except its own extension.

  • No Lumi internals. The page is given none of Lumi's own machinery.
  • No network. Every response carries default-src 'self', and the window cannot navigate away from its own files.
  • Its own origin — which is where the bridge is.

So the page draws and the extension acts. A button that needs the network asks the extension, and the extension calls lumi::fetch() behind its own network capability; there is deliberately no way to do it from the page.

The bridge

Same-origin fetch from the page, to four addresses:

RequestDoes
GET /__lumi__/settingsYour settings as a JSON object — the same one lumi::settings() reads.
PUT /__lumi__/settingsSaves. Send only the fields that changed; the rest keep their values, undeclared fields are dropped, and a per-profile field is saved for the live profile. A value is stored as text, and one that breaks its field's required, pattern or bounds refuses the whole save with a non-2xx response naming the field.
POST /__lumi__/callAny string. It arrives at your run_ui with the window's name, and your answer comes back as the response body — an error as a non-2xx response.
GET /__lumi__/lumi.cssLumi's own look — its colours, type and controls — as a stylesheet you can link, so a window matches the app around it. Every rule in it is written to lose to any rule of yours.

A call is an ordinary run: the same budget, the same capability checks and the same 15-second limit as a command. The window is a face on the sandbox, never a way around it.

What the answers mean:

StatusMeans
200A call answered, or settings read — the body is the answer.
204Settings saved.
400A settings save refused; the body is a sentence naming the field.
403An embedded page called while not in front.
404No such address.
410The extension was switched off or removed while the page was open.
413A request body — a call's string or a settings save — over 1 MB.
502Your run_ui answered an error, or the run was stopped; the body says why.

POST saves settings the same way PUT does.

async function call(request) {
  const answer = await fetch("/__lumi__/call", {
    method: "POST",
    body: JSON.stringify(request),
  });
  const body = await answer.text();
  if (!answer.ok) throw new Error(body);
  return JSON.parse(body);
}

const { version } = await call({ kind: "about" });
fn run_ui(window: String, request: String) -> Result<String, String> {
    let request: serde_json::Value =
        serde_json::from_str(&request).map_err(|err| err.to_string())?;
    match request["kind"].as_str() {
        Some("about") => Ok(serde_json::json!({ "version": lumi::about().version }).to_string()),
        other => Err(format!("{window} has no {other:?} request")),
    }
}

What the strings mean is between your extension and its own page — JSON by convention, but Lumi only carries them.

About and Settings pages

Every extension has a page under Settings → Extensions, with About, Settings and Permissions tabs. Lumi fills the About tab from the manifest, and the Settings tab from your settings, unless you ship pages of your own: put them under ui/ and name them with about = "about.html" and settings-page = "settings.html".

Your page is drawn inside the pane, in place of what Lumi would draw there. Everything around it stays Lumi's — the header, the switch, Uninstall and the Permissions tab — so what your extension reaches is always shown in Lumi's words, not yours.

It looks like Lumi unless you say otherwise. Lumi puts its own stylesheet first in every page it embeds — its fonts, its colours for both themes, and its text fields, pop-up menus, buttons, labels and focus rings — so a page with no CSS of its own already matches the tab around it. Every rule in that sheet has no specificity at all, so anything you write wins, even a bare button { … }, with no !important. Change one colour by overriding its token:

:root {
  --accent: #7c5cf1;
}

The page is drawn on a transparent background with nothing around it, and Lumi's sheet gives body no background and no inset, so the page sits on the pane the way Lumi's own tabs do. If you add an inset or draw something outside a control, remember the page's box ends at its edge.

A window can use the same sheet by linking it:

<link rel="stylesheet" href="/__lumi__/lumi.css">

A page follows the rules of a window, with three differences:

  • It is told the theme. The page opens with ?theme=light or ?theme=dark, so it can match the pane on its first paint. It is built fresh each time the person opens its tab, and again when they switch theme, so it never has to follow a change itself — and cannot keep anything in the page between visits.
  • Its calls are named. A POST /__lumi__/call reaches your run_ui with :about, :settings or :page:<name> as the window's name. The colon is outside every window name, so your code can always tell which page is asking.
  • It calls only while it is in front. A page can stay loaded after it leaves the screen — under a sheet, or in a Settings window that has been closed — so a call is answered only while the page is showing and the Settings window is the active one. Otherwise it gets a 403 saying why. A press on your own button always qualifies; a timer that keeps calling in the background does not. Call when the page loads or when it is pressed, and handle the 403 quietly.

A settings page saves through PUT /__lumi__/settings, so your [[settings]] still decide what can be stored — anything they do not declare is dropped.

A page appears only while the extension is switched on. While it is off, Lumi's own content stands in and says so.

Lumi also marks an embedded page before its first paint: the root element carries data-lumi-embedded, and data-theme="light" or "dark". One page can serve both a window and a tab by keying on them — the sample drops its window's padding and title that way:

:root:not([data-lumi-embedded]) body { padding: 20px 24px; }
:root[data-lumi-embedded] h1 { display: none; }
fn run_ui(window: String, request: String) -> Result<String, String> {
    match window.as_str() {
        "settings" | ":settings" => settings_request(&request),
        ":about" => about_request(&request),
        ":page:keytest" => keytest_request(&request),
        other => Err(format!("no {other} window")),
    }
}

Tabs of your own

About and Settings replace a tab Lumi would draw anyway. A [[page]] adds a tab that is yours alone — a keyboard tester, a dashboard, a log — between About and Settings:

[[page]]
name = "keytest"
label = "Keyboard test"
path = "keytest.html"

It is the same kind of page as About and Settings: the same stylesheet, the same ?theme=, the same bridge, and the same rule that it calls only while it is in front. Two things differ:

  • It is named :page:<name> when it calls your run_ui. The page: keeps it apart from :about and :settings, and from any page Lumi adds later.

  • It can ask for the keyboard. A page never has focus until it is clicked, so keys typed right after opening the tab go to Lumi. With focus = true Lumi hands the page the keyboard when the tab opens — only then, and only while the Settings window is in front, so a page the person clicked away from stays that way.

  • It can hear fn on its own. A web page is never sent fn / Globe pressed alone — macOS reports it as a change of modifiers, not a key, and WebKit makes no keydown of it. With fn-key = true, Lumi hears it for the page and dispatches a lumi:fn event on its window each time fn goes down or up:

    window.addEventListener("lumi:fn", (event) => {
      console.log(event.detail.down ? "fn down" : "fn up");
    });

    Only while the page is on screen and the Settings window is in front — the same moments it can call — and only with Lumi's Accessibility permission, which its keyboard features already need. Without it, or on a Lumi older than 1.24.0, the event never comes, so treat it as a bonus.

  • It can take F1–F12 from macOS. Some F-keys are system shortcuts — F11 is Show Desktop — and pressing one sends the page nothing and moves its window aside. With function-keys = true, Lumi takes F1–F12 while the page is on screen, the Settings window is in front and Lumi is the active application, and sends each as a lumi:key event instead:

    window.addEventListener("lumi:key", (event) => {
      const { code, down } = event.detail; // "F11", true
    });

    Taken means taken: in those moments Show Desktop, and any shortcut bound to an F-key, does not fire. The moment the Settings window is not in front, every F-key is the system's again. Media keys are untouched — macOS sends brightness and volume as another kind of event — so only an F-key pressed as one (with fn, or with "Use F1, F2… as standard function keys" on) is taken. Like fn-key, it needs Lumi's Accessibility permission and Lumi 1.24.0 or later.

  • Nothing stands in while the extension is off. Lumi has no content of its own for your tab, so it says the extension is off instead.

A tab that wants to remember something — the layout the person picked, the last result — saves it through PUT /__lumi__/settings like any page, so the field has to be declared in [[settings]]. If it is not something the person should set by hand, add settings-tab = false to [extension] and Lumi keeps the field without drawing a Settings tab for it.

The label cannot be About, Settings or Permissions: Permissions is where Lumi shows what your extension reaches, in its own words, and a second tab by that name would be yours.

An installer of your own

An extension can ask its questions before it is installed rather than after — the language to translate into, an account to connect to, whatever the first run needs. Ship a page under ui/ and name it in the manifest's [install] table.

Installing then takes two steps:

  1. Lumi's review comes first, always. The sheet still says what the package reaches and who signed it — that is never your page's to say. Its button reads Continue… instead of Install.
  2. Your installer opens in a window of its own. Nothing is installed yet. Your page asks, and your Install button installs the extension with the answers already saved. Your Cancel button — or closing the window — abandons the install, and nothing is written at all.

The installer is shown only on a first install. An update keeps the settings the person already chose and installs in one press, with no window.

What the installer can reach

Less than a window, because nothing is installed yet: your extension's code has not been installed, so there is no call. The page gets its own files, Lumi's stylesheet, and these addresses:

RequestDoes
GET /__lumi__/install{ id, name, version, settings, layout, values } — your [[settings]] as declared (labels, kinds, options, defaults), their layout, and what each one holds right now.
GET /__lumi__/settingsWhat each setting holds right now: your answer so far, or its default.
PUT /__lumi__/settingsHolds answers until Install. Send only what changed; undeclared fields are dropped, and a per-profile field is saved for the live profile — exactly as a save after installing would. A refusal comes back as a non-2xx response with a sentence to show.
POST /__lumi__/installInstalls the extension with the answers held. A 2xx answer means it is installed; anything else carries a sentence to show beside the button.
POST /__lumi__/cancelAbandons the install.

Lumi closes the window after Install or Cancel, so your page does not have to.

const info = await (await fetch("/__lumi__/install")).json();

select.addEventListener("change", () =>
  fetch("/__lumi__/settings", {
    method: "PUT",
    body: JSON.stringify({ defaultTarget: select.value }),
  }),
);

installButton.addEventListener("click", async () => {
  const answer = await fetch("/__lumi__/install", { method: "POST" });
  if (!answer.ok) showError(await answer.text());
});

cancelButton.addEventListener("click", () =>
  fetch("/__lumi__/cancel", { method: "POST" }),
);

Draw the controls from settings in the GET /__lumi__/install answer rather than repeating them in the page, and a setting added to the manifest turns up in the installer with no edit to it. The sample's ui/install.js reads its one question's label, options and default that way.

The installer opens with ?mode=install on the address. Its title bar reads your title, with your extension's name added unless the title already holds it. A call there answers 404: the code is not installed yet.

Anything your installer does not ask is simply left at its default, so an installer can ask one question out of ten.

A Welcome window

A window your extension opens on its own the moment it is installed — a thank-you, a first step, a button into its settings. It is an ordinary declared window: declare it in the manifest like any other, and open it from your code when Lumi tells you the extension was installed — on_lifecycle:

[[window]]
name = "welcome"
title = "Welcome"
path = "welcome.html"
width = 420
height = 340
fn on_lifecycle(event: lumi::Lifecycle) -> Result<(), String> {
    match event {
        lumi::Lifecycle::Installed => lumi::open_window("welcome"),
        _ => Ok(()),
    }
}

By the time it opens the extension is installed, so the page has the whole bridge, call included. An uninstall cannot open a window — your files are about to go — so a feedback form belongs on a web page opened with open_url.

On this page

<