The tiny SDK for dimOS Desktop apps.
It's a library: plain ES modules for an app's page (and, optionally, its Deno backend), with no build step. Import it by URL, or vendor it into the app. It decodes dimos streams, follows Desktop's theme, and talks to Desktop: notifications, the terminal tool, errors for Desktop's agent, insets, opening other apps.
Your app keeps its own server, in any language. dim-app also has an optional ready-made one, serve.js, for an app whose backend is a Deno module: see Optional: dim-app's own server.
Layout: mod.js re-exports the whole API (import { DimApp, initTheme } from "./dim-app/mod.js"); the files
are in source/, their tests in tests/ (deno test -A), and tools/vendor.js
vendors them into an app.
Every app has a dimos.yaml (Desktop's docs/apps.md has the full spec). uses: lists everything the app calls outside
itself (each gateway's paths relative to it, e.g. "@desktop-gateway" under /gateways/desktop/), and
Desktop refuses undeclared calls (403 at runtime). dim-app's own dimos.yaml lists what each helper
calls, so copy the lines for the helpers you use: GET /msgs.js for DimApp, GET /history/recordings for
recordingsDir() (+ "@zenoh-gateway" for watchRecordingsDir()), GET /config (+ "@zenoh-gateway" to watch it)
for sharedConfig(),
POST /ui/notifications + GET /info/apps/{pkg}/icon for notify(), and so on.
provides: is an OpenAPI 3.1 document of the app's own API: its x-whens and descriptions are what Desktop's agent and
the agents' skill files read, and x-private lists what only the app's own pages call.
title: My App
spec-version: v1.4
uses:
"@dimos-gateway": # paths relative to /gateways/dimos/
- GET /msgs.js
"@desktop-gateway": # paths relative to /gateways/desktop/
- POST /ui/notifications
"@zenoh-gateway": ">=0.5 <0.6"
zenoh: # what the page may read and write through the zenoh-gateway (without this, nothing)
default_permission: deny
read:
- id: odom
messages: [declare_subscriber, put]
key_exprs: ["dimos/**/geometry_msgs.PoseStamped"]
permission: allow
provides:
openapi: 3.1.0
info:
title: My App
version: 1.0.0
description: "My App: what it is, in a sentence or two"
x-when: you need to do the thing My App does
x-private:
- api/internal/*
paths:
/api/status:
get:
description: What it's doing now. Returns {state, since}.
x-when: you need to know what My App is doingThe dimos gateway (reached through Desktop) serves a codec generated from the installed dimos's own message types:
GET /gateways/dimos/msgs.js. DimApp requires you to point at it, and the
app must declare it under uses: in its dimos.yaml ("@dimos-gateway": [GET /msgs.js], as above).
import { DimApp } from "./dim-app/mod.js"
// relative to the page (served at /apps/<name>/), so ../../ is Desktop's root
const app = new DimApp({ msgDecodeEndpoint: "../../gateways/dimos/msgs.js" })
app.subscribe("odom", (odom) => console.log(odom.pose.pose.position)) // already decodedThere is no default: an app without msgDecodeEndpoint throws, so every app states the endpoint it depends on.
The dimos gateway is the one thing that knows: GET /gateways/dimos/history/recordings answers {"path": "<dir>"}, and
every launch is handed that same folder (--record-dir), so what it says and what a run writes to can't drift apart.
recordings.js:
import { recordingsDir, watchRecordingsDir } from "./dim-app/mod.js"
const dir = await recordingsDir() // "/home/you/.dimos/recordings"
const same = await recordingsDir({ origin: dimContext().desktopUrl }) // from a Deno backend: it has no page URL
const stop = watchRecordingsDir((dir) => { where = dir }) // the folder now, and again every time it movesThe app must declare the call under uses: in its dimos.yaml, and "@zenoh-gateway" as well if it watches, or
Desktop refuses them with a 403:
uses:
"@dimos-gateway":
- GET /history/recordings
"@zenoh-gateway": ">=0.5.1" # watchRecordingsDir onlyThe folder can move while an app runs, so the listening half is built in rather than something each app wires itself:
watchRecordingsDir subscribes to the one topic the gateway busts for this setting
(<ns>/dimos/events/config/recordingsDir) and re-asks this same endpoint, so there is still one answer to where
recordings are. eager is the choice of when: eager (the default) re-asks at once and hands the callback the new
folder; { eager: false } calls back with null and leaves the round trip to the next recordingsDir(), which an app
that has navigated away from the folder never makes.
It asks once per Desktop and keeps the answer for the life of the page (or the process), so calling it wherever you need
the folder is free; a failed ask isn't kept, and { fresh: true } asks again. Unlike the other helpers it throws
when the gateway can't be reached or refuses (a 403 says to add the line above) — a folder path is not something to
guess at, and the wrong one writes recordings where nothing looks for them. A page that would rather draw an
emptyState() than fail: await recordingsDir().catch(() => null).
Before v0.24.0 DIMOS_APP carried a recordingsDir as well. Two answers to one question is how they drifted apart (a
recording made from Desktop landed in the dimos checkout while the Recordings app looked somewhere else), so the field
is gone and this is its replacement.
sharedConfig().recordingsDir (below) is that
same resolved directory, so an app already asking for the config doesn't need recordingsDir() too; an app that only
wants the folder keeps this one and doesn't have to declare GET /config.
The machine's dimos settings live in $DIMOS_HOME/config.yaml (~/.dimos/config.yaml), and the dimos gateway is the
one thing that reads it: GET /gateways/dimos/config answers {"path": "<the file>", "recordingsDir": "<dir>"},
resolved exactly the way every dimos process resolves it (dimos.utils.shared_config), so a dimos run in a terminal
and an app agree. Nothing reads the file, and nothing does a blocking HTTP call per read either: the gateway watches the
file and publishes {"type": "config", "setting": "<setting>"} on <ns>/dimos/events/config/<setting> — one topic
per setting, so an app wakes for the one it reads and not for the rest of the file, and a bust signal with no value
in it, so a listener's cache and the event can never say different things. config.js is both
halves:
import { sharedConfig, watchSharedConfig } from "./dim-app/mod.js"
const config = await sharedConfig() // { path: "/home/you/.dimos/config.yaml", recordingsDir: "…" } — cached
const stop = watchSharedConfig((config) => render(config)) // the config now, and again on every change
watchSharedConfig() // no callback: the same subscription, keeping sharedConfig()'s cache honest for the whole app
watchSharedConfig((config) => render(config), { settings: ["recordingsDir"] }) // only that setting's topic
watchSharedConfig(() => invalidate(), { eager: false }) // tells you it changed; the next sharedConfig() is the valueEager or lazy is one option, not two functions. Eager (the default) re-asks the moment the gateway says a setting
moved, so the callback gets the new config and a sharedConfig() elsewhere in the app is already free — that is what
an app showing a setting wants. Lazy marks the kept answer stale and calls back with null instead: the round trip
is paid by the next sharedConfig(), so an app that has stopped looking at the setting never pays it.
The app must declare the read under uses: in its dimos.yaml, and "@zenoh-gateway" as well if it watches, or
Desktop refuses the calls with a 403:
uses:
"@dimos-gateway":
- GET /config
"@zenoh-gateway": ">=0.5.1" # watchSharedConfig onlyA cache nobody watches is never invalidated. With no watchSharedConfig running, sharedConfig() keeps its first
answer until { fresh: true } — the only thing that knows the file changed is the event. The watcher also re-asks after
a reconnect, because events published while the link was down are gone.
The live half is why this is opt-in by that one call: nothing here has a top-level import of
zenoh.js — gateway_base.js, which both helpers share, loads it lazily, so
asking never pulls in the zenoh-gateway client. In a Deno backend (no zenoh-gateway there) subscribing fails, onError
says so, and the cache is left a plain cache; a backend that has to be sure asks with { fresh: true }.
gateway_base.js holds the two things every config-backed answer needs — where Desktop is, and the per-setting bust
subscription — in one place rather than copied into each helper. vendor.js follows a file's imports, so an app that
vendors recordings.js or config.js gets it on the next refresh without naming it.
A path is not something to guess at, so with no answer at all sharedConfig() throws (a 403 says to add the line
above), the same call as recordingsDir() makes for the same kind of answer. Once there is an answer, a failed re-ask
does not take it away: the last one stands, stale, and the call after that asks again, because a gateway blip shouldn't
turn a working app into a broken one. sharedConfig({ fresh: true }) is the strict read that throws instead — use it
right before writing somewhere the config names, where a path that has since moved is worse than no answer.
watchSharedConfig never throws; its failures go to onError (default: console.debug).
If your app's backend is a Deno module, dim-app can be the whole server: serve.js serves the
frontend, runs the backend module in the same process, and bridges the two over a websocket at dim-app/ws, relative to
the app (the request/answer channel for send, popups and sudo). mkDimosApp in this repo's flake builds it for
Desktop (Building it for Desktop). Pushes to pages that aren't SDK calls go over zenoh
(below). The rest of this README works with or without it.
import { DimAppFrontend } from "./dim-app/source/frontend.js"
const app = new DimAppFrontend() // connects to new URL("dim-app/ws", location.href)
app.receiveRequest((kind, payload) => { ... }) // ← backend → us
app.send("setGoal", 350) // → our backendimport { DimAppBackend, dimContext } from "https://raw.githubusercontent.com/dimensionalOS/ddtk/v0.22.1/source/backend.js"
const app = new DimAppBackend()
const ctx = dimContext() // { name, url, path, dataDir, desktopUrl, zenohGatewayUrl, dimosDir, dimosPython, ... }
app.onReceive((kind, payload) => { ... }) // ← a frontend → us
app.send("hello", { n: 1 }) // → all of this app's open frontendsdimContext() is what Desktop passes the app's server in the DIMOS_APP environment variable, a JSON object (Desktop's
docs/apps.md; readDimosApp() from app_env.js reads it outside a backend too). It is the whole interface: Desktop
passes no flags or other variables.
name— the name the app is installed under;path— where Desktop serves it (/apps/<name>/);url— that path on Desktop's loopback origindataDir— the app's own writable folderdimosDir— the dimos checkout Desktop uses;dimosPython(alsopython) — its venv's pythonzenohGatewayUrl— Desktop's zenoh-gateway ;zenohConnect— the zenoh endpoint dimos modules are ondesktopUrl— Desktop's HTTP base URL
Where recordings are kept is not in it (it was, before v0.24.0): ask the dimos gateway, recordingsDir() from
recordings.js — Where recordings are kept.
Both halves carry a VERSION; the frontend sends it on connect so the backend can warn when they differ.
sendBytes(kind, bytes, meta) on either side sends bytes in a binary frame (no base64); the other side's handlers get
(kind, { ...meta, bytes }).
Desktop's rule (its docs/events.md):
backend → frontend is always zenoh, delivered to the browser by Desktop's zenoh-gateway, on one connection
per page; frontend → backend is plain HTTP (POST, PUT, …). No SSE, no websockets, no polling for changes. State
is "snapshot + live": the page GETs it, then applies events (or re-GETs when an event says it changed), and GETs
again when its zenoh-gateway connection comes back.
Keys: an app's frontend topics are <ns>/apps/<name>/frontend/<topic…> (<name> = the install name); Desktop's events
are <ns>/desktop/events/<type>, its jobs <ns>/desktop/jobs/<id>, the dimos server's <ns>/dimos/events/<type>.
Nobody hardcodes <ns>: pages get it from GET ../../gateways/desktop/management/gateways/zenoh?app=<name>, backends from DIMOS_APP
(zenohNamespace, zenohPrefix).
Page: dimos streams, decoded — dim_app.js (start here)
import { DimApp } from "./dim-app/source/dim_app.js"
// the dimos gateway's generated codec; declare GET /msgs.js under uses: "@dimos-gateway" in dimos.yaml
const app = new DimApp({ msgDecodeEndpoint: "../../gateways/dimos/msgs.js" })
app.subscribe("odom", (odom, { key, type, receivedAt }) => draw(odom.pose.pose)) // dimos/odom/*, decoded
app.subscribe("camera/image", show, { type: "sensor_msgs.Image", delivery: "reliable" }) // one type, every frame
const camera = app.subscribe("color_image", show, { maxHz: 10 }) // calling it unsubscribes; it also has .update()
await camera.update({ maxHz: 30, playoutDelay: [100, 400] }) // same channel and video track, no resubscribe
await app.publish("cmd_vel", "geometry_msgs.Twist", { linear: { x: 0.3 } }) // encoded, put on dimos/cmd_vel/<type>
const drive = await app.publisher("cmd_vel", app.msgs.geometry_msgs.Twist) // a steady stream; silent until put()
await drive.setDeadman({}) // the stop value (a zero Twist); armed only once the user drives (needs heartbeatHz)
onPress(() => drive.put({ angular: { z: 0.5 } })) // a drive: sent, and the deadman is armed on the gateway
onRelease(() => drive.stop()) // the zero Twist goes out and the deadman is disarmed: idle is silent againNever actuate without a user action. A page must publish nothing on a command topic (cmd_vel, tele_cmd_vel, …) until
the user presses a drive control, and nothing while idle. publisher() enforces the deadman half of that: it opens no
channel before the first put(), and the deadman (which the gateway publishes when the page's heartbeat lapses, e.g. a
background tab, or the page closes or reloads) is armed only by a put of something other than its stop value, and
disarmed by stop() or a put of the stop value. The rest is the page's: call put() only from a user's input, and send
the stop only after that input (a pointerleave fires on hover too, so guard it with "was driving").
Changing a running subscription. subscribe() returns its unsubscribe function, which also has .unsubscribe() and
.update(changes) (zenoh-gateway ≥ 0.5.1's Subscription.update): the gateway applies the new options to the same
channel and video track, so a quality preset switches without a freeze or a new subscription. It takes maxHz,
minQuality, qualityToHzTradeoff, bandwidthPriority, maxBitrate, minResolutionScale, maxResolution,
playoutDelay and encodeOptions: { quality }; null puts one back to its default (a resolution change starts at a
keyframe) and anything else (delivery, type) is refused. playoutDelay: [minMs, maxMs] (video only, default [0, 0])
lets the browser hold frames that long to smooth out jitter, trading latency for smoothness. zenoh.subscribe() and the
subscribe* helpers below return the same kind of function; when its channel is shared with other subscribers of the
same key and options, update() moves only this subscriber to a channel with the new options.
dimos keys topic <topic> of type <pkg>.<Type> as dimos/<topic>/<pkg>.<Type>, so the type comes from the key. A type
the codec doesn't know arrives as raw bytes (one warning). msgDecodeEndpoint is required (relative to the page or
absolute) and imported once; app.msgs is that module, app.zenoh the page's shared connection below (its .client
is the zenoh-gateway client). Other options go to getZenoh() (e.g. connectOptions: { heartbeatHz: 5 } for deadmen).
Page: ROS 2 messages (CDR) — ros.js
const app = new DimApp({ msgDecodeEndpoint: "../../gateways/dimos/msgs.js", rosDistro: "jazzy" }) // humble, iron, jazzy (default), kilted, lyrical
app.subscribeKey("0/chatter/**", (msg, { type }) => log(msg.data)) // rmw_zenoh: the key names the type
app.subscribeKey("cmd_vel", show, { rosType: "geometry_msgs/msg/Twist" }) // zenoh-bridge-ros2dds: the key doesn't
const odom = await app.ros.decode("nav_msgs/msg/Odometry", bytes) // or encode(type, value) → CDR bytes
await app.ros.define("my_msgs/msg/Battery", "float32 percent\nstd_msgs/Header header") // a non-standard type, from .msg textEvery standard ROS 2 message (std_msgs, geometry_msgs, sensor_msgs, nav_msgs, tf2_msgs, visualization_msgs, …) decodes
with Foxglove's @foxglove/rosmsg2-serialization@3.1.2 and @foxglove/rosmsg-msgs-common@3.3.0 (and
@foxglove/rosmsg@5.0.5 for define()), vendored in source/vendor/ros/ (bundled from npm at those exact versions by tools/vendor_ros.js) and loaded only when a ROS type is first used.
subscribeKey(keyExpr, ...) is subscribe() for any zenoh key: a sample is decoded as ROS CDR when its key has an
rmw_zenoh type chunk (<domain>/<topic>/<pkg>::msg::dds_::<Type>_/<hash>), when its encoding is CDR with a schema
(application/cdr;sensor_msgs/msg/Image), or when the subscription passes rosType (zenoh-bridge-ros2dds keys are
just the topic name); anything else goes through the dimos codec. info.type is then "pkg/msg/Type". Types are spelled
sensor_msgs/msg/Image, sensor_msgs/Image or sensor_msgs::msg::dds_::Image_; bytes carry CDR's 4-byte
encapsulation header, as DDS sends them. rosCodec() is the same codec without a DimApp.
Page: the one connection — zenoh.js
import { getZenoh } from "./dim-app/source/zenoh.js"
const zenoh = getZenoh() // the page's one connection (module singleton); app name from /apps/<name>/ in the URL
zenoh.subscribeFrontend("status", (status) => render(status)) // JSON by default; { parse: "text" | "bytes" }
zenoh.subscribeFrontend("robot/pose", draw, { delivery: "latest" }) // latest-wins (state that replaces itself)
zenoh.subscribeDesktop("apps", reloadApps) // <ns>/desktop/events/apps; "*" = every type
zenoh.subscribeDimos("launch", onLaunch) // <ns>/dimos/events/launch
zenoh.onState((state) => showLink(state)) // connecting | connected | degraded | lost
zenoh.onReconnect(reloadEverything) // back after "lost": events sent meanwhile are gone, re-GET
zenoh.subscribe("dimos/**", { delivery: "latest" }, onMessage) // any raw key, same connection (robot topics)Every subscription returns its unsubscribe. Discovery and the first connect retry with backoff (0.5 s → 10 s), after
which the zenoh-gateway client reconnects by itself and re-opens the subscriptions (zenoh-gateway ≥ 0.5.25: a page
hidden or frozen longer than connectOptions.freshAfterMs, default 2000, starts a fresh connection when it comes back; ≥ 0.5.30: connectOptions.linkBandwidthBytesPerSec, or zenoh.client.setLinkBandwidth(n | undefined) once connected, is a known link speed the gateway uses instead of its estimate; ≥ 0.5.32: every encoded message carries msg.header (stamp, sec, nanosec, frameId, seq) plus msg.encoding and msg.attachment, and subscribe(key, { metadata: false }, ...) opts out). The client is vendored
(source/vendor/zenoh-gateway/, at the commit Desktop's gateway is built from; tools/vendor_zenoh_client.js <commit> re-vendors it), so nothing is fetched from
the network; an app with its own copy passes it: getZenoh({ connect, connectOptions: { heartbeatHz: 10 } }) (the first
call's options win, so make that call early).
Leases (zenoh-gateway ≥ 0.5.16, Desktop ≥ 0.2.185; only an app that may publish gets one): on a thin robot uplink, the newest viewer takes the heavy streams for itself so the others stop pulling them.
const zenoh = getZenoh({ connectOptions: { heartbeatHz: 10, heartbeatMisses: 4 } }) // a lease needs a heartbeat (misses / hz ≤ 60 s)
const lease = await zenoh.lease({
subscribe: ["dimos/**/sensor_msgs.Image"], // other pages' subscriptions here end ("rejected") and are refused
publish: ["dimos/cmd_vel/geometry_msgs.Twist"], // other pages' puts here are dropped
allowTakeover: true, // a newer page's lease ends this one instead of being refused
timeoutMs: 5000, // heartbeat silence that ends it (zenoh-gateway ≥ 0.5.17; lease.timeoutMs is what it applied, null if ignored)
onLost: (reason) => reason === "takenOver" && showTakeBack(), // or "heartbeat", "disconnected", "renewed", …
})Take it before subscribing to its keys (a page without it is refused there). Leasing the same group again renews it
(the old handle is lost with "renewed"). Leases end on disconnect and the client doesn't re-take them; a subscription
refused or ended by another page's lease stays closed, so subscribe again after taking it back.
Page: backend state — backend_state.js, react.js
import { useBackendState } from "./dim-app/source/react.js"
const [recordings, { loading, error, refresh }] = useBackendState("recordings") // GET api/state/recordings
const [library] = useBackendState("api/library?sort=name") // key "library" (the path's last segment)It GETs the snapshot, subscribes to frontend/state/<key>, re-GETs (debounced, 100 ms) when an event's version is
newer than what it has (an event without a version always counts), and re-GETs on reconnect; a failed GET keeps the
last data and sets error. loading is true until the first answer. Without React:
watchBackendState(source, ({ data, loading, error }) => …) → { refresh, stop }.
Page: an app's ordered event stream — events.js
appEvents((event) => …, { onOpen, onClose }) subscribes to the frontend topic events (one key, reliable, so events
arrive in the order they were published); onOpen runs on connect and every reconnect (re-GET there).
Backend: publishing — frontend_publish.js
import { publishFrontend, stateChanged } from "./dim-app/source/frontend_publish.js"
publishFrontend("status", { battery: 0.82 }) // POST <desktopUrl>/gateways/desktop/ui/apps/<name>/frontend/status
publishFrontend("events", { type: "saved", id }) // what the page's appEvents() hears
publishFrontend("map/png", bytes, { contentType: "image/png", ordered: false }) // bytes; unordered = sent at once
stateChanged("recordings") // {key, version} on state/recordings → useBackendState("recordings") re-GETsAny language can do the same with one HTTP call to Desktop's relay, POST /gateways/desktop/ui/apps/<name>/frontend/<topic…> (body as
is, its content-type becomes the sample's encoding; ≤ 1 MiB, events should stay ≤ 64 KiB: put an id in the event and
serve the bytes over HTTP). A backend that links zenoh (Rust, C++, Python) may publish on
DIMOS_APP.zenohPrefix + "/frontend/<topic>" directly. The relay's topic chunks are letters, digits, -, _, ..
publishFrontend sends in order (one at a time), never throws for a failed publish (logs once, resolves to null), and
reads Desktop's URL and the app's name from DIMOS_APP. DimAppBackend's publishEvent(event) is
publishFrontend("events", event).
Insets. On a desktop-width window Desktop's floating dock sits over the bottom of every app. The shell posts how
much ({type: "dimos-inset", top, bottom, left, right}, Desktop's docs/apps.md) and initTheme() keeps
--dim-inset-top, --dim-inset-bottom, --dim-inset-left and --dim-inset-right current on :root (all 0px
outside Desktop and on a phone, where the dock has its own row; initInsets() alone if an app doesn't use the theme).
Anything the user has to reach (drive bars, toolbars, panels, the end of a scrolling list) stays above it;
backgrounds and 3D views can stay full-bleed:
.drive-bar {
bottom: calc(12px + var(--dim-inset-bottom));
}
.side-panel {
bottom: var(--dim-inset-bottom);
}
.list {
padding-bottom: var(--dim-inset-bottom);
}Opening other apps — desktop.js:
import { appInstalled, emptyState, openApp } from "./dim-app/source/desktop.js"
await openApp("launcher", { stream: "cmd_vel" }) // the Launcher, on blueprints that drive a robot
await openApp("dim-controller", { path: "#record" }) // an app, by install name or title, at a path inside it
await openApp("appstore") // built-ins: launcher, appstore, settings, desktop
await appInstalled("dim-controller") // built-ins are always installed; false outside DesktopInside Desktop's shell the app opens in the same window; a page opened on its own under Desktop opens it in a new tab;
outside Desktop openApp resolves to false. For the Launcher, params sets its filters (query, robot, selected,
stream), passed in its link.
First-run / empty / error messages. Every state a first-time user can hit (nothing running, the topic the app
needs missing, no recordings, the backend down, not logged in) gets a message that says what's wrong and a button for
the next step. emptyState() draws it in the theme (.dim-empty; wrap in .dim-empty-layer to center it over a
canvas, above the dock); a button to an app that isn't installed becomes "Install from the App Store":
view.replaceChildren(emptyState({
label: "No blueprint running",
title: "You need to launch a blueprint with a cmd_vel topic before you can control a robot",
body: "No robot? Turn on replay in the Launcher to drive a recorded one.",
actions: [
{ label: "Open the Launcher", app: "launcher", params: { stream: "cmd_vel" } },
{ label: "Record with the Controller", app: "dim-controller", appTitle: "the Controller" },
{ label: "Try again", onClick: retry },
],
}))React: <EmptyState layer title=… actions=… /> and useAppInstalled(id) from react.js.
errors.js sends a page's errors to Desktop's error feed (POST /gateways/desktop/ui/errors, relative to the app:
../../gateways/desktop/ui/errors), where a connected agent sees them (its recent_errors tool; the newest unacknowledged ones are in
desktop_context), so "it broke" comes with the error.
import { captureErrors, reportError } from "./dim-app/source/errors.js"
captureErrors() // uncaught errors + unhandled rejections; DimAppFrontend calls it for you ({ captureErrors: false } opts out)
reportError("Couldn't save the map", error.stack, { level: "error" }) // handled failures worth knowing aboutsource defaults to the app's name (from /apps/<name>/). Reporting never throws and never reports its own failure; it
is throttled (the same message at most once per 10 s, at most 20 a minute). Desktop dedupes repeats into a count.
desktop_events.js: onDesktopEvent(type | "*", callback) → unsubscribe. In a page it is
getZenoh().subscribeDesktop(type, callback) (<ns>/desktop/events/<type>: apps, endpoints, endpoint-stats,
blueprints, runs, recordings, notification, notifications, ui-settings, job, error, launcher, …; see
Desktop's docs/events.md for each payload), on the page's one zenoh-gateway connection; onDimosEvent(type, callback) is
the dimos server's (launch, blueprints, uploads, upload, upload-removed, cloud-login, cloud-account,
config), and onDesktopReconnect(callback) says when to re-GET. In a Deno backend (no zenoh-gateway there) it
still reads Desktop's GET /api/events stream from DIMOS_APP's desktopUrl, which Desktop keeps, deprecated, for one
release.
import { onDesktopEvent } from "./dim-app/source/desktop_events.js"
const off = onDesktopEvent("endpoints", ({ app, added, removed }) => {
console.log(`${app}: +${added.length} -${removed.length} endpoints`)
})notify.js posts to Desktop's POST /gateways/desktop/ui/notifications (an absolute path: apps share Desktop's origin under
/apps/<name>/). Outside Desktop it does nothing and resolves to null; it never throws.
import { lowLevelAlert, notify } from "./dim-app/source/notify.js"
notify({ title: "Map saved", body: "office_2f.pgm", kind: "ok" }) // kind: ok | warn | agent | events
notify({ title: "Robot fell", body: "G1 is down", kind: "warn", sound: "urgent", actions: [["Open", "open_app:g1"]] })
// more than a banner holds: a modal (markdown body, a log, Copy, its own buttons), kept with the notification
notify({
title: "Calibration failed",
body: "camera 2 didn't answer",
kind: "warn",
actions: [["Show details", "modal:details"], ["Retry", "post:/apps/my-app/api/calibrate"]],
modals: { details: { body: "**camera 2** timed out", pre: logTail, copy: true, actions: [["Close", "close"]] } },
})
// once per dip below 20 %, re-armed above 25 %
const battery = lowLevelAlert({
low: 20,
hysteresis: 5,
notification: (pct) => ({ title: "Battery low", body: `${pct}%`, kind: "warn", sound: "battery" }),
})
battery(percent) // on every readingsound is default (vibraphone), urgent (arpeggio) or battery (game-over drop); icon defaults to the app's own
icon. Actions: an app name, open:<app>/<path>, chat, run:<blueprint>, stop, logs:<blueprint>, toast:<text>,
post:<path>[ <json body>], modal:<key> (opens modals[key]) and dismiss; modals (Desktop 0.2.137+, older ones
ignore them) are Desktop's docs/api.md "Notification actions and modals". A Deno backend passes Desktop's URL: notify({ ..., app: "my_app" }, { origin: dimContext().desktopUrl }).
shell.js asks Desktop to run shell commands (POST /gateways/desktop/ui/shell, Desktop's docs/shell.md). Desktop
shows them over the app with a note for each, nothing runs until the user presses Run, they run in one terminal (sudo
asks for the password once), and when one fails the user or Desktop's agent fixes it in that terminal, then retries it,
marks it done or skips it. It resolves when the session ends (succeeded, failed or cancelled, also when nobody
presses Run within timeout); outside Desktop it resolves to { status: "unavailable" } without running anything.
import { runCommand, runShell } from "./dim-app/source/shell.js"
const result = await runShell({
title: "Fix LAN discovery",
message: "A VPN took the route the Go2 probe needs.",
commands: [
{ run: "sudo route -n add -host 231.1.1.1 -interface en0", note: "Send the probe over Wi-Fi" },
{ run: "route -n get 231.1.1.1", note: "Check the route", needsStdout: true }, // stdout/stderr come back apart
],
})
// result.status: succeeded | failed | cancelled; result.commands[i]: { status, exitCode, output, stdout, stderr }
const one = await runCommand("id -u", { title: "Who am I", needsStdout: true }) // { status, exitCode, stdout, ... }needsStdout pipes the command's output through tee, so it sees pipes instead of a terminal (no progress bars); such
a command always runs again after a fix, so its stdout is the real one. A Deno backend passes Desktop's URL and its own
name: runShell({ ..., app: "my_app" }, { origin: dimContext().desktopUrl }). A command can also take cwd and env;
options take onUpdate(session) (each poll) and an AbortSignal signal (cancels the session).
All theme values live in dimOS Desktop: it serves every skin's tokens (the theme contract: --bg --fg --muted-fg --primary --primary-fg --ok --warn --danger --info --surface --raised --input-bg --hover --sel --border --border-strong --hair --radius --radius-sm --radius-lg --radius-xl --radius-pill --shadow --shadow-lg --blur --sans --mono --display --label-case --track-label, and the rest of the names below) as /theme.css, Portal as :root and each skin as
html[data-skin="<id>"]. theme.css here is only the components, written against those tokens.
theme.js links Desktop's stylesheet (an app at /apps/<name>/ reaches it as ../../theme.css) and sets
html[data-skin] and html[data-corners] from what Desktop saved (localStorage["portal.theme"] /
["portal.corners"], Desktop's origin), so an app looks like the Desktop around it in every skin — Vibeslop, Hackerman,
Research, … — and re-themes the moment Desktop changes skin or corners (the storage event); the skin's color-scheme
picks the light or dark structural rules (body.science.dark). Off Desktop, theme.css's one bundled fallback: Portal's
tokens. Apps have no theme switch of their own.
Components: .dim-btn (.primary .ghost .danger .sm .lg .icon .round, .on / aria-pressed),
.dim-input / .dim-select / .dim-textarea, .dim-ask (input + send button), .dim-check (.box / .ring),
.dim-switch (.track), .dim-range, .dim-progress, .dim-tabs + .dim-tab (segmented; .dim-tabs.line for
underline tabs), .dim-chip / .dim-badge (.ok .warn .danger .solid), .dim-dot, .dim-menu +
.dim-menu-item, .dim-tooltip, .dim-card / .dim-panel (.glass), .dim-sheet / .dim-modal (header, footer),
.dim-kv, .dim-table, .dim-alert, .dim-toast, and type: .dim-title (the app's name in its bar), .dim-h1,
.dim-h2, .dim-h3 / .dim-label, .dim-mono. App CSS stays on the variables (--fg, --muted-fg, --card,
--border, --primary, --sel, --radius, --radius-lg, --radius-pill, --sans, --mono, --display, …): a
square skin (panel radius 0, like Portal) also squares every corner, as Portal's doc does.
The four faces are bundled in fonts/ (latin subset, OFL) and declared with font-display: block; initTheme()
starts loading all of them, and themeFontsReady() resolves when they are in. Nothing is fetched from the network.
Apps vendor dim-app (no build step at runtime, works offline) into a dim-app/ folder that mirrors this repo:
dim-app/mod.js (everything) and/or dim-app/source/<file> (one file and what it imports: zenoh.js needs
vendor/zenoh-gateway/, react.js needs backend_state.js and desktop.js, …). vendor.js
refreshes the files the folder already has (and brings any file they import) from the version in its URL, which is how an
app pins a version. A folder from before v0.18.0 (files at its top, dim-app/zenoh.js) is moved into source/, so its
imports become dim-app/source/zenoh.js (or dim-app/mod.js).
deno run -A https://raw.githubusercontent.com/dimensionalOS/ddtk/v0.21.0/tools/vendor.js frontend/src/dim-app --index frontend/index.html--index also keeps first_paint.html in the app's index.html (inserted at the top of <head>,
then replaced in place): Desktop's /theme.css and its saved skin before any CSS or JS loads (off Desktop, Portal's page
color), so an app's first frame is already in Desktop's look. initTheme() also posts
{type: "dimos-ready"} to Desktop once the themed page has painted, and Desktop fades the app's frame in.
import "./dim-app/source/theme.css" // the vendored copy (vendor.js, above)
import { initTheme, onThemeChange, themeColors } from "./dim-app/source/theme.js"
initTheme() // Desktop's /theme.css, <html data-skin data-corners>, <body class="science [dark]">
onThemeChange(() => renderer.setClearColor(themeColors().sceneBg)) // canvases + 3D: --scene-bg, --scene-grid, --cat-1..4Both DimAppFrontend and DimAppBackend instances have:
app.ui.toast("saved")
if (await app.ui.confirm("Delete it?")) { ... }
const name = await app.ui.ask("New name?", { default: "Rex" }) // null if cancelled
await app.ui.askBoolean("Use the Wi-Fi NIC?")
const res = await app.sudo.run(["route", "-n", "add", "-host", "231.1.1.1", "-interface", "en0"])
// res = { exitCode, out, stdout, stderr, cancelled }The frontend draws them (a plain overlay, styled by the page's --bg/--fg/--accent if it has them). A backend's
call is shown on every open frontend of the app and the first answer wins; with none open, it rejects. sudo.run shows
a password prompt with the exact command, then the server runs it with sudo -S; Cancel resolves with
cancelled: true.
Desktop builds every app with nix build .#dimosApp. For an app using dim-app's own server, this repo's flake makes
that output (an app with its own server writes its own dimosApp):
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
inputs.dim-app.url = "github:dimensionalOS/ddtk/v0.22.3";
outputs = { self, nixpkgs, dim-app }: {
packages = dim-app.lib.forAllSystems nixpkgs (pkgs: {
dimosApp = dim-app.lib.mkDimosApp {
inherit pkgs;
src = self;
frontend = "dim/apps/my_app/frontend";
backend = "dim/apps/my_app/main.js"; # optional: without it the frontend is served as a static app
};
});
};
}Pin the flake input and the import URLs to the same version. To run an app's server by hand:
DIMOS_APP='{"version":1,"name":"my-app","socket":"/tmp/my-app.sock"}' \
deno run -A source/serve.js --frontend dim/apps/my_app/frontend --backend dim/apps/my_app/main.jsLicensed under the Apache License, Version 2.0.