diff --git a/runtime/_data.ts b/runtime/_data.ts index 54632587b..1141f9409 100644 --- a/runtime/_data.ts +++ b/runtime/_data.ts @@ -89,6 +89,75 @@ export const sidebar = [ }, ], }, + { + title: "Desktop apps", + items: [ + { + title: "Overview", + href: "/runtime/desktop/", + }, + { + title: "Configuration", + href: "/runtime/desktop/configuration/", + }, + { + title: "Backends", + href: "/runtime/desktop/backends/", + }, + { + title: "HTTP serving", + href: "/runtime/desktop/serving/", + }, + { + title: "Frameworks", + href: "/runtime/desktop/frameworks/", + }, + { + title: "Windows", + href: "/runtime/desktop/windows/", + }, + { + title: "Bindings", + href: "/runtime/desktop/bindings/", + }, + { + title: "Menus", + href: "/runtime/desktop/menus/", + }, + { + title: "Tray and dock", + href: "/runtime/desktop/tray_and_dock/", + }, + { + title: "Dialogs", + href: "/runtime/desktop/dialogs/", + }, + { + title: "Hot module replacement", + href: "/runtime/desktop/hmr/", + }, + { + title: "DevTools", + href: "/runtime/desktop/devtools/", + }, + { + title: "Auto-update", + href: "/runtime/desktop/auto_update/", + }, + { + title: "Error reporting", + href: "/runtime/desktop/error_reporting/", + }, + { + title: "Distribution", + href: "/runtime/desktop/distribution/", + }, + { + title: "Comparison", + href: "/runtime/desktop/comparison/", + }, + ], + }, { title: "Reference guides", items: [ @@ -144,6 +213,10 @@ export const sidebar = [ title: "deno deploy", href: "/runtime/reference/cli/deploy/", }, + { + title: "deno desktop", + href: "/runtime/reference/cli/desktop/", + }, { title: "deno doc", href: "/runtime/reference/cli/doc/", diff --git a/runtime/desktop/auto_update.md b/runtime/desktop/auto_update.md new file mode 100644 index 000000000..9d80e826c --- /dev/null +++ b/runtime/desktop/auto_update.md @@ -0,0 +1,202 @@ +--- +title: "Auto-update" +description: "Ship binary-diff updates to deno desktop apps with Deno.autoUpdate() — bsdiff patches, manifest polling, automatic rollback on failed launch." +--- + +`Deno.autoUpdate()` polls a release server for new versions, downloads +binary-diff patches, applies them to the runtime dylib, and stages the result +for the next launch. If the next launch fails, the runtime rolls back to the +previous version automatically. + +The update mechanism is inspired by Electrobun: small `bsdiff` patches instead +of full binary downloads, and rollback baked into the launcher. + +## Prerequisites + +Two pieces of configuration are required: + +1. A `version` in your `deno.json`: + ```jsonc + { "version": "1.4.0" } + ``` +2. A `desktop.release.baseUrl` in your `deno.json`: + ```jsonc + { + "desktop": { + "release": { "baseUrl": "https://releases.example.com/my-app" } + } + } + ``` + +Both are baked into the compiled binary. The version is exposed at runtime as +`Deno.desktopVersion`: + +```ts +console.log(Deno.desktopVersion); // "1.4.0", or null if no version was set +``` + +If `Deno.desktopVersion` is `null`, `Deno.autoUpdate()` is a no-op — the runtime +warns once and returns. + +## Calling `autoUpdate()` + +```ts +Deno.autoUpdate({ + url: "https://releases.example.com/my-app", + interval: 60 * 60 * 1000, // hourly + onUpdateReady(version) { + console.log("Update", version, "ready; will apply on next launch"); + }, + onRollback(reason) { + console.warn("Previous launch failed; rolled back:", reason); + }, +}); +``` + +Or pass a URL string for a single one-shot check on startup: + +```ts +Deno.autoUpdate("https://releases.example.com/my-app"); +``` + +| Option | Type | Notes | +| --------------- | --------------------------- | --------------------------------------------------------------- | +| `url` | `string` | Required if no `desktop.release.baseUrl` is set in `deno.json`. | +| `interval` | `number` (milliseconds) | Poll interval. If omitted, only a single check is performed. | +| `onUpdateReady` | `(version: string) => void` | Called once a patch is applied and staged for next launch. | +| `onRollback` | `(reason: string) => void` | Called shortly after this call if the previous launch failed. | + +## Manifest format + +The runtime fetches `/latest.json` and parses it as JSON: + +```json +{ + "version": "1.5.0", + "patches": { + "1.4.0": "patch-1.4.0-to-1.5.0.bin", + "1.4.1": "patch-1.4.1-to-1.5.0.bin", + "1.3.9": "patch-1.3.9-to-1.5.0.bin" + } +} +``` + +| Field | Meaning | +| --------- | -------------------------------------------------------------------- | +| `version` | The latest available version. Compared with `Deno.desktopVersion`. | +| `patches` | Map of from-version → patch filename relative to the manifest's URL. | + +Old versions you no longer want to support can be omitted from `patches`. Users +on those versions log a "no patch available for X" message and stay on their +current version. + +## Update flow + +1. **Fetch manifest.** `GET /latest.json`. On a non-2xx response, the check + silently returns and waits for the next interval. +2. **Compare versions.** If `manifest.version === Deno.desktopVersion`, nothing + to do. +3. **Look up a patch.** `manifest.patches[Deno.desktopVersion]` → patch + filename. +4. **Download the patch.** `GET /`. The whole patch is buffered + into memory; for typical bsdiff outputs (a few MB) this is fine. +5. **Apply with `bspatch`.** The runtime applies the binary diff to the current + executable / dylib using the [`qbsdiff`](https://crates.io/crates/qbsdiff) + crate. +6. **Stage the result.** Write the patched binary as `.update` next to + the original. Do not overwrite the running binary in place. +7. **Fire `onUpdateReady`.** + +The original binary is untouched until the next launch. If the user closes the +app and reopens it, the launcher swaps `.update` into place and starts +the new version. + +## Rollback on failed launch + +When `.update` exists at startup, the launcher: + +1. Renames the **current** binary to `.previous`. +2. Renames `.update` to the current binary path. +3. Runs the new binary with a `--first-launch-after-update` marker. + +If the new binary completes its first launch successfully (the runtime calls an +internal "confirm update" op shortly after startup), the `.previous` is +deleted and a sentinel file (`.update-ok`) is created. + +If the new binary fails to launch — crashes during startup, returns a non-zero +exit before confirming, or never confirms — the launcher: + +1. Restores `.previous` as the current binary. +2. Deletes the failed `.update`. +3. Records the rollback so the next launch's `Deno.autoUpdate()` call fires + `onRollback` with the reason. + +This makes broken updates self-healing. Users do not need to know anything +happened beyond seeing the same version they had before. + +## Generating patches + +The patches are produced with `bsdiff`. Any tool that produces compatible output +works; the simplest is the `bsdiff` CLI: + +```sh +bsdiff old-binary new-binary patch-1.4.0-to-1.5.0.bin +``` + +Then upload `patch-1.4.0-to-1.5.0.bin` and the new `latest.json` to your release +server. + +For shipping multiple architectures (macOS arm64, x86_64; Windows x86_64; Linux +arm64, x86_64), generate patches per-architecture. Either serve the right +manifest based on user-agent, or include all patches under architecture-specific +keys and pick on the client: + +```jsonc +// release/macos-arm64/latest.json +{ "version": "1.5.0", "patches": { "1.4.0": "patch-1.4.0-to-1.5.0.bin" } } +``` + +```ts +const arch = Deno.build.os + "-" + Deno.build.arch; +Deno.autoUpdate({ + url: "https://releases.example.com/" + arch, + interval: 60 * 60 * 1000, +}); +``` + +## Events + +In addition to the callbacks, the runtime dispatches DOM-style events on the +global `EventTarget`: + +```ts +addEventListener("desktop-update-ready", (e) => { + const version = (e as CustomEvent<{ version: string }>).detail.version; + // … +}); + +addEventListener("desktop-update-rollback", (e) => { + const reason = (e as CustomEvent<{ reason: string }>).detail.reason; + // … +}); +``` + +The events fire alongside `onUpdateReady` / `onRollback`, so use whichever style +fits your code better. + +## Best practices + +- **Sign your manifests.** The runtime does not currently verify a signature on + `latest.json` — anyone able to MITM the connection (or serve from your URL) + could push an arbitrary patch. Use HTTPS with certificate pinning at the + network level, host the manifest on a domain you control, and consider adding + a signature field once the runtime supports verification. +- **Test patches against a real install.** A patch that applies cleanly but + produces a non-bootable binary triggers rollback, but only after a failed + launch — your users see a brief startup failure once. Run the patched binary + in CI before publishing the manifest. +- **Choose a sensible interval.** Hourly is fine for most apps. Polling more + often than every few minutes is wasteful for both you and your users. +- **Handle `onRollback`.** A rollback is a signal that a recent release was + broken on at least one machine. Log it to your telemetry so you notice broken + releases quickly. diff --git a/runtime/desktop/backends.md b/runtime/desktop/backends.md new file mode 100644 index 000000000..6172a9e6e --- /dev/null +++ b/runtime/desktop/backends.md @@ -0,0 +1,131 @@ +--- +title: "Backends" +description: "Pick a rendering engine for your desktop app — bundled Chromium, the OS webview, raw windowing, or Servo. Tradeoffs and how to switch." +--- + +`deno desktop` is built on **WEF**, a Rust abstraction layer over multiple web +engines. `--backend` (or the `desktop.backend` field in `deno.json`) selects +which engine your app embeds. + +## Available backends + +### CEF (default) + +```sh +deno desktop --backend cef main.ts +``` + +**Bundled Chromium Embedded Framework.** The same engine that powers Chrome, +Electron, and Slack. The framework ships inside your `.app` / app directory +under `Contents/Frameworks/`. + +- Identical rendering on macOS, Windows, and Linux. +- Full web platform support — modern CSS, ES modules, WebGPU, WebRTC, the works. +- Largest binary size (~150 MB for the framework alone). +- All Deno desktop features supported (DevTools, autoUpdate, tray, dock). + +Choose CEF when consistent rendering across platforms matters, or when you need +a feature only Chromium ships (e.g. WebGPU on Linux). + +### WebView + +```sh +deno desktop --backend webview main.ts +``` + +**The operating system's own webview** — WKWebView on macOS, WebView2 on +Windows, WebKitGTK on Linux. + +- Smaller app size (just your code + the WEF shim). +- Rendering and feature support varies per platform and OS version. +- Some web features may be missing or behave differently (Web Audio variants, + WebGPU availability, etc.). +- DevTools not available (the unified DevTools mux supports CEF only at this + time). + +Choose WebView when binary size is the primary concern and your UI sticks to +broadly-supported web features. + +### Raw + +```sh +deno desktop --backend raw main.ts +``` + +**Winit-based, no web engine.** Provides window management, input events, +clipboard, and the native API surface — but no webview, no `Deno.serve()` +auto-binding, no `bindings.()` proxy. + +Useful for apps that draw their own UI (WebGPU, Skia, custom rendering) or as a +foundation for non-web desktop programs. + +### Servo (experimental) + +```sh +deno desktop --backend servo main.ts +``` + +**Mozilla's Servo engine.** Experimental; do not ship apps on Servo today. +Available for prototyping the future of independent web engines. + +## Picking a backend + +| Need | Best fit | +| ------------------------------------------------- | --------- | +| Identical rendering everywhere | `cef` | +| Smallest possible binary | `webview` | +| WebGPU / cutting-edge web APIs on all platforms | `cef` | +| Custom 2D/3D rendering, no HTML | `raw` | +| Internal app on macOS, you control the OS version | `webview` | +| Browser-engine experimentation | `servo` | + +## Switching backends + +Backends are interchangeable for anything except the `raw` backend: the same app +code (windows, bindings, events, navigation, JS execution) works on CEF, +WebView, and Servo without changes. + +To switch, change the config or pass the flag: + +```jsonc title="deno.json" +{ "desktop": { "backend": "webview" } } +``` + +```sh +deno desktop --backend webview main.ts +``` + +The `raw` backend has no webview, so any APIs that interact with web content +(navigation, bindings, `executeJs`, etc.) are unavailable when using it. + +## How backends are obtained + +You do not build WEF yourself. The Deno CLI downloads prebuilt backend binaries +from +[`github.com/denoland/wef/releases`](https://github.com/denoland/wef/releases). +The version is pinned via `Cargo.lock` in the Deno tree, downloads are +checksum-verified, and binaries are cached under +`/wef////`. + +The first build for a new backend / target / WEF version downloads the archive +(a few hundred megabytes for CEF). Subsequent builds use the cache. + +### Working against a local `wef` checkout + +If you are developing WEF itself, set `WEF_DEV_DIR` to your `wef` checkout and +the CLI uses your local build instead of downloading: + +```sh +export WEF_DEV_DIR=/path/to/wef-checkout +deno desktop main.ts +``` + +The CLI looks for the backend binary in well-known build subdirectories under +`$WEF_DEV_DIR` (`result/`, `result-cef/`, `webview/build/`, +`target/release/wef_winit`, etc.). + +## Cross-compilation + +`--target` and `--all-targets` work with any backend. The CLI downloads the +prebuilt backend archive matching the target triple — no local engine toolchain +needed. See [Distribution](/runtime/desktop/distribution/). diff --git a/runtime/desktop/bindings.md b/runtime/desktop/bindings.md new file mode 100644 index 000000000..54783e2bf --- /dev/null +++ b/runtime/desktop/bindings.md @@ -0,0 +1,192 @@ +--- +title: "Bindings" +description: "Call Deno-side functions from webview JavaScript via win.bind() — type-safe RPC over in-process channels, no IPC, no serialization tax beyond the call boundary." +--- + +`win.bind(name, handler)` exposes a Deno-side function to the webview. From the +webview, call it as `bindings.(args)` — the call returns a `Promise` that +resolves with the handler's return value. + +```ts title="Deno side" +win.bind("readSettings", async () => { + const text = await Deno.readTextFile("settings.json"); + return JSON.parse(text); +}); + +win.bind("saveSettings", async (settings) => { + await Deno.writeTextFile("settings.json", JSON.stringify(settings, null, 2)); +}); +``` + +```ts title="Webview side" +const settings = await bindings.readSettings(); +settings.theme = "dark"; +await bindings.saveSettings(settings); +``` + +## How it works + +Bindings are **not** IPC. The Deno runtime and the rendering backend run as +threads / processes inside the same address space (CEF) or coordinated process +group (WebView). Calls go through `tokio::sync::mpsc` channels and `oneshot` +channels for responses; the WEF capi layer dispatches via a notify / poll +pattern in `wef::run()`. + +This avoids the serialization round-trip that socket-based IPC frameworks +(Electron's `ipcMain` / `ipcRenderer`, Tauri's `invoke`) impose. Argument +encoding still happens — values cross a JS realm boundary, so they go through +the V8 serializer — but no socket, no JSON-over-pipe, no cross-process +scheduling. + +In practical terms: bindings are fast enough that you do not need to worry about +call frequency for typical app workloads. + +## The webview proxy + +`bindings` on the webview side is a `Proxy`. Any property access creates a +function on demand: + +```js +bindings.foo; // function +bindings.foo("a", 1); // Promise +``` + +The proxy does not validate names — typing `bindings.readSetings` instead of +`bindings.readSettings` does not throw at the property access; it throws when +you call it (the call rejects with a "binding not registered" error). + +## Argument and return value semantics + +Arguments are serialized with the V8 structured-clone algorithm, the same one +used by `postMessage`. This means: + +- Plain objects, arrays, strings, numbers, booleans, `null`, `undefined`: fine. +- Typed arrays, `ArrayBuffer`, `Date`, `Map`, `Set`, `RegExp`: fine. +- Functions, DOM nodes, prototypes: not transferable — clone them as data before + sending. +- Cyclic references: fine. +- Errors: serialized as `{ name, message, stack }`. The Deno side receives a + plain object, not an `Error` instance. + +Return values follow the same rules. + +## Async handlers + +Handlers can be sync or async. The webview always sees a `Promise`: + +```ts +win.bind("now", () => Date.now()); // sync +win.bind("delay", async (ms) => { // async + await new Promise((r) => setTimeout(r, ms)); +}); +``` + +```ts +const t = await bindings.now(); +await bindings.delay(500); +``` + +## Errors + +A handler that throws — synchronously or via a rejected promise — causes the +webview-side call to reject: + +```ts +win.bind("readFile", async (path) => { + return await Deno.readTextFile(path); +}); +``` + +```ts +try { + await bindings.readFile("/missing"); +} catch (e) { + console.error(e); // NotFound: … +} +``` + +The error reaches the webview as a structured-cloned `{ name, message, +stack }`. +To distinguish error types, check `error.name`. + +## Unbinding + +```ts +win.unbind("readSettings"); +``` + +Removes the binding. Subsequent `bindings.readSettings()` calls reject. + +## Permissions + +Bindings run inside the Deno runtime, so they inherit the process's permissions. +A binding that calls `Deno.readTextFile` requires `--allow-read` to have been +granted at startup. The webview cannot escalate the runtime's permissions +through bindings. + +For desktop apps you typically run with broad permissions baked into the +compiled binary (`deno desktop` does not currently enforce a separate permission +prompt at runtime). If you expose bindings that act on the filesystem or +network, validate inputs as carefully as you would in any trust-boundary code. + +## Per-window bindings + +Bindings are per-window. A binding registered on `winA` is not callable from +`winB`'s webview. To share, register on each window: + +```ts +function bindShared(win: Deno.BrowserWindow) { + win.bind("now", () => Date.now()); + win.bind("readSettings", readSettings); +} + +const main = Deno.BrowserWindow.main; +bindShared(main); + +const settings = new Deno.BrowserWindow(); +bindShared(settings); +``` + +## Type safety + +There is no built-in type bridge between the Deno side's `win.bind()` and the +webview side's `bindings.()`. The two sides are separate JS realms. + +A small shared declaration file gives you both ends: + +```ts title="bindings.d.ts" +export interface Bindings { + readSettings(): Promise; + saveSettings(s: Settings): Promise; + now(): Promise; +} + +declare global { + // Make `bindings` typed in the webview. + const bindings: Bindings; +} + +export interface Settings { + theme: "light" | "dark"; +} +``` + +Reference it from the webview's `tsconfig` / Deno project config and use the +same `Bindings` interface to type-check your `win.bind` calls. Mismatches +between the registration and the declaration will be caught at compile time on +the Deno side. + +## Migrating from Electron + +If you are coming from Electron's `ipcMain.handle('channel', handler)` / +`ipcRenderer.invoke('channel', ...)`, the mental model is identical: + +| Electron | `deno desktop` | +| --------------------------------------------------- | ---------------------------------------------- | +| `ipcMain.handle('channel', (e, ...args) => result)` | `win.bind('channel', (...args) => result)` | +| `ipcRenderer.invoke('channel', ...args)` | `bindings.channel(...args)` | +| `contextBridge.exposeInMainWorld('api', {...})` | Not needed — `bindings` is exposed by default. | + +The `event` object Electron passes as the first arg has no equivalent because +there is no separate process to attribute the call to. Per-window context lives +on the `win` you registered the binding on. diff --git a/runtime/desktop/comparison.md b/runtime/desktop/comparison.md new file mode 100644 index 000000000..2c7d20b33 --- /dev/null +++ b/runtime/desktop/comparison.md @@ -0,0 +1,113 @@ +--- +title: "Comparison with other tools" +description: "How deno desktop compares to Electron, Electrobun, Tauri, and Dioxus — language, engine, process model, app size, ecosystem, and what's built-in." +--- + +`deno desktop` is one of several ways to ship desktop apps with web +technologies. Here is how it compares to the alternatives. + +## At a glance + +| | Electron | Electrobun | Tauri | Dioxus | `deno desktop` | +| --------------------------- | ---------------------- | --------------- | -------------------- | ---------------- | ------------------------ | +| **Language** | JS/TS (Node.js) | JS/TS (Bun) | Rust + web frontend | Rust | JS/TS (Deno) | +| **Web engine** | Bundled Chromium | System WebView | System WebView | System WebView | Bundled CEF or WebView | +| **Consistent rendering** | Yes | No | No | No | Yes (CEF) | +| **Process model** | Multi-process | Multi-process | Multi-process | Single process | Multi-thread | +| **Backend ↔ UI** | IPC | IPC | IPC | Native Rust | In-process channels | +| **App size** | ~100 MB+ | ~14 MB | ~2–10 MB | ~5 MB | varies (CEF or system) | +| **npm / Node compat** | Yes (it is Node) | Yes (via Bun) | No | No | Yes (Deno's Node compat) | +| **Framework auto-detect** | No | No | No | No | Yes | +| **HMR** | No | Yes | Yes (Vite-based) | Yes (`dx serve`) | Yes | +| **Built-in auto-update** | Full binary | bsdiff | Plugin | None | bsdiff | +| **Built-in installers** | Yes | No | Yes | No | Partial (DMG, AppImage) | +| **Cross-compile** | Yes (electron-builder) | No (macOS only) | No (needs target OS) | No | Yes (`--target`) | +| **macOS / Windows / Linux** | All three | macOS only | All three | All three | All three | +| **iOS / Android** | No | No | Yes | Yes | Not yet | + +## What `deno desktop` is good at + +**Zero-config framework support.** `deno desktop .` on a Next.js, Astro, or +Fresh project just works. No adapter, no config, no reading docs about how to +wire your dev server up — the production server runs in release mode, the dev +server runs under `--hmr`. None of the other tools auto-detect frameworks at +this level. + +**Cross-compile from one machine.** Same as `deno compile --target`. Tauri and +Dioxus need the target platform locally to build (their toolchain includes Rust, +which has to compile for the target). Electrobun only ships on macOS. Electron +supports cross-platform builds via electron-builder, but needs Node and +platform-specific signing tools per target. + +**Bundled engine plus full Node compatibility.** Electron has both, but is +massive (Chromium plus Node). Tauri and Dioxus are small but have no JS +ecosystem. `deno desktop` bundles CEF for consistent rendering and gives you the +full Node compat layer through Deno — including `npm:` imports in your handlers +and `bindings`. + +**In-process bindings instead of IPC.** Electron / Electrobun / Tauri all use +socket-based IPC between the backend and the UI. Calls serialize, cross a +process boundary, and deserialize. `deno desktop` runs the Deno runtime and the +rendering backend inside the same process, talking over tokio channels. No +serialization tax beyond the structured-clone boundary. + +**Built-in auto-update with binary diffs.** Electron ships full binaries. +Tauri's update plugin downloads full builds. Electrobun and `deno desktop` both +do `bsdiff` patches, but `deno desktop` integrates the update flow with the +runtime — no separate updater binary, automatic rollback, manifest polling all +in one API. + +## What other tools are good at + +**Electron — ecosystem.** Years of tooling, packaging, and signing machinery. +Every major editor and chat app uses it. If you need mature plugin ecosystems +(Spectron, electron-builder, autoUpdater abstractions), Electron has them. + +**Tauri — small footprint and mobile.** Tauri's binaries are an order of +magnitude smaller than `deno desktop` (or Electron) and Tauri 2 supports iOS and +Android. If size or mobile is the priority, Tauri wins. + +**Electrobun — fast iteration on macOS.** Electrobun's start-up speed and HMR +are excellent on macOS. If you only ship Mac apps and like the Bun ecosystem, it +is a strong choice. + +**Dioxus — Rust-only.** No JS runtime at all. If you are writing everything in +Rust and want a unified codebase, Dioxus is a good pick. + +## What `deno desktop` doesn't have yet + +These are documented on the relevant pages of this section, but worth listing in +one place: + +- **Code-signing and notarization** as a flag (`--sign`). +- **Windows MSI** and **Linux `.deb` / `.rpm`** installer outputs. +- **iOS / Android** targets. +- **Native `Deno.notifications`, `Deno.clipboard`, `Deno.secureStorage`** APIs + (use the Web equivalents from the webview side until they land). +- **Runtime permissions for desktop apps** (a permission prompt on every + filesystem / network access — Deno's permission system applied to desktop + sandboxing). +- **Shared CEF runtime across apps.** Every app currently bundles its own CEF + copy. A managed shared runtime would drop binary sizes to a few MB per app. On + the roadmap. + +## When to pick `deno desktop` + +- Your codebase is JavaScript / TypeScript and you do not want to write Rust. +- You want consistent rendering across platforms and are OK with the binary size + that comes with bundling Chromium. +- You already have a Next.js / Astro / Fresh / etc. web app and want a desktop + version with no code changes. +- You want cross-compilation from one machine. +- You need full Node compatibility (npm packages, native modules) in your + backend code. +- You want auto-update built in, not bolted on. + +## When to pick something else + +- **Tauri** if binary size is non-negotiable, you don't need npm, and you want + mobile. +- **Electron** if your team's existing tooling, signing, and CI already target + Electron. +- **Dioxus** if you are writing Rust top to bottom. +- **Electrobun** if you only ship macOS and want to live on the Bun side. diff --git a/runtime/desktop/configuration.md b/runtime/desktop/configuration.md new file mode 100644 index 000000000..e4737ffa7 --- /dev/null +++ b/runtime/desktop/configuration.md @@ -0,0 +1,194 @@ +--- +title: "Configuration" +description: "Configure deno desktop in deno.json — app metadata, icons, backend selection, output paths, error reporting, and the auto-update server." +--- + +All configuration for `deno desktop` lives in the `desktop` block in +`deno.json`. Most fields are optional — a project with no `desktop` block at all +still compiles, using sensible defaults. + +## Full example + +```jsonc title="deno.json" +{ + "name": "my-app", + "version": "1.4.0", + "desktop": { + "app": { + "name": "My App", + "icons": { + "macos": "./icons/app.icns", + "windows": "./icons/app.ico", + "linux": "./icons/app.png" + } + }, + "backend": "cef", + "output": { + "macos": "./dist/MyApp.app", + "windows": "./dist/MyApp", + "linux": "./dist/my-app" + }, + "release": { + "baseUrl": "https://releases.example.com/my-app" + }, + "errorReporting": { + "url": "https://errors.example.com/report" + } + } +} +``` + +## `app` + +Metadata baked into the compiled binary. + +### `app.name` + +Display name of the application. Used as the window title default, the macOS +menu bar app name, the Windows taskbar tooltip, and the Linux `.desktop` entry +name. Falls back to the `name` field at the root of `deno.json`. + +### `app.icons` + +Per-platform icon paths, relative to `deno.json`. + +```jsonc +"icons": { + "macos": "./icons/app.icns", + "windows": "./icons/app.ico", + "linux": "./icons/app.png" +} +``` + +For macOS and Linux you may also pass an array of PNGs to be assembled into a +multi-resolution icon at build time: + +```jsonc +"icons": { + "macos": [ + { "path": "./icons/16.png", "size": 16 }, + { "path": "./icons/32.png", "size": 32 }, + { "path": "./icons/128.png", "size": 128 }, + { "path": "./icons/256.png", "size": 256 }, + { "path": "./icons/512.png", "size": 512 } + ] +} +``` + +`.icns` (macOS) and `.ico` (Windows) inputs are passed through unchanged. PNGs +are assembled into the right container per platform. + +If no `icons` entry is set for a platform, the default Deno icon is used. + +## `backend` + +Which web rendering engine to embed. One of `"cef"`, `"webview"`, `"servo"`, or +`"raw"`. Default: `"cef"`. + +```jsonc +"backend": "webview" +``` + +The CLI flag `--backend` overrides this for one build. See +[Backends](/runtime/desktop/backends/) for tradeoffs and supported targets. + +## `output` + +Per-platform output paths. + +```jsonc +"output": { + "macos": "./dist/MyApp.app", + "windows": "./dist/MyApp", + "linux": "./dist/my-app" +} +``` + +The path's extension determines what is produced: + +| Extension on macOS | Output | +| ------------------ | ------------------------------------ | +| `.app` | macOS application bundle | +| `.dmg` | DMG disk image (built via `hdiutil`) | + +| Extension on Windows | Output | +| -------------------- | ----------------------------------------- | +| (none) / directory | `.exe` plus a sibling DLL directory | + +| Extension on Linux | Output | +| ------------------ | -------------------------------------- | +| (none) / directory | App directory with launcher script | +| `.AppImage` | `.AppImage` (built via `appimagetool`) | + +The CLI flag `--output` overrides this for one build. + +## `release` + +Configuration for the auto-update system. + +### `release.baseUrl` + +Base URL of the release server. The runtime fetches `/latest.json` and +downloads patch files relative to this URL. See +[Auto-update](/runtime/desktop/auto_update/) for the full manifest format and +patch flow. + +```jsonc +"release": { + "baseUrl": "https://releases.example.com/my-app" +} +``` + +This is the **only** server URL the runtime polls automatically. +`Deno.autoUpdate()` defaults to this URL, but can override it per call. + +## `errorReporting` + +Capture uncaught exceptions, unhandled rejections, and panics, show a native +alert, and optionally `POST` a JSON report to a server. + +### `errorReporting.url` + +```jsonc +"errorReporting": { + "url": "https://errors.example.com/report" +} +``` + +If unset, error reporting is in "alert only" mode — uncaught errors still show a +native alert, but no report is sent. + +See [Error reporting](/runtime/desktop/error_reporting/) for the report schema. + +## Working directory & assets + +The compiled binary runs with the current working directory set to the user's +`cwd`, not the directory containing the binary. If your app needs to find files +relative to itself — framework build outputs, static assets — use `import.meta` +or the framework's own resolution; do not assume `Deno.cwd()`. + +For framework projects this is handled automatically: detected build outputs +(`.next/`, `dist/`, `_fresh/`, `.output/`, etc.) are embedded in the binary's +virtual filesystem and self-extracted at runtime so framework code finds them +relative to its own working directory. + +## Environment variables + +A few environment variables affect builds and runtime behavior. They are +documented on the relevant pages: + +- `WEF_DEV_DIR` — point at a local `wef` checkout for development. See + [Backends](/runtime/desktop/backends/). +- `DENO_SERVE_ADDRESS` — set automatically by the runtime; do not override. See + [HTTP serving](/runtime/desktop/serving/). + +## Validation + +Configuration is validated at the start of `deno desktop`: + +- `backend` must be one of the listed values. +- Icon paths must resolve to existing files. +- Output paths must be writable. +- `release.baseUrl` must parse as a URL. + +Errors are reported with the offending `deno.json` location. diff --git a/runtime/desktop/devtools.md b/runtime/desktop/devtools.md new file mode 100644 index 000000000..7f39a466f --- /dev/null +++ b/runtime/desktop/devtools.md @@ -0,0 +1,142 @@ +--- +title: "DevTools" +description: "Attach Chrome DevTools to a deno desktop app — single session shows both the Deno runtime V8 and the renderer V8 as inspectable targets." +--- + +`deno desktop` exposes **unified DevTools**: a single Chrome DevTools session +that attaches to both V8 isolates inside your app — the **Deno runtime** (your +handlers, bindings, top-level code) and the **renderer** (webview-side +JavaScript). One Console dropdown, one Sources panel with both threads, one +debugging session. + +## Starting an inspector session + +```sh +deno desktop --inspect main.ts +``` + +Then open `chrome://inspect` (or `edge://inspect`). The app appears as a target. +Click "inspect" — DevTools opens with both isolates attached. + +Three flags control startup behavior: + +| Flag | Behavior | +| ---------------- | ----------------------------------------------------------------- | +| `--inspect` | Listen for a debugger; the app starts running immediately. | +| `--inspect-wait` | Wait for a debugger to attach before running any user code. | +| `--inspect-brk` | Wait for a debugger and break on the first line in both isolates. | + +Default listen address is `127.0.0.1:9229`; pass `--inspect=host:port` to +override. + +```sh +deno desktop --inspect=127.0.0.1:9230 main.ts +deno desktop --inspect-brk main.ts +deno desktop --inspect-wait main.ts +``` + +## What you see in DevTools + +After attaching, the DevTools UI shows: + +- **Sources** — both isolates appear in the **Threads** sidebar. Set + breakpoints, step through, inspect the call stack on either side. +- **Console** — a **target dropdown** at the top of the panel switches between + **Renderer** (the webview) and **Deno** (the runtime). Console output from + each isolate is labelled. +- **Network** — requests originating from the webview (the webview's `fetch`, + `XMLHttpRequest`, image loads). Requests made from the Deno side via `fetch` + are not currently surfaced here. +- **Performance / Memory** — profile each isolate separately; switch via the + same target dropdown. + +Source maps are honored on both sides. TypeScript files in the Deno runtime show +up with their original line numbers; bundled webview JS maps back to its +original source if the bundler emits maps. + +## Renderer-only or Deno-only sessions + +If you only want to debug one side, use the per-target endpoints in the DevTools +target list, or use `Deno.BrowserWindow.openDevtools()` from your own code: + +```ts +win.openDevtools(); // both isolates (default) +win.openDevtools({ deno: false }); // renderer only +win.openDevtools({ renderer: false }); // Deno runtime only +``` + +`openDevtools()` shows a DevTools window inside the app — useful for shipping a +debug build with built-in inspection without needing `chrome://inspect`. + +## How it works + +`deno desktop` runs a CDP (Chrome DevTools Protocol) **multiplexer** that fronts +both V8 inspectors: + +``` + ┌──────────────────────────────────┐ +DevTools │ CDP Multiplexer (Deno CLI) │ +(one ws) ◄─►│ /json/version /json/list │ + │ /unified /deno /cef │ + └─────┬─────────────────┬──────────┘ + │ │ + Deno V8 inspector Renderer V8 inspector + (deno_core CDP) (CEF remote-debugging) +``` + +The mux presents itself as one CDP "browser target" with two children: a "page" +target for the renderer and a "worker" target for the Deno runtime. DevTools' +built-in multi-target support handles the rest — the same mechanism it uses for +`iframe` and `worker` debugging on the open web. + +No CDP protocol changes, no DevTools fork, no frontend modifications. + +## Backend support + +Unified DevTools is implemented for the **CEF** backend. On other backends: + +| Backend | DevTools status | +| --------- | ----------------------------------------------- | +| `cef` | Full unified DevTools. | +| `webview` | Not currently supported — system webviews speak | +| | a different inspector protocol. | +| `raw` | Deno-side `--inspect` only — there is no | +| | renderer to inspect. | +| `servo` | Not currently supported. | + +If `--inspect` is passed with a backend that does not support unified DevTools, +the Deno side still runs an inspector and you can attach to it the same way as a +normal `deno run --inspect` session. + +## Debugging across the binding boundary + +When a webview-side `bindings.foo()` call enters a Deno-side handler, the two +sides currently appear as separate stack traces. Cross-realm correlation — +automatically stitching a renderer call into the Deno handler stack — is on the +roadmap. Today, you can switch threads in the Sources panel to follow execution +manually. + +A practical approach: tag both sides of a binding with matching console output +during development: + +```ts +win.bind("readSettings", async () => { + console.log("[bindings:readSettings] enter"); + const data = await readSettings(); + console.log("[bindings:readSettings] exit"); + return data; +}); +``` + +In the unified Console, you see both lines under the "Deno" target, matched +against the renderer's `bindings.readSettings()` invocation visible under +"Renderer". + +## Known limitations + +- WebView and Servo backends have no DevTools integration. +- The renderer Network panel does not show Deno-side `fetch` calls. +- Cross-realm step-through (clicking a `bindings.foo()` call and stepping into + the Deno handler) is not yet implemented — switch threads manually. +- `--inspect-brk` pauses both isolates before navigation. Resuming each one is + independent — you may need to click "Resume" on each thread. diff --git a/runtime/desktop/dialogs.md b/runtime/desktop/dialogs.md new file mode 100644 index 000000000..0e9390b60 --- /dev/null +++ b/runtime/desktop/dialogs.md @@ -0,0 +1,117 @@ +--- +title: "Dialogs" +description: "prompt(), alert(), and confirm() show native popup dialogs in deno desktop apps instead of terminal prompts." +--- + +The familiar browser globals `prompt()`, `alert()`, and `confirm()` work inside +`deno desktop` apps — but instead of reading from the terminal, they show +**native popup dialogs**. + +This makes desktop apps feel native without any platform-specific code, and +keeps the same API you would write for a browser-side script. + +## `alert(message)` + +Shows a modal dialog with an OK button. Returns `void`. + +```ts +alert("Save complete."); +``` + +The current window is the parent — clicking outside the dialog does not dismiss +it; the user must click OK. + +## `confirm(message)` + +Shows a modal dialog with OK and Cancel. Returns `boolean` — `true` for OK, +`false` for Cancel. + +```ts +if (confirm("Discard unsaved changes?")) { + await closeDocument(); +} +``` + +## `prompt(message, defaultValue?)` + +Shows a modal dialog with a text input plus OK and Cancel. Returns the entered +string, or `null` if the user cancelled. + +```ts +const name = prompt("New document name:", "Untitled"); +if (name !== null) { + await createDocument(name); +} +``` + +## When they fire + +These functions block the calling code (synchronously) until the user responds. +They run **on the Deno runtime thread**, not the webview — so they do not freeze +the rendered UI, but they do pause your handler. + +```ts +win.addEventListener("menuclick", (e) => { + if (e.detail.id === "delete") { + if (confirm("Really delete?")) { + // … do the deletion + } + } +}); +``` + +If you call them from the webview side (via JavaScript inside the rendered +page), the webview's own native dialogs are used instead — these are +`window.alert()` and friends as the browser implements them. The behavior is +similar: a native modal scoped to that webview. + +## Differences from terminal Deno + +In a normal `deno run` script, these functions read from / write to the terminal +— `prompt` reads a line of stdin, `confirm` accepts `y` / `n`. That terminal +behavior would be invisible inside a desktop app, so `deno desktop` swaps them +out for native dialogs without any code change on your part. + +## File and folder dialogs + +Native file-picker and folder-picker dialogs are not yet exposed as a +first-class API. Until they are, two workarounds exist: + +1. **Use the webview's ``**. The webview shows the OS-native + picker, and the resulting `File` object can be sent over a binding for the + Deno side to handle: + + ```html + + + ``` + + ```ts + win.bind("handleFile", async (name, bytes) => { + await Deno.writeFile(name + ".bak", new Uint8Array(bytes)); + }); + ``` + +2. **Drag-and-drop into the webview**. Drop a file onto a `
`, read it with + the File API, and pass the bytes through a binding. + +A native file-picker API is on the roadmap. + +## Notification API + +Notifications are not yet a Deno desktop API. Use the Web `Notification` API +from the webview side — it works in the embedded webview and looks native: + +```js +new Notification("Build complete", { body: "Your binary is ready." }); +``` + +## Clipboard + +Same situation as notifications: use the Web `Clipboard` API +(`navigator.clipboard.readText()`, `writeText()`) from the webview side for now. diff --git a/runtime/desktop/distribution.md b/runtime/desktop/distribution.md new file mode 100644 index 000000000..9b01e351b --- /dev/null +++ b/runtime/desktop/distribution.md @@ -0,0 +1,209 @@ +--- +title: "Distribution" +description: "Cross-compile a deno desktop app for macOS, Windows, and Linux from one machine, and produce per-platform output formats — .app, .dmg, .exe directory, AppImage." +--- + +`deno desktop` cross-compiles from any host. The same machine builds for macOS +Intel, macOS arm64, Windows x86_64, Linux arm64, and Linux x86_64. Backend +binaries (CEF, WebView, etc.) are downloaded as needed. + +## Per-platform output + +```sh +# Build for the host platform. +deno desktop main.ts + +# Build for a specific target. +deno desktop --target aarch64-apple-darwin main.ts + +# Build for every supported target in one go. +deno desktop --all-targets main.ts +``` + +Supported triples: + +| Triple | OS | Architecture | +| --------------------------- | ------- | ------------ | +| `aarch64-apple-darwin` | macOS | arm64 | +| `x86_64-apple-darwin` | macOS | Intel | +| `x86_64-pc-windows-msvc` | Windows | x86_64 | +| `aarch64-unknown-linux-gnu` | Linux | arm64 | +| `x86_64-unknown-linux-gnu` | Linux | x86_64 | + +The CLI fetches the matching prebuilt `denort` and the matching prebuilt WEF +backend archive from `github.com/denoland/wef/releases`. No platform-specific +toolchain is needed on the host. + +## Output formats + +The output extension determines the format: + +### macOS + +| Output | Produced by | +| ------------ | -------------------------------------------- | +| `MyApp.app/` | Default — `.app` bundle. | +| `MyApp.dmg` | `hdiutil` — drag-to-Applications disk image. | + +The `.app` bundle has the standard layout: + +``` +MyApp.app/ + Contents/ + Info.plist + MacOS/ + MyApp # the launcher + Resources/ + icon.icns + Frameworks/ + Chromium Embedded Framework.framework/ # CEF backend + … +``` + +Self-extracting mode is enabled by default: the embedded virtual filesystem +(your code, framework build outputs, static assets) extracts to disk on first +run so frameworks like Next.js find their build output relative to CWD. + +### Windows + +| Output | Produced by | +| -------- | --------------------------------------------------- | +| `MyApp/` | Default — directory containing `.exe` and CEF DLLs. | + +The `MyApp/` directory contains: + +``` +MyApp/ + MyApp.exe + *.dll # CEF runtime DLLs + resources.pak # CEF resources + locales/ # CEF locales + … +``` + +Zip the directory or feed it into an installer toolchain. Windows MSI output is +not yet implemented; for now, use a third-party installer generator such as Inno +Setup, NSIS, or WiX with the directory as input. + +### Linux + +| Output | Produced by | +| ----------------- | --------------------------------------------- | +| `my-app/` | Default — app directory with launcher script. | +| `my-app.AppImage` | `appimagetool` — single-file portable bundle. | + +The app directory layout: + +``` +my-app/ + AppRun # launcher script + my-app # the binary + *.so # CEF shared libraries + resources.pak + locales/ + … +``` + +`AppImage` is the most portable Linux format — one file, no install step, runs +on any modern distro. It is built by passing `appimagetool` (which must be on +`PATH`) the staged directory plus a `.desktop` entry and an icon. + +`.deb` / `.rpm` packaging is not yet implemented. For now, use `fpm` or +`dpkg-deb` against the app directory. + +## Choosing the output path + +The output path can be set in three places, in priority order: + +1. The `--output` CLI flag. +2. The `desktop.output` field in `deno.json` (per-platform). +3. The default — the project name, with the platform-appropriate extension. + +```sh +# Override per build: +deno desktop --output ./builds/MyApp-1.4.0.dmg main.ts +``` + +```jsonc title="deno.json" +{ + "desktop": { + "output": { + "macos": "./dist/macos/MyApp.app", + "windows": "./dist/windows/MyApp", + "linux": "./dist/linux/my-app.AppImage" + } + } +} +``` + +## Cross-compilation details + +Cross-compiling from one OS to another requires: + +- The right `denort` binary for the target. Downloaded automatically from + `github.com/denoland/deno/releases`, matching your local Deno version. +- The right WEF backend archive for the target. Downloaded automatically from + `github.com/denoland/wef/releases`, pinned via `Cargo.lock`. + +Both downloads are SHA-256 verified and cached under `/`. + +There is **no Rust toolchain involved** in cross-compiling a desktop app. You +are not compiling Rust on the host — you are downloading prebuilt artifacts for +the target and packaging them with your code. This is the same model as +`deno compile --target`. + +The only thing the host can affect is the **icon assembly**: `.icns` generation +works on any host, `.ico` generation works on any host, but making installers +(`.dmg`, `.AppImage`) requires the matching tool — `hdiutil` for `.dmg` (macOS +only), `appimagetool` for `.AppImage`. To produce installers for a platform you +cannot run the tool on, build the bundle on a CI machine that can. + +## CI + +A typical GitHub Actions matrix builds platform-native installers in parallel: + +```yaml title=".github/workflows/release.yml" +jobs: + build: + strategy: + matrix: + include: + - { os: macos-14, target: aarch64-apple-darwin } + - { os: macos-15-intel, target: x86_64-apple-darwin } + - { os: windows-latest, target: x86_64-pc-windows-msvc } + - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: denoland/setup-deno@v2 + + - run: deno desktop --target ${{ matrix.target }} main.ts + + - uses: actions/upload-artifact@v4 + with: + name: my-app-${{ matrix.target }} + path: dist/ +``` + +Cross-compiling from a single host (e.g. only running on `ubuntu-latest` with +`--all-targets`) works for the bundles themselves. Producing `.dmg` / +`.AppImage` installers needs the matching native host. + +## Code signing + +Code-signing and notarization (a `--sign` flag) are not yet implemented. For +now, sign the produced bundle externally: + +- macOS: `codesign --deep --sign "Developer ID Application: …" MyApp.app` + followed by `xcrun notarytool submit`. +- Windows: `signtool sign /f cert.pfx /tr MyApp.exe`. + +A unified `--sign` story (with hardened runtime, helper-process entitlements, +and `notarytool` integration) is on the roadmap. Until then, treat signing as a +post-build CI step. + +## Distributing updates after release + +Once your binary is in users' hands, ship updates via +[`Deno.autoUpdate()`](/runtime/desktop/auto_update/) — `bsdiff` patches shipped +from your own server, no app store required. diff --git a/runtime/desktop/error_reporting.md b/runtime/desktop/error_reporting.md new file mode 100644 index 000000000..bda3a55c1 --- /dev/null +++ b/runtime/desktop/error_reporting.md @@ -0,0 +1,136 @@ +--- +title: "Error reporting" +description: "Capture uncaught errors, unhandled rejections, and Rust panics — show a native alert and POST a JSON report to your server." +--- + +`deno desktop` apps automatically catch: + +- Uncaught JavaScript exceptions (in both Deno-side and renderer-side code). +- Unhandled promise rejections. +- Rust panics inside the runtime or the rendering backend. + +When one of these happens, the runtime shows a native alert with the error +message, and — if you have configured a reporting URL — `POST`s a JSON report. + +## Configuration + +Set `desktop.errorReporting.url` in your `deno.json`: + +```jsonc +{ + "desktop": { + "errorReporting": { + "url": "https://errors.example.com/report" + } + } +} +``` + +The URL can use `https://`, `http://`, or `file://` (the last is useful for +local testing — the runtime writes the JSON to that path instead of making an +HTTP request). + +If `errorReporting.url` is not set, the alert still appears but no report is +sent. + +## Report format + +```json +{ + "version": 1, + "message": "TypeError: Cannot read properties of null", + "stack": "TypeError: Cannot read properties of null (reading 'foo')\n at handler (file:///main.ts:12:14)\n at …", + "appVersion": "1.4.0", + "timestamp": "2026-04-08T12:00:00.000Z", + "platform": "darwin", + "arch": "aarch64" +} +``` + +| Field | Type | Notes | +| ------------ | ---------------------------------- | -------------------------------------------------- | +| `version` | `1` | Schema version. Check this on the server side. | +| `message` | `string` | The error's `message`. | +| `stack` | `string` | The error's `stack`. Source-mapped where possible. | +| `appVersion` | `string \| null` | `Deno.desktopVersion` at the time of the error. | +| `timestamp` | ISO 8601 string | UTC timestamp of when the error was caught. | +| `platform` | `"darwin" \| "windows" \| "linux"` | `Deno.build.os`. | +| `arch` | string | `Deno.build.arch`. | + +The `Content-Type` header is `application/json`. Reports are sent as a single +POST with no retry — if your server is down, the report is lost. For +high-importance reports, queue them locally and resend on next launch. + +## What gets reported + +| Source | Captured? | +| -------------------------------------------- | ---------------------------------------------- | +| Uncaught exception in Deno-side code | Yes. | +| Unhandled rejection in Deno-side code | Yes. | +| Uncaught exception in renderer-side JS | Yes — caught via the renderer's `error` event. | +| Rust panic in the Deno runtime | Yes. | +| Rust panic in the rendering backend (CEF, …) | Yes — the WEF capi bridges these. | +| `console.error` / `console.warn` | No — these are not errors. | +| Exceptions you `try`/`catch` yourself | No. | + +Errors thrown inside a [binding](/runtime/desktop/bindings/) handler propagate +to the webview side and reject the calling promise. They are **not** reported as +uncaught errors — the webview catches them. To report them anyway, log them +yourself in the binding handler. + +## Disabling the alert + +The alert is meant to keep the user informed when something goes wrong. There is +no flag to disable it; if you don't want it, install your own handlers earlier: + +```ts +addEventListener("error", (e) => { + e.preventDefault(); // suppresses the default alert + reportToOwnTelemetry(e.error); +}); + +addEventListener("unhandledrejection", (e) => { + e.preventDefault(); + reportToOwnTelemetry(e.reason); +}); +``` + +Once `preventDefault` is called, the runtime does not show an alert and does not +send a report. You take full responsibility for surfacing the error to the user. + +## Server-side example + +A minimal reporter receiver: + +```ts title="server/report.ts" +Deno.serve({ port: 8080 }, async (req) => { + if (req.method !== "POST") return new Response(null, { status: 405 }); + + const report = await req.json(); + if (report.version !== 1) { + return new Response("unsupported version", { status: 400 }); + } + + await Deno.writeTextFile( + `./reports/${report.timestamp}.json`, + JSON.stringify(report, null, 2), + ); + return new Response(null, { status: 204 }); +}); +``` + +In production you would write to a database or forward to a proper +crash-collection service. + +## Privacy considerations + +The default report includes `stack` traces, which may contain user data embedded +in error messages (filenames, URLs, query parameters, serialized object fields). +If your app handles sensitive data, consider: + +- Stripping arguments from stack frames before sending. +- Redacting URLs of `Deno.readTextFile` calls and similar. +- Asking the user before sending the first report (a one-time consent prompt). + +These are app-level decisions; the runtime sends what it has. To filter, +implement your own `error` / `unhandledrejection` handlers as shown above. diff --git a/runtime/desktop/frameworks.md b/runtime/desktop/frameworks.md new file mode 100644 index 000000000..ebeb442ca --- /dev/null +++ b/runtime/desktop/frameworks.md @@ -0,0 +1,162 @@ +--- +title: "Frameworks" +description: "Run Next.js, Astro, Fresh, Remix, Nuxt, SvelteKit, SolidStart, TanStack Start, and Vite SSR projects as desktop apps with no code changes." +--- + +Point `deno desktop` at a directory and it auto-detects the framework, picks the +right entry point, embeds the build output in the binary, and runs the +framework's production server (or dev server under `--hmr`) with the webview +pointed at it. + +```sh +# Inside a Next.js / Astro / Fresh / etc. project: +deno desktop . +``` + +No code changes, no special adapter. The same project that runs as a web app +ships as a desktop app. + +## Detection + +Detection is based on config files and `package.json` dependencies. The first +match wins. + +| Framework | Detected by | +| --------------- | -------------------------------------------------------- | +| Next.js | `next.config.{js,mjs,ts}` | +| Astro | `astro.config.{mjs,ts,js}` | +| Fresh | `fresh.gen.ts` or `_fresh/` directory | +| Remix | `@remix-run/react` or `@remix-run/dev` in `package.json` | +| Nuxt | `nuxt.config.{ts,js,mjs}` | +| SvelteKit | `svelte.config.{js,ts}` | +| SolidStart | `@solidjs/start` in `package.json` | +| TanStack Start | `@tanstack/{react,solid}-start` in `package.json` | +| Vite (SSR mode) | `vite.config.*` plus a `server.{js,ts,mjs}` entry | + +If none match, `deno desktop` falls back to treating the path as a script — the +same as `deno desktop main.ts`. You write a `Deno.serve()` handler and serve +your own UI. + +## What detection does + +When a framework is detected, the CLI: + +1. **Generates a synthetic entry point** that imports the framework's production + server (or dev server under `--hmr`). +2. **Embeds the build output** into the binary's virtual filesystem (`.next/`, + `dist/`, `.output/`, `_fresh/`, `build/`, etc., depending on the framework). +3. **Self-extracts the VFS at runtime** so framework code finds its build output + relative to its own working directory — Next.js looks under `.next/`, Astro + under `dist/`, and so on. +4. **Runs the framework server** as your `Deno.serve()` handler. The webview + navigates to the bound port like any other desktop app. + +You should still build your project before running `deno desktop` — +`deno +desktop` does not run `next build`, `astro build`, etc. for you. Run the +framework's build step first. + +## Per-framework notes + +### Next.js + +```sh +cd my-next-app +npx next build # produce .next/ +deno desktop . +``` + +Production: imports `next/dist/cli/next-start.js`. Dev (under `--hmr`): +`next/dist/cli/next-dev.js`. The `.next/` directory is embedded. + +App Router and Pages Router both work. + +### Astro + +```sh +npm run build # produce dist/ +deno desktop . +``` + +Astro projects with an SSR adapter import `./dist/server/entry.mjs`. Static +projects (no adapter) are served via Deno's static file server pointed at +`dist/`. + +Both modes work; SSR has access to the full Astro request lifecycle, static mode +is faster to start. + +### Fresh + +```sh +deno task build # produce _fresh/ +deno desktop . +``` + +Fresh 2.x: imports `_fresh/server.js` and runs the Vite dev server under +`--hmr`. Fresh 1.x: imports `./main.ts` directly. + +### Remix + +```sh +npm run build +deno desktop . +``` + +Production: runs `remix-serve` against the `build/` directory. Dev (under +`--hmr`): `@remix-run/dev` CLI. + +### Nuxt + +```sh +npm run build # produce .output/ +deno desktop . +``` + +Uses Nuxt's Nitro output at `.output/server/index.{ts,mjs}`. Dev (under +`--hmr`): `nuxi dev`. + +### SvelteKit + +```sh +npm run build +deno desktop . +``` + +Looks for `.deno-deploy/server.ts` first (the Deno Deploy adapter's output), +falling back to `.output/server/index.{ts,mjs}` (the Node adapter's output). +Dev: Vite dev server. + +If you use a different adapter (`@sveltejs/adapter-static`, etc.), serve the +output directory yourself with `Deno.serve()` instead of relying on detection. + +### SolidStart and TanStack Start + +Both use the Nitro framework underneath; detection handles them via the +`.output/server/index.*` entry. Build first (`npm run build`) before running +`deno desktop`. + +### Vite SSR + +Plain Vite projects with a custom SSR entry (`server.ts`, `server.js`, +`server.mjs`) work with `deno desktop` if there is also a `vite.config.*`. +Production runs the SSR entry directly; dev (under `--hmr`) runs the Vite dev +server in middleware mode. + +## Forcing a framework or opting out + +There is no flag to force detection. To opt out — to ship a framework project +without using detection — pass an explicit script entry: + +```sh +deno desktop ./my-server.ts +``` + +In `my-server.ts` you import and start the framework yourself. Use this when you +need control over startup that the detection cannot express. + +## Hot reload in framework projects + +Under `--hmr` the framework's own dev server runs and the webview connects to it +directly. State preservation, fast refresh, and error overlays all work the same +as in a browser. See [HMR](/runtime/desktop/hmr/) for details on both framework +and non-framework HMR modes. diff --git a/runtime/desktop/hmr.md b/runtime/desktop/hmr.md new file mode 100644 index 000000000..1d0c12440 --- /dev/null +++ b/runtime/desktop/hmr.md @@ -0,0 +1,123 @@ +--- +title: "Hot module replacement" +description: "deno desktop --hmr keeps the runtime and rendering backend alive across edits — framework dev servers in framework projects, V8 hot-swap in everything else." +--- + +```sh +deno desktop --hmr . +``` + +`--hmr` enables hot module replacement during development. The mode is selected +automatically based on what your project looks like: + +| Project type | HMR mechanism | +| --------------------------------- | ------------------------------------------ | +| Detected framework (Next.js etc.) | The framework's own dev server. | +| Plain `Deno.serve()` script | File watcher + `Debugger.setScriptSource`. | + +In both modes the Deno runtime and the rendering backend (CEF, WebView, …) stay +alive across changes. There is no full restart, no webview teardown, no +reconnect. + +## Framework HMR + +When framework detection identifies your project (see +[Frameworks](/runtime/desktop/frameworks/)), `--hmr` runs the framework's own +dev server instead of its production server. The webview connects to that dev +server directly — fast refresh, state preservation, and error overlays all work +the same as in a browser tab. + +```sh +deno desktop --hmr . # in a Next.js / Astro / Fresh / … project +``` + +The dev server's exact behavior comes from the framework. If `next dev` +preserves component state across edits in a browser, it preserves it in your +desktop app too. If `astro dev` shows an in-page error overlay on a syntax +error, you see the same overlay. + +You do not need to run the framework's dev script separately. +`deno +desktop --hmr` starts it as part of the desktop runtime. + +## Plain-app HMR + +For projects without a detected framework, `--hmr` watches your source files and +uses V8's `Debugger.setScriptSource` to hot-swap modules into the running +isolate. + +```ts title="main.ts" +Deno.serve((req) => { + return new Response("hello world"); +}); +``` + +```sh +deno desktop --hmr main.ts +``` + +Edit `main.ts` — change the response body, add a route — and the change applies +on save. The runtime does not restart, the webview does not reload, the +listening socket stays bound. + +### What persists across reloads + +`Debugger.setScriptSource` replaces the **code** of a function with new code. +Live values stay the same: + +- Module-level state (top-level `let`, top-level `Map`, etc.) is preserved. +- Open file handles, network connections, child processes — all preserved. +- The HTTP listener is preserved. +- Timers and intervals keep firing on their original schedule unless you + `clearTimeout` / `clearInterval` them. + +### What changes on the next call + +The replaced functions execute their new bodies the next time they are called. +So: + +- A request handler change takes effect on the next request. +- A timer callback change takes effect on the next firing. +- An event listener change takes effect on the next event. + +### What HMR cannot do + +`Debugger.setScriptSource` has limits. It cannot replace: + +- Top-level statements that have already executed (a `console.log` at module + scope only runs when the module is first loaded). +- The signature of a class — adding fields, changing constructors. The class + declaration is replaced; existing instances keep their old shape. +- The set of imports — adding a new `import` line requires a full reload. + +When the change is too disruptive to apply incrementally, `--hmr` falls back to +a full reload of the affected module. If even that is not safe (for example, +top-level state would be lost in a way the runtime cannot recover from), it logs +a warning suggesting a full restart. + +## Browser-side HMR + +The webview is a browser. Browser HMR — fast refresh in React, Vue's HMR +runtime, etc. — runs entirely inside the rendering backend, talking to your dev +server. `deno desktop --hmr` does not interfere with it; if your framework wires +browser HMR up, it works as designed. + +The Deno-side HMR described on this page is **separate** from browser HMR. The +two coexist: + +- A change to a React component file → browser HMR applies it inside the + webview. +- A change to your `Deno.serve()` handler or a binding implementation → + Deno-side HMR applies it inside the runtime. + +You almost never need to think about the split — both happen on save. + +## Limitations and caveats + +- `--hmr` is for development only. Do not ship a binary built with `--hmr`; the + file watcher and inspector overhead are not appropriate for end users. +- Source maps are required for accurate line numbers in stack traces after a hot + swap. They are emitted by default; do not disable them in your bundler config. +- HMR cooperates with `--inspect` (see [DevTools](/runtime/desktop/devtools/)). + You can attach a debugger to a running `--hmr` session and step through + newly-swapped code. diff --git a/runtime/desktop/index.md b/runtime/desktop/index.md new file mode 100644 index 000000000..a24058e03 --- /dev/null +++ b/runtime/desktop/index.md @@ -0,0 +1,103 @@ +--- +title: "Desktop apps" +description: "Build self-contained desktop applications from a Deno project, with framework auto-detection, hot reload, native windowing, auto-update, and cross-platform distribution." +--- + +`deno desktop` turns a Deno project — anything from a single TypeScript file to +a Next.js app — into a self-contained desktop application. The output is a +redistributable binary that bundles your code, the Deno runtime, and a web +rendering engine into one bundle per platform. + +:::info Experimental + +`deno desktop` is new in Deno 2.8. The command, configuration keys, and +TypeScript APIs described in this section may change before the feature is +considered stable. + +::: + +## Why `deno desktop` + +Web technology is the most widely-known UI toolkit in the world. Desktop apps +built on web stacks (Electron, Tauri, Electrobun) take advantage of that, but +each has tradeoffs you have to live with: huge binaries, missing platform +support, no JavaScript ecosystem, no built-in update story, no framework +integration. + +`deno desktop` is opinionated about those tradeoffs: + +- **Bundled engine, full Node compatibility.** The default backend is Chromium + (CEF). Rendering is consistent across macOS, Windows, and Linux, and you still + have the entire npm ecosystem available through Deno's Node compat layer. +- **Framework auto-detection.** Point `deno desktop` at a Next.js, Astro, Fresh, + Remix, Nuxt, SvelteKit, SolidStart, TanStack Start, or Vite SSR project and it + just works — production server in release mode, dev server with hot reload + under `--hmr`. No code changes required to take an existing web project to the + desktop. +- **In-process bindings instead of IPC.** Backend / UI communication goes + through tokio channels, not socket-based IPC. No serialization tax between + your Deno code and the webview. +- **Cross-compile from one machine.** The same machine can build for macOS, + Windows, and Linux. Backends are downloaded as needed, not built locally. +- **Built-in binary-diff auto-update.** Ship a single `latest.json` manifest and + bsdiff patches; the runtime polls, applies, and rolls back automatically on + failed launches. + +## Hello, desktop + +Create a one-file desktop app: + +```ts title="main.ts" +Deno.serve(() => + new Response("

Hello, desktop

", { + headers: { "content-type": "text/html" }, + }) +); +``` + +```sh +deno desktop main.ts +``` + +The compiled binary opens a window pointed at a local HTTP server bound to your +`Deno.serve()` handler. Run it directly: + +```sh +./main # macOS / Linux +.\main.exe # Windows +``` + +`Deno.serve()` automatically binds to the address the webview navigates to — you +do not need to pass a port or hostname. See +[HTTP serving](/runtime/desktop/serving/) for details. + +## What's in this section + +- [Configuration](/runtime/desktop/configuration/) — the `desktop` block in + `deno.json`. +- [Backends](/runtime/desktop/backends/) — CEF, webview, raw; how to choose. +- [HTTP serving](/runtime/desktop/serving/) — `Deno.serve()` integration and the + serving model. +- [Frameworks](/runtime/desktop/frameworks/) — Next.js, Astro, Fresh, Remix, + Nuxt, SvelteKit, and friends. +- [Windows](/runtime/desktop/windows/) — `Deno.BrowserWindow` lifecycle, + multiple windows, events. +- [Bindings](/runtime/desktop/bindings/) — calling Deno code from the webview + via `bindings.()`. +- [Menus](/runtime/desktop/menus/) — application and context menus. +- [Tray and dock](/runtime/desktop/tray_and_dock/) — system status icons and the + macOS dock. +- [Dialogs](/runtime/desktop/dialogs/) — `prompt()`, `alert()`, `confirm()` as + native popups. +- [Hot module replacement](/runtime/desktop/hmr/) — `--hmr` for framework and + non-framework apps. +- [DevTools](/runtime/desktop/devtools/) — unified DevTools attached to both the + Deno runtime and the webview. +- [Auto-update](/runtime/desktop/auto_update/) — `Deno.autoUpdate()`, manifests, + bsdiff, rollback. +- [Error reporting](/runtime/desktop/error_reporting/) — capturing uncaught + exceptions and panics. +- [Distribution](/runtime/desktop/distribution/) — cross-compilation, output + formats, installers. +- [Comparison](/runtime/desktop/comparison/) — how `deno desktop` relates to + Electron, Tauri, Electrobun, Dioxus. diff --git a/runtime/desktop/menus.md b/runtime/desktop/menus.md new file mode 100644 index 000000000..cdbabc2db --- /dev/null +++ b/runtime/desktop/menus.md @@ -0,0 +1,197 @@ +--- +title: "Menus" +description: "Build native application menu bars and right-click context menus, with submenus, accelerators, separators, checkboxes, and click events." +--- + +`deno desktop` exposes two kinds of native menus: the **application menu** +(macOS menu bar, Windows / Linux window menu) and **context menus** (right-click +popups). + +Both use the same `MenuItem` shape. + +## `MenuItem` shape + +```ts +interface MenuItem { + id?: string; // returned in the click event + label: string; + type?: "normal" | "separator" | "checkbox" | "submenu"; + enabled?: boolean; + checked?: boolean; // for type: "checkbox" + accelerator?: string; // e.g. "CmdOrCtrl+S", "F11" + submenu?: MenuItem[]; // for type: "submenu" +} +``` + +Set `type: "separator"` for a divider; the `label` is ignored. + +Set `type: "submenu"` and `submenu: [...]` for nested menus. + +## Application menu + +Set the menu shown in the macOS menu bar (or the Windows / Linux window menu) +for a window: + +```ts +win.setApplicationMenu([ + { + label: "File", + type: "submenu", + submenu: [ + { id: "new", label: "New", accelerator: "CmdOrCtrl+N" }, + { id: "open", label: "Open…", accelerator: "CmdOrCtrl+O" }, + { type: "separator", label: "" }, + { id: "save", label: "Save", accelerator: "CmdOrCtrl+S" }, + { id: "quit", label: "Quit", accelerator: "CmdOrCtrl+Q" }, + ], + }, + { + label: "View", + type: "submenu", + submenu: [ + { + id: "fullscreen", + label: "Full Screen", + accelerator: "F11", + type: "checkbox", + checked: false, + }, + ], + }, +]); +``` + +Listen for clicks via the `menuclick` event: + +```ts +win.addEventListener("menuclick", (e) => { + switch (e.detail.id) { + case "new": + newDocument(); + break; + case "open": + openDocument(); + break; + case "save": + saveDocument(); + break; + case "quit": + Deno.exit(0); + break; + } +}); +``` + +`e.detail.id` is the `id` field you set on the item. Items without an `id` do +not produce events. + +### Accelerators + +Accelerators are global within the focused window. The string format is +`Modifier+Modifier+Key`: + +| Modifier | Notes | +| ----------- | ------------------------------------------- | +| `Cmd` | macOS only | +| `Ctrl` | All platforms | +| `CmdOrCtrl` | `Cmd` on macOS, `Ctrl` elsewhere | +| `Alt` | All platforms (`Option` on macOS keyboards) | +| `Shift` | All platforms | +| `Super` | The "Windows" / `Meta` key | + +Keys are letters (`A`-`Z`), numbers (`0`-`9`), function keys (`F1`-`F24`), or +named keys (`Enter`, `Esc`, `Up`, `Down`, `Left`, `Right`, `Tab`, `Space`, +`Backspace`, `Delete`). + +Use `CmdOrCtrl` rather than `Cmd` or `Ctrl` directly so you do not need to +branch on platform for the most common shortcuts. + +### macOS menu bar peculiarities + +On macOS, the **first** top-level menu item is the application menu (the one +with your app's name). If you do not provide one, it is generated automatically +with sensible defaults: About, Hide, Show All, Quit. + +The "Edit" menu's standard items (Cut, Copy, Paste, Select All, Undo, Redo) are +wired automatically when you include an item with the matching `role`. (Roles +are not yet exposed in the API; users get default Cut / Copy / Paste behavior in +editable webview content for free, but native menu items pointing at them +require manual `executeJs` for now.) + +## Context menus + +Show a context menu on right-click: + +```ts +win.addEventListener("contextmenu", (e) => { + e.preventDefault(); + win.showContextMenu([ + { id: "copy", label: "Copy" }, + { id: "paste", label: "Paste" }, + { type: "separator", label: "" }, + { id: "props", label: "Properties…" }, + ]); +}); + +win.addEventListener("menuclick", (e) => { + if (e.detail.id === "copy") { /* ... */ } + if (e.detail.id === "paste") { /* ... */ } +}); +``` + +`showContextMenu` opens at the current pointer position. To open at a specific +point, pass coordinates: + +```ts +win.showContextMenu(items, { x: 100, y: 200 }); +``` + +The same `menuclick` event handles both application and context menu clicks. If +you need to distinguish them, namespace the IDs (e.g. `"file:save"` vs +`"ctx:copy"`) or set state before opening the context menu. + +## Dynamic menus + +The application menu can be replaced at any time by calling `setApplicationMenu` +again — the OS replaces the menu in place. There is no "update single item" API; +rebuild the array and call `setApplicationMenu` when state changes: + +```ts +function rebuildEditMenu(canUndo: boolean) { + win.setApplicationMenu([ + { + label: "Edit", + type: "submenu", + submenu: [ + { + id: "undo", + label: "Undo", + accelerator: "CmdOrCtrl+Z", + enabled: canUndo, + }, + ], + }, + ]); +} +``` + +For frequently-updated menus (every keystroke), batch updates rather than +calling on every change. + +## Disabled and hidden items + +```ts +{ id: "save", label: "Save", enabled: false } +``` + +There is no `visible: false` flag. To hide an item, exclude it from the array. + +## Checkbox items + +```ts +{ id: "fullscreen", type: "checkbox", label: "Full Screen", checked: true } +``` + +The check state is **not** toggled automatically when the user clicks. You must +update `checked` and call `setApplicationMenu` again, or manage your own state +and rebuild on the next click. diff --git a/runtime/desktop/serving.md b/runtime/desktop/serving.md new file mode 100644 index 000000000..17665a5f3 --- /dev/null +++ b/runtime/desktop/serving.md @@ -0,0 +1,114 @@ +--- +title: "HTTP serving" +description: "How Deno.serve() works inside a desktop app — automatic port binding, the DENO_SERVE_ADDRESS env var, and serving local UI to the embedded webview." +--- + +A `deno desktop` app serves its UI over local HTTP and points the embedded +webview at it. This keeps the app structure identical to a normal Deno website — +`Deno.serve()` is the entry point, every request flows through your handler — +but with no port to manage and no remote network exposure. + +## How it works + +When the binary starts: + +1. The runtime picks an unused local port and sets the `DENO_SERVE_ADDRESS` + environment variable to `http://127.0.0.1:`. +2. Your code calls `Deno.serve(...)`. The serve API reads `DENO_SERVE_ADDRESS` + (set by Deno itself in this mode, not by the user) and binds to that port — + ignoring whatever port you pass. +3. The webview navigates to `http://127.0.0.1:` once the listener is + ready. + +You write the same handler you would for any Deno HTTP server. There is no +desktop-specific serving API. + +```ts title="main.ts" +Deno.serve((req) => { + const url = new URL(req.url); + if (url.pathname === "/api/hello") { + return Response.json({ hello: "world" }); + } + return new Response(HOMEPAGE, { + headers: { "content-type": "text/html" }, + }); +}); + +const HOMEPAGE = ` + +

Hello, desktop

+ +`; +``` + +```sh +deno desktop main.ts +``` + +The default-export form works too: + +```ts title="main.ts" +export default { + fetch(req: Request): Response { + return new Response("Hello!"); + }, +}; +``` + +## Why local HTTP? + +The local-HTTP architecture trades a tiny amount of overhead for properties that +matter for desktop apps: + +- **Same code in browser and desktop.** The homepage, fetch, websockets, and + cookies all behave identically in `deno run` and `deno desktop`. You can + develop in a browser tab and ship the same code as a desktop binary. +- **No special module system.** Imports, static assets, and module-level code + all run the way they would for a web server. +- **Frameworks "just work".** Next.js, Astro, Fresh, and the rest already ship a + production HTTP server. `deno desktop` runs that server and points the webview + at it. See [Frameworks](/runtime/desktop/frameworks/). + +The cost is a single network hop within `127.0.0.1` per request. For UI serving +— HTML, CSS, bundled JS, JSON API responses — this is negligible. + +For high-throughput Deno → webview communication where the overhead matters, use +[bindings](/runtime/desktop/bindings/), which bypass HTTP entirely and route +through tokio channels. + +## Network exposure + +The bound address is **always** `127.0.0.1` (or `[::1]`). The compiled binary +never binds to a public interface, even if you pass `0.0.0.0` to `Deno.serve()`. +Other apps and other users on the same machine cannot reach your server. + +If you need to serve users on other machines (a self-hosted local server), do +not use `deno desktop` for that part of your stack — use `deno run` with an +explicit address, or build a separate service. + +## Custom port behavior + +You cannot override the port `Deno.serve()` binds to inside `deno desktop`. This +is intentional — the webview needs to navigate to the same port the runtime is +listening on, and the runtime is the source of truth for that value. + +If you need to know the URL inside your handler: + +```ts +console.log("Serving on:", Deno.env.get("DENO_SERVE_ADDRESS")); +``` + +## Serving multiple windows + +When you create additional [windows](/runtime/desktop/windows/), they all load +from the same local HTTP server by default. Use different paths per window to +differentiate: + +```ts +const settings = new Deno.BrowserWindow(); +settings.navigate("http://127.0.0.1:" + port + "/settings"); +``` + +The `port` is available via `Deno.env.get("DENO_SERVE_ADDRESS")`. diff --git a/runtime/desktop/tray_and_dock.md b/runtime/desktop/tray_and_dock.md new file mode 100644 index 000000000..7907e0273 --- /dev/null +++ b/runtime/desktop/tray_and_dock.md @@ -0,0 +1,211 @@ +--- +title: "Tray and dock" +description: "Add icons to the OS status area and the macOS dock — tooltips, dark-mode variants, click events, and right-click context menus." +--- + +`Deno.Tray` puts an icon in the system status area (macOS menu bar extras, +Windows system tray, Linux AppIndicator). `Deno.dock` controls the macOS dock +icon — badge, bounce, hide, and show. + +## `Deno.Tray` + +```ts +const icon = await Deno.readFile("./icons/tray.png"); + +const tray = new Deno.Tray(); +tray.setIcon(icon); +tray.setTooltip("My App"); + +tray.setMenu([ + { id: "open", label: "Open" }, + { id: "quit", label: "Quit" }, +]); + +tray.addEventListener("menuclick", (e) => { + if (e.detail.id === "open") Deno.BrowserWindow.main.show(); + if (e.detail.id === "quit") Deno.exit(0); +}); +``` + +### Lifecycle + +The icon stays in the status area until you call `tray.destroy()` (or the +process exits). Multiple trays can coexist — useful for app indicators that need +separate control surfaces. + +```ts +tray.destroy(); +``` + +`Tray` is also a `Disposable`, so it works with `using`: + +```ts +{ + using tray = new Deno.Tray(); + // ... +} // automatically destroyed at scope exit +``` + +### Setting the icon + +```ts +tray.setIcon(pngBytes); // bytes, not a path +tray.setIconDark(darkPngBytes); // optional dark-mode variant +tray.setIconDark(null); // clear the dark icon +``` + +Pass PNG-encoded bytes, not a file path. Read the file yourself: + +```ts +const png = await Deno.readFile("./icons/tray.png"); +tray.setIcon(png); +``` + +Provide a separate dark-mode icon via `setIconDark` if you want different +contrast for dark menu bars (macOS 10.14+, modern Linux). Without one, the same +icon is used in both modes. + +For best results, use a **template image** style (mostly opaque silhouette, +transparent elsewhere) at a small size — 22×22 logical pixels for macOS, 16×16 +for Windows. + +### Tooltip + +```ts +tray.setTooltip("My App — 3 unread"); +tray.setTooltip(null); // remove tooltip +``` + +### Context menu + +Right-click on the tray icon opens the menu set by `setMenu`. The shape is the +same `MenuItem[]` used by +[application and context menus](/runtime/desktop/menus/): + +```ts +tray.setMenu([ + { id: "open", label: "Open" }, + { type: "separator", label: "" }, + { id: "settings", label: "Settings…", accelerator: "CmdOrCtrl+," }, + { type: "separator", label: "" }, + { id: "quit", label: "Quit", accelerator: "CmdOrCtrl+Q" }, +]); + +tray.addEventListener("menuclick", (e) => { + switch (e.detail.id) { + case "open": + showMain(); + break; + case "settings": + showSettings(); + break; + case "quit": + Deno.exit(0); + break; + } +}); + +tray.clearMenu(); // remove the menu without destroying the tray +``` + +Submenus and checkboxes work the same as in the application menu. + +### Click events + +```ts +tray.addEventListener("click", () => Deno.BrowserWindow.main.show()); +tray.addEventListener("dblclick", () => openSettings()); +``` + +`click` fires on a primary-button click. `dblclick` fires on a double-click. On +platforms where right-click is reserved for the context menu (everywhere), only +left-click produces these events. + +### Platform support + +Tray icons rely on the OS providing a status area. The relevant backends support +tray on: + +- **macOS**: status menu items (NSStatusItem). +- **Windows**: system tray (NotifyIcon). +- **Linux**: AppIndicator / KStatusNotifierItem. Requires a desktop environment + that surfaces them — most do, but some minimal i3 setups need extras like + `swaync` or `polybar` configuration. + +If the backend cannot create a tray icon, the constructor's underlying `trayId` +is `0` and subsequent calls are no-ops (silently). Check `tray.trayId !== 0` if +you need to fall back gracefully. + +## `Deno.dock` (macOS) + +`Deno.dock` is a single object exposing macOS dock controls. On Windows and +Linux, the same APIs exist but most are no-ops — they fail gracefully rather +than throwing. + +### Badge + +```ts +Deno.dock.setBadge("3"); // small label on the dock icon +Deno.dock.setBadge(null); // clear +``` + +Badges are short strings — typically a count. The OS truncates long strings. + +### Bounce + +```ts +Deno.dock.bounce("informational"); // gentle bounce +Deno.dock.bounce("critical"); // bounces until the app gains focus + +Deno.dock.cancelBounce(); +``` + +`"informational"` bounces once. `"critical"` bounces until cancelled or the app +gains focus. + +### Visibility + +```ts +Deno.dock.hide(); // remove the icon from the dock +Deno.dock.show(); // restore it +``` + +A hidden dock icon does not show the app in the dock or the Cmd-Tab switcher. +Use this for menu-bar-only apps that should not appear in the dock. + +When hidden, the application still runs and can show windows; users can reach it +via Spotlight or the tray icon. + +### Setting the dock image + +```ts +const png = await Deno.readFile("./icons/dock.png"); +Deno.dock.setIcon(png); + +Deno.dock.setIcon(null); // restore the bundled icon +``` + +## Pattern: tray-only background app + +To run as a status-bar-only background process (no dock, no main window): + +```ts +Deno.dock.hide(); // macOS: hide the dock icon +Deno.BrowserWindow.main.hide(); // hide the implicit main window + +const tray = new Deno.Tray(); +tray.setIcon(await Deno.readFile("./icons/tray.png")); +tray.setTooltip("My App"); +tray.setMenu([ + { id: "show", label: "Show window" }, + { id: "quit", label: "Quit" }, +]); + +tray.addEventListener("menuclick", (e) => { + if (e.detail.id === "show") Deno.BrowserWindow.main.show(); + if (e.detail.id === "quit") Deno.exit(0); +}); +``` + +The implicit main window is created when your binary starts; hiding it keeps it +ready to be shown without a startup delay later. diff --git a/runtime/desktop/windows.md b/runtime/desktop/windows.md new file mode 100644 index 000000000..77203c2c7 --- /dev/null +++ b/runtime/desktop/windows.md @@ -0,0 +1,185 @@ +--- +title: "Windows" +description: "Create and manage native windows with Deno.BrowserWindow — lifecycle, multiple windows, sizing, navigation, keyboard / mouse / focus events, and native window handles." +--- + +The `Deno.BrowserWindow` class controls native windows. The first window opens +automatically when your binary starts; create more by constructing +`new Deno.BrowserWindow()`. All windows share the same Deno runtime — there is +one tokio runtime per process, regardless of how many windows are open. + +## Creating windows + +```ts +const main = Deno.BrowserWindow.main; // the implicit main window + +const settings = new Deno.BrowserWindow(); +settings.setTitle("Settings"); +settings.setSize(420, 320); +settings.navigate( + "http://127.0.0.1:" + Deno.env.get("DENO_SERVE_ADDRESS") + "/settings", +); +``` + +`new Deno.BrowserWindow()` opens a window immediately. The window is alive until +`close()` is called or the user closes it from the OS. + +Multiple windows are independent: each has its own size, position, focus state, +and webview. They can navigate to different paths or different origins, set +their own bindings, and emit their own events. + +## Lifecycle + +```ts +win.show(); +win.hide(); +win.focus(); +win.close(); // sends close request, fires "close" event +win.reload(); // reload the webview's current document + +if (win.isClosed) { /* … */ } +if (win.isVisible) { /* … */ } +``` + +Closing a window does not stop the runtime — the process keeps running until all +windows are closed (or you call `Deno.exit()`). + +## Size and position + +```ts +const [w, h] = win.getSize(); +win.setSize(800, 600); + +const [x, y] = win.getPosition(); +win.setPosition(100, 100); + +if (win.isResizable) { /* … */ } +win.setResizable(false); + +if (win.isAlwaysOnTop) { /* … */ } +win.setAlwaysOnTop(true); +``` + +Sizes are in logical pixels. The OS handles HiDPI scaling. + +## Title + +```ts +win.setTitle("My App — Untitled"); +``` + +Use a stable prefix plus a document-specific suffix; this is what users see in +window switchers, the dock, and the taskbar. + +## Navigation + +```ts +win.navigate("http://127.0.0.1:" + port); +``` + +Navigation works with any URL the embedded webview can load — most commonly the +local HTTP server (see [HTTP serving](/runtime/desktop/serving/)), but also +`https://` URLs, `file://` URLs, and `data:` URLs. + +For multi-page apps, use the local HTTP server's routing rather than swapping +windows. For modal dialogs, prefer creating a child window over navigating away. + +## Events + +`Deno.BrowserWindow` is an `EventTarget`. Listen with `addEventListener` or +assign to the matching `on` property. + +```ts +win.addEventListener("resize", (e) => { + console.log("resized to", e.width, e.height); +}); + +win.onfocus = () => console.log("focused"); +win.onblur = () => console.log("blurred"); +``` + +| Event | When it fires | +| ----------- | ----------------------------------------------- | +| `resize` | The window's size changed. | +| `move` | The window's position changed. | +| `focus` | The window gained focus. | +| `blur` | The window lost focus. | +| `close` | The user requested the window close. | +| `keydown` | A key was pressed while the window was focused. | +| `keyup` | A key was released. | +| `mousemove` | The pointer moved over the window. | +| `mousedown` | A mouse button was pressed. | +| `mouseup` | A mouse button was released. | +| `click` | A mouse click landed on the window chrome. | +| `wheel` | A scroll wheel / trackpad scroll happened. | + +The event objects mirror the browser equivalents (`KeyboardEvent`, `MouseEvent`, +`WheelEvent`) where applicable. + +```ts +win.addEventListener("keydown", (e) => { + if (e.key === "Escape") win.close(); +}); +``` + +## Running JavaScript in the webview + +```ts +const result = await win.executeJs( + "document.querySelectorAll('li').length", +); +console.log(result); // number of
  • on the current page +``` + +`executeJs` runs the code in the webview's main world and returns the result. +The result must be JSON-serializable. + +For richer Deno ↔ webview communication, use +[bindings](/runtime/desktop/bindings/) instead. + +## Native window handle + +```ts +const handle = win.getNativeWindow(); +``` + +Returns the platform-native handle (`NSWindow*` on macOS, `HWND` on Windows, the +X11 / Wayland handle on Linux). Use this to integrate with platform APIs that +need a window handle — for example, native graphics overlays, drag sources, or +accessibility APIs. + +`getNativeWindow()` returns a `Deno.UnsafePointer`. You are responsible for +calling the right platform APIs and not retaining the handle past the window's +lifetime. + +## DevTools + +```ts +win.openDevtools(); // both isolates +win.openDevtools({ deno: false }); // renderer only +win.openDevtools({ renderer: false }); // Deno runtime only +``` + +See [DevTools](/runtime/desktop/devtools/). + +## Closing the app + +The runtime exits when no windows are open and there are no other live async +tasks (timers, pending fetches, etc.). To exit explicitly: + +```ts +Deno.exit(0); +``` + +To prevent close — for example, to show a "Save?" dialog — listen for `close` +and call `event.preventDefault()`: + +```ts +win.addEventListener("close", async (e) => { + if (hasUnsavedChanges) { + e.preventDefault(); + const ok = await win.executeJs("confirm('Discard changes?')"); + if (ok) win.close(); + } +}); +``` diff --git a/runtime/reference/cli/desktop.md b/runtime/reference/cli/desktop.md new file mode 100644 index 000000000..84d822b16 --- /dev/null +++ b/runtime/reference/cli/desktop.md @@ -0,0 +1,74 @@ +--- +last_modified: 2026-04-24 +title: "deno desktop" +description: "Build self-contained desktop applications from a Deno project" +--- + +Build a self-contained desktop application from a Deno project. The compiled +binary bundles your code, the Deno runtime, and a rendering backend (Chromium, +the OS webview, or a raw windowing system) into one redistributable executable. + +For an in-depth guide — backends, framework auto-detection, the +`Deno.BrowserWindow` API, auto-update, DevTools, distribution, and more — see +the [Desktop apps section](/runtime/desktop/). + +```sh +deno desktop main.ts +deno desktop --hmr main.ts +deno desktop --output MyApp.app main.ts +``` + +## Synopsis + +``` +Usage: deno desktop [OPTIONS] [SCRIPT_ARG]... +``` + +## Options + +| Flag | Description | +| -------------------------------- | ------------------------------------------------------------------------------------------- | +| `--all-targets` | Build for all supported target platforms. | +| `--backend ` | WEF backend: `cef` (default), `webview`, `servo`, `raw`. | +| `--exclude ` | Exclude a file or directory from the compiled binary. | +| `--hmr` | Run the desktop app with hot module replacement. | +| `--icon ` | Application icon (`.ico` on Windows, `.icns` or `.png` on macOS). | +| `--include ` | Include an additional file, directory, or module in the compiled binary. | +| `-o`, `--output ` | Output path. Extension determines format (`.app`, `.dmg`, `.AppImage`, …). | +| `--target ` | Target triple — see [Distribution](/runtime/desktop/distribution/). | +| `--inspect[=host:port]` | Listen for a DevTools session on both isolates. See [DevTools](/runtime/desktop/devtools/). | +| `--inspect-brk[=host:port]` | Listen and break on first line in both isolates. | +| `--inspect-wait[=host:port]` | Listen and wait for a debugger before running. | +| `--inspect-renderer[=host:port]` | Override the CEF renderer's debugger listen address. | + +Permission flags from `deno run` apply too — the compiled binary inherits the +permissions you grant at compile time. + +## Configuration + +Most settings live in the `desktop` block of `deno.json`: + +```jsonc title="deno.json" +{ + "desktop": { + "app": { + "name": "MyApp", + "icons": { + "macos": "./icons/icon.icns", + "windows": "./icons/icon.ico", + "linux": "./icons/icon.png" + } + }, + "backend": "cef", + "output": { + "macos": "./dist/macos", + "windows": "./dist/windows", + "linux": "./dist/linux" + }, + "release": { "baseUrl": "https://updates.example.com" }, + "errorReporting": { "url": "https://errors.example.com/report" } + } +} +``` + +Full schema and examples: [Configuration](/runtime/desktop/configuration/). diff --git a/runtime/reference/cli/index.md b/runtime/reference/cli/index.md index e910755dc..67faf73ac 100644 --- a/runtime/reference/cli/index.md +++ b/runtime/reference/cli/index.md @@ -51,6 +51,8 @@ below for more information on each subcommand. - [deno doc](/runtime/reference/cli/doc/) - generate documentation for a module - [deno deploy](/runtime/reference/cli/deploy) - Manage and publish your projects on the web +- [deno desktop](/runtime/reference/cli/desktop/) - build a desktop app from + the current Deno project - [deno fmt](/runtime/reference/cli/fmt/) - format your code - [deno info](/runtime/reference/cli/info/) - inspect an ES module and all of its dependencies