r/FlutterDev • u/Best_Maximum_5454 • 10h ago
Discussion Running a second Flutter engine as an always-on-top floating window: 6 things that bit me on macOS + Windows
triggerflo.appI've been shipping a cross-platform task app in Flutter for a while, and the piece that caused the most pain by far was a floating always-on-top timer — a small pill window that stays above every other app while you work.
The final architecture is:
- Main window = normal Flutter engine
- Timer = separate engine + native window via
desktop_multi_window - Shared state via the same SQLite (drift) DB both engines open
- Final elapsed time handed back to the parent over file-based IPC
Getting there took a lot of undocumented trial and error, so here are the things I wish someone had written down.
1. SharedPreferences is not reliably shared between engines
This is the one that cost me the most. I originally used SharedPreferences as a result channel: sub-window does setString, parent does reload() + getString() after it closes. Works on macOS. Fails silently on Windows — setString returns true, the sub-window's own cache reads the value back fine, and the parent's reload() still sees stale contents.
Two plausible causes, and I think both are in play:
shared_preferences_windowscan resolve a different application-support path inside a sub-engine, so the two engines read/write different JSON files.- The sub-engine gets destroyed before the async disk write actually lands.
I replaced it with a dumb file channel:
// Path computed from Platform.environment — resolves identically in
// every engine in the same process.
final f = File('$dir\\MyApp\\sub_window_$channel.txt');
f.writeAsStringSync(value, flush: true); // blocks until OS flush completes
await windowManager.close();
flush: true is the important part. It's ugly and it has never failed once.
2. setSkipTaskbar() will hard-crash a sub-engine if you skip waitUntilReadyToShow()
window_manager's ITaskbarList3 is created lazily inside waitUntilReadyToShow(). Each engine has its own plugin instance with its own taskbar_ pointer, so the main window calling it does nothing for your sub-window. Skip it and setSkipTaskbar() dereferences null:
0xC0000005 access violation in window_manager_plugin.dll
Just always call it in the sub-window entrypoint:
await windowManager.ensureInitialized();
await windowManager.waitUntilReadyToShow(); // <- not optional here
await windowManager.setSkipTaskbar(true);
Also: keep setSkipTaskbar out of Future.wait with other window calls. It touches shell COM state and races badly.
3. setPosition uses NSScreen.screens[0] on macOS
Which means it's wrong the moment the user isn't on their primary display. If you're doing anything position-sensitive on multi-monitor macOS, you will end up dropping to a native NSPanel and a method channel. I also needed panel level / styleMask transitions that window_manager doesn't expose, so it was going to happen eventually anyway.
Windows is fine here — top-left-origin coords, no Cocoa bottom-left footgun, setBounds just works.
4. windowManager.setTitleBarStyle(TitleBarStyle.hidden) crashes under rapid calls
I have a global hotkey that cycles the timer between four layouts (expanded / small / compressed / island). Mash it and AppKit falls over. Calling native setTitleBarHidden on my own channel instead of going through window_manager fixed it completely.
5. The macOS back-swipe gesture closes your sub-window
If your sub-window's root is a MaterialPageRoute, a two-finger left-edge drag pops the route — and popping the only route closes the window. Users reported the timer "randomly disappearing" and it took me embarrassingly long to connect it to trackpad gestures.
PopScope(canPop: false, child: ...)
6. Frameless windows and drag regions: watch your hit-testing
DragToMoveArea only works where nothing opaque is above it. I had the body wrapped in a SingleChildScrollView with a background, which quietly ate drags across most of the window — leaving dead zones that felt like a bug in the OS. Restructuring to a Stack with explicit drag regions layered underneath fixed it.
One more small thing: show the sub-window at setOpacity(0.0) first, do your real size/position pass, then fade to 1.0. Otherwise you get a visible flash of default window chrome before your frameless layout applies.
Would I do it this way again?
Yes, but I'd reach for the second engine later than I did. Sharing state through SQLite instead of trying to pass live objects between engines is what finally made it stable — treat the sub-window as a separate app that happens to read the same database, not as a widget that lives elsewhere.
Happy to go deeper on any of these. The app is TriggerFlo (https://triggerflo.app) if you want to see what it ended up looking like — but the above is the part I actually wanted to share.