---
title: "Open your app in a desktop window"
audience: "anyone who wants their FSR app as a desktop app"
description: "You'll open an ordinary FSR app in a native window, name the window and choose how the window reaches the host. The pages, loaders and islands are the…"
snippet_caption: "config/app.toml"
snippet: "[desktop]\nname = \"Shop\"\nserve = \"scheme\"\ncookies = \"disk\""
---

import { Install } from "@src/ui/Install";

You'll open an ordinary FSR app in a native window, name the window and choose how the window reaches the host. The pages, loaders and islands are the ones a browser gets. The window is the system webview: WebKit on macOS and Linux, WebView2 on Windows.

> Desktop mode is experimental. It has been tried on macOS only. The Windows and Linux code is written but has not been built or run. Packaging and signing do not exist yet, so there is no app bundle to hand to someone else.

<Install captured="0.x" />

## Install the desktop build

The window is behind the `desktop` feature. Install the CLI with it:

```shell-session
$ cargo install snapfire_fsr_cli --features desktop
```

Without the feature, `fsr serve` and `fsr dev` have no `--desktop` flag. With it, `fsr serve --help` lists it:

```shell-session
$ fsr serve --help
Serves an application that is already built

Usage: fsr serve [OPTIONS] <APP_DIR>

Arguments:
  <APP_DIR>  The application directory

Options:
      --listen <LISTEN>    The address to listen on, over the configured one
      --mount <NAME=PATH>  Mounts a site from a path for this run, replacing the table's row of that name; repeatable
      --verbose            Prints the whole boot report rather than only what needs attention
      --desktop            Opens the application in a native window over the system webview, on an ephemeral loopback port unless --listen names one
  -h, --help               Print help
```

## Build a small shop

Scaffold an app with React:

```shell-session
$ fsr new shop --with react
...
next      fsr dev shop/app
```

Give it a product list and an about page. The products are a module constant:

```ts title="app/src/products.ts"
export interface Product {
  sku: string;
  name: string;
  price_cents: number;
}

export const products: Product[] = [
  { sku: "MUG-01", name: "Enamel mug", price_cents: 1400 },
  { sku: "TEA-02", name: "Loose leaf tea", price_cents: 900 },
  { sku: "PAN-03", name: "Cast iron pan", price_cents: 4800 },
];
```

The root loader returns them and titles the page with `meta`:

```ts title="app/routes/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";

import { products } from "@src/products";

export async function load(_ctx: Ctx<"/">) {
  return { products };
}

export const meta = () => ({ title: "Products" });
```

```tsx title="app/routes/page.tsx"
import type { RootProps } from "@generated/client";

export default function Products({ products }: RootProps) {
  return (
    <section>
      <h1>Products</h1>
      <ul className="products">
        {products.map((product) => (
          <li key={product.sku}>
            {product.name} <span className="price">${(product.price_cents / 100).toFixed(2)}</span>
          </li>
        ))}
      </ul>
    </section>
  );
}
```

The about page has a title of its own:

```ts title="app/routes/about/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";

export async function load(_ctx: Ctx<"/about">) {
  return {};
}

export const meta = () => ({ title: "About" });
```

```tsx title="app/routes/about/page.tsx"
export default function About() {
  return (
    <section>
      <h1>About</h1>
      <p>A small shop, run from a desktop window.</p>
    </section>
  );
}
```

Link the two from the layout:

```tsx title="app/routes/layout.tsx"
import type { Children } from "@snapfire/fsr/template";
import { Link } from "@snapfire/fsr/components";

export default function Layout({ children }: { children: Children }) {
  return (
    <div className="shell">
      <header className="bar">
        <Link href="/" className="brand">
          Shop
        </Link>
        <Link href="/about">About</Link>
      </header>
      <main className="content">{children}</main>
    </div>
  );
}
```

Give the app a fixed port with `[server] listen = "127.0.0.1:3402"` in `config/app.toml`, then run it in a browser first with `fsr build shop/app` and `fsr serve shop/app`. Open `http://127.0.0.1:3402/` and the tab reads `Products`. Click About and it reads `About`. That is the same app the window will show.

