Deno Desktop Framework

2024-10-02

This document is a work in progress

Goal

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:

  1. Use Deno’s FFI to write a rust binding to call Wry directly
  2. 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 Deno.Command4 also takes a URL. 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 (dlopen) but takes web URLs to resolve it. I wrote my own instead of using that.

So my plan is this:

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 WindowBuilder5 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

  1. Initialize the window and webview
  2. 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

  1. I recorded a DevTools.fm episode with the Tauri creators

  2. I did think about using named pipes but I wanted to keep the interface as simple as possible

  3. Turns out you can include binaries on JSR

  4. How subprocesses are created in Deno. Similar to child_process.spawn in Node.js

  5. Read this guide to learn more about the builder pattern in rust