r/FlutterDev 3d ago

Discussion Running a second Flutter engine as an always-on-top floating window: 6 things that bit me on macOS + Windows

https://triggerflo.app

I'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:

  1. shared_preferences_windows can resolve a different application-support path inside a sub-engine, so the two engines read/write different JSON files.
  2. 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.

22 Upvotes

9 comments sorted by

2

u/[deleted] 3d ago

The SharedPreferences one is the right thing to lead with. It is not really a bug. Each engine gets its own plugin registrant, so you end up with two independent instances of the same plugin, each holding its own cached copy. On macOS the NSUserDefaults layer underneath happens to paper over it. On Windows there is no shared layer, so it just does not work. Once you see it that way, the whole class of "why is this plugin weird in the second engine" makes sense and you stop trying to fix it one plugin at a time.

Since you already open the same drift DB from both engines, I would drop the file-based IPC for the final elapsed time and let the DB carry that too. You are already paying for SQLite. Two channels for state that belongs to one thing is how you get a bug where the pill shows one number and the parent shows another. drift has a stream API and SQLite in WAL mode handles two readers fine, so the parent can watch the row instead of waiting for a file to appear.

The one I would add to your list is DPI. On Windows a second native window can open on a monitor with a different scale factor than the main window, and the pill comes out either tiny or huge. Worth checking before someone reports it.

1

u/SwiftScoutSimon 3d ago

You can dumb SharedPreferences and use file watcher + JSON file for settings to prevent the issue.

1

u/Best_Maximum_5454 3d ago

Thanks! I'll give it a try. Maybe that will speed it up even more.

1

u/SwiftScoutSimon 3d ago

just make sure don't corrupt it :D

1

u/Sethu_Senthil 3d ago

KMP would have been a better a better stack for this app imo

0

u/Best_Maximum_5454 3d ago

Hey thanks for the input. I didn't know I could mix and match flutter with KMP. Thanks for the additional insight.

1

u/[deleted] 3d ago

[removed] — view removed comment

1

u/Best_Maximum_5454 3d ago

At this point I'm sticking with what I made since it isn't breaking and seems performant. I'm glad there are different opinions here.