## Name the window

Add a `[desktop]` section to the configuration:

```toml title="config/app.toml"
[document]
title = "Shop"

[desktop]
name = "Shop"
```

The window's title is the name, a dash and the page's `<title>`. On the root it reads `Shop - Products`. On `/about` it reads `Shop - About`. A page whose title is empty or is the name itself gets the name alone, `Shop`. Without `name`, the window takes the project directory's name.

`fsr check` reads the section along with the rest of the configuration:

```shell-session
$ fsr check shop/app
```

It prints nothing when the configuration loads.

## Open the window

Build the app, then serve it in a window:

```shell-session
$ fsr build shop/app
$ fsr serve shop/app --desktop
```

`fsr serve --desktop` binds an ephemeral port on `127.0.0.1`, opens the window once the port answers and exits when you close the window. Pass `--listen` to pick the port yourself:

```shell-session
$ fsr serve shop/app --desktop --listen 127.0.0.1:8080
```

While you work on the app, use the dev loop instead:

```shell-session
$ fsr dev shop/app --desktop
```

The dev loop owns the window, so the window stays open across rebuilds and server restarts. Save a page and the window reloads it the way a browser tab does. The window opens on `[server] listen`, so that needs a fixed port. Closing the window stops the loop the way Ctrl-C does.

On macOS the window has a menu bar. The `Shop` menu holds Quit (⌘Q). Edit holds undo, redo and the clipboard. History holds Back (⌘[), Forward (⌘]) and Reload (⌘R). A two-finger swipe goes back and forward.

## Serve the window without a port

By default the window loads the host over loopback, so any program on the machine can reach that port too. `serve = "scheme"` serves the window from `app://localhost` through the host in the same process instead, with no port at all:

```toml title="config/app.toml"
[desktop]
name = "Shop"
serve = "scheme"
cookies = "disk"
```

The origin is the same on every launch, so the page's storage survives a restart. `serve` takes `loopback` or `scheme`. Any other value stops the build:

```shell-session
$ fsr check shop/app
the configuration does not load: shop/config/app.toml: desktop: unknown variant `window`, expected `loopback` or `scheme`
```

The webview takes each scheme response whole and keeps none of its cookies, which costs you a few things:

- A route behind `loading.tsx` arrives whole once its slowest loader finishes.
- Live topics and sockets do not connect.
- `fsr dev --desktop` stays on loopback whatever `serve` says, so development refresh keeps working.
- FSR keeps the cookies for the window itself. With the default they last the run. With `cookies = "disk"` they are kept in `~/Library/Application Support/Shop/cookies.json` on macOS, readable by you alone.

There is no port under the scheme, so `fsr serve shop/app --desktop` refuses `--listen` with it.

## Find what the scheme would slow down

`fsr doctor` reports a route that streams under a scheme window. Add a route with a loading boundary:

```ts title="app/routes/orders/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";

export async function load(_ctx: Ctx<"/orders">) {
  return { count: 0 };
}
```

```tsx title="app/routes/orders/page.tsx"
import type { OrdersProps } from "@generated/client";

export default function Orders({ count }: OrdersProps) {
  return <p>{count} orders</p>;
}
```

```tsx title="app/routes/orders/loading.tsx"
export default function Loading() {
  return <p aria-busy="true">Loading orders</p>;
}
```

Build and ask the doctor:

```shell-session
$ fsr build shop/app
$ fsr doctor shop/app
desktop      `[desktop] serve = "scheme"` with `/orders` behind a loading boundary
             a scheme response arrives whole, so the window shows nothing of these until their loaders finish, and live topics and sockets do not connect. Serve the window over loopback, the default, to keep streaming
doctor       1 of 22 checks found something
```

Remove `serve = "scheme"` to keep streaming. Keep it when no port matters more to you. Without the `orders` route the doctor reports `22 checks, nothing to report`.

Next up: [710. Open a file from the desktop app](https://snapfirers.com/fsr/learn/710-open-a-file-from-the-desktop-app).
