Skip to content

Build an App

Braden Seaborn edited this page Aug 23, 2026 · 1 revision

Build an App

An app takes three edits and no more. This page adds one with the id notes.

Step File
1. Register it src-tauri/src/apps/mod.rs
2. Write the Rust half src-tauri/src/apps/notes.rs
3. Write the frontend apps/notes/ui/
4. Declare the Vite entry vite.config.ts

Step 4 is the one the build cannot infer. Miss it, and the app compiles, reaches the switcher bar, and mounts a 404.

1. Register it

Add a module line and a registry row in src-tauri/src/apps/mod.rs:

mod design;
mod files;
mod home;
mod notes;
mod trash;
pub mod tutorial;
const REGISTRY: &[Registered] = &[
    // ...
    Registered {
        id: "notes",
        name: "Notes",
        description: "Short notes against the open project.",
        call: notes::call,
    },
];

The row order is the order of the switcher bar. The description reaches the Apps menu. Write one sentence that names what the surface shows.

The registry is compiled in, and not declared in helve.toml. That manifest pins other repositories at versions this orchestrator expects. An app has no version to disagree with. A manifest row for one reports "fine" and nothing else.

2. Write the Rust half

Every app exports one entry point with this shape:

pub fn call(
    app: &AppHandle,
    context: &CallContext,
    method: &str,
    params: Option<Value>,
) -> Result<Value, RpcError>

Match on the method name and return JSON:

pub fn call(
    app: &AppHandle,
    context: &CallContext,
    method: &str,
    params: Option<Value>,
) -> Result<Value, RpcError> {
    match method {
        "notes/list" => list(app, context),
        "notes/write" => write(app, context, params),
        _ => Err(RpcError::new(
            METHOD_NOT_FOUND,
            format!("no such method: {method}"),
        )),
    }
}

Prefix each method with the app id. The Rust Half covers CallContext, error codes, and where state belongs.

3. Write the frontend

Create apps/notes/ui/index.html. Copy the head from apps/home/ui/index.html. The stylesheet link and the two background rules apply before your code runs, which stops the iframe from flashing white inside a dark window.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Notes</title>
    <link rel="stylesheet" href="/src/tokens.css" />
    <style>
      html,
      body {
        background: var(--bg);
      }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./src/main.tsx"></script>
  </body>
</html>

Create apps/notes/ui/src/main.tsx. Take the palette and the app chrome from the shell, root-relative, instead of restating them:

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "/src/tokens.css";
import "/apps/shared/app.css";

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);

No package.json lives under apps/. These are entry points of the orchestrator's own frontend build, and they are not workspace packages.

4. Declare the Vite entry

Add one line to build.rollupOptions.input in vite.config.ts:

input: {
  main: resolve(__dirname, "index.html"),
  splash: resolve(__dirname, "splash.html"),
  home: resolve(__dirname, "apps/home/ui/index.html"),
  files: resolve(__dirname, "apps/files/ui/index.html"),
  viewer: resolve(__dirname, "apps/viewer/ui/index.html"),
  tutorial: resolve(__dirname, "apps/tutorial/ui/index.html"),
  design: resolve(__dirname, "apps/design/ui/index.html"),
  notes: resolve(__dirname, "apps/notes/ui/index.html"),
},

One entry per registry row. An entry's HTML lands in dist/ at the path it holds in the source tree. That one path then answers under the dev server and under the release asset host alike: /apps/notes/ui/index.html.

pnpm typecheck passes without this line. pnpm build is the check that catches the omission, which is why the last run before a commit is the full pnpm verify.

5. Report the first paint

Every app owes the shell one call:

import { reportPainted } from "@helve/bridge";

Call reportPainted() once your first meaningful content reaches the DOM. The splash window stays up until every registered app reports, which makes the first frame after the splash the app itself.

Report the content, and not the fetch that produced it. Home reports once home/state has landed and rendered. File Explorer reports once the tree has rows.

An error state counts. A screen that reports a failed read is finished, and holding the window back makes the bad news slower. An app that never reports is waited on for four seconds, logged, and left behind. Forgetting the call costs a slow launch.

What you get for free

The shell answers the hello handshake, the menu bar, and the switcher tab. The helve/open and helve/publish routes work with no shell edit, because the shell matches an appId against the layout and treats a topic as a map key. Two apps agree on a new message with no edit under src/shell/.

Checklist

  • mod line and registry row in src-tauri/src/apps/mod.rs
  • call entry point that returns METHOD_NOT_FOUND for an unknown method
  • apps/notes/ui/index.html with the token stylesheet in the head
  • Entry in vite.config.ts
  • reportPainted() on first content
  • pnpm verify passes

Clone this wiki locally