Targ Apps Docs
Chroma

Desktop

Import chroma build .zip bundles and run Chroma apps outside the browser with the Chroma Desktop shell.

Chroma Desktop is a Tauri 2 desktop shell for running Chroma apps outside the browser. It imports the .zip bundles produced by chroma build and runs them locally — no browser tab, no chroma dev, no network. It lives in the sibling repo Chroma-Desktop.

Once an app is imported it stays imported across launches, and every imported app is reachable from a small library screen.

Install

Download the installer for your platform — each one is a normal, native installer (no terminal required):

Building from source instead? Chroma Desktop is a Tauri 2 app — from a checkout of Chroma-Desktop, run cargo tauri dev to run it locally, or ./build-installer.sh (Linux/macOS) / .\build-installer.ps1 (Windows) to produce the same installers as above.

Develop mode

When chroma dev runs on a dynamic app (including the standard template) with dev.desktop: true, the CLI launches Chroma Desktop in develop mode instead of opening the browser:

  1. engine starts in the background on dev.port
  2. chroma-desktop opens with --develop --url http://127.0.0.1:<port>/<entry> --title <name>
  3. The library/import screen is skipped — the app iframe loads your dev URL immediately
  4. Closing Chroma Desktop stops the dev session (engine is terminated)

Pass chroma dev --no-desktop to serve in the browser only. Develop mode requires chroma install from a workspace that includes Chroma-Desktop (the installer builds chroma-desktop into $CHROMA_HOME/bin).

You can also launch develop mode manually:

chroma-desktop --develop --url http://127.0.0.1:5173/index.html --title "My app"

Or via URL query param: ?develop=http://127.0.0.1:5173/index.html&title=My%20App.

Importing an app

There are two ways to bring a chroma build .zip into the library:

  • Drag and drop the .zip file anywhere onto the window.
  • Click "Import .zip" on the library screen to open a native file picker.

Import is handled by src/importer.rs:

  1. Reject anything whose extension isn't .zip.
  2. Derive a display name from the zip's file stem, sanitize it to filesystem-safe characters ([A-Za-z0-9_-], everything else becomes -), and pick a unique folder name under the apps directory — appending -2, -3, … on collision.
  3. Extract the zip into that folder. Entry paths are resolved with enclosed_name() (from the zip crate), which rejects absolute paths and .. traversal, so a malicious zip can't write outside its destination folder.
  4. Require an index.html at the extracted root. If it's missing, the import is rejected and the partial folder is removed.
  5. Write a small .chroma-desktop.json metadata file ({ "name": "<display name>" }) next to the app so the original display name survives even if the folder id doesn't match it (e.g. after de-duplication).

Every import gets its own folder — even re-importing a bundle with the exact same name creates a fresh, independent copy (name, name-2, name-3, …); nothing is silently overwritten. A successful import opens the app immediately; a failed one shows a toast at the bottom of the window with the error, and the library stays as it was.

For a bundle to be accepted, the zip must contain an index.html at its root — exactly what chroma build produces when package.zip: true is set in chroma.json (dynamic mode). Zipping a project's source directory by hand instead of using chroma build will fail unless index.html happens to sit at the archive root too.

The library

list_apps scans the apps directory for folders containing an index.html, reads back the display name from each folder's .chroma-desktop.json (falling back to the folder id if that file is missing or unreadable), and returns them sorted alphabetically (case-insensitively) by display name.

On launch, Chroma Desktop shows a grid of every previously imported app — an "Import .zip" card is always first — each labeled with its display name and an icon showing its initials. Selecting a card opens that app full-window; a back arrow returns to the library.

Imported apps persist across restarts: they live under the OS's Tauri app-data directory, in an apps subfolder — see Data location below.

Chroma-Fs bridge

Apps in the iframe cannot call Tauri directly. Chroma Desktop listens for postMessage messages on channel chroma-fs and forwards them to scoped Rust fs_* commands. Filesystem storage in develop mode uses app-id develop under <app-data>/apps/develop/data/ — try the standard template file explorer to verify read/write/list. See Chroma-Fs backends.

App view

Opening an app points an <iframe> at that app's local server URL (http://127.0.0.1:<port>/<app-id>/index.html) — full-window, with a back button in the titlebar to return to the library. Assets resolve relative to that same path, exactly as they would under chroma dev or any static host.

User Interface Design

