matejknopp.com

> arcane arts of desktop software development

Anatomy of a NativeShell Project

June 9, 2021

One of the first questions I got after releasing NativeShell was about adding it to an existing Flutter project. So it seems like a good idea to write a little bit about NativeShell projects.

Let's start with some cool unicode block diagrams :)

This is what a regular Futter desktop project looks like:

                            ┊
Your Project                ┊   Flutter
                            ┊
Platform specific code      ┊
to create and manage        ┊
windows, engines            ┊
                            ┊
Generated code added        ┊
by Flutter Tool             ┊
                            ┊
╭───────────────────────╮   ┊   ╭──────────────────╮
│ macOS specific code   │   ┊   │ macOS Flutter    │
│ (Objc/Swift + Cocoa)  │───────│ Desktop Embedder │────┐
╰───────────────────────╯   ┊   ╰──────────────────╯    │
╭───────────────────────╮   ┊   ╭──────────────────╮    │    ╭─────────╮
│ Windows specific code │   ┊   │ Windows Flutter  │    │    │ Flutter │
│ (C++, Win32/UWP)      │───────│ Desktop Embedder │────┼────│ Engine  │
╰───────────────────────╯   ┊   ╰──────────────────╯    │    ╰─────────╯
╭───────────────────────╮   ┊   ╭──────────────────╮    │
│ Linux specific code   │   ┊   │ Linux Flutter    │    │
│ (C, Gtk)              │───────│ Desktop Embedder │────┘
╰───────────────────────╯   ┊   ╰──────────────────╯
                            ┊

For comparison, this is a project built with NativeShell:

                   ┊                        ┊
Your Project       ┊  NativeShell           ┊  Flutter
                   ┊                        ┊
                   ┊  Window Management     ┊   ╭──────────────────╮
                   ┊  Engine Management     ┊   │ macOS Flutter    │
                   ┊  DnD, Menus, etc.   ┌──────│ Desktop Embedder │────┐
                   ┊                     │  ┊   ╰──────────────────╯    │
╭───────────────╮  ┊                     │  ┊   ╭──────────────────╮    │    ╭─────────╮
│ Platform      │  ┊  ╭──────────────╮   │  ┊   │ Windows Flutter  │    │    │ Flutter │
│ agnostic Code │─────│ NativeShell  │───┼──────│ Desktop Embedder │────┼────│ Engine  │
│ (Rust)        │  ┊  ╰──────────────╯   │  ┊   ╰──────────────────╯    │    ╰─────────╯
╰───────────────╯  ┊                     │  ┊   ╭──────────────────╮    │
                   ┊                     │  ┊   │ Linux Flutter    │    │
                   ┊                     └──────│ Desktop Embedder │────┘
                   ┊                        ┊   ╰──────────────────╯
                   ┊                        ┊

NativeShell provides all the necessary platform abstractions for things that you might not want to deal with directly, but at the same time it will let you write low level platform specific code when you need to.

This is what a minimal NativeShell project contains:

├ .cargo
│ └ config.toml   # Settings for Rust compiler to set RPATH on macOS and Linux.*
├ lib             # Dart code
│ └ main.dart     # Dart entry-point. The main() method that will be called for every window.
├ src             # Rust code
│ └ main.rs       # Rust entry-point.
├ Cargo.toml      # Cargo crate configuration (similar to what pubspec.yaml is for Dart)
├ build.rs        # Rust build script. This is the secret sauce - it integrates Flutter# build with cargo (Rust build system). More details on this below.
├ pubspec.yaml    # Dart package configuration.

* Hopefully no longer necessary once cargo:rustc-link-arg=FLAG becomes stable

If you compare this to a regular Flutter desktop project you will notice that this structure is much simpler. The regular Flutter project is a somewhat messy combination of source files, IDE projects (i.e. XCode), CMake files, generated files and ephemeral libraries (added to your tree by Flutter tool to get around the fact that C++ has no standard package manager or stable ABI).

I personally find all this a little bit wonky. For mobile it may have sense to have separate projects for Android and iOS, the platforms are very different and platform IDEs may be required to launch on device and debug native code, but I think for desktop we can do better. So you'll need none of those files with NativeShell. There are no generated files or platform specific projects in the source tree. All these details are handled transparently by build.rs.

So what does build.rs do?

Cargo (the Rust build system) has one really nice feature - it can run arbitrary Rust code during build. NativeShell takes full advantage of this when building your project: It makes sure that Dart code is compiled to kernel snapshots for debug builds, resources from Dart packages are assembled, AOT native libraries are built for release builds, and all required artifacts (i.e. the desktop embedder library) are copied from Flutter cache to your build folder. It can now also build, link and register native Flutter plugins if your project is using any.

All this is done transparently and everything is contained within the build folder.

This is what a simple build.rs file looks like:

// Import the nativeshell_build crate which contains the logic required for
// Flutter project build
use nativeshell_build::{
AppBundleOptions, BuildResult, Flutter, FlutterOptions, MacOSBundle
};

fn build_flutter() -> BuildResult<()> {
// Static method that takes care of Flutter build process; In FlutterOption
// you can specify the main dart file if it is something other than
// lib/main.dart, or choose local engine name and path if you built the flutter
// engine yourself.
Flutter::build(FlutterOptions {
..Default::default()
})?;

// macOS specific: This will create a "fake" app bundle structure that links
// to real executable. This is necessary because running unbundled macOS
// apps has unexpected side-effects.
if cfg!(target_os = "macos") {
let options = AppBundleOptions {
bundle_name: "AppTemplate.app".into(),
bundle_display_name: "App Template".into(),
icon_file: "icons/AppIcon.icns".into(),
..Default::default()
};
// Build the macOS bundle and return path to bundle resources
let resources = MacOSBundle::build(options)?;

// Make subdirectory in bundle resources and copy app icons
resources.mkdir("icons")?;
resources.link("resources/mac_icon.icns", "icons/AppIcon.icns")?;
}

Ok(())
}

fn main() {
// Call the build_flutter method and if it fails, print error and abort build.
if let Err(error) = build_flutter() {
println!("\n** Build failed with error **\n\n{}", error);
panic!();
}
}

The Rust code might seem weird at bit first glance (it is quite easy to get used to), but hopefully with comments it shouldn't be too difficult to figure out what's going on.

Which finally brings us back to...

Adding NativeShell to existing project

If you have an existing Flutter project, fingers crossed adding NativeShell to it shoud be fairly straightforward. Check out the NativeShell App Template and copy the following items to your existing project:

The resources folder only contains macOS app icon. You can skip it, just make sure you remove the icon symlink step from build.rs.

Make sure to also merge contents of .gitignore as cargo uses the target folder for build output, which you don't want to check in your version control.

After this is done, install the nativeshell dart package:

$ flutter pub add nativeshell

The last remaining step is adding WindowWidget and WindowLayoutProbe widgets to your widget hierarchy. These should be high enough to cover the entire contents of your app, otherwise window sizing might not work properly. You can see a simple example of using the WindowWidget here.