Skip to content

Commit 6119178

Browse files
committed
docs(recipes): add deno desktop integration guide
- Add backends and distribution page covering output formats and signing - Add bindings page explaining serve-path incompatibility and HTTP bridge - Add building the app page covering desktop block and path resolution - Add native APIs page covering windows, menus, tray, and dialogs - Add notifications, auto-update, and error reporting page - Add overview page with architecture and feature compatibility table - Add serving page covering port assignment and DVE rendering
1 parent 9b1b051 commit 6119178

7 files changed

Lines changed: 889 additions & 0 deletions

File tree

docs/recipes/desktop/bindings.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
---
2+
description: 'Why win.bind and the bindings proxy do not survive the Deserve serve path, and the HTTP API plus executeJs pattern that carries data between the page and the Deno side in both directions.'
3+
---
4+
5+
# Bindings and the HTTP Bridge
6+
7+
> **Reference**: [Deno Desktop Bindings](https://docs.deno.com/runtime/desktop/bindings/)
8+
9+
The `deno desktop` runtime ships a binding channel. [`win.bind()`](https://docs.deno.com/api/deno/~/Deno.BrowserWindow.bind) registers a Deno function, and the page calls it through a `bindings.<name>()` proxy as if the function were local, with no HTTP hop. It is the one part of the desktop surface that does not work behind a Deserve server, so this page explains the behaviour and the pattern that replaces it.
10+
11+
## What the Channel Promises
12+
13+
Outside Deserve, a binding looks clean. The Deno side registers a handler, the page calls it, and the call resolves across the boundary:
14+
15+
```typescript twoslash
16+
// deno-lint-ignore no-explicit-any
17+
const win = {} as any
18+
// ---cut---
19+
// Deno side, registers a handler
20+
win.bind('saveNote', async (text: string) => {
21+
await Deno.writeTextFile('./note.txt', text)
22+
return { ok: true }
23+
})
24+
```
25+
26+
```typescript twoslash
27+
// Page side, calls it like a local function
28+
// deno-lint-ignore no-explicit-any
29+
declare const bindings: any
30+
// ---cut---
31+
const result = await bindings.saveNote('hello')
32+
```
33+
34+
## Why It Breaks Behind Deserve
35+
36+
The binding bridge attaches to the webview when the runtime stands up its own server. Deserve runs its serve path through an internal `Deno.serve` call wrapped in framework logic, and that wrapper detaches the bridge from the visible webview. The proxy still answers on the page, `typeof bindings.saveNote` reads `function`, because the proxy builds a function on access. The call itself then rejects:
37+
38+
```
39+
Error: No callback bound for: saveNote
40+
```
41+
42+
The failure does not depend on timing. Binding before serve, binding after the server starts, reloading the window, or constructing the window later all reject the same way. A raw `Deno.serve` plus `win.bind` works, and the same `win.bind` behind `router.serve()` does not, which places the cause on the serve path rather than the binding call.
43+
44+
The practical takeaway is short. Treat bindings as unavailable in a Deserve desktop app and carry data over HTTP instead.
45+
46+
## The Replacement: Two Directions
47+
48+
A desktop app needs traffic both ways, page to Deno and Deno to page. Two transports already cover both, and neither touches the binding channel.
49+
50+
### Page to Deno over HTTP
51+
52+
The page calls an API route, and the route runs with Deno's permissions. This is the same call shape from [Serving the UI](/recipes/desktop/serving#talking-back-to-the-server), now framed as the binding replacement:
53+
54+
```typescript twoslash
55+
// Page side, replaces a bindings call
56+
async function saveNote(text: string): Promise<{ path: string }> {
57+
// Post to a local API route instead
58+
const response = await fetch('/api/note', {
59+
method: 'POST',
60+
headers: { 'content-type': 'application/json' },
61+
body: JSON.stringify({ text })
62+
})
63+
return await response.json()
64+
}
65+
```
66+
67+
```typescript twoslash
68+
import type { Context } from '@neabyte/deserve'
69+
70+
// routes/api/note.ts
71+
export async function POST(ctx: Context): Promise<Response> {
72+
// Read the typed JSON request body
73+
const requestBody = await ctx.get.body<{ text?: string }>()
74+
const homeDir = Deno.env.get('HOME') ?? '.'
75+
const path = `${homeDir}/.note.txt`
76+
// Write the note with Deno permissions
77+
await Deno.writeTextFile(path, requestBody?.text ?? '')
78+
return ctx.send.json({ path })
79+
}
80+
```
81+
82+
The route owns the disk access, so a single handler serves the page in a window and a browser on a host without a branch.
83+
84+
### Deno to Page with executeJs
85+
86+
The other direction runs a snippet inside the page from the Deno side with `executeJs()`, the same call the [menu handler](/recipes/desktop/native-apis#application-menu) uses. The page assigns the `saveNote` function from above onto `window`, and the native side calls it by name:
87+
88+
```typescript twoslash
89+
// deno-lint-ignore no-explicit-any
90+
async function saveNote(text: string): Promise<{ path: string }> {
91+
return { path: '' }
92+
}
93+
// ---cut---
94+
// Read the textarea, then reuse saveNote
95+
function saveNoteFromPage(): Promise<{ path: string }> {
96+
const field = document.querySelector('textarea')
97+
return saveNote(field?.value ?? '')
98+
}
99+
// Expose it for the native menu
100+
// deno-lint-ignore no-explicit-any
101+
;(window as any).saveNote = saveNoteFromPage
102+
```
103+
104+
```typescript twoslash
105+
// deno-lint-ignore no-explicit-any
106+
const win = {} as any
107+
// ---cut---
108+
// Run the page handler if it exists
109+
win.executeJs('if (window.saveNote) window.saveNote()')
110+
```
111+
112+
A menu shortcut and an in-page button now share one save path. The menu calls `executeJs`, the button calls `saveNote` directly, and both land in the same API route. The guard matters because `executeJs` can run before the page finishes loading, when `window.saveNote` is still undefined.
113+
114+
## Reading the Result
115+
116+
A page that needs a value from Deno, such as the running state or system info, reads it from a JSON route rather than a binding return. The desktop check from [Serving the UI](/recipes/desktop/serving#detecting-desktop-mode) follows this exact shape, where `Deno.desktopVersion` travels over the API instead of through `bindings`.
117+
118+
## Cost and Payoff
119+
120+
The HTTP hop adds a loopback round trip that a binding would skip. On a local connection the cost is below human perception for the request rates a desktop UI produces, so the tradeoff favours the simpler model. The payoff is one server that behaves identically on a host and in a window, with no separate binding layer to register, debug, or keep in sync.
121+
122+
With data flowing both ways, the remaining pieces are the runtime services that sit on the Deno side: [Notifications, Auto-update and Error Reporting](/recipes/desktop/notifications-updates).
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
---
2+
description: 'Pick a rendering backend for a Deserve desktop app, build platform bundles like app, dmg, msi, and AppImage, cross-compile to other targets from one host, and code-sign the macOS bundle.'
3+
---
4+
5+
# Backends and Distribution
6+
7+
> **Reference**: [Deno Desktop Backends](https://docs.deno.com/runtime/desktop/backends/)
8+
9+
The final step turns the project into shippable bundles. A backend choice decides how the page renders and whether DevTools is available, the output extension decides the package format, and one host can cross-compile for every target. The Deserve server stays the same across all of it, since distribution is a packaging concern.
10+
11+
## Choosing a Backend
12+
13+
The `backend` field, or the `--backend` flag, selects the rendering engine baked into the bundle. Three options exist, and only two suit a Deserve app:
14+
15+
| Backend | Rendering | Size | DevTools | Fits Deserve |
16+
| --------- | ---------------------------------- | --------------- | -------- | ------------ |
17+
| `webview` | The OS webview, default | Small | No | Yes |
18+
| `cef` | Bundled Chromium | Large, ~150 MB | Yes | Yes |
19+
| `raw` | No web engine | Smallest | No | No |
20+
21+
The `webview` backend uses the OS engine, WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux. It keeps the bundle small and renders the Deserve page well, at the cost of per-platform rendering differences.
22+
23+
The `cef` backend bundles Chromium for identical rendering everywhere and full DevTools, in exchange for a much larger download. The framework binary downloads once and caches.
24+
25+
The `raw` backend has no webview at all, so a Deserve UI served over HTTP has nothing to render it. A build succeeds and the server still runs, but no page appears. Reserve `raw` for apps that draw their own surface, not for a web UI.
26+
27+
```json
28+
{
29+
"desktop": {
30+
"backend": "webview"
31+
}
32+
}
33+
```
34+
35+
The `--backend` flag overrides the field for one build and accepts only `cef` and `webview`. Selecting `raw` happens through the field. Switching between `cef` and `webview` needs no code change, since the same window, menu, and event APIs work on both.
36+
37+
## DevTools
38+
39+
DevTools attaches to the page for inspecting elements, the console, and the network panel. It is available on the `cef` backend only. The default `webview` backend speaks a different inspector protocol that the unified DevTools does not target yet, so [`win.openDevtools()`](https://docs.deno.com/api/deno/~/Deno.BrowserWindow.openDevtools) is a no-op there.
40+
41+
A build that needs DevTools switches to `cef` for development, then ships on whichever backend suits the release:
42+
43+
```bash
44+
# Run with Chromium for DevTools
45+
deno desktop --backend cef --include routes --include views main.ts
46+
```
47+
48+
The Deno side runs an inspector under `--inspect` regardless of backend, so server-side debugging stays available even on `webview`. The full inspector flow is in the [DevTools reference](https://docs.deno.com/runtime/desktop/devtools/).
49+
50+
## Output Formats
51+
52+
The output extension decides the package the build produces. The `output` block sets a path per platform, and the [`--output`](https://docs.deno.com/runtime/desktop/distribution/) flag overrides it for one build:
53+
54+
```json
55+
{
56+
"desktop": {
57+
"output": {
58+
"macos": "./dist/DeserveDesktop.app",
59+
"windows": "./dist/DeserveDesktop",
60+
"linux": "./dist/deserve-desktop"
61+
}
62+
}
63+
}
64+
```
65+
66+
Each platform accepts several extensions:
67+
68+
| Platform | Extension | Produces |
69+
| -------- | ------------- | ------------------------------ |
70+
| macOS | `.app` | Application bundle, the default |
71+
| macOS | `.dmg` | Drag-to-Applications disk image |
72+
| Windows | directory | App folder with a launcher |
73+
| Windows | `.msi` | Windows Installer package |
74+
| Linux | directory | App folder with a launcher |
75+
| Linux | `.AppImage` | Single-file portable bundle |
76+
| Linux | `.deb` | Debian or Ubuntu package |
77+
| Linux | `.rpm` | Fedora or RHEL package |
78+
79+
A `.dmg` shells out to `hdiutil`, so it has to build on a macOS host. The rest assemble in pure Rust and build from any host:
80+
81+
```bash
82+
# Build a drag-to-Applications disk image
83+
deno desktop --include routes --include views --output ./dist/DeserveDesktop.dmg main.ts
84+
```
85+
86+
## Cross-Compilation
87+
88+
One host builds for every supported target. `--target` names a single triple, and `--all-targets` covers them all. The CLI downloads the matching runtime and backend archive for the target, with no platform toolchain on the host:
89+
90+
```bash
91+
# Build for macOS Intel from any host
92+
deno desktop --target x86_64-apple-darwin --include routes --include views main.ts
93+
```
94+
95+
The supported triples are `aarch64-apple-darwin`, `x86_64-apple-darwin`, `x86_64-pc-windows-msvc`, `aarch64-unknown-linux-gnu`, and `x86_64-unknown-linux-gnu`. The lone exception to host-free cross-building is the macOS `.dmg`, which needs `hdiutil` and therefore a macOS host. The full matrix and a CI example are in the [distribution reference](https://docs.deno.com/runtime/desktop/distribution/).
96+
97+
## Compressing the Bundle
98+
99+
`--compress` ships a self-extracting bundle. The heavy runtime payload is compressed in the distributed app and unpacked to a per-user folder on first launch, which shrinks the download in exchange for a one-time decompression:
100+
101+
```bash
102+
# Smaller download, unpacks on first launch
103+
deno desktop --compress --include routes --include views main.ts
104+
```
105+
106+
The codec defaults to a smaller-artifact setting and can be chosen with `--compress=xz` or `--compress=zstd`, where `zstd` trades some size for a faster first launch.
107+
108+
## Code Signing
109+
110+
On macOS, `deno desktop` signs the bundle on its own. The default is an ad-hoc signature, written as `-`, which gives the app a stable code identity, enough for the OS to grant [notification permission](/recipes/desktop/notifications-updates#conditions-on-macos), but not enough to distribute without Gatekeeper warnings:
111+
112+
```json
113+
{
114+
"desktop": {
115+
"macos": {
116+
"codesignIdentity": "-"
117+
}
118+
}
119+
}
120+
```
121+
122+
A real Developer ID identity replaces the `-` and produces a notarizable bundle signed with Hardened Runtime. Notarization stays a separate step run with `xcrun notarytool`. Signing runs on a macOS host, since it shells out to `codesign`. The signing and notarization detail is in the [distribution reference](https://docs.deno.com/runtime/desktop/distribution/#code-signing).
123+
124+
## Back to the Map
125+
126+
That closes the loop from a first build to a shipped bundle. The [overview](/recipes/desktop/overview#feature-compatibility) holds the compatibility map for the whole surface, and a production Deserve server outside the desktop context is covered in [Production Deploy](/recipes/production-deploy).
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
---
2+
description: 'Set up the desktop block in deno.json, compile a Deserve server into a native bundle, embed the routes and views, and resolve template paths against the bundle instead of the working directory.'
3+
---
4+
5+
# Building the App
6+
7+
> **Reference**: [Deno Desktop CLI](https://docs.deno.com/runtime/desktop/)
8+
9+
A desktop build starts from an ordinary Deserve project, the kind from [Quick Start](/getting-started/quick-start), and adds a `desktop` block to `deno.json` plus a couple of build flags. The server code stays the same. The compile step bakes that server, the routes, the views, and a rendering backend into one application bundle.
10+
11+
## The Desktop Block
12+
13+
Configuration for `deno desktop` lives in a `desktop` block inside `deno.json`. A minimal block names the app and picks the rendering backend, while the root `name` and `version` fields feed the bundle metadata:
14+
15+
```json
16+
{
17+
"name": "deserve-desktop",
18+
"version": "0.1.0",
19+
"imports": {
20+
"@neabyte/deserve": "jsr:@neabyte/deserve@0.15.0"
21+
},
22+
"desktop": {
23+
"app": {
24+
"name": "Deserve Desktop",
25+
"identifier": "com.example.deservedesktop"
26+
},
27+
"backend": "webview"
28+
}
29+
}
30+
```
31+
32+
The `app.identifier` is a reverse-DNS string. It feeds the macOS bundle id, the Linux desktop entry, and the Windows app id, and a stable value here is what lets [notifications](/recipes/desktop/notifications-updates#notifications) ask for permission. The `backend` choice and the rest of the block are covered in [Backends and Distribution](/recipes/desktop/distribution).
33+
34+
## Defining the Tasks
35+
36+
A [`deno task`](https://docs.deno.com/runtime/reference/cli/task/) saves the long build command. The `--include` flags matter most, since the routes and views folders are read at runtime and have to ride inside the bundle:
37+
38+
```json
39+
{
40+
"tasks": {
41+
"desktop": "deno desktop --allow-net --allow-read --allow-env --allow-write --include routes --include views main.ts"
42+
}
43+
}
44+
```
45+
46+
Each permission flag carries into the bundle, the same set a [production deploy](/recipes/production-deploy#permission-checklist) uses. Running `deno task desktop` then compiles the app for the host platform.
47+
48+
## Embedding Routes and Views
49+
50+
Without `--include`, the compile step bakes in `main.ts` and the modules it imports, but not the route and view folders that Deserve reads from disk at request time. The build output shows what made it in:
51+
52+
```
53+
Embedded Files
54+
DeserveDesktop.dylib
55+
├── main.ts
56+
├── routes/*
57+
└── views/*
58+
```
59+
60+
When the routes and views are missing from that list, the running app answers every request with a 404, since the router scans an empty folder. Adding `--include routes --include views` puts both folders in the embedded virtual filesystem, where the router finds them at runtime.
61+
62+
## The Working Directory Trap
63+
64+
A compiled bundle runs with the current working directory set to wherever the user launched it, not the folder that holds the binary. A relative path like `./routes` then resolves against the user's location and points at nothing. The page renders a 404 even though the folders were embedded.
65+
66+
The fix anchors the paths to the module instead of the working directory. [`import.meta.dirname`](https://docs.deno.com/api/web/~/ImportMeta) holds the absolute folder of the current module, so joining the route and view folders to it resolves the same way on a host and inside a bundle:
67+
68+
```typescript twoslash
69+
import { Router } from '@neabyte/deserve'
70+
71+
// Anchor paths to this module folder
72+
const base = import.meta.dirname
73+
74+
const router = new Router({
75+
routes: { directory: `${base}/routes` },
76+
views: { directory: `${base}/views` }
77+
})
78+
79+
await router.serve(8000, '127.0.0.1')
80+
```
81+
82+
Binding `127.0.0.1` keeps the server on loopback, which is the only interface a desktop app needs. The port argument is a starting point, since the desktop runtime hands the server a port of its own, a detail covered in [Serving the UI](/recipes/desktop/serving#finding-the-port).
83+
84+
## First Run
85+
86+
After `deno task desktop` finishes, the bundle lands next to the project. Launching it opens the window and the page loads from the embedded server:
87+
88+
```bash
89+
# Open the freshly built bundle
90+
open "Deserve Desktop.app"
91+
```
92+
93+
The same entry file also runs on a host with `deno run`, since the native pieces stay dormant when no window exists. That dual path is what the [native API guards](/recipes/desktop/native-apis#staying-dual-mode) rely on, and it keeps browser-based development fast while the desktop build stays one command away.

0 commit comments

Comments
 (0)