Developing Desk Apps
A desk app is a static web bundle that the Monitor panel runs in a window,
inside a sandboxed frame. It is packaged as a .fsba file, which the
Monitor’s owner installs and approves (see Desk Apps).
The full contract, including the message protocol, is the
Desk App Reference.
| Kind | What it contains | What it can reach |
|---|---|---|
web |
A static bundle (HTML, JavaScript, CSS) | The desk, through serverbox-desk |
wasm |
A web bundle and backend.wasm, a WebAssembly component the Monitor runs |
The desk, and its backend’s service through desk.call() and desk.job() |
Start from the template
Section titled “Start from the template”The template in
monitor/sdk/template
uses vite and the serverbox-desk
package (Apache-2.0, so an app built on it keeps its own license, closed source
included). Copy the directory, then point the dependency at the published
package:
npm install serverbox-desknpm run pack # builds dist/ and writes <publisher>.<name>.fsbaInside this repository the template depends on file:../serverbox-desk, which
needs npm run build --prefix ../serverbox-desk once.
The bundle runs with no network access: everything it needs must be inside the
package, and its URLs must be relative (base: './' in vite).
Talk to the desk
Section titled “Talk to the desk”import { connect } from 'serverbox-desk'
const desk = await connect()await desk.toolbar({ actions: [{ id: 'refresh', label: 'Refresh', icon: 'refresh' }] })desk.on('action', ({ id }) => { if (id === 'refresh') void load()})const count = (await desk.storage.get<number>('count')) ?? 0await desk.storage.set('count', count + 1)connect() resolves once the desk has handed the frame its channel. The
returned object offers:
| Area | Calls |
|---|---|
| Window | setTitle, setAppName, setIcon, setBadge, setAppState (JSON up to 16 KiB that the window comes back with; never secrets), close |
| Chrome | toolbar({ title, subtitle, actions }), menus([...]); the desk draws them and sends an action event when one is used |
| Notifications | notify({ title, body, level }), with the notifications permission |
| Storage | storage.get/set/remove/keys: JSON per app, kept on the Monitor (256 KiB per app) |
| Other apps | open(appId, { path }) opens another app, optionally with a file or folder; handlers(path, kind) lists the apps that can open it |
| Background | keepAlive(reason), with the background permission |
| Backend | call(fn, args, { sudo }), job(fn, args, { sudo, onLine }), jobView, follow, cancelJob; wasm apps only |
| Links and power | openUrl(url) opens an http(s) page in a tab (a link in the frame cannot); power('reboot') shows the desk’s own restart dialog, with the root permission |
| Words and numbers | translator(dictionaries, locale) (the panel’s {value} and {{one|other}} syntax), formatBytes, formatTime |
Events (desk.on): lifecycle (active, visible, background,
suspended), theme, locale, intent, action. desk.info holds what the
desk answered when connecting: the app and window ids, the saved appState,
the locale and the granted permissions. A refused call throws DeskError.
Look like the desk
Section titled “Look like the desk”connect() loads the desk’s stylesheet into the page, keeps the page in the
desk’s light or dark mode, and applies the tokens of the theme the desk
uses. Build the UI with its classes and tokens instead of your own colors:
<div class="lk-card"> <button class="lk-btn lk-btn--primary"> <span class="lk-icon" data-icon="play_arrow"></span>Run </button></div>Buttons (lk-btn with --primary, --secondary, --tinted, --ghost,
--destructive), icon buttons (lk-iconbtn), fields (lk-input,
lk-select), switches (lk-switch), cards (lk-card), badges, tables
(lk-table), settings groups (lk-setgroup, lk-setrow) and icons
(lk-icon with a Material Symbols Rounded name) are listed in the
UI section of the reference.
The stylesheet is served by the Monitor and is not in the package, so the app
looks right only inside the panel. connect({ style: false }) leaves the page
alone.
Manifest
Section titled “Manifest”manifest.json sits at the root of the package. The Monitor refuses unknown
fields.
{ "publisher": "acme", "name": "notes", "version": "1.0.0", "api": 1, "kind": "web", "title": { "en": "Notes", "zh-CN": "笔记" }, "glyph": "sticky_note_2", "tone": "amber", "permissions": ["notifications"], "size": { "width": 640, "height": 440 }}| Field | Meaning |
|---|---|
publisher, name |
Each [a-z][a-z0-9_]*, at most 24 characters, a publisher at least 2. The app’s id is <publisher>.<name>; the panel’s own serverbox publisher is refused |
version |
Up to 32 characters |
api |
2 (1 is still accepted for web) |
kind |
web or wasm |
title |
A string, or translations by locale with en required; up to 64 characters |
glyph |
A Material Symbols Rounded name, such as sticky_note_2 |
tone |
berry, soft, ink, sky, teal, violet, amber, leaf, pale, bright or mist |
permissions |
See Permissions. A web app may ask for notifications and background; a wasm app also for status, process and root |
instances |
How many windows may be open, 1 to 8 (default 1) |
size, min_size |
{ "width", "height" }: the first window’s size and the smallest allowed |
opens |
{ "ext": ["md", "txt"], "dirs": false }: the files ("*" for any) and whether folders the app offers to open in Files’ “Open with” |
keywords |
Extra words Spotlight matches |
os |
The systems the app is for, by /etc/os-release’s ID (["ubuntu"]); the Monitor’s catalog offers it only there. Any when absent |
Package
Section titled “Package”A .fsba is a gzipped tar of manifest.json, ui/ (the bundle, with
ui/index.html as the entry), backend.wasm (present exactly when the kind is
wasm), and optionally LICENSE, NOTICE, README.md and SIGNATURE; nothing else is
accepted. ServerBox signs its own packages; one without a signature the Monitor knows is
shown as from an unknown publisher. The Monitor accepts regular files only, with plain relative paths,
at most 2000 files and 20 MiB compressed and unpacked. On macOS set
COPYFILE_DISABLE=1 for tar, or it adds ._ files that the Monitor refuses;
the template’s npm run pack does this.
A backend (wasm)
Section titled “A backend (wasm)”backend.wasm is a WebAssembly component (wasm32-wasip2). Its world
includes sbm:app/imports
(monitor/wit/app.wit)
and exports the app’s own service, written in WIT as a gRPC service is in
protobuf: api, functions the frame calls and waits for, and jobs, longer
work the Monitor runs as the app’s job (one at a time), its lines streamed to
the frame. Every function returns result<T, error>; error holds gRPC’s
canonical codes, which the frame gets as DeskError with the app’s words in
detail.
package acme:notes;
interface api { use sbm:app/types@0.1.0.{error}; record note { title: string, words: u32 } list: func(folder: string) -> result<list<note>, error>;}
world notes { include sbm:app/imports@0.1.0; export api;}const notes = await desk.call<Note[]>('list', { folder: 'work' })Parameters go as one object by name and values cross as JSON: names in
camelCase, a variant as { tag, val }, an option as null or its value, a
result as { ok } or { err }. The Monitor checks the component when it is
installed and refuses one whose functions take or answer resources or
streams.
The Monitor runs it with wasmtime, in a fresh instance for each call or job;
what an app keeps goes through kv. It has no files, network or environment
of its own. Host functions:
| Function | Needs |
|---|---|
log |
— |
kv-get, kv-set, kv-remove |
— (the calling account’s app storage, JSON texts) |
status |
status |
exec |
process: a script as the Monitor’s system account |
exec-root |
root, and the sudo password the desk asked for with this call or job |
job.line, job.run-root |
In a job: a line of its log; a script as root, its output streamed to the log |
To run as root, the frame asks with sudo: { title, body, confirmWord? };
the desk shows its own dialog with that text under the app’s name, and sends
sudo’s password to the Monitor with the request. Each call has 64 MiB of memory,
10 seconds of its own CPU time and 60 seconds in all; a job runs until it
returns or is cancelled. process and root follow the Monitor’s transport
rule: HTTPS or a caller on the same host, unless [remote_access] allow_insecure.
monitor/examples/desk-app-uptime
is a small example in Rust (wit-bindgen), and
monitor/apps/cleanup
a complete one with jobs and root:
cargo build --release --target wasm32-wasip2cp target/wasm32-wasip2/release/desk_app_uptime.wasm backend.wasmCOPYFILE_DISABLE=1 tar -czf example.uptime.fsba manifest.json backend.wasm uimonitor/examples/desk-app-hello
is a web app without a build step.
Test it
Section titled “Test it”Install the package in Settings → Apps → Installed and approve it. Each new package needs approval again, so a test cycle is: pack, install, approve, open. Remove the app to clear what it stored.