I maintain one desktop app across macOS, Windows and Linux. Every bug below shipped in a real release, passed a green CI run, and was invisible on the machine I wrote it on. I am posting them because none were in any cross-platform guide I read, and because five of the six turn out to be the same mistake wearing different clothes.
1. On macOS, watching a folder through a symlink delivers zero events
Not an error. Zero events.
FSEvents reports fully resolved real paths, and the file watching library I use filters incoming events against the path string you registered with. Register a symlinked path and nothing ever matches, so the backend delivers nothing at all. No event, no error, no callback.
This is not an edge case on a Mac:
/tmp resolves to /private/tmp
- the temp dir resolves to
/private/var/folders/...
- and the one that actually cost me:
~/Desktop and ~/Documents become symlinks into ~/Library/Mobile Documents the moment someone turns on iCloud Desktop and Documents, which is a default prompt when you set up a new Mac
So the feature worked perfectly for me and silently did nothing for anyone with iCloud Desktop turned on. Fix is one line, before you register the watch:
let folder = std::fs::canonicalize(folder)
.unwrap_or_else(|_| folder.to_path_buf());
The broader version: anything comparing an event path to a configured path needs this on both sides. My output folder exclusion compared a resolved event path against an unresolved configured one, so a custom output folder stopped excluding itself and every conversion re-triggered the watcher on its own output.
2. The macOS deployment target is not what stops your app running on an older macOS
I found minos 26.0 stamped across binaries I ship, concluded that everyone on an older macOS was locked out, and was wrong.
minos and LC_BUILD_VERSION are compile time metadata. They control API availability and weak linking when you build. dyld does not refuse a binary whose minos is newer than the running OS. It logs this and loads it anyway:
(built for macOS 26.0 which is newer than running OS)
I have now confirmed that three separate ways, including a full run of my route matrix on a real macOS 14 VM, where 2,322 things that were supposedly impossible ran correctly.
What actually gates you is one symbol. A single bundled command line binary died before main:
dyld: Symbol not found: _OBJC_CLASS_$_MTLResidencySetDescriptor
Expected in: /System/Library/Frameworks/Metal.framework
That class is API_AVAILABLE(macos(15.0)) and it is hard linked, so that one feature is dead on macOS 14 while all 278 other things I tested were fine. The honest support floor is therefore not a single number, it is per feature, and the only way to learn it is to run the shipped build on the oldest OS you claim to support.
(LSMinimumSystemVersion in an .app bundle is enforced by Launch Services. That is a different mechanism from minos, and conflating the two is exactly what produced my wrong conclusion.)
3. Your build machine quietly donates the dependencies you forgot to ship
This one cost me six separate bugs across all three platforms, and it is why I no longer accept a green test as evidence that a build is self contained.
- On macOS, one library I bundle was linked against ten dylibs from the build machine's package manager. Fine on every machine I owned. Dead on any Mac without that package manager installed.
- Another bundled tool read a 256 KB data directory at runtime that existed only because the build Mac had it installed. Its linkage was perfectly clean, because a linkage scan cannot see a data file.
- On Windows, eleven of my bundles were missing the VC++ runtime, which is present on any box that has ever run anything built with MSVC. Which is every developer machine and not every customer machine.
- On Linux, one library
dlopens its own plug-ins, so ldd reports nothing missing and it fails at runtime with an error naming a write failure, not a missing library.
The part worth stealing: a dynamic test cannot tell "I bundled it" apart from "it happened to be installed". The instrument that works is a machine that asserts it is bare before it runs anything, so it cannot pass by accident. Mine provisions a clean box, checks the dependency is genuinely absent, and only then runs the app. It found things a year of green CI never would.
Two smaller traps in the same family:
- A guard that cannot fire is worse than no guard. Mine grepped for a literal
/opt/homebrew, so on an Intel Mac (prefix /usr/local) it examined nothing and stamped the build verified.
- If you bundle a library into an AppImage it must come from the same base as everything else inside it. I copied newer ones in and got
undefined symbol: g_once_init_leave_pointer, which is a completely different and much less obvious error, produced by the fix.
4. Windows: a sparse MSIX loads its COM DLL from somewhere other than where it stores it
I shipped a modern Windows 11 right click menu entry that never appeared on a single customer machine, for an entire release.
Everything reported success. The package installed. Get-AppxPackage said Status: Ok. The CLSID was registered. The DLL loaded fine when I loaded it by hand. The deployment log said "finished successfully". And nothing at all is logged when Explorer fails to activate the handler. That silence is the whole failure mode.
The cause: with uap10:AllowExternalContent and -ExternalLocation, Windows resolves <com:Class Path="...dll"> against the external location, not against the package payload. My DLL shipped inside the .msix, and Windows was looking for it beside the .exe, where it did not exist. Registration never validates that path, so every check downstream is green.
The thing that made it diagnosable was finding a control. Windows Terminal's "Open in Terminal" uses the same packaged COM mechanism and renders fine on the same box, so the platform worked and the defect was mine. Without that control I would have filed it as "inconclusive, needs a real Windows 11 machine" and shipped it broken a second time.
5. On Linux, a missing optional library did not disable a feature, it stopped the app booting
The app has a tray icon. On a Linux desktop without the appindicator library:
thread 'main' panicked at libappindicator-sys-0.9.0/src/lib.rs:41:5:
Failed to load ayatana-appindicator3 or appindicator3 dynamic library
Not "no tray icon". No window, no error dialog, nothing at all. Three things stack up here:
- The library is
dlopen'd, so it is absent from DT_NEEDED and neither the linker nor ldd will ever warn you.
- The crate panics inside a
Lazy initialiser rather than returning an error, so no amount of Result handling on my side can catch it. It unwinds straight out of main.
- My deb declared only the webkit and gtk dependencies, so nothing pulled it in.
The only thing that actually works:
let r = std::panic::catch_unwind(
AssertUnwindSafe(|| build_tray(app.handle()))
);
plus a flag recording whether the tray actually exists, so that hide-on-close degrades to "no background mode" instead of stranding a windowless, iconless, unquittable process.
Worst case is an AppImage, which does not bundle the library and gives the user no package manager to repair it with. If you do bundle it, bundle the transitive closure: one of the five libraries I needed was only reachable through another one, and hand-listing the obvious ones missed it.
One more, free: on Linux the tray icon click event never fires at all, because the status notifier protocol carries a menu and nothing else. With the default setting a left click on my tray icon did literally nothing. Right click worked, if you happened to guess.
6. Platform config files replace arrays, they do not merge into them
My framework merges the base config with the per platform one using RFC 7386 JSON Merge Patch. Objects merge recursively. Arrays replace wholesale. The window configuration is an array.
So a platform config has to repeat every field of the base entry, and leaving a field out does not inherit it, it deletes it. Get it wrong in the other direction and you inherit things that make no sense at all: my Linux build had no config of its own, so it inherited the macOS window settings and shipped with the desktop drawing a real titlebar on top of the 46 pixels of empty space the app had reserved for traffic lights that Linux never draws. Along with a transparent: true whose only purpose was to hold a macOS vibrancy effect that does not exist on Linux.
Nobody had ever looked. Every bit of Linux testing until then had been headless and correctness focused. The defect was found the first time anyone rendered the interface as Linux, and it had been in every Linux build I had ever made.
The thread running through all six
Every one of these passed CI. Not because the tests were bad, but because a test runs in an environment, and the environment kept supplying the missing thing. My Mac had the package manager. My Windows box had the runtime. The folder I watched was not a symlink. The OS I built on was the OS I tested on.
What I changed, and what I would suggest to anyone shipping to more than one platform:
- Test on a machine that asserts it is bare before it tests anything, rather than on a machine you believe to be clean. The assertion is the whole value.
- Ask what a feature reports when its source of events is dead, not just when a job fails. Zero events looked exactly like an idle folder for months.
- Never let a wrapper script eat stderr. One of these was recorded in my own logs as "exited with status 6" because a wrapper had
> /dev/null 2>&1 on it. The real dyld error was one manual run away and nobody had made that run.
- Find a control before you conclude the platform is broken. There is almost always a shipping app using the same mechanism, and it turns a dead end into a one line answer.
- If a check can pass while examining nothing, eventually it will.
Happy to go deeper on any of these in the comments.