Creating a WebAssembly Plugin System for Window Management (and beyond) in Miracle

RSS Feed

Nothing quite makes you feel like you’re using an exciting technology like a plugin system. In fact, I would argue that this is one of the greatest draws of the web today, at least from a business perspective. Browsers provide a sandbox that runs JavaScript, HTML, CSS, and more. Developers build apps using these technologies that the browser downloads and executes safely. The entire program is delivered to the end-user on demand within the confines of the browser.

Extensibility is a large part of what makes the modern web useful for businesses and tinkerers alike. In my opinion, it is also a large part of what makes software exciting in general, as it provides a point of entry for hobbyists to craft a system to their liking.

The State of Pluggable Window Management in Wayland

Ever since the transition from X11 to Wayland, the Linux desktop has lost the ability to make window management pluggable. Different window managers and desktops have explored ways of accomplishing this, each with pros and cons. Hyprland has a plugin system that dynamically loads and executes .so files. GNOME has an extension system where users write extensions in JavaScript. River has introduced a new Wayland protocol for window management. Lots of ideas are floating around, but this is definitely a place that is still being explored 🔭.

In my opinion, the ideal version of pluggable window management in Wayland would have the following properties:

Without going into too much detail, each of the solutions mentioned previously makes some tradeoff between these considerations. You can read more about what I view as the pros and cons of each solution below.

Miracle’s WebAssembly Plugin System

Miracle’s new plugin system is based on WebAssembly. WebAssembly is a lightweight, bytecode language that can be embedded in multiple contexts. While it began in the web, it is truly an agnostic runtime that has utility everywhere from embedded devices, to browsers, and now desktops.

By using WebAssembly, we’ve created a plugin environment that very nearly meets all of the criteria of my ideal plugin environment, minus the “window manager agnostic” property. As I said previously, every solution will have some tradeoff 😅.

Here’s how it all works. Miracle runs a WebAssembly bytecode engine inside of it (we’re using WasmEdge specifically). At runtime, Miracle loads the plugins (.wasm files) that were specified by the user. Miracle then looks for special function signatures exposed by the WebAssembly bytecode and calls them at the appropriate times. While running, the plugin may access information about Miracle via thread-safe methods that Miracle explicitly exposes to the plugin. Additionally, plugins can use WASI to access standard services from the host. In this way, plugins run confined in the WebAssembly bytecode engine with just the level of access that they require. And because WebAssembly is just bytecode, the host can execute and run it with near-native speed.

Plugin authors can write plugins for Miracle in any language that supports WebAssembly as a compilation target. The Miracle project will support an idiomatic Rust crate for writing plugins for Miracle, which you can find here: https://docs.miracle-wm.org/miracle_plugin/ 🦀. This crate abstracts away the tricky communication-layer between Miracle and the WebAssembly plugin. If plugin authors wish to write a plugin in another language, they can use the Rust implementation as a reference.

The plugin interface has a pleasant design. Whenever an event happens on a Miracle object, the plugin is prompted to take some action on an event. For example, here is a handful of events that the plugin can handle:

If the plugin does not want to handle the event, they can simply not do so and let Miracle (or the next plugin) handle it instead.

Plugins will also be notified when an event happens, such as:

The plugin need not respond to any of these events, but they could take some action if they choose to. For example, a plugin may focus a window on the appropriate workspace when the workspace changes.

If you’re interested in checking out an example usage of this API, the best thus far is my niri clone, aptly named miri. While this is in no way a full Niri clone (yet!), it demonstrates the core semantics of the API, and just how easy it is to use. Here is a little snippet of the window management that goes on in Miri, with a bunch of details commented out:

// ...

impl Plugin for Miri {
    fn place_new_window(&mut self, info: &WindowInfo) -> Option<Placement> {
        if info.window_type != WindowType::Normal && info.window_type != WindowType::Freestyle {
            return None;
        }

        if info.state == WindowState::Attached {
            return None;
        }

        // ... commented out implementation details...

        Some(Placement::Freestyle(FreestylePlacement {
            top_left: Point::new(rect.x, rect.y),
            depth_layer: DepthLayer::Application,
            workspace: None,
            size: Size::new(rect.width, rect.height),
            transform: Mat4::IDENTITY,
            alpha: 1.0,
            movable: false,
            resizable: false,
        }))
    }

    fn window_deleted(&mut self, info: &WindowInfo) {
        // .. remove the window and scroll to a new window if its in focus ...
    }

    fn window_focused(&mut self, info: &WindowInfo) {
        // ... scroll the new window into focus ...
    }

    fn workspace_created(&mut self, workspace: &Workspace) {
        // ... set the workspace up with empty data ...
    }

    fn workspace_removed(&mut self, workspace: &Workspace) {
        // ... re-home windows on that workspace ...
    }

    fn workspace_focused(&mut self, _previous_id: Option<u64>, current: &Workspace) {
        // ... see if we should focus another window ...
    }

    fn workspace_area_changed(&mut self, workspace: &Workspace) {
        // ... change the size of existing windows ...
    }

    fn handle_keyboard_input(&mut self, event: KeyboardEvent) -> bool {
        // ... handle Niri-specific keyboard inputs ...
    }

    // ... and much more...!
}

Miri will continue to be developed over the coming months 😄.

But Miracle doesn’t just offer window management via its plugin system. One of the things that I always dislike about window managers is that I have to configure them using some clunky file format. This format is good up to a point, but it soon happens that my configuration is quite complex. What I really want in this situation is a real programming language. So that’s just what I added to Miracle! Instead of configuring your window manager in YAML, you can now configure it in Rust 🦀. Here is a real example from my dotfiles:

impl Plugin for MyPlugin {
    fn configure(&mut self) -> Option<Configuration> {
        let mut config: Configuration = Configuration::default();

        config.primary_modifier = Some(miracle_plugin::Modifier::Meta);

        let mut custom_actions: Vec<CustomKeyAction> = vec![];
        custom_actions.push(CustomKeyAction {
            action: miracle_plugin::BindingAction::Down,
            key: Key("d".to_string()),
            modifiers: vec![Modifier::Primary],
            command: "wofi --show=drun".to_string(),
        });
        custom_actions.push(CustomKeyAction {
            action: miracle_plugin::BindingAction::Down,
            key: Key("S".to_string()),
            modifiers: vec![Modifier::Primary],
            command: "grimshot copy area".to_string(),
        });
        custom_actions.push(CustomKeyAction {
            action: miracle_plugin::BindingAction::Down,
            key: Key("L".to_string()),
            modifiers: vec![Modifier::Primary],
            command: "swaylock".to_string(),
        });
        custom_actions.push(CustomKeyAction {
            action: miracle_plugin::BindingAction::Down,
            key: Key("XF86AudioLowerVolume".to_string()),
            modifiers: vec![],
            command: "pamixer -d 10".to_string(),
        });
        custom_actions.push(CustomKeyAction {
            action: miracle_plugin::BindingAction::Down,
            key: Key("XF86AudioRaiseVolume".to_string()),
            modifiers: vec![],
            command: "pamixer -i 10".to_string(),
        });
        custom_actions.push(CustomKeyAction {
            action: miracle_plugin::BindingAction::Down,
            key: Key("XF86AudioMute".to_string()),
            modifiers: vec![],
            command: "pamixer -t".to_string(),
        });
        custom_actions.push(CustomKeyAction {
            action: miracle_plugin::BindingAction::Down,
            key: Key("XF86MonBrightnessDown".to_string()),
            modifiers: vec![],
            command: "brightnessctl s 100-".to_string(),
        });
        custom_actions.push(CustomKeyAction {
            action: miracle_plugin::BindingAction::Down,
            key: Key("XF86MonBrightnessUp".to_string()),
            modifiers: vec![],
            command: "brightnessctl s +100".to_string(),
        });
        custom_actions.push(CustomKeyAction {
            action: miracle_plugin::BindingAction::Down,
            key: Key("E".to_string()),
            modifiers: vec![Modifier::Primary],
            command: "wlogout --protocol layer-shell".to_string(),
        });
        custom_actions.push(CustomKeyAction {
            action: miracle_plugin::BindingAction::Down,
            key: Key("M".to_string()),
            modifiers: vec![Modifier::Primary],
            command: "miraclemsg workspace music".to_string(),
        });

        config.custom_key_actions = Some(custom_actions);
        config.inner_gaps = Some(Gaps { x: 16, y: 16 });
        config.outer_gaps = Some(Gaps { x: 8, y: 8 });

        let mut startup_apps: Vec<StartupApp> = vec![];
        startup_apps.push(StartupApp {
            command: "waybar".to_string(),
            restart_on_death: false,
            no_startup_id: false,
            should_halt_compositor_on_death: false,
            in_systemd_scope: false,
        });
        startup_apps.push(StartupApp {
            command: "~/.local/bin/launch-swaybg.sh".to_string(),
            restart_on_death: false,
            no_startup_id: false,
            should_halt_compositor_on_death: false,
            in_systemd_scope: false,
        });
        startup_apps.push(StartupApp {
            command: "swaync".to_string(),
            restart_on_death: false,
            no_startup_id: false,
            should_halt_compositor_on_death: false,
            in_systemd_scope: false,
        });
        config.startup_apps = Some(startup_apps);

        config.terminal = Some("kitty".to_string());

        config.resize_jump = Some(50);
        config.border = Some(BorderConfig {
            size: 2,
            radius: 4.0,
            color: "0xbd93f9ff".to_string(),
            focus_color: "0x50fa7bff".to_string(),
        });

        Some(config)
    }
}

I love using a real programming language to configure. It makes me happy.

All this being said, I think that the WebAssembly solution provides a solid middleground for all of my ideals:

I am excited about the work that is being done in this area right now, especially River’s implementation. Perhaps one day I will implement it myself in Miracle, but I plan to focus mostly on Miracle’s WASM plugin system in the near future.

What’s next?

The plugin system will be released in Miracle 0.9.0, which should be published later this week. Miri will receive an official version afterward. I am excited to see what you all build with the new API. I will be around to offer support for the plugins that you build on the matrix channel.

As you may be well aware, window management is only half the part of the desktop story. The other part is the shell, which includes bars, launchers, and a whole bunch of other stuff. My idea for this is to let Wayland clients speak with plugins over the MIRACLESOCK (i.e. SWAYSOCK) that Mir already exposes to clients like waybar. In this way, the window manager will be able to tell clients about events so that the shell can update accordingly. This bit is yet to be designed yet.

Miracle is turning into so much more than just the tiling window manager that I originally designed it to be. The plugin system is a culmination of a lot of my thinking over the past couple years, and I am very excited to get it into people’s hands. Happy coding!

Resources

P.S.: Survey of Other Approaches

Below are my thoughts weighing the pros and cons of existing solutions. Considering each of them guided much of my thinking as a designed Miracle’s system, so it is worthwhile noting them here. I still think that River’s solution is the best of the existing bunch, if only for its interest in being cross-compositor.

Dynamically Loading Shared Libraries

This approach has been taken by Hyprland.

Pros:

Cons:

Scripting Language

This approach is common to GNOME and KDE, and is usually accomplished with JavaScript, Lua, or some other scripting language embedded in the desktop.

Pros:

Cons:

Wayland Protocol

This is the approach recently suggested by the River project, which I find very interesting.

Pros:

Cons:

I actually think this is the best solution of the bunch thus far.