Deno Desktop Framework
2024-10-02
This document is a work in progressGoal
Build a cross platform desktop application framework in Deno that uses the native system webview to display UI. I want it to be incredibly simple. It should be as easy to create a small desktop app as it is to create a CLI too. The reason I’m choosing Deno as the target is because it allows running code from urls (Deno run <url>
) which means desktop apps could be distributed as URLs. I’m not trying to reinvent the wheel here, so I want to use existing tech and boring decisions to make it as simple as is feasible.
Prior Art
There’s an Electron alternative called Tauri 1that focuses on building secure applications that integrate with the native system webview instead of shipping chromium. If you’re building a desktop app as a product, Tauri is probably the right choice. For me though, it’s a little heavy and slow to get started with. It also requires uses to write the “backend” part of the app in Rust which isn’t a desirable trait for my goals. That said, they have some really solid open source crates that I plan to use.
Building a Webview Binding
The first deliverable is being able to render UI in an OS-specific Webview from Deno. I’ll note that @webview/webview exists and is likely a viable option for simple setups. I’d like to build my own version though because I want to have deeper control over the shell of the application which that library doesn’t allow. The plan actually is to use Wry to interact with the OS’s native webview and Tao to manage the OS window.
[!info] If you’d like to skip this section and just look directly at the code check it out here.
Integrating Deno and Wry
Wry is written in rust so there are two ways I could interface with it:
- Use Deno’s FFI to write a rust binding to call Wry directly
- Compile the Wry side into an executable and execute it as a subprocess communicating over stdin/stdout2 ^11abe7
I’ve played with this idea already. My initial experimentations were going the FFI route where I learned that (AFAICT) when rendering native UI on OSX, the OS wants it done on the main thread of the process. At least when trying to invoke the webview in a separate thread I’d get a panic with an error saying it needed to be run from the main thread. Of course, running it from the main thread means that the Deno process was locked up (which isn’t what I want). I’d found a crate called crossmist which defined a macro that would enable creating a separate process from a single rust file while providing an IPC mechanism. This approach feels like it would be satisfying to just say that I got it working, but realistically it’s overly complex. Another point against doing FFI is that Deno’s own bindgen library is a little broken. That’s really just an aid to helping make rust FFI easier, but it’s kind of unfortunate that they’ve let it bit rot. The last bit here is that Deno’s FFI is still behind an unstable flag which doesn’t feel the best. Also, it’s pretty easy to spin up a subprocess in about every language, so the library will be a bit more portable that way.
As an aside, I was wondering how the previously mentioned webview library on Deno was being distributed on JSR because AFAIK JSR doesn’t support random binary files like NPM does3. Evidently they’re just running Deno.dlopen
on a URL? That’s both neat and absolutely terrifying, haha. So, of course I’m going to do something similar. It turns out Edit: Turns out I was wrong about this. It takes a file url, but if you provide it an http url it won’t work. Under the hood the webview library is using a library called @denosaurs/plug which mimics deno’s FFI function (Deno.Command
4 also takes a URL.dlopen
) but takes web URLs to resolve it. I wrote my own instead of using that.
So my plan is this:
- Write a rust program that uses Wry to have some hooks to create a webview / do other stuff
- Set up the Deno library in a way that on dev calls to a locally built binary and on prod calls out to GitHub releases
- Figure out some restricted message passing format they can use to communicate.
Building the Webview
The first step is to actually write the rust to control the webview.
This section is going to be Rust heavy, but I’ll try to break the parts down in a way that even if you don’t have Rust experience it still should be understandable.
Deconstructing a Wry Example
The Wry repo has a simple example that uses Tao to render the Tauri site and print out debug info for a drag and drop event. There’s a few important pieces:
let event_loop = EventLoop::new(); let window = WindowBuilder::new().build(&event_loop).unwrap();
EventLoop
and WindowBuilder
5 are from Tao. The OS window will have an event loop that determines when the UI should be re-rendered. Then the webview builder is initialized with a reference to the window:
let builder = WebViewBuilder::new(&window);
I’m going to skip over the drag and drop code from this example as it’s not entirely relevant at the moment. There are two ways to initialize the webview, either with_url
as this example code uses or with_html
. To simplify down the example here’s the webview with just a URL configured.
let _webview = builder .with_url("http://tauri.app") .build()?;
The last bit is running the event loop. All the below code does is run the event loop when some user input happens (this part comes from the ControlFlow::Wait
) and exits the event loop when the CloseRequest
window event happens.
event_loop.run(move |event, _, control_flow| { *control_flow = ControlFlow::Wait; if let Event::WindowEvent { event: WindowEvent::CloseRequested, .. } = event { *control_flow = ControlFlow::Exit } });
In Tao’s docs it notes that the main body of the loop (where you execute user specific code not related to drawing the webview) should be put in the MainEventsCleared
event. That’s where we’d want to take action when a user specifies something should happen (like changing what’s being rendered in the webview). This’ll be important for later.
event_loop.run(move |event, _, control_flow| { *control_flow = ControlFlow::Wait; match event { Event::WindowEvent { event: WindowEvent::CloseRequested, .. } => { *control_flow = ControlFlow::Exit } } });
Breaking Down the Problem
There are really only two main functions of this program
- Initialize the window and webview
- Do something to the webview when the user acts on it
As previously mentioned, this’ll be compiled to a binary and will use stdin/stdout for communication with its host process. The easiest way to get data into the program for the initialization phase is just passing it as an arg. That’s a fairly well known problem so I’ll just come back to it when I talk about Communicating with the Rust Subprocess.
In the example code where the webview was initialized via the builder, the reference to the webview wasn’t being used later.
Communicating with the Rust Subprocess
As far as I’m aware, stdin and stdout don’t really have a way of saying a write is done until the stream itself is closed. Once closed, you can’t really re-open it. That’s a bit of a problem because Typical expects to take a reader
interface that either has the bytes it expects and is coerced into a known type or results in an error. It’s not really intended to operate on a non-ending stream of information that may at any one point only contain a partial payload. I would somehow need to read the Typical headers and buffer until the message is complete which somewhat defeats the purpose of using it to begin with. I didn’t want to leak Typical’s internals into my codebase.
Giving up Typical can’t mean giving up types though. I evaluated various options and went back to some solutions I’m readily familiar with from my time at Oxide: serde and schemars. Serde is the most popular JSON serialization/deserialization library for rust and Schemars is a library for generating JSON schemas from rust data types.
Footnotes
-
I recorded a DevTools.fm episode with the Tauri creators ↩
-
I did think about using named pipes but I wanted to keep the interface as simple as possible ↩
-
Turns out you can include binaries on JSR ↩
-
How subprocesses are created in Deno. Similar to
child_process.spawn
in Node.js ↩ -
Read this guide to learn more about the builder pattern in rust ↩