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:
| Request | Does |
|---|---|
GET /__lumi__/settings | Your settings as a JSON object — the same one lumi::settings() reads. |
PUT /__lumi__/settings | Saves. 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__/call | Any 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.css | Lumi'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:
| Status | Means |
|---|---|
200 | A call answered, or settings read — the body is the answer. |
204 | Settings saved. |
400 | A settings save refused; the body is a sentence naming the field. |
403 | An embedded page called while not in front. |
404 | No such address. |
410 | The extension was switched off or removed while the page was open. |
413 | A request body — a call's string or a settings save — over 1 MB. |
502 | Your 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=lightor?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__/callreaches yourrun_uiwith:about,:settingsor: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
callis answered only while the page is showing and the Settings window is the active one. Otherwise it gets a403saying 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 the403quietly.
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 yourrun_ui. Thepage:keeps it apart from:aboutand: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 = trueLumi 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
keydownof it. Withfn-key = true, Lumi hears it for the page and dispatches alumi:fnevent 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 alumi:keyevent 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:
- 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.
- 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:
| Request | Does |
|---|---|
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__/settings | What each setting holds right now: your answer so far, or its default. |
PUT /__lumi__/settings | Holds 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__/install | Installs 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__/cancel | Abandons 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 = 340fn 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.