The Chroma Desktop interface embodies the Targ Apps brand identity with a minimalist B2B aesthetic:

  • Brand color palette (Dark Mode): charcoal background (#262624), warm terracotta accent (#d97757), with crisp foreground text (#c3c0b6) for optimal readability and visual sophistication
  • Typography: Grift Variable typeface as the primary font, paired with careful letter-spacing and clear visual hierarchy (24px headers, 13px body text)
  • Branding: Targ Apps logo displayed in the titlebar, reinforcing brand presence in every interaction
  • Entry animations: Staggered cascade animations on app launch—titlebar fades down (0.6s), library content fades up (0.7s offset 0.1s), then cards flow in with 50ms intervals for a polished, intentional entrance
  • Generous spacing: 40px padding in the library view, 16px gap between grid items, creating breathing room and reducing cognitive load
  • Smooth interactions: 0.2s transitions on hover states with warm accent highlights, subtle elevation changes (shadows with brand color tints) on cards for tactile feedback

Custom titlebar

tauri.conf.json sets "decorations": false on the main window, so there's no native title bar or window chrome — Chroma Desktop draws its own in src/view/index.html and wires it to the Tauri window API:

  • Minimize / maximize / close buttons call appWindow.minimize(), appWindow.toggleMaximize(), and appWindow.close().
  • The titlebar area itself is draggable ("dragDropEnabled": true plus the core:window:allow-start-dragging permission), so the window can be moved without native decorations.
  • The titlebar height is 48px with a single accent dot indicator.

The window starts at 1080×720 with a minimum size of 640×480 (tauri.conf.json).

How apps run

Every imported app is served — not opened via file:// — by a small local HTTP server (src/server.rs) that starts automatically when Chroma Desktop launches:

  • Binds 127.0.0.1:0 — the OS assigns an ephemeral port at startup, stored in Tauri state and exposed to the frontend via the get_server_port command.
  • Serves the entire apps directory as its root, so every imported app is reachable under its own path prefix: /<app-id>/....
  • One thread per connection, GET/HEAD only — the same request-handling design as the CLI's engine binary, detailed on the Server page.

src/server.rs and Chroma-Server's engine are independent implementations of the same design, not the same binary — Chroma Desktop doesn't shell out to engine; the server is a Rust module compiled directly into the Tauri binary, with one difference in scope: engine serves one project directory rooted at whatever --dir was passed, while Chroma Desktop's server always serves the whole apps directory, so it can multiplex every imported app behind one listener using the /<app-id>/ path prefix.

Data location

Imported apps live under the platform's Tauri app-data directory, in an apps subfolder — for example ~/.local/share/com.chroma.desktop/apps on Linux. Each app is its own folder named after its (sanitized, de-duplicated) display name, containing the extracted bundle plus a .chroma-desktop.json metadata file.

Per-app persistent storage for Chroma-Fs lives at <apps>/<app-id>/data/ (e.g. apps/my-app/data/notes/hello.txt).

Security notes

  • The bundled server binds 127.0.0.1 only and is never exposed to the network — an imported app is reachable only from Chroma Desktop itself.
  • Zip extraction rejects absolute paths and .. traversal in entries via enclosed_name(), so an imported bundle can't write outside its own destination folder.
  • HTTP path resolution applies the same style of root-escape check as extraction: every resolved path is canonicalized (fs::canonicalize) and checked with starts_with(root), so a request can't read files outside the apps directory even via .. segments or symlink tricks.
  • The main window's Tauri capability set (capabilities/main.json) is intentionally narrow: core:default plus window close/minimize/toggle-maximize/start-dragging and dialog:allow-open — no filesystem, shell, or HTTP client permissions are granted to the frontend beyond that.
  • tauri.conf.json sets "csp": null — Content-Security-Policy enforcement is left to each imported app's own index.html, not centrally imposed by the shell.

csp: null means Chroma Desktop does not sandbox the JavaScript inside an imported app beyond loopback-only networking and the filesystem/zip-traversal protections above. Only import bundles you trust, the same way you'd only run a native binary you trust.

Building and running

From the Chroma-Desktop repo, with the Tauri CLI installed:

cargo build              # compile the Rust side
cargo test                # run importer + server unit tests
cargo tauri dev           # run the app in dev mode
cargo tauri build         # produce a release bundle

cargo test exercises both src/importer.rs (zip extraction into a fresh folder, de-duplication when importing the same name twice, rejection of non-.zip files and zips without a root index.html, alphabetical listing) and src/server.rs (rejecting paths that ..-escape the served root).

Project structure

Chroma-Desktop/
├── src/
│   ├── main.rs          # Tauri app setup, commands, drag-and-drop handling
│   ├── importer.rs      # zip import + app listing (+ unit tests)
│   ├── server.rs        # local static file server (+ unit tests)
│   ├── fs.rs            # scoped per-app filesystem commands for Chroma-Fs
│   └── view/index.html  # frontend (library + app view, titlebar, fs bridge)
├── pkg/                  # vendored Chroma engine build (wasm + JS loader)
├── capabilities/main.json  # Tauri permission set for the main window
├── build.rs              # tauri-build hook
├── Cargo.toml
└── tauri.conf.json

Troubleshooting

SymptomCauseFix
Toast: '<file>' is not a .zip fileA non-.zip file was passed to the file picker.Import the .zip produced by chroma build with package.zip: true, not a raw project folder.
Toast: the zip does not contain an index.html at its rootThe zip's index.html is nested in a subfolder, or missing entirely.Rebuild with chroma build — its zip output already has index.html at the archive root — rather than zipping the project source directly.
Dropped file does nothingThe drop handler in main.rs only imports paths with a .zip extension; anything else in the drop event is silently skipped.Use the "Import .zip" file picker instead to see the actual rejection reason.
An app looks blank or shows a 404 for its assetsThe zip was built for a different base path, or a required file wasn't included in the bundle.Re-check the chroma build output locally (serve dist/ with chroma dev — see Server — or any static server) before importing; if it works there, re-import, otherwise fix the build first.
Re-importing doesn't update an already-open appEach import is a new, separate folder/app entry — it never overwrites the one you already have.Re-import to get the new version as a new library entry, then open that one; delete the stale folder from the apps directory if you don't need it.

Where this fits in the pipeline

Chroma Desktop is the last step of the Chroma tooling pipeline: write an app, iterate with chroma dev (backed by the dev server), package it with chroma build (package.zip: true), then drop the resulting .zip here to run it as a standalone local app, with its own always-on local server and no browser tab required.

On this page