DEV Community

Muhammet Ali
Muhammet Ali

Posted on

Why I Rewrote My Flutter Tray App as a C++ Daemon

I built a screen-capture tool for Linux in Flutter. Tray icon, global hotkey, region select, done — that was the plan. It worked, right up until three separate parts of the Linux desktop stack quietly broke it in ways that never showed up as errors. I ended up splitting the app into a long-running C++ daemon plus an on-demand Flutter UI. Here's what actually forced that.

Bug 1: the tray menu ignores your second click

system_tray and tray_manager (the two Flutter packages everyone points you to for Linux tray icons) both go through libayatana-appindicator. First click on the tray icon: menu opens, works fine. Second click, same session: nothing. Menu doesn't open. Not a crash, not a log line — the click event just doesn't make it to the app.

This turned out to be a dispatch issue specific to the ubuntu-appindicators GNOME Shell extension on GNOME 46 — after the first menu activation, it stops forwarding the next click to the same indicator until something else resets its internal state. It's not a Flutter bug and not really an appindicator bug either; it's an interaction between GNOME 46's extension and any app using the older appindicator activation pattern. Nothing in either package's issue tracker fixes this at the plugin layer, because the plugin layer isn't where the problem is.

Bug 2: your hotkey stops firing, silently

hotkey_manager_linux 0.2.0 calls keybinder_bind under the hood and does not check its return value. When keybinder_bind fails — which it does, for reasons that have nothing to do with your code — the plugin reports success anyway. Your app thinks the hotkey is registered. It isn't. Press it, nothing happens. Restart the app, still nothing. There's no exception to catch, because as far as the plugin is concerned, nothing went wrong.

I only found this by instrumenting the native call myself and comparing the actual keybinder_bind return value against what the Dart side believed. They disagreed.

Bug 3: the editor window freezes with visual garbage

The pattern I was using — spawn an off-screen window, hide it, show it on demand for the annotation editor — interacts badly with Mutter's compositor. Under specific show/hide timing, the window would come back on screen with stale, frozen frame content baked in: old cursor positions, half-rendered UI from before it was hidden. Not every time. Often enough to make the editor unusable in a live demo.

None of these three are the kind of bug you fix with a patch release. They're structural — a consequence of driving a compositor-managed, extension-dependent desktop shell through a cross-platform abstraction that doesn't know GNOME 46 changed, doesn't check the return value it should, and manages window visibility in a way the compositor doesn't expect.

The fix: stop asking Flutter to be the always-on part

The actual redesign is a two-process split:

Binary Language Lifetime Job
yakala-daemon C++17 long-running, autostarted tray icon, global hotkey, IPC server, native capture, clipboard, notifications
yakala-ui Flutter on-demand, exits when done annotation editor, region overlay, settings window

The daemon talks directly to GTK/libayatana-appindicator3/GIO — no Flutter plugin abstraction between it and the desktop shell, so there's no cross-platform layer that can silently swallow a keybinder_bind-style failure. It spawns yakala-ui as a subprocess (fork+execvp via GSubprocess) only when a window is actually needed, with stdin/stdout/stderr inherited so the UI's own debug output flows straight into the daemon's log. Communication happens over a Unix domain socket at $XDG_RUNTIME_DIR/yakala-daemon.sock, line-delimited JSON. The UI sends {"cmd": "ui_result", "ok": true, "output": "/tmp/yakala_edit_xxx.png"} when the user finishes, then calls exit(0) — it never lingers.

The hotkey problem got solved by leaving hotkey_manager out of the picture entirely: yakala-daemon also runs as a CLI client of itself. yakala-daemon --capture-fullscreen opens the same Unix socket, sends {"cmd": "capture_full"} to whatever instance is already running as the daemon, and exits. linux/install-launcher.sh registers that exact command as a GNOME custom keybinding via gsettings — the same mechanism GNOME uses for its own built-in shortcuts, which sidesteps keybinder_bind and its silent-failure mode completely. It's the same pattern git, docker, and most daemon/CLI pairs use, just applied to a desktop tray app instead of a server.

What this actually bought

Capture latency dropped to ~190ms typical (the daemon shells straight out to grim/import/scrot/maim, no Flutter engine sitting on the hot path). The tray menu opens every time, because activation now goes through the same GTK/appindicator code path GNOME's own indicators use, not a wrapper that assumes GNOME 45 behavior. And the frozen-editor artifact went away on its own — a Flutter window that only exists for the seconds it's actually being used has no stale-frame state to accumulate.

Linux is production-ready with this architecture now. macOS is still on the old single-binary model — the daemon there is scaffolded but not wired up to the OS's own tray/hotkey APIs (Carbon RegisterEventHotKey planned, not implemented).

Source and a prebuilt Linux release: github.com/muhammetali/yakala.

Top comments (0)