# 09 FEB 2026 · DEVELOPMENT

One codebase, three desktops: shipping to macOS, Windows and Linux

Practical notes on cross-platform desktop app development: file paths, line endings, installers, code signing, auto-update, a testing matrix and making one codebase feel native on macOS, Windows and Linux.

>_[ FIG. 00 · DEVELOPMENT ]×
One shared core box with arrows fanning out to three platform packages: macOS, Windows and Linux, each with its installer formats and signing stepSHARED CORELOGIC + FILE FORMATSPATHS VIA PLATFORM APIUPDATE CHECKTESTS1ONE REPO, ONE CI RUNMACOS.APP IN .DMG / .PKGDEVELOPER ID SIGNNOTARIZE + STAPLE2WINDOWS.MSI / .MSIX / .EXEAUTHENTICODE SIGNSMARTSCREEN3LINUXAPPIMAGE / DEB / RPMFLATPAKGPG-SIGNED REPO4PER-PLATFORMADAPTERS1CODE THAT DOESNOT CARE ABOUTTHE OS2APPLE SIGNINGAND NOTARY3WINDOWS SIGNINGAND INSTALLERS4MANY DISTROS,MANY FORMATSDIAGRAM

Cross-platform desktop development has a reputation for being easy until the last ten percent, and then very hard. Writing the app once is the easy part. Shipping it to macOS, Windows and Linux means three sets of installers, three code signing systems, three update stories and three ideas of what a "normal" app looks like. Our desktop apps, serve, Exif AI and dskcopy, all ship for all three, and Grabbr will too, so these are the notes we wish someone had handed us at the start.

We will not argue for one framework here. Electron, Tauri, Qt, Flutter, .NET and plain native code with a shared core all work. The problems below show up with every one of them.

Keep the core boring and platform-blind

The drawing at the top shows the shape we aim for. A shared core that holds the logic, file formats, update checks and tests, and knows nothing about which operating system it is on. Around it, thin per-platform adapters that handle the parts that really differ: menus, file dialogs, secure storage, notifications and packaging.

The rule is that platform checks live at the edge. If you find if windows scattered through business logic, something has leaked. It is much easier to test a core that takes a path and returns a result than one that also decides where the user's settings folder is.

Paths, files and line endings

Most of the bugs we see in cross-platform code are about files. A short list of the usual suspects:

  • Never build paths by joining strings with a slash. Use your language's path library. Windows accepts forward slashes in most APIs, but not everywhere, and drive letters and UNC paths like \\server\share break naive code.
  • Ask the OS where things go. Settings and data belong in ~/Library/Application Support on macOS, %APPDATA% or %LOCALAPPDATA% on Windows, and $XDG_CONFIG_HOME and $XDG_DATA_HOME on Linux (falling back to ~/.config and ~/.local/share). Every serious framework has a function for this. Hard-coding a dotfolder in the home directory is the Linux habit that annoys Mac and Windows users most.
  • Case sensitivity differs. Linux file systems are case-sensitive. macOS (APFS by default) and Windows (NTFS) are case-insensitive but case-preserving. Code that works on a Mac can fail on Linux because Logo.png and logo.png are different files there.
  • Windows has reserved names and length limits. Names like con, nul and aux are reserved device names, and even con.txt causes trouble in many tools. Paths longer than 260 characters still trip up plenty of tools unless long path support is enabled.
  • Line endings. Windows tools like CRLF, everything else uses LF. Put a .gitattributes file in the repository, for example * text=auto eol=lf, and mark binary files explicitly. When your app writes text files, pick one convention on purpose and read both.

Installers: three platforms, many formats

macOS is the simplest. You ship a .app bundle, usually inside a .dmg disk image that people drag to Applications. A .pkg installer is for when you need to put things outside the app bundle, such as a command-line tool or a system extension. Build a universal binary so one download runs natively on Apple silicon and Intel Macs, while Intel is still around.

Windows has more choice than it needs. An .exe installer built with a tool like Inno Setup or NSIS is the most familiar to users. .msi is what IT departments want, because it deploys cleanly through group policy and management tools. .msix is Microsoft's modern package format, with clean installs and uninstalls and a container-like model, and it is required for some Microsoft Store routes. Ship whichever your users need. If they are businesses, that usually means MSI.

Linux is where people give up. There is no single format. Debian and Ubuntu use .deb. Fedora and openSUSE use .rpm. AppImage is a single file that runs on most distributions without installing anything. Flatpak, usually through Flathub, sandboxes the app and handles updates, and it is the closest Linux has to a common store for desktop apps. Snap fills a similar role on Ubuntu. A reasonable starting point is an AppImage and a .deb, then Flatpak once the app is stable.

Code signing on each platform

Unsigned apps work less and less well on every desktop. Budget time for this. It is always more than you think.

macOS needs a Developer ID certificate from the Apple Developer Program, the hardened runtime enabled, and then notarization: you upload the build to Apple with xcrun notarytool submit, Apple scans it, and you attach the ticket with xcrun stapler staple so it verifies offline. Without that, Gatekeeper warns users that the app cannot be checked, and recent macOS versions make it deliberately awkward to open anyway. Apple's notarization documentation is the reference.

Windows uses Authenticode. You sign executables and installers with signtool, using a SHA-256 digest and a timestamp server so the signature outlives the certificate. Since 2023, publicly trusted code signing keys have to live in hardware, a token or a cloud HSM, which makes signing in CI harder than it used to be. Microsoft also runs its own cloud signing service, which is worth a look. Either way, a new certificate starts with no reputation, and SmartScreen may still warn users until enough people have installed your app. There is no shortcut for that any more.

Linux signing is mostly about the channel rather than the binary. If you host an apt or dnf repository, you sign the repository metadata with GPG and users trust your key once. Flathub and the Snap Store handle their own signing. A bare AppImage can be signed, but few people verify it, so publish checksums alongside it.

Auto-update without surprises

An update system is a remote code execution feature you are building on purpose, so it deserves the same care as anything security-related. The basics: fetch update metadata over HTTPS, verify a signature on the update itself with a key that is separate from your TLS certificate, and never install something that fails verification.

On macOS, Sparkle is the long-standing choice for apps outside the App Store and uses EdDSA signatures on its update feed. Electron and Tauri ship their own updaters. On Windows, MSIX can update through App Installer files, and other installers usually need an updater built into the app. On Linux, the kindest option is to let the package manager or Flatpak do it. Users there generally do not want apps updating themselves.

Whatever you use, keep updates quiet. Download in the background, install on next launch, and never interrupt someone mid-task to announce a new version.

A testing matrix you can afford

You cannot test every combination, so decide which ones matter. A matrix we think is reasonable for a small team:

macOSCurrent and previous major version, Apple silicon, one Intel machine while we still ship for it
WindowsWindows 11 x64, one Windows 10 machine while it has users, Windows on Arm when the framework supports it
LinuxLatest Ubuntu LTS and Fedora, on both GNOME and KDE, X11 and Wayland
Displays100 and 200 percent scaling, plus one odd value like 150 percent on Windows
Every buildUnit tests on all three in CI, then a manual install, update and uninstall before release

Hosted CI runners for all three systems make the automated part straightforward. The manual pass is the part people skip, and it is where signing mistakes, broken shortcuts and leftover files after uninstall get caught.

Native feel is in the details

A cross-platform app does not have to look identical everywhere. It has to behave like it belongs. The small things matter more than the theme.

  • Keyboard shortcuts use Command on macOS and Ctrl elsewhere. Quit is Command Q on a Mac. Settings live in the app menu on macOS, and usually under a File or Edit menu, or a gear icon, on Windows and Linux.
  • Window controls are on the left on macOS and on the right on Windows and most Linux desktops. Custom title bars need to respect that.
  • Secrets go in the system store: Keychain on macOS, Credential Manager on Windows, and the Secret Service API (GNOME Keyring or KWallet) on Linux.
  • Dialog button order differs. macOS and GNOME put the default action on the right. Windows traditionally puts OK before Cancel.

If you are planning a desktop app that has to run on all three and would rather not learn all of this the hard way, that is a large part of what our custom development work covers. And if you only take one thing from this post, make it this: sign and notarize your very first test build, not your release candidate, so the certificates are sorted out long before anything depends on them.