# Windows (/extensions/windows)



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](/extensions/manifest#window).

## Opening one [#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`](/extensions/sdk#open_windowname).

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 [#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:

```bash
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.

<Callout type="warn" title="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.
</Callout>

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 [#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 [#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](#about-and-settings-pages) 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.

```js
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" });
```

```rust
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 [#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](/extensions/manifest#extension), and the Settings tab from your
[settings](/extensions/manifest#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:

```css
: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:

```html
<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:

```css
:root:not([data-lumi-embedded]) body { padding: 20px 24px; }
:root[data-lumi-embedded] h1 { display: none; }
```

```rust
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 [#tabs-of-your-own]

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

```toml
[[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:

  ```js
  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:

  ```js
  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-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]`](/extensions/manifest#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 &#x2A;*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 [#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.

```js
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-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`](/extensions/sdk#on_lifecycleevent):

```toml
[[window]]
name = "welcome"
title = "Welcome"
path = "welcome.html"
width = 420
height = 340
```

```rust
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`.
