Skip to content
dimensionalOSPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

74 Commits

Folders and files

Repository files navigation

dim-app

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.

Your app's dimos.yaml

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 doing

Decoding dimos messages: the Desktop's codec endpoint

The 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 decoded

There is no default: an app without msgDecodeEndpoint throws, so every app states the endpoint it depends on.

Where recordings are kept: ask the dimos gateway

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 moves

The 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 only

The 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 shared dimos config: ask the gateway, and it says when it changed

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 value

Eager 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 only

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

Optional: dim-app's own server

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.

Frontend (browser)

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 backend

Backend (Deno)

import { 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 frontends

dimContext() 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 origin
  • dataDir — the app's own writable folder
  • dimosDir — the dimos checkout Desktop uses; dimosPython (also python) — its venv's python
  • zenohGatewayUrl — Desktop's zenoh-gateway ; zenohConnect — the zenoh endpoint dimos modules are on
  • desktopUrl — 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 }).

Backend → page: zenoh (the rule for every app)

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 again

Never 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 text

Every 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-GETs

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

Inside Desktop: insets, opening apps, first-run messages

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 Desktop

Inside 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 → Desktop's agent

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 about

source 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 → apps

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`)
})

Notifications → Desktop's notification center

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 reading

sound 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 }).

Terminal tool: run a shell command (sudo too) in Desktop

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

Theme: Desktop's /theme.css

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

Popups + privileged commands

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

Building it for Desktop

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

Licensed under the Apache License, Version 2.0.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages