君子生非異也,善假於物也

You Don't Know Client Development: Stack, Native, Platforms, and Borrowing

Categories: Share

You Don't Know Client Development article cover

Quite a few folks have been curious how I build Windows software on a Mac, and what stack Windows apps are usually built with these days. I happen to have some time, so here are some loose thoughts on choosing technology across multiple clients, plus a few ideas of my own. I posted three threads about this on X over the past few days; this is them pulled together into one post for the blog.

The Three Roads of Client Development

Anyone who has done front-end or client work probably knows that over the years, client development has mostly taken one of three roads.

The first and simplest is to just write Web code and drop it into a browser. Early PhoneGap and Cordova were exactly that: stuff a web page into a native shell, then provide some bridge capabilities so JS can call into native features, like opening the phone camera, the kind of thing the Web is bad at.

The second is cross-platform development, the “one codebase, many clients” idea everyone heard about years ago. On mobile there were React Native, Weex and the like, mainly to fix the Web’s poor performance, building iOS and Android from one codebase. In China, a big reason large companies used cross-platform tech was dynamic delivery and hot updates; put plainly, you could skip App Store review and push new pages directly, which is a big reason cross-platform was so popular there while mobile internet was growing fast.

On the desktop, the one everyone knows is Electron. Plenty of AI desktop apps, VS Code and Slack are built on it. The cost is that every app ships its own copy of Chromium and Node.js, so the installer is over a hundred MB and it uses a lot of memory. The upside is that with everything rendered by the same Chromium engine, the display stays consistent across platforms, and the code is very easy to write, so a lot of quickly-built desktop apps since then use it.

Cross-platform later grew into more interesting approaches. Flutter implements its own rendering engine, Skia at first and now its own Impeller, to balance cross-platform consistency with performance, and the veteran desktop framework Qt likewise draws its own UI.

In the last couple of years there’s also Tauri, written in Rust, and Wails, written in Go. What sets them apart from Electron is that they don’t bundle a Chromium; they use the WebView that comes with the system, so packages are much smaller. But overall they still follow the Web way of building things and bridge into the corresponding implementation language. And because the system WebView differs on each platform, WebView2 on Windows, WKWebView on the Mac, WebKitGTK on Linux, cross-platform consistency is actually worse than Electron and you still write compatibility code. That’s why Electron, even though it’s big and memory-hungry, still gets picked more often.

The third is native development, which before AI everyone saw as the hard one, especially Objective-C on Apple platforms, Java on Android, and C++ and C# on Windows. All of them, in my view, take a trained developer a lot of time to learn, read books, and write code by hand. To do it well you need to spend a lot of time studying how the underlying system works, which lets you write code that performs better and is more stable than average. To me it’s a technology with a big upfront investment that users really enjoy later. Back in 2017, not long after I started working, a big reason my career went well was that I spent half a year learning Objective-C and iOS development, which moved my front-end job toward cross-platform early, and that let me solve some hard problems that people who only knew front-end or only knew client couldn’t. That was a really fun period of doing hands-on development.

Later, as Swift + SwiftUI, Kotlin + Jetpack Compose and C# + WinUI 3 matured, native languages gradually moved toward the declarative style of the front end, and got easier to write; declarative code is shorter and easier to read. But in China, native has been completely overshadowed by the widespread use of React Native, Flutter and Electron. There aren’t many developers, and the ecosystem moves forward only because the platform owners push it. That’s why many apps ship features fast but generally feel laggy after a while, and apps like that are the majority. If you want to migrate to native and the original architecture wasn’t done well, you’ll most likely have to rewrite with nothing reused. If it was done well, say you pulled the business logic out long ago, something like Kotlin Multiplatform lets you share the business logic while each platform writes its own native UI. That’s sort of a fourth road, and the trend of the last few years.

By now you probably get the idea: there’s an impossible triangle here, dev speed, experience and performance. Web gives you more speed and less performance, native gives you more performance and experience but less speed, and cross-platform sits right in the middle. Done well, it gets a bit of everything; done badly, you end up in the awkward spot where no side really benefits.

The impossible triangle of dev speed, experience and performance: Web sits near speed, native near experience and performance, cross-platform in the middle

So now that AI Coding has made writing code a non-issue, which technology should we pick? A lot of the time it depends on your scenario. If you’re building something for browsing, clicking a few buttons and reading, the Web 1.0 kind of thing, I’d still strongly suggest just using Web tech. If you need low-level system capabilities, want something that feels smoother and is easier to distribute, or it’s an app by nature, I’d still strongly suggest going native. That way you get all of the platform’s own tuning right away, the most native interactions and the lowest memory use. And with AI making two native codebases much cheaper, the speed side of the triangle gets filled in; debugging and verifying on two platforms just becomes the new cost, and that’s exactly where a person needs to focus.

Of course, if the tech choice really doesn’t matter much, the workload is huge, and business results matter far more than the technical experience, cross-platform is fine too. It’s fast, iteration and releases are fast, and your users get your newest features right away.

No Settling on Windows Either

On the stack side, the Mac version of Mole is also native, Swift + SwiftUI, which lets me call system capabilities easily and gives a very good performance experience. I didn’t want to settle, so on Windows I also went straight for the most direct language for writing Windows software, C# with WinUI 3, running on .NET 10. The process was rough, but compared with the Windows development vczh (known as 轮子哥 in China) used to talk about ten years ago when I first read about it, it’s simpler and easier to understand.

Windows actually has quite a few native UI frameworks, including Win32, WinForms, WPF and UWP that I’d looked at years ago. WPF came out in 2006 and is still maintained, but its controls and design language aren’t very nice and feel very last-generation, while UWP is tied to the sandbox and the Store. What Microsoft pushes now is WinUI 3 in the Windows App SDK. The UI framework was split out of the OS and ships on its own, which has the benefit that regular Win32 programs can use it too. The base components are Windows 11’s own Fluent style, which I quite like, and HiDPI scaling and ARM64 are supported natively.

.NET 10 is the long-term support release that came out last November. When packaging I bundle the runtime in, so users don’t need to install .NET separately, and there’s a native installer for both x64 and ARM64, so Snapdragon Windows laptops don’t go through emulation. Basically I try to keep things simple and hassle-free for users. Install and update use the open source Velopack framework, which makes delta updates easy and works really well.

When engineers start writing code, they naturally want to reuse as much as possible, so at first I really wanted to reuse the Mac’s Swift code. Later I had AI analyze it: of the more than 100,000 lines of Swift, less than 20% could be used without system frameworks like AppKit and SwiftUI, and the logic also deals with macOS’s own path rules like ~/Library, so basically none of it was usable. The Windows version was written almost from scratch.

What Carries Over Is Design, Copy and Rules

The relationship between the two is actually very simple. I keep the look and interactions as identical as I can, so that the impression many people have of Windows software not being refined or nice-looking doesn’t hold here, and it stays at what I consider a good level. The underlying code is written separately for each. With AI, keeping the visuals consistent is easy: I wrote a script that turns the design tokens in the Mac app’s Swift code, colors, font sizes, spacing, corner radii, into XAML resources for Windows. The copy is also converted from the Mac’s localization files into the Windows format, all 11 languages at once, and even the runner animation you see in the menu bar is synced over by the same script.

On the left, the Clean page of Mole for Mac; on the right, the Windows version. Colors, font sizes, spacing and copy are generated from the same design tokens and localization files

Syncing might sound simple, but it actually takes some effort to keep AI from drifting off course. Every sync records which Mac commit it corresponds to, so the two sides don’t drift apart. For features there’s a SYNC.md that works as a reconciliation table: every time the Mac adds a feature or fixes a security issue, the Windows side marks clearly whether it’s implemented, intentionally different or not applicable, and why.

With these two mechanisms, the two sides’ underlying code can be completely separate while features stay aligned, with no need to force a compatibility layer to smooth things over, which would only make things more complex. What actually gets reused is the design, the copy and all the rules settled after hitting problems, and those are exactly the “capability abstraction layer” AI likes most. In other words, as times change, so-called code abstraction matters less and less, and rule abstraction matters more.

Signing Is a Lot More Trouble Than on the Mac

There’s another part of Windows development that’s a lot more trouble than on the Mac: code signing. Without a signature, opening the app shows “Windows protected your PC”, Windows 11’s Smart App Control may refuse to run it at all, and many security apps in China will flag unsigned programs as suspicious. It’s similar to the “damaged” warning for unsigned, unnotarized software on the Mac, where people can at least run a command to get past it. On Windows, if you want to distribute properly, there’s no good way around this step; you just need it.

After researching it, Microsoft’s signing ecosystem really is far behind Apple’s. Microsoft has its own Azure Artifact Signing, $9.99 a month and the least hassle, yet for individuals it’s only open to developers in the US and Canada, so I couldn’t use it. A very strange rule. On top of that, the industry has required since 2023 that signing private keys live in a hardware or cloud HSM rather than as a file on your computer, and from this year a single certificate can last at most 460 days, so you have to replace it every year or so.

In the end I bought a Certum cloud OV certificate, a bit over $300 for three years. To sign, I log in to SimplySign on the computer and then get a one-time code from the phone app, and SmartScreen reputation builds up slowly around the verified name on the certificate. For a while at the start, the signed packages were still blocked by SmartScreen on the ThinkPad, so with every release I submitted the installer to Microsoft’s malware analysis portal to let the reputation build. Also, a SimplySign login session disconnects after a few hours idle and needs a new code before you can sign again, so it’s really hard to turn this into automated cloud packaging.

Everything Still Happens on the Mac

With the certificate sorted out, next is the dev environment. My requirement is that the code and the AI’s context all stay on the Mac. I’m so used to the Mac that it’s really hard to develop on Windows, so Windows only handles building, checking how things look, and all kinds of tests. So I installed Parallels Desktop on the Mac, a bit over 300 RMB a year. I’ve heard plenty of folks complain about it, but on my M5 Pro, Windows 11 runs quite smoothly, and I feel the money is well spent.

The best thing about it is that my Claude can drive it very easily. The repository is mounted straight into the VM as a Parallels shared folder and becomes an X drive. After changing code on the Mac, it builds right in the VM without copying files back and forth; the two sides’ build outputs just need to go into different directories. Building, launching and reading logs are all scripts I wrote, run directly inside the VM through Parallels’ own prlctl exec. When the program crashes on startup, Windows only gives you a 0xc000027b, so Mole writes the real exception to a log, the script prints it at the end, and Claude reads it directly. So writing code still happens in Claude Code on the Mac, while debugging and running happen in the VM. Where scripts can’t help, Codex’s computer use clicks through it directly, and only a few system confirmation dialogs can’t be clicked. That’s my job here: clicking OK on system dialogs, haha.

The code and Claude Code are on the Mac, most building and testing runs in the Parallels VM, and what the VM can't measure accurately is checked on the ThinkPad

While we’re here, let me share how Mole for Windows is layered. There are four layers. The bottom one, Core, depends only on .NET and touches no Windows APIs; cleanup rules, path safety checks and uninstall leftover decisions all live here, and tests use an in-memory file system that simulates Windows path rules, so they run fine on the Mac too. Above that is Platform, where the real system interfaces live: the registry, the file system and Win32 calls; with EnableWindowsTargeting turned on, it compiles on the Mac as well. Above that is the ViewModel layer for UI state, very familiar to anyone who has written apps; it maps one-to-one to the Mac version, so it can be tested on the Mac too. Only the WinUI interface at the very top has to be built on Windows.

The four layers of Mole for Windows: the lower the layer, the less it touches Windows, and the more code can be checked on the Mac

This is what let me write over 80,000 lines of Windows code and more than 4,000 tests in one month, with almost all of it done on the Mac, which is really convenient and is the clever part of the architecture. Of course I only provided my ideas and suggestions when designing the architecture; most of it was put together by Opus 5.5, which is seriously impressive.

Next, testing. I actually think that in the AI era, an engineer’s biggest value, besides architecture design, is testing and verification. To make sure the product is good enough to sell, you definitely need to test in a real environment and keep finding and fixing experience issues. So I got a ThinkPad X1 Carbon, a machine that feels pretty well made, and use it as my real-hardware environment. It’s set to never sleep, so my agents and I can verify, test and run regressions 24 hours a day, including reading all kinds of real system parameters and testing uninstalls, leftover cleanup, registry and startup items for more than 400 Windows apps. Most of that runs in the VM; things the VM can’t measure accurately, like the x64 installer, a real HiDPI screen, the battery and SmartScreen, get checked directly on this machine. So this machine also feels very worth it to me.

Then, since I didn’t want to copy things back and forth between two machines, I ended up using UU Remote: open a remote terminal on the ThinkPad and operate it directly from the Mac, with an ssh connection as a backup. Most installing, uninstalling and debugging is done through command-line scripts, and for the rest that needs eyes on the UI, computer use operates it over remote desktop, which is really convenient. In the end, I’m still doing everything on the Mac.

My desk: the Mac in the middle, the ThinkPad X1 on the left

After all this, I had a few of Mole’s angel users try the finished product, and they really enjoyed it. The development process felt very worth it and a lot of fun, and it helps solve plenty of pain points. Plus, Windows probably has many more non-technical users than the Mac, and letting them enjoy good software too is something worth being happy about.

Borrowing Other Languages’ Ecosystems

With the stack chosen, let’s talk about how, when building client apps, we can use the strengths of different languages to make our products better.

Say you’re writing a Mac app and your main language is Swift. You can absolutely bring in other languages to solve the problems Swift isn’t so good at. That’s what I did six years ago with MiaoYan, a native Mac Markdown note app written in pure Swift. At the time I was stuck on a very hard problem: how to implement Markdown formatting in Swift, much like Prettier’s Markdown formatting in VS Code.

I wanted to write it by hand in Swift, but the original is written in JS and very complex, and I was close to giving up. Then in the shower I had an idea: from Swift I can call JavaScriptCore, which can run JS. So could I pass the messy md in as a string, run it there, have it output clean, normalized md, and then parse and render that? I tested it right away, and it really worked, and the result felt great. Back then, with no AI, if I’d implemented that formatting engine in Swift, who knows how long it would have taken.

Later it got even more interesting. When I wrote Pake, the desktop packaging tool, it was mainly written in Rust, but Rust’s ecosystem was also very thin then. For example, I wanted to add downloads and pop up a toast when they finished, and there wasn’t even a ready-made toast component. Since I know front-end, my idea was to have Rust call JS and let JS create a document container inside the web page. That way, when there’s a notification, Rust calls JS to render it. It worked really well, without hurting performance, and it looked nicer too.

WeRead, a Chinese reading site, packaged into a desktop app with Pake

A lot of the time, if you know a bit about many languages, when you hit a tough problem it helps you quickly think of a hacky way to solve it, and you’ll even start connecting things and calling the other way around. For instance, the rendered web page needed to download files, and here it could call back into Rust’s mature system capabilities to do the download, with better performance.

There’s a really interesting technique in here. Put plainly, you can think of it as messaging: different languages exchange information through a middle layer and each listens and runs on its own side. The Rust and JS calls above use exactly this trick, and the bridge PhoneGap opened up for JS, mentioned at the start, is the same kind of messaging. And when you let your imagination run, you can even have several languages talk to each other. When I built MiaoYan’s split editing, one side was a native Markdown editor and the other a preview rendered in a WebView. Markdown parsing used cmark-gfm, GitHub’s open source C library, which is seriously impressive in both performance and capability but very low-level. It can mark each block of HTML with the line of the source it came from. Then JS in the preview reads those marks, works out which line you’ve scrolled to, and tells Swift through a message channel, and the editor follows to the same position. That makes scrolling between the two sides stay really in sync.

MiaoYan's split editing: the native editor on the left, the WebView preview on the right

Kaku also uses something similar. There’s a feature where you type # plus a sentence of natural language right in the terminal and press Enter, and I call a fast AI to generate an executable terminal command for you. This effect is really cool.

In Kaku, ## plus a sentence offers a few commands to choose from, while # fills in one directly

But it’s actually hard to implement, because normally when you press Enter, zsh just throws an error and won’t run it, since it doesn’t understand it. I thought about it for a long time and found I could use a terminal escape sequence to send the sentence to Kaku. The Lua script inside catches it and hands it to the Rust layer to request the model, and the generated command is filled back into the command line by Lua. It feels a bit like a sleight of hand: after a short wait the command is filled in, then you can just naturally press Enter and the command generated from natural language runs. In effect, one sentence takes a lap through zsh, Lua and Rust and ends up back in the terminal.

MiaoYan's synced scrolling passes through C, JS and Swift; Kaku's sentence takes a lap through zsh, Lua and Rust

There are quite a few scenarios like this. Solving problems like these was, before AI, what I thought was the most fun part of being an engineer. You can treat every language purely as a tool, use them without any bias, and pick the language that fits each scenario best, rather than liking one language and using it to build every feature.

In other words, when we build products, we should make full use of the strengths of each language’s ecosystem to make up for the weak spots of the main language on the client, while getting the most out of its strengths in performance and interaction. That way the end result complements itself really well, which is a lot of fun.

Solving the Problem Matters More Than the Stack

But in the end, technology serves the business, and it still has to head toward solving problems. Take grabbing concert tickets: nobody cares why your client is so laggy, they only care whether they can get tickets with it. Even if it runs perfectly smoothly, if they can’t get tickets you’ll still get roasted. Picking a good stack here is only the icing on the cake. First make sure the user’s most real need is solved, do that well, then think about how to make the experience better, and only after that consider whether you need native technology.

It’s just that now, in the AI era, those really fun scenarios of solving hard problems through code have become rarer. You throw Opus 5.5 at it and it solves everything in one go. But if you know the strengths of these different languages, when it goes off track you can nudge it to try that feature or that library of some language, and it might save you a lot of detours in building the product.

Haha, right now I just want to make the most of still being able to guide AI in some areas. Maybe in another six months engineers won’t be needed for this either and AI will do it all for you. Will that be more interesting or more boring?

An AI optimist like me thinks coding itself will probably stop being interesting, but there will be more fun, unknown new things worth exploring, and that will be even more interesting!

Feel free to reach out, and if you’re doing Windows development too, I’d love to hear your best practices.

Read More

From Mole CLI to Mole for Mac: What I Learned

【2026-08-16】How Mole grew from an open-source Mac cleaner CLI into a native app, and what I learned about safe cleanup, quiet design, and building for real users.