Software you can love: miroir Ô mon beau miroir ❤️
A few years ago, I was really into Raspberry Pis. I think they are amazing pieces of hardware to own if you love tinkering and learning new stuff. By stuff, I mean: software, embedded systems, kernel development, self-hosting, networks, clusters, robotics, and more. I still own quite a few. Raspberry Pis are a great educational tool, they only require swapping out a single SD card to boot up into some new project and there are hundreds of projects out there using Raspberry Pis. Enough about raspberry pis, I’m not here to sell you one.
I’ve started writing this post on December 2024 and it got a little bit out of hand so I decided to split it into two parts. The first one is a rant about my view on software quality as a whole. It’s more of a broader discussion about the “why” I chose to write MagicMirror in Rust. The second part is dedicated to diving into technical details of the design choices I made when building the app. Feel free to jump to any section !
The state of software quality
One of those fun projects using Raspberry Pi is https://magicmirror.builders/. One afternoon coming back from work, I found a broken window thrown outside, but the mirror attached to it was still in good condition. I immediately thought of building my little magic mirror. One weekend and a few screws later, I put a badly built magic mirror frame on a wall in my bedroom. I still can’t figure out why my wife let me put such a piece of crap on the wall. I guess she took pity on me when she saw how excited I was. But that’s neither here nor there…
Back to our little magic mirror. I installed the magic mirror software on a Raspberry Pi 3 A, which comes with a Broadcom BCM2837B0, Cortex-A53 running at 1.4 GHz, and 512MB of RAM. I used the official magic mirror project. The project is written in JS and uses Electron to render the widgets to the screen. Electron, yes, you know where this is going…
At the time, I just wanted something working that looked cool. I set up the config and chose the widgets I was interested in and threw that together. A few weeks went by, and the magic mirror kept falling apart. It was either frozen, the widget fetching stuck in an infinite loop, the RSS feed didn’t update, or one of two hundred other weird issues that just kept nagging at me. So I looked around one afternoon and found the senses-mirror project. It looked a little bit better than the magic mirror, had a lot of built-in widgets, and I liked the idea of having an app on my phone to set up and control the magic mirror. My experience modifying the MM config.js
was really bad, and a mobile app could help with that. The obvious catch was that senses-smartmirror
was also written as an Electron app in vue.js
…
So now I have a piece of wood hanging to my wall that sometimes worked but often didn’t. It sometimes completely froze the RPi, and I needed to manually restart it once a month. The site recommended using a Raspberry Pi 4, but I thought that the Raspberry Pi 3 A with a processor that is 700 times faster than the Apollo guidance computer that we used to send a rocket to outer space was enough… Silly me.
Small note: The RPi3 also has 128,000 times more RAM than the Apollo guidance computer. I thought it could at least render a black screen with some white text that refreshes every 2 hours. But I guess that’s not how modern software people view software performance these days …
Now that’s why I am writing this article. I am here to bring you some bad news:the state of software as a whole has been on a decline other than a few rare spots.
Yes, I know this sounds like a doomsday guy kind of sentence, but please hear me out (also stop reading here if you don’t want to; I am not going to force you to continue reading; we are all adults here). I’ll try to break down what I mean by a declining state.
I think we can all agree that a very small number of applications are ACTUALLY usable. Meaning never crash, always work the way you want, are rock-solid stable, and their performance is great. The vast majority of software is filled with bugs, consumes way too many resources, and is underutilizing the hardware so much that it’s borderline criminal.
One hilarious display of modern software being quite bad is the tweet from Rerun’s CTO comparing their software to the Microsoft team’s improved version of Microsoft team. Now can you explain to me how in the world an app that shows some blocks of static text runs orders of magnitudes slower than a 3D cloud point visualizer graphics on the same piece of hardware?
One (of the many) reasons for this demise in software quality was pinpointed by Casey Muratori in an interview with Richard Feldman. I’ll paraphrase it a bit, but the gist of his thought is the following: Programmers forgot that their primary job is to program a computer, ie program a piece of hardware to do a certain job! That’s it!
Programming an actual piece of “raw” hardware is quite hard, so software engineers did what they do best: Simplify doing this job through abstraction! The abstractions help developers build on top of existing pieces of software so they can do their job quickly. Then we ran this cycle for decades, pushing these abstractions more and more. Now somewhere along the way, developers lost the ability to understand the underlying piece of hardware and what it is capable of. What you gained or lost developers before them had to tradeoff when building the abstraction were completely erased from common knowledge. Nobody seems to care anymore about the theoretical limits of the software they are writing. We all heard phrases like “ Don’t “reinvent the wheel”” or “Don’t fix what isn’t broken”. These phrases became “common wisdom” in software engineering. Next time someone tells you one of these (usually some manager) as if he had some deep wisdom to cast upon you, please tell him to explain to you how that wheel ACTUALLY works. Or how would he rebuild the same wheel from scratch? He’ll probably babble some nonsense and tell you to write him a Jira ticket.
Some industries over-optimized on this notion of never understanding the underlying systems and call it: “developer experience”. I am talking about the web of course. In multiple interactions with frontend engineers where the subject of building electron apps came up, I was shocked to hear that they qualify it as ‘amazing’ or ‘great’, and that they don’t see any issue about shipping a 500 Mb single page app that renders text or that includes an entire chromium engine for funsies.
Another horrible consequence of this “don’t have to worry about low-level details” attitude is that we (as devs) are all programmed to think about solving problems in an artificially constrained space. We usually make tradeoffs against some predefined set of technologies and software without thinking of pushing beyond these arbitrary limits. We rarely ask questions about actual hardware limits, about the overhead for OS, about building sites without React, about training ML models without PyTorch… I can go on and on forever.
Now that I have painted a gloomy, bleak dev world, I’ll stop here and say that I am seeing a push against this way of thinking everywhere! Moore’s law has come to an end (at least the linear scaling of computing), and software engineers are starting to question these preconceived notions and peel off unnecessary abstractions that are slowing them down.
Some examples of this push back: the return of statically typed compiled languages (Go, Rust, Zig, Odin…), blazing fast written-from-scratch IDEs like Zed, the push for unikernels, shared user/kernel space ring buffers like iouring… All examples of this trend to make “software great again”.
The rant is over. Let’s go back to the Magic Mirror project.
Mirrors
I kept staring at that magic mirror. I asked myself a simple question: How would I build it? Under the thousands of lines of JS code and the millions of lines of Electron, was a very simple app. A black background, fixed blocks with white text that needed to be updated every X seconds. Why won’t I build it the way that god intended to? Isn’t that why I became a programmer in the first place? To build cool shit on my own that I can hang on the wall!
Let’s first line up some hard requirements I want for the solution:
- I want to build a native app. The app will be running on RPi3, so keep that in mind.
- I want to be able to cross-compile the app. I usually work on a M4 Mac or a Linux station with a powerful CPU. I didn’t want to compile on a RPi3 at all…
- I wanted the app to be a single, self-contained binary.
- The app should consume a minimal footprint. Again, RPi3 isn’t some 10Mhz processor, but it is far from a M4 MacBook. So, low RAM consumption, low CPU consumption.
- I wanted to be rock solid. High-quality software. Never fails to work.
Until very recently, shipping for easy solutions for writing native apps was really messy. No wonder Electron apps are still so successful.
Using Electron, you could easily use your favorite JS framework library to write a 300Mb slow native app. Why is it slow and buggy? Well, the idea of having to go through a DOM layer to render stuff to your screen is quite offensive. Anyone who thinks otherwise can catch these hands.
I clearly didn’t want to use JavaScript. What is the next runner-up? Maximum control and performance are usually associated with GTK / QT. These APIs are clunky and difficult to work with. They are written in C++, another bloated, overly complicated language. Also, I didn’t want to spend my weekend setting up CMake. So, that goes out the window.
Going back to my last point, my experience with writing Rust code is that it forces you to write correct, stable code. The type system and compiler force you to think hard about every line of code you write and think about failure cases. Some love this aspect of Rust, others don’t. Personally, I like the built-in rigor in the language. You can write stable software in C, of course, but IMHO it is way harder than to do it in Rust.
So, I took a look at the famous are we GUI yet to see if I can find some Rust GUI libs that meet my requirements. I narrowed down my choice to these 3 libraries:
- egui
- slint
- iced
I took a few hours to dig a little bit deeper into these libraries to get a sense of the design constraints they impose and the general usuability to build something like MagicMirror.
egui
was at the top of my list for a good reason. The simplicity of immediate-mode GUIs is a very cool concept. If you’re not familiar with immediate-mode GUIs. We can divide GUIs into two groups (this is a gross simplification I know… ): immediate and retained mode. The mode dictates how we draw elements on screen. In retained mode, you “retain” some structure of your elements in memory, each interaction with the GUI updates the elements and you just need to display them each frame. Immediate mode, on the other hand, simplifies all of this by approaching the GUI mode from a video game programmer’s perspective. For each frame, you loop through elements and you draw them. Simple as that. This might seem stupid but think about it for a minute. If you can draw stuff at >120fps in a loop, why ever have some complicated hierarchical data structure of elements? Simple elegant solution. You can take a look at the web demo of egui
running on WASM if you want to see the speed of immediate mode GUIs : egui-demo
Immediate mode does come with its drawback though, one huge one is Layout. You can read more about the specifics on why layout in immediate mode is difficult. I tried building a basic magic mirror scaffolding but found myself stuck on weird layout issues. I have done a very small amount of frontend dev but I’ve come to think about layout in Flex/CSS which was hard to reconcile with the immediate mode. I think that egui
is a great library that I would use to build high-performance native apps, but the additional difficulty of paradigm switch was not worth it for a simple project as a magic mirror.
The next one was slint
. I saw this video of Slint running on Raspberry Pi Pico / RP2040 with 264K of RAM and was immediately appealed to by the low footprint. To cut to the chase, it’s an amazing, very mature library, with well-built docs and examples. The only showstopper for me was the slint language. Slint app uses .slint
files where you define your UI elements. Think of something like JSX. I didn’t like that. Although they provide LSP integration with VSCode, this design choice made by Slint didn’t appeal to me at all. I do understand their reason behind it, but it wasn’t for me.
What I want is a library where I don’t need to spend time learning some weird DSL to declare my components. I also didn’t want macros in the library because they are usually a nightmare to debug, and I want the library to feel rusty. The library should leverage the Rust type system, traits, lifetimes, and generics. iced
was exactly this!
iced is a cross-platform GUI library inspired by the Elm architecture. I have heard about Elm from frontend devs, and they usually fall into two categories: either they have used Elm and swear to never write anything but Elm for WebApps or they have looked at the syntax and never ever used Elm! The Elm architecture, on the other hand, consists of three defining parts:
- Model — the state of your application
- View — a way to turn your state into renderable items
- Update — a way to update your state based on messages.
The project is under heavy refactors but is stable enough to be useful (just make sure you fix your version). It has a huge number of examples, and the Rust doc is well crafted.
Elm Architecture + Rust was a match made in heaven! Let me break down how I implemented mirro.rs
.
At the core , we have the MagicMirror
struct that is our entry point to an iced application. This struct holds the entire state of our app for the duration of the program:
struct MagicMirror {
theme: Theme,
left_pane: Vec<MMWidget>,
center_pane: Vec<MMWidget>,
right_pane: Vec<MMWidget>,
config: MMConfig,
client: Client, // reqwest client to perform HTTP calls
debug: bool, // Uses
debug_color: Color,
}
You can see that I declared 3 Panels (left, center, right) I might need to refactor these later but don’t have any limitations with this design choice yet. Each panel is a Vec<MMWidget>
, let’s see what’s inside it:
pub enum MMWidget {
Crypto(Crypto),
Reddit(Reddit),
Clock(DateTime<Local>),
Weather(Weather),
Wifi(Wifi),
SystemInfo(SystemInfo),
}
This enum defines the set of widgets that I implemented. Each widget holds its internal state, receives filtered messages, updates itself, and returns a View
of itself. Think of them as mini apps running inside. The most important thing here is that if I wanted to add a new widget I just need to add it to this list and the compiler will guide me to a working widget! It’s that simple and elegant!
Now before diving into how messages flow through the app to update the state, let’s see how we render our app:
impl Application for MagicMirror {
// ...
fn view(&self) -> Element<Self::Message> {
let left_pane = panel_layout(
&self.left_pane,
Alignment::Start,
self.debug,
self.debug_color,
);
let center_pane = panel_layout(
&self.center_pane,
Alignment::Center,
self.debug,
self.debug_color,
);
let right_pane = panel_layout(
&self.right_pane,
Alignment::End,
self.debug,
self.debug_color,
);
let mm_page = row!(left_pane, center_pane, right_pane)
.width(Length::Fill)
.height(Length::Fill)
.align_items(Alignment::Center);
// Main container
container(mm_page)
.padding([40, 0, 0, 0])
.width(Length::Fill)
.height(Length::Fill)
.center_x()
.center_y()
.into()
}
}
The layout and rendering code uses plain rust structs to define alignment, padding, centering… You get compiler errors if you mess something up. You can implement arbitrary complex code in this function and you can define pretty complex widgets using Canva
to draw complex shapes and path objects (spoiler I used that to draw a crypto chart).
Now how do we update the state? Well, your program is a state machine. The application receives messages and updates its internal state. The application has a Message
associated type. So usually defined as an enum with all possible messages that can flow through your app. Here is the one for our MagicMirror:
#[derive(Debug, Clone)]
enum Message {
ClockTick(DateTime<Local>),
FetchSysInfo,
SysInfosReceived(system::Information),
FetchReddit,
RedditReceived(Vec<Feed>),
FetchWeather,
WeatherDataReceived(WeatherData),
ServerRunning,
FetchError,
CryptoDataReceived(HashMap<Coin, CryptoData>),
FetchCrypto,
}
Now in the update
methods, your app needs to handle these messages and emit Command
that can issue additional messages. Neat!
fn update(&mut self, message: Self::Message) -> Command<Self::Message>
Here is an example of the update
method in Magic Mirror for Message::ClockTick
and Message::FetchReddit
:
fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::ClockTick(_) => {
self.center_pane
.iter_mut()
.filter(|p| matches!(*p, MMWidget::Clock(_)))
.for_each(|e| e.update(message.clone()));
Command::none()
}
Message::FetchReddit => Command::perform(fetch_rss(self.client.clone()), |e| match e {
Ok(feeds) => Message::RedditReceived(feeds),
Err(_) => Message::FetchError,
}),
/// .....
}
}
The first one passes itself to our Clock
widget to update to track time. We update the clock (inside the center panel) and then return a Command::none
which is a no-op. The Message::FetchReddit
signals that it is time to update our Reddit feed so it spawns a Command::perform
action which takes in a future impl Future<Output = A>
and a callback that takes the result of the future: impl FnOnce(A) ->T
. This centralized dealing with messages is amazing! But wait how do we get those Tick
and Fetch
messages from?
Well, an iced app also has a subscription
method defined as :
fn subscription(&self) -> Subscription<Self::Message>
So besides performing async actions on demand with Command
in the update
method, the subscription
makes it possible to listen to external events passively and inject them in the state machine. Here is the implementation for both Fetch
and Tick
.
fn subscription(&self) -> Subscription<Message> {
let tick = iced::time::every(std::time::Duration::from_millis(500))
.map(|_| Message::ClockTick(Local::now()));
let fetch_reddit =
iced::time::every(Duration::from_secs(3600)).map(|_| Message::FetchReddit);
//....
Subscription::batch(vec![
tick,
fetch_reddit,
//....
])
}
Every 500 ms the runtime injects a Message
with the current time, and every hour we inject a FetchReddit
Message. The subscription mechanism is pretty powerful, and we could imagine injecting messages from any source. For instance, one can use a stream connected to a WebSocket server that is running in the background! What’s beautiful is that you don’t have to hobble around from file to file to see where and how you received a message.
I had so much fun writing the magic mirror that I finished what I thought would take me days in a few hours. I had in mind to build the basic widgets I wanted: a clock, news feed, and weather. But this was so quick that I decided to build something a little bit more involved: a cryptograph. This wasn’t too bad (~300 loc), and I had exact control over how lines were drawn into a canvas. The code is a bit long to share in a blog post form, but I’ll put a link once I open-source the app.
Cross-compiling for RPi3
If you recall the requirements I set at the beginning of the project, one of those was cross-compilation. I was a little bit anxious about cross-compiling Rust projects as I had faced some issues porting static binaries with Rust to different platforms. The issue usually involves glibc
, Rust default behavior on x86 and aarch64 target is to dynamically link to glibc
which is not something you want if you plan to cross-compile to armv7
. The trick is to statically link with muslc
and configure the linker but this trick didn’t work for this project.
If you’re interested in how to statically link to
muslc
in Rust . Here is how to do it:
// 1. First make sur you have armv7 as rustup target
// 2. Configure that linker for the armv7 target in $PROJECT/.cargo/config.toml
[target.armv7-unknown-linux-musleabihf]
linker = "arm-linux-gnueabihf-ld"
// 3. Build using
cargo build --release --target=armv7-unknown-linux-musleabihf
After battling with linkers and toolchains, I found the project cross. The project claims to have “Zero setup” cross-compilation but after testing the default docker images they didn’t seem to work for RPI3 and kept getting linker issues on ARM. I then remembered that slint
was using Cross
and decided to use their cross Docker image and it worked like a charm!
// Cross.toml
[target.aarch64-unknown-linux-gnu]
image = "ghcr.io/slint-ui/slint/aarch64-unknown-linux-gnu"
[target.armv7-unknown-linux-gnueabihf]
image = "ghcr.io/slint-ui/slint/armv7-unknown-linux-gnueabihf"
cross
uses Docker to cross-compile the binaries which is quite slow on Macos. The default slint images needed amd64
platform which is an emulated target for Docker on macos. I don’t know how I feel about Docker these days but let me tell you this I’ll always try to ship a statically linked fully contained small binary image before a Docker image with 20Gb of dependencies…
But I digress. I set a small bash script that I ran to build,rsync, and start the binary on my Raspberry Pi
#!/bin/bash
set -o errexit
set -o nounset
set -o pipefail
set -o xtrace
readonly TARGET_HOST=smartmirror
readonly BIN=mirrors
readonly TARGET_PATH=/home/pi/${BIN}
#readonly TARGET_ARCH=aarch64-unknown-linux-gnu
readonly TARGET_ARCH=armv7-unknown-linux-gnueabihf
readonly SOURCE_PATH=./target/${TARGET_ARCH}/release/${BIN}
cross build --release --target=${TARGET_ARCH}
rsync ${SOURCE_PATH} ${TARGET_HOST}:${TARGET_PATH}
ssh -t ${TARGET_HOST} "DISPLAY=:0 ${TARGET_PATH}"
Binary size reduction
Another point on my list was resource consumption and binary size. Luckily, iced
core lib ships a render-agnostic GUI library and you have multiple render backends. A black screen with some text that updates every hour didn’t need to have a crazy hardware GPU accelerated backend so I opted for the tiny-skia
backend. The tiny-skia
project aims to be small, simple, and easy to build. It has around 14 KLOC, compiles fast adds around 200KiB to your binary. I used other tricks to reduce binary size. I used optimized compilation for size, stripped symbol from the release build, and set code-gen units to 1. Other tricks can be found in min-sized-rust
[profile.release]
strip = true
opt-level = "s"
panic = "abort"
codegen-units = 1
lto = true
The final binary was 9 Mb in size 🎉 🎉!
I have been running the magic mirror for a few months now and never had to restart it. Again with the simplicity philosophy, I used systemd
to start a service on Linux on start and a cronjob to power off the screen at night and back on in the morning.
Resource consumption is not even close to the MagicMirror Js project. The current consumption of RAM is between 19 and 21 Mb, and CPU is always <10%!
That’s how big of a difference this rewrite made…
Rust dependencies
Small note here, reducing the binary size of the app surfaced a problem I didn’t think about that much: Rust dependencies. For all the greatness that comes with using Rust and especially cargo, there is still a tradeoff you make there. I’d say that I love the Rust ecosystem and the quality of crates is very high but you quickly and up with a project with hundreds of transitive dependencies you didn’t plan on adding. Dependency management is still an unsolved problem at large and you run into some weird corner cases from time to time. Ginger Bill (the creator of Odin) had a very extreme opinion which is no dependencies. He doesn’t want to add a dependency manager in Odin (at least for now). Want something, copy-paste the code, or sit down and write it yourself like an adult. Very interesting take. It would have taken me orders of magnitude more time to write the app with zero dependency so I’ll live with the extra megabytes.
I hope that after painting such a bleak picture of the software engineering world I succeeded in sharing the joy I had building this project.
The SE landscape is changing, and I do feel that there is a push for less bloated, faster more elegant software. Modern languages like GO, Rust, Zig, Jai, or Odin are all a push in the right direction. We are in a sweet spot in the industry. You don’t have to suffer using C++ to have performant stable code.
The sense of achievement and pride I get walking past that piece of wood on my wall is awesome. I’ve built a stable reliable piece of software and this brings a smile to my face every morning. That is why I became an engineer, and why I strive to get better at my craft:
Building cool shit I can be proud of.