---
title: "Open a file from the desktop app"
audience: "anyone whose desktop app needs a file from the user's machine"
description: "You'll add an Import CSV button to the shop from 700. In the desktop window it opens the system file dialog limited to CSV files and the shop imports the products…"
snippet_caption: "app/src/ui/ImportProducts.tsx"
snippet: "import { useEffect, useState } from \"react\";\nimport { desktop } from \"@snapfire/fsr/client\";\n\nexport function ImportProducts() {\n  const [inWindow, setInWindow] = useState(false);\n  const [chosen, setChosen] = useState<string | null>(null);\n  const [problem, setProblem] = useState<string | null>(null);\n\n  useEffect(() => {\n    setInWindow(desktop.isDesktop());\n  }, []);\n\n  async function pick() {\n    setProblem(null);\n    try {\n      const paths = await desktop.openFile({ filters: [{ name: \"CSV\", extensions: [\"csv\"] }] });\n      if (paths) setChosen(paths[0]);\n    } catch (error) {\n      setProblem(error instanceof Error ? error.message : String(error));\n    }\n  }\n\n  async function help() {\n    try {\n      await desktop.openExternal(\"https://example.com/csv-format\");\n    } catch (error) {\n      setProblem(error instanceof Error ? error.message : String(error));\n    }\n  }\n\n  if (!inWindow) {\n    return <p className=\"import-note\">Importing a CSV works in the desktop app.</p>;\n  }\n\n  return (\n    <div className=\"import\">\n      <button type=\"button\" onClick={pick}>\n        Import CSV\n      </button>\n      <button type=\"button\" onClick={help}>\n        CSV format\n      </button>\n      {chosen ? <p className=\"chosen\">{chosen}</p> : null}\n      {problem ? <p className=\"problem\">{problem}</p> : null}\n    </div>\n  );\n}"
---

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

You'll add an Import CSV button to the shop from [700](https://snapfirers.com/fsr/learn/700-open-your-app-in-a-desktop-window). In the desktop window it opens the system file dialog limited to CSV files and the shop imports the products from the file you choose. A second button opens a help page in the system browser. In an ordinary browser tab the same page shows a note instead of the buttons.

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

<Install captured="0.x" />

You need the CLI built with the `desktop` feature, as in 700: `cargo install snapfire_fsr_cli --features desktop`.

## The desktop bridge

A window opened by `fsr serve --desktop` or `fsr dev --desktop` installs a bridge before the page's own scripts run. Page code reaches it through `desktop` from `@snapfire/fsr/client`:

| Call | What it does |
| --- | --- |
| `desktop.isDesktop()` | `true` in a desktop window, `false` in a browser |
| `desktop.openFile(options)` | Opens the system's open dialog and answers the chosen absolute paths. It answers `null` when the user cancels |
| `desktop.openExternal(url)` | Hands an `http`, `https` or `mailto` link to the system's browser or mail app |

`openFile` takes `multiple` to allow several files and `filters` to limit what the dialog shows. A filter is a label and its extensions without the dot.

The calls run in the browser, so a component that makes them is an island.

## Write the import island

```tsx title="app/src/ui/ImportProducts.tsx"
import { useEffect, useState } from "react";
import { desktop } from "@snapfire/fsr/client";

export function ImportProducts() {
  const [inWindow, setInWindow] = useState(false);
  const [chosen, setChosen] = useState<string | null>(null);
  const [problem, setProblem] = useState<string | null>(null);

  useEffect(() => {
    setInWindow(desktop.isDesktop());
  }, []);

  async function pick() {
    setProblem(null);
    try {
      const paths = await desktop.openFile({ filters: [{ name: "CSV", extensions: ["csv"] }] });
      if (paths) setChosen(paths[0]);
    } catch (error) {
      setProblem(error instanceof Error ? error.message : String(error));
    }
  }

  async function help() {
    try {
      await desktop.openExternal("https://example.com/csv-format");
    } catch (error) {
      setProblem(error instanceof Error ? error.message : String(error));
    }
  }

  if (!inWindow) {
    return <p className="import-note">Importing a CSV works in the desktop app.</p>;
  }

  return (
    <div className="import">
      <button type="button" onClick={pick}>
        Import CSV
      </button>
      <button type="button" onClick={help}>
        CSV format
      </button>
      {chosen ? <p className="chosen">{chosen}</p> : null}
      {problem ? <p className="problem">{problem}</p> : null}
    </div>
  );
}
```

`inWindow` starts `false` and the effect sets it once the island is mounted. The server has no window, so it renders the note. The browser then asks the bridge and swaps the note for the buttons when there is one.

Outside a window, both calls reject. The `try` keeps the message on the page instead of an uncaught error in the console:

```text
desktop.openFile runs only in an fsr --desktop window
```

Place the island at the top of the product list:

```tsx title="app/routes/page.tsx" {1,4,10-12}
import { Island } from "@snapfire/fsr/template";
import type { RootProps } from "@generated/client";

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

export default function Products({ products }: RootProps) {
  return (
    <section>
      <h1>Products</h1>
      <Island when="load">
        <ImportProducts />
      </Island>
      <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>
  );
}
```

## Build it

```shell-session
$ fsr build shop/app --verbose
...
          src/ui/ImportProducts.tsx#ImportProducts lowered     
browser   src/ui/ImportProducts.tsx#ImportProducts src/ui/ImportProducts.tsx:16:27: `desktop` comes from `@snapfire/fsr/client`, which the build cannot follow
          src/ui/ImportProducts.tsx#ImportProducts src/ui/ImportProducts.tsx:25:13: `.openExternal()` in a handler; a handler is `const`s, branches, calls to state setters and calls to actions
...
typecheck tsc 7.0.2 from cache, clean
```

The island is `lowered`, so the server writes its first render. The two `browser` rows name the handlers that run only in the browser. That is what you want here: the dialog and the system browser are the window's, never the server's.

## Try it in a browser first

```shell-session
$ fsr serve shop/app
fsr server on http://127.0.0.1:3402/
fsr traces on http://127.0.0.1:3402/__fsr/traces
```

The island's markup is the note:

```html
<sf-i id="sf-i0" data-sf-module="src/ui/ImportProducts.tsx#ImportProducts" data-sf-ssr><p class="import-note">Importing a CSV works in the desktop app.</p></sf-i>
```

Open `http://127.0.0.1:3402/` in a browser. The note stays, since a browser has no bridge.

## Pick a file in the window

Make a CSV to import, anywhere on your machine:

```text title="products.csv"
sku,name,price_cents
CUP-04,Stoneware cup,1200
JAR-05,Glass jar,600
```

Open the window:

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

The window shows the two buttons in place of the note. Click Import CSV and the system's open dialog appears, showing CSV files only. Choose `products.csv` and its absolute path appears under the buttons. Cancel and `openFile` answers `null`, so nothing changes. Click CSV format and the link opens in your default browser. `openExternal` refuses any link that is not `http`, `https` or `mailto`.

## Read the file

`openFile` answers paths, not contents. The page runs in the webview, which can't read a path on the disk, so the island hands the path to an action. The action runs in the host the window started, on your machine, so it reads the file with `file` from `@snapfire/fsr/host`.

The imported products are kept in the session. Add them to the session schema:

```ts title="app/schemas/session.ts"
import type { Product } from "@src/products";

export interface Session {
  imported: Product[];
}

export const defaults: Session = {
  imported: [],
};
```

Declare the action's input:

```ts title="app/schemas/products.ts"
export interface ImportProducts {
  path: string;
}
```

The action reads the file, skips the header row and turns each line into a product:

```ts title="app/routes/actions.ts"
import { action } from "@snapfire/fsr/host";
import type { ActionCtx } from "@snapfire/fsr/host";
import { file } from "@snapfire/fsr/host";
import type { ImportProducts } from "@schemas/products";

export const importProducts = action(async ({ input, session }: ActionCtx<ImportProducts>) => {
  const [, ...rows] = file.read(input.path).trim().split("\n");
  session.imported = rows.map((row) => {
    const [sku, name, price] = row.split(",");
    return { sku, name, price_cents: Number(price) };
  });
  return { count: session.imported.length };
});
```

The root loader lists the imported products after the built-in ones:

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

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

export async function load({ session }: Ctx<"/">) {
  return { products: [...products, ...session.imported] };
}

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

## Call the action from the island

`pick` now hands the chosen path to the action. A successful call revalidates the route, so the loader runs again and the list shows the new rows:

```tsx title="app/src/ui/ImportProducts.tsx" {3,18-20}
import { useEffect, useState } from "react";
import { desktop } from "@snapfire/fsr/client";
import { actions } from "@generated/client";

export function ImportProducts() {
  const [inWindow, setInWindow] = useState(false);
  const [chosen, setChosen] = useState<string | null>(null);
  const [problem, setProblem] = useState<string | null>(null);

  useEffect(() => {
    setInWindow(desktop.isDesktop());
  }, []);

  async function pick() {
    setProblem(null);
    try {
      const paths = await desktop.openFile({ filters: [{ name: "CSV", extensions: ["csv"] }] });
      if (!paths) return;
      const { count } = await actions.$root.importProducts({ path: paths[0] });
      setChosen(`${count} products from ${paths[0]}`);
    } catch (error) {
      setProblem(error instanceof Error ? error.message : String(error));
    }
  }
```

The rest of the island is the same as before.

## Import it

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

Click Import CSV and choose `products.csv`. The line under the buttons reads `2 products from` followed by the file's path. The list grows to five:

```text
Products
Enamel mug $14.00
Loose leaf tea $9.00
Cast iron pan $48.00
Stoneware cup $12.00
Glass jar $6.00
```

The import is in the session, so it is still there after a reload of the window. Import another file and its rows replace the ones before.

Next up: [500. Move a loader into Rust](https://snapfirers.com/fsr/learn/500-move-a-loader-into-rust).
