zenkai: A Zig App Launcher That Starts in 20ms Sometimes
Every few months I install another app launcher, convince myself this is the one, and then uninstall it about a week later because something small irritates me. After what felt like dozens of those I finally gave up looking and wrote my own, because I love Zig, I love Qt, and I wanted something that was mine.
I have always struggled with naming things and this project was no exception. After an embarrassingly long time I settled on Zenkai, which comes from my childhood obsession, Dragon Ball. A Zenkai is a massive power up that a Saiyan obtains after a difficult battle, and I thought that was neat because that is exactly the vision I had for this project. I wanted it stronger, faster and more performant after every single iteration. I wanted to dive deep into performance optimisation rabbit holes where I spend hours on something that maybe shaves off 20ms or so. I wanted this to be a part time hobby.
It runs on Linux, macOS and Windows. It scans whatever your system uses to work out what you have installed and does fuzzy search over the result, and it has 65 themes and a Lua plugin system, but the part I did not expect to care about is how it looks. A launcher is something you stare at all day, and one that looks like everybody else’s is miserable in a way you only really notice after a few weeks of using it.
Making the same thing work on all three of those operating systems is a different story, though, and the different story is almost entirely about Windows.
Why Zig, Why Qt
I have been using Zig for quite a while now and with every line I write I love the language more and more. It fits really well into my mental model. It feels a bit like C with sane memory defaults and helpers that don’t throw a compiler hissy fit about it, unlike some other languages, but I won’t say more about that.
The memory is yours to manage, but it gives you a very nice and professional well sorted toolbox to help you along the way.
The best tools are the ones you forget you are using. A carpenter with a plane he trusts thinks about the grain, not the blade.
None of that is a coincidence either. It’s a low level, systems programming language with no garbage collector and manual memory management, obviously it’s fast.
Qt I did not choose on principle, I chose because I have used it before. I have shipped Qt applications professionally, I like its widget set, I like that you can make it look genuinely beautiful instead of the usual grey business-tool look that everything ends up as, and it is cross-platform. That last point matters more than it sounds, because getting the same application to look and behave correctly on Linux, macOS and Windows is genuinely hard, and Qt is one of the few toolkits where I can point at three screenshots and they look like the same application.
So that is the entire strategic decision, and it is not a particularly clever one. I picked the two things I already loved and hoped the rest would sort itself out. What I did not expect was how much of this project would end up being about a single number.
The 200ms Obsession
I have been obsessed with performance for as long as I can remember. Usually there is no room for it, because whatever you are working on belongs to somebody else, there is a deadline, and spending a week shaving 40ms off a startup path makes you a difficult person to work with. zenkai is mine, and more of a hobby than a deliverable, which means nobody is waiting on it and nothing is going to break if I spend three days on something that makes it 15ms faster. For the first time in forever I could indulge my performance obsession properly, and it felt liberating.
Anything under 200ms is instant to a human, so that was the ultimate goal, the entire program has to get itself onto your screen and give you a choice of application in under 200ms, starting up, scanning every entry your system knows about and parsing it included.
The Number Is 140ms
--debug times from process start, and --quit-when-shown closes the launcher on the first paint, which is the frame a person would press ESC on, so a run ends on exactly the frame it measures.
$ zenkai --debug --quit-when-shown
[zenkai] window fully visible in 140msThat is the whole experience, window up and every icon you can see loaded. I got there slowly, because I spent a while benchmarking a window that was never on a screen at all, which is trivially easy over ssh or in a container, where the same build reported 33. Almost all of that gap is compositing and icons, neither of which exist in a fake environment. If you take one thing from this section, do not benchmark offscreen and believe it.
Fifteen Milliseconds Is All The Code I Own
Here is the thing that changed how I think about this project, and I wish I had measured it first.
$ zenkai --benchmark-all
benchmark:
qt init 2.21ms
theme apply 0.02ms
icon theme 0.04ms
plugins 0.12ms
window setup 12.27ms
cache load 0.09ms
total 14.75msI had been optimising this code path for months. It is fifteen of the one hundred and forty. The rest is Qt building widgets and the compositor putting pixels on your screen, neither of which is mine to touch.
So the game changes completely. If the code I control is fifteen milliseconds then shaving microseconds out of my own functions is theatre. The only question left is which work can be skipped until somebody actually asks for it, and almost everything below is an answer to that question.
Do Not Do The Work Yet
Finding out what you have installed is the part that looks expensive, so that is what got moved.
The window is built and populated from a small JSON cache first, 113 applications on this machine, and that cache is deliberately the slowest thing to write and the fastest thing to read. Plain JSON with a version stamp, because parsing a few kilobytes of text is faster than unpacking anything cleverer. If that cache is missing, or older than five minutes, the real scan is scheduled anyway.
But never before the window is up. window.show() happens first and the rescan goes onto a QTimer with a fifty millisecond delay, which is there to let the compositor paint before anything heavy starts competing for the thread. Freshness is an mtime comparison and nothing cleverer, and being clever there would be the bug.
The result is that cold costs the same as warm, which is the whole trick in one measurement. With the cache aged out so a full scan was guaranteed I got 106.40ms cold from cold disk caches, then 84.43, 83.61, 85.43 and 83.29. The scan gets its own table when it does happen:
benchmark:
reader load 0.13ms
reader scan 3.29ms
total 3.42msThree and a half milliseconds to read and parse every application on the machine, run after you are already looking at the launcher.
Do Not Decode What Nobody Will See
Icons were the bigger half and I had this completely backwards. I wrote the no-icon mode up as a rounding error of about 5ms, because that is what the fake environment told me. On a real session it is nothing like a rounding error. Rendering a screenful of icons costs more than every bit of startup work in the table above put together.
What stops it being even worse is the model. The list is a QAbstractListModel and icons resolve inside onData, which Qt only calls for the decoration role on rows the view is actually painting, so the first paint decodes the first screenful and nothing else. An application you never scroll down to never gets its icon read at all. You are not paying for the hundred below the fold, only for the ten you can see, and --no-icons exists to skip even those ten.
The other half of that is sorting by how often you actually launch things, which keeps a small frequency.dat of scores and puts your real applications in the first screenful in the first place. So the ten icons that do get decoded are the ten you are most likely looking at.
Then Microsoft Set A Number For Me
Microsoft announced that their native Run dialog, actual native code with no Qt runtime to load, runs in under 100ms. I read that as a challenge, because a tiny native dialog beating something I wrote in Zig on top of Qt should not be possible.
It was not a language problem.
--run is Windows only, not for any technical reason but because the thing I am competing with is a Windows thing, and measuring against it on Linux would mean measuring against nothing. It strips the launcher to a prompt that executes what you type.
Measured on my own machine, and I will keep saying it is not a rigorous benchmark across hardware, because I have not built a harness for it. It is a Qt application in Zig rather than a native binary, which still surprises me. Qt being that cheap to start, as long as nothing expensive happens before the first paint, is the part I did not predict.
So the answer to whether a GUI toolkit quietly costs you the thing you were trying to build is no. My 200ms target turned out to be one I never came close to needing, and not because Zig is fast or Qt is fast. Almost none of that time was ever mine to begin with, and every millisecond I won came from finding work that nobody was waiting on.
Three Desktops And One Nightmare
Linux was painless. Applications are .desktop files sitting in a handful of known locations, you parse them, you are done, and I cannot think of a single complaint about it. MacOS turned out to be basically the same deal, the format is clear, it is predictable, and everything you need is inside each application directory. I expected one of these two to be the hard one and neither of them was.
Windows is where the real work went.
The problem is that Windows has an absurd number of places where application metadata can live. There are Start Menu shortcuts, per-user and shared, there are .url shortcuts, there are registry uninstall keys at machine and per-user scope, there is the WOW6432Node branch for 32-bit applications on 64-bit Windows, there are Store apps which use a completely separate registration system, and there are the odd ones. You can find the same application described in five different places at once, and some applications only exist in one of them.
My original plan was to support all of them, because that is the correct thing to do if you want complete coverage. I settled on two or three that are reasonable instead, because complete coverage on Windows is a genuinely large amount of work for a diminishing return, and I would rather ship something correct for most applications than something complete and subtly broken for all of them. That is a compromise and not a victory, and I would like to revisit it.
The One Namespace That Gives You Everything
This is the part that saved me from most of that mess. Windows exposes a virtual namespace called shell:AppsFolder, which is the shell’s own aggregated view of everything installed. It includes Store and UWP applications that have no Start Menu shortcut anywhere, because those applications genuinely do not create shortcuts. So instead of talking to five different systems, I can ask the shell for the one view it has already assembled for me, and that is where the bulk of the applications come from.
And Why That Meant Writing C++
I actually wanted to avoid C++ here. PowerShell talks to COM natively, New-Object -ComObject Shell.Application basically gets you there, and the whole Windows discovery path would have been one script instead of a compiled shim. Except that PowerShell startup on its own eats a huge chunk of the budget I am trying to beat, and you cannot shave 40ms off somebody else’s interpreter. So the C++ is not a preference, it is the only version of this that survives the requirement.
And the API is not particularly friendly either way. That namespace is only reachable through the shell COM automation interface. Not COM in general, but late-bound IDispatch with OLE Automation, which means every single call takes a VARIANT and hands back a BSTR, and you talk to it by string name. There is no nice header to include, you get oleauto.h and you do the BSTR dance yourself.
So src/core/windows/appsfolder.cpp exists, 148 lines of it, and it does this:
hr = CLSIDFromProgID(L"Shell.Application", &shellClass);
if (SUCCEEDED(hr))
hr = CoCreateInstance(shellClass, nullptr,
CLSCTX_INPROC_SERVER | CLSCTX_LOCAL_SERVER,
IID_IDispatch, reinterpret_cast<void **>(&shell));
folderArg.bstrVal = SysAllocString(L"shell:AppsFolder");
hr = getDispatch(shell, L"Namespace", DISPATCH_METHOD, &folderArg, 1, &folder);
hr = getDispatch(folder, L"Items", DISPATCH_METHOD, nullptr, 0, &items);That is the entire reason there is any C++ in an otherwise pure Zig codebase. It is not that Zig cannot talk to COM, it is that this particular API is only exposed through OLE Automation and writing that part by hand in Zig would have been miserable for no benefit. appsfolder.zig wraps the result so nothing else in the codebase knows C++ exists.
The rest of the Windows side does its deduping in Zig, and this is the bit that made me realise how messy this platform is, because look at what “is this already in my list” has to check:
for (context.existing_apps) |app| {
if (std.ascii.eqlIgnoreCase(app.name, utf8_name)) return false;
}
for (context.entries.items) |entry| {
if (std.ascii.eqlIgnoreCase(entry.name, utf8_name)) return false;
}Two sources, two loops, case-insensitive, and the second loop exists because the same shell can hand you the same application twice. The entire problem of a launcher on Windows is being sure you have not listed the same thing four times.
What I did not expect to spend any time on at all was the plugin system, which is now by a wide margin the largest file in the repository.
Plugins, And The Three Things A Sandbox Actually Has To Do
zenkai has a Lua plugin system, which started because I wanted themes and actions to be extendable without rebuilding. It works through ziglua and it grew, because plugins.zig is now 26KB where I expected a fraction of that.
What came with it was the sandbox, and if you are embedding a scripting language in a GUI application a sandbox turns out to need three separate things. I only know that because I got the first two wrong.
Capabilities Should Be Removed, Not Policed
Rather than a permission system that a plugin author might misconfigure, setupSandbox just deletes the globals:
const removed_globals = .{ "os", "io", "loadfile", "dofile", "require", "package", "debug", "load", "loadstring" };
inline for (removed_globals) |name| {
lua.lua_pushnil(L);
lua.lua_setglobal(L, @as([*:0]const u8, name));
}No os and no io means a plugin cannot read your files or shell out. The interesting part is that this is self sealing. You cannot get them back from inside, because require, load and loadstring are gone too, so there is no way to load a module that has them or compile new bytecode at runtime. The removal cannot be undone.
Permissions Alone Will Still Hang Your GUI
This one is easy to get wrong and worth being careful about. It is entirely possible for a plugin to be doing nothing remotely malicious and still hard hang your entire window with an infinite loop, because the Qt event loop is sitting on the other side of that call and it never gets a chance to run again. Sandboxing what a plugin is allowed to do says nothing about how long it is allowed to do it, so the work itself has to be bounded:
lua.lua_sethook(plugin.state, instructionLimitHook, lua.LUA_MASKCOUNT, 50000);
const result = lua.lua_pcall(plugin.state, argument_count, 0, error_handler_index);
lua.lua_sethook(plugin.state, null, 0, 0);Fifty thousand VM instructions, then it raises plugin exceeded instruction limit. That single line is the difference between a plugin freezing your launcher forever and a plugin being a bad plugin for a moment.
Failures Go To The Log
A Lua error propagating out through Qt and into your window is a bad time, so errors get trapped, attributed to the plugin’s manifest name and the method that was being called, written to the log, and swallowed. Related to that, print is replaced with a C function that routes through zenkai’s own logger, so plugin output ends up interleaved in the launcher log instead of sprayed across stdout where nobody will ever read it.
Themes, Briefly
I support most of the popular colour schemes. Catppuccin, dracula, tokyo night, ayu, gruvbox, adwaita and plenty of others, ported across into QSS, which is Qt’s stylesheet format and turns a theme into mostly a data file rather than code, which is most of why there are 65 of them and why adding another is not work. I did some creative ones as well, including a few native-looking ones, and the one I am genuinely proud of is the native Windows theme, because it reads your accent colour and themes itself around it, so on a machine where you have already told Windows what colour you like, zenkai just quietly agrees with you.
What I Want It To Become
The honest version is that this started as a launcher and keeps turning into something else, and I am fine with that.
The biggest thing I want is more capabilities. Right now it launches applications and runs things, which is a launcher. What I actually want is closer to what Vicinae and Raycast do, which is a command palette with genuinely useful commands beyond starting processes. Clipboard history, quick calculations, scripts, window and workspace control, that whole category. I use a launcher every single day and the fastest thing on my keyboard right now is starting applications, which is a waste of a good global hotkey. And I do not mean that as a complaint, because the thing already keeps a scorecard of what I actually use it for, so at that point it might as well be funny about it.
I also want a Hyprland commander mode, which is a slightly more niche thing but I use Hyprland and I want zenkai to be able to drive it properly rather than being an application launcher I happen to also run on that machine.
And then there is the part that is less fun to admit, which is technical debt and performance. A few months in, this has left some genuinely ugly corners that I know about and have been choosing not to look at, in the way you do not open a file you know is going to cost you an afternoon when you are having a good day. Technical debt is a bad joint, you can get away with one and you can get away with a few if you are careful about which ones, but eventually the whole thing comes apart in your hands and you find out which ones mattered all along. I want to clean that up properly. And I want it faster still, because 20ms is a good number and there is no reason it should be the number I stop at.
Build Something
None of the parts I am actually proud of were the plan. The run mode that sits at 20ms came from staring at a number, the Windows discovery came from a nightmare I did not see coming, and the plugin system ended up more capable than anything I set out to build. Power up, then do it again.
So if you have been uninstalling launchers looking for one, or if you have looked at Zig and wondered whether it is any use for anything graphical, this is my answer to both. Go build the thing you kept looking for, even if the only reason it does not exist yet is that nobody bothered.
Thank you for your time and patience reading this article