Tutorial 53 of 54 · for anyone who wants their FSR app as a desktop app · Markdown

Open your app in a desktop window

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.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Install the desktop build#

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

$ 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:

$ 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:

$ 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:

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:

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

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

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:

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:

$ fsr check shop/app

It prints nothing when the configuration loads.

Open the window#

Build the app, then serve it in a window:

$ 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:

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

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

$ 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:

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:

$ 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:

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

export async function load(_ctx: Ctx<"/orders">) {
  return { count: 0 };
}
app/routes/orders/page.tsx
import type { OrdersProps } from "@generated/client";

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

Build and ask the doctor:

$ 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.