Skip to content

Commit 03fa5c5

Browse files
authored
fix(linux): dlopen appindicator at runtime so release trays are never stubs (#73)
The Linux cef/webview release artifacts shipped the tray as a stub (trayId always 0): CMake probed for appindicator3-0.1 / ayatana, but tray_linux.cc unconditionally included the legacy <libappindicator/app-indicator.h>, so installing the Ayatana dev package broke the build and CI deliberately built without it. Load the library at runtime instead: tray_linux.cc dlopen()s libayatana-appindicator3.so.1, falling back to libappindicator3.so.1, declaring the four ABI-stable symbols it uses locally. This removes the appindicator build dependency everywhere (probes and LAUFEY_HAVE_APPINDICATOR dropped from backend-common/webview/cef CMakeLists, dead include dropped from cef runtime_loader_linux.cc), keeps release binaries free of a hard DT_NEEDED on a library many distros don't preinstall, works with whichever flavor the system has, and degrades to trayId 0 — now with a one-time g_warning naming the missing libraries instead of failing silently. Gate the regression: native_e2e grows LAUFEY_E2E_REQUIRE_TRAY, which turns a stub tray into a FAIL instead of N/A; the native-e2e cef/linux leg sets it and installs the Ayatana runtime lib, so a backend whose tray creation path regresses to trayId 0 can no longer pass CI. Verified in an Ubuntu 24.04 container: compiles with no appindicator dev headers; CreateTrayIconLinux returns a nonzero id with only libayatana-appindicator3-1 installed (the dlopened app_indicator_new executes) and 0 with no appindicator lib present; the webview backend builds and links with no appindicator DT_NEEDED. docs/tray.md now documents the runtime dependency for .deb/.rpm packagers and the StatusNotifier-host requirement. Fixes #63
1 parent 7a430ab commit 03fa5c5

11 files changed

Lines changed: 151 additions & 104 deletions

File tree

.github/workflows/ci.yml

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,10 @@ jobs:
169169

170170
# Per-backend deps mirror the proven build-{winit,webview,cef} jobs, plus
171171
# the runtime libs the *run* step needs (xvfb, dbus, libxkbcommon-x11 for
172-
# winit). Note: the backend-common (cef/webview) tray needs no
173-
# appindicator dev package — installing one makes cmake define
174-
# LAUFEY_HAVE_APPINDICATOR while tray_linux.cc includes the non-ayatana
175-
# header, breaking the build; without it the tray compiles as a stub (N/A).
172+
# winit). The backend-common (cef/webview) tray dlopens the appindicator
173+
# library at runtime — no dev package is needed to build; the cef leg
174+
# installs the Ayatana *runtime* lib so the real (non-stub) tray path is
175+
# exercised and gated via LAUFEY_E2E_REQUIRE_TRAY (issue #63).
176176
- name: Install dependencies (Linux, winit)
177177
if: runner.os == 'Linux' && matrix.backend == 'winit'
178178
run: |
@@ -197,7 +197,7 @@ jobs:
197197
libatk1.0-dev libatk-bridge2.0-dev libcups2-dev libdrm-dev libgtk-3-dev \
198198
libgbm-dev libasound2-dev libpango1.0-dev libcairo2-dev libxcomposite-dev \
199199
libxdamage-dev libxrandr-dev libxkbcommon-dev libdbus-1-dev libxext-dev \
200-
libxfixes-dev
200+
libxfixes-dev libayatana-appindicator3-1
201201
202202
- name: Install dependencies (macOS)
203203
if: runner.os == 'macOS'
@@ -237,13 +237,17 @@ jobs:
237237
238238
- name: Layer 0 — readback / events / clipboard / menu+tray clicks
239239
shell: bash
240+
env:
241+
# On the cef/linux leg the Ayatana runtime lib is installed above, so
242+
# a tray id of 0 means the dlopen path regressed to a stub — fail
243+
# instead of reporting N/A (issue #63).
244+
LAUFEY_E2E_REQUIRE_TRAY: ${{ (runner.os == 'Linux' && matrix.backend == 'cef') && '1' || '' }}
240245
run: scripts/native-e2e-run.sh ${{ matrix.backend }}
241246

242247
# Layer 1 (Linux D-Bus StatusNotifier/dbusmenu observer) is built above
243-
# (compile-checked on real Linux) but not run here: the only tray impl
244-
# that registers an SNI is the winit backend's tray-icon crate, and
245-
# winit/linux is excluded above pending its GTK-menu fix. Re-enable the
246-
# driver run once a tray-capable Linux leg is in the matrix.
248+
# (compile-checked on real Linux) but not run here yet: the cef leg now
249+
# creates a real appindicator tray (runtime lib installed above), so
250+
# running the driver there is the next step once it's been proven green.
247251

248252
build-webview:
249253
name: webview / ${{ matrix.target }}

backend-common/CMakeLists.txt

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,25 +102,19 @@ elseif(APPLE)
102102
elseif(UNIX)
103103
find_package(PkgConfig REQUIRED)
104104
pkg_check_modules(LAUFEY_COMMON_GTK REQUIRED gtk+-3.0)
105-
pkg_check_modules(LAUFEY_COMMON_APPINDICATOR appindicator3-0.1)
106-
if(NOT LAUFEY_COMMON_APPINDICATOR_FOUND)
107-
pkg_check_modules(LAUFEY_COMMON_APPINDICATOR ayatana-appindicator3-0.1)
108-
endif()
109105
target_include_directories(laufey_backend_common PUBLIC
110106
${LAUFEY_COMMON_GTK_INCLUDE_DIRS}
111-
${LAUFEY_COMMON_APPINDICATOR_INCLUDE_DIRS}
112107
)
108+
# No appindicator build dependency: tray_linux.cc dlopen()s the
109+
# Ayatana or legacy appindicator library at runtime. CMAKE_DL_LIBS
110+
# covers dlopen/dlsym on toolchains that still need -ldl.
113111
target_link_libraries(laufey_backend_common PUBLIC
114112
${LAUFEY_COMMON_GTK_LIBRARIES}
115-
${LAUFEY_COMMON_APPINDICATOR_LIBRARIES}
113+
${CMAKE_DL_LIBS}
116114
)
117115
target_compile_options(laufey_backend_common PUBLIC
118116
${LAUFEY_COMMON_GTK_CFLAGS_OTHER}
119-
${LAUFEY_COMMON_APPINDICATOR_CFLAGS_OTHER}
120117
)
121-
if(LAUFEY_COMMON_APPINDICATOR_FOUND)
122-
target_compile_definitions(laufey_backend_common PUBLIC LAUFEY_HAVE_APPINDICATOR=1)
123-
endif()
124118
endif()
125119

126120
set_target_properties(laufey_backend_common PROPERTIES

backend-common/include/laufey_backend_common.h

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -396,13 +396,14 @@ void* BuildGtkMenuFromValue(laufey_value_t* val,
396396
#endif
397397

398398
// ---------------------------------------------------------------------------
399-
// Tray / status-bar icon (Linux, libappindicator)
399+
// Tray / status-bar icon (Linux, appindicator)
400400
// ---------------------------------------------------------------------------
401401
//
402402
// All functions must be called on the GTK main thread; backends with
403403
// off-main-thread callers should marshal first (CEF uses CefPostTask).
404-
// When libappindicator is not available (LAUFEY_HAVE_APPINDICATOR not
405-
// defined), CreateTrayIconLinux returns 0 and all other calls no-op.
404+
// The Ayatana or legacy appindicator library is dlopen()ed at runtime;
405+
// when neither is present on the system, CreateTrayIconLinux returns 0
406+
// and all other calls no-op.
406407
//
407408
// AppIndicator has no left-click event — a click anywhere pops the
408409
// indicator's menu — so SetTrayClickHandlerLinux and

backend-common/src/tray_linux.cc

Lines changed: 90 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@
55
// these from any thread. tray_id is allocated synchronously so the
66
// caller gets a useful return value immediately.
77
//
8-
// When libappindicator is not available (no LAUFEY_HAVE_APPINDICATOR
9-
// define), CreateTrayIconLinux returns 0 and the other functions no-op.
10-
8+
// The appindicator library is dlopen()ed at runtime — Ayatana first,
9+
// then the legacy libappindicator — instead of being linked at build
10+
// time. Release artifacts must work both on distros that ship only the
11+
// Ayatana fork and on ones with the legacy library, and must still
12+
// start on systems with neither; the two flavors are ABI-compatible for
13+
// the handful of symbols used here (the fork kept names and
14+
// signatures). When no library can be loaded, CreateTrayIconLinux
15+
// returns 0 and the other functions no-op.
16+
17+
#include <dlfcn.h>
1118
#include <gtk/gtk.h>
1219

1320
#include "laufey_backend_common.h"
@@ -20,18 +27,67 @@
2027
#include <string>
2128
#include <vector>
2229

23-
#ifdef LAUFEY_HAVE_APPINDICATOR
24-
extern "C" {
25-
#include <libappindicator/app-indicator.h>
26-
}
27-
#endif
28-
2930
namespace laufey_common {
3031

31-
#ifdef LAUFEY_HAVE_APPINDICATOR
32-
3332
namespace {
3433

34+
// Minimal appindicator ABI. AppIndicator is opaque; the enum values are
35+
// stable across both flavors (APP_INDICATOR_CATEGORY_APPLICATION_STATUS,
36+
// APP_INDICATOR_STATUS_{PASSIVE,ACTIVE}).
37+
typedef struct _AppIndicator AppIndicator;
38+
39+
constexpr int kAppIndicatorCategoryApplicationStatus = 0;
40+
constexpr int kAppIndicatorStatusPassive = 0;
41+
constexpr int kAppIndicatorStatusActive = 1;
42+
43+
struct AppIndicatorApi {
44+
AppIndicator* (*app_indicator_new)(const gchar* id, const gchar* icon_name,
45+
int category);
46+
void (*set_status)(AppIndicator* self, int status);
47+
void (*set_menu)(AppIndicator* self, GtkMenu* menu);
48+
void (*set_icon_full)(AppIndicator* self, const gchar* icon_name,
49+
const gchar* icon_desc);
50+
};
51+
52+
// Loads the appindicator library once; nullptr if unavailable. Safe to
53+
// call from any thread (dlopen is thread-safe and the initializer is a
54+
// C++11 magic static).
55+
const AppIndicatorApi* GetAppIndicatorApi() {
56+
static AppIndicatorApi api;
57+
static const bool loaded = [] {
58+
static const char* const kSonames[] = {
59+
"libayatana-appindicator3.so.1",
60+
"libappindicator3.so.1",
61+
};
62+
void* lib = nullptr;
63+
for (const char* soname : kSonames) {
64+
// RTLD_GLOBAL: the library registers GObject types whose symbols
65+
// its dbusmenu helpers may resolve across module boundaries.
66+
lib = dlopen(soname, RTLD_NOW | RTLD_GLOBAL);
67+
if (lib) break;
68+
}
69+
if (!lib) {
70+
// Say why the tray id will be 0 — a silent stub cost real debugging
71+
// time downstream (issue #63).
72+
g_warning(
73+
"laufey: tray icons unavailable: could not load "
74+
"libayatana-appindicator3.so.1 or libappindicator3.so.1");
75+
return false;
76+
}
77+
api.app_indicator_new = reinterpret_cast<decltype(api.app_indicator_new)>(
78+
dlsym(lib, "app_indicator_new"));
79+
api.set_status = reinterpret_cast<decltype(api.set_status)>(
80+
dlsym(lib, "app_indicator_set_status"));
81+
api.set_menu = reinterpret_cast<decltype(api.set_menu)>(
82+
dlsym(lib, "app_indicator_set_menu"));
83+
api.set_icon_full = reinterpret_cast<decltype(api.set_icon_full)>(
84+
dlsym(lib, "app_indicator_set_icon_full"));
85+
return api.app_indicator_new && api.set_status && api.set_menu &&
86+
api.set_icon_full;
87+
}();
88+
return loaded ? &api : nullptr;
89+
}
90+
3591
struct LinuxTrayEntry {
3692
AppIndicator* indicator;
3793
GtkWidget* menu;
@@ -68,17 +124,19 @@ void OnGtkMain(Fn&& fn) {
68124
} // namespace
69125

70126
uint32_t CreateTrayIconLinux() {
127+
const AppIndicatorApi* api = GetAppIndicatorApi();
128+
if (!api) return 0;
71129
uint32_t tray_id =
72130
g_next_tray_id_linux.fetch_add(1, std::memory_order_relaxed);
73-
OnGtkMain([tray_id] {
131+
OnGtkMain([api, tray_id] {
74132
std::string idstr = "laufey-tray-" + std::to_string(tray_id);
75-
AppIndicator* ind = app_indicator_new(
76-
idstr.c_str(), "", APP_INDICATOR_CATEGORY_APPLICATION_STATUS);
133+
AppIndicator* ind = api->app_indicator_new(
134+
idstr.c_str(), "", kAppIndicatorCategoryApplicationStatus);
77135
if (!ind) return;
78-
app_indicator_set_status(ind, APP_INDICATOR_STATUS_ACTIVE);
136+
api->set_status(ind, kAppIndicatorStatusActive);
79137
GtkWidget* placeholder = gtk_menu_new();
80138
gtk_widget_show_all(placeholder);
81-
app_indicator_set_menu(ind, GTK_MENU(placeholder));
139+
api->set_menu(ind, GTK_MENU(placeholder));
82140

83141
LinuxTrayEntry entry{};
84142
entry.indicator = ind;
@@ -90,27 +148,29 @@ uint32_t CreateTrayIconLinux() {
90148
}
91149

92150
void DestroyTrayIconLinux(uint32_t tray_id) {
93-
OnGtkMain([tray_id] {
151+
const AppIndicatorApi* api = GetAppIndicatorApi();
152+
if (!api) return;
153+
OnGtkMain([api, tray_id] {
94154
std::lock_guard<std::mutex> lock(LinuxTrayMutex());
95155
auto it = LinuxTrayMap().find(tray_id);
96156
if (it == LinuxTrayMap().end()) return;
97157
if (it->second.indicator) {
98-
app_indicator_set_status(it->second.indicator,
99-
APP_INDICATOR_STATUS_PASSIVE);
158+
api->set_status(it->second.indicator, kAppIndicatorStatusPassive);
100159
g_object_unref(it->second.indicator);
101160
}
102161
LinuxTrayMap().erase(it);
103162
});
104163
}
105164

106165
void SetTrayIconLinux(uint32_t tray_id, const void* png_bytes, size_t len) {
107-
if (!png_bytes || len == 0) return;
166+
const AppIndicatorApi* api = GetAppIndicatorApi();
167+
if (!api || !png_bytes || len == 0) return;
108168
// AppIndicator on most DEs reads icons by name from the icon theme,
109169
// not from raw bytes. Write the bytes to a per-tray temp file and
110170
// point the indicator at its full path.
111171
std::vector<uint8_t> bytes(static_cast<const uint8_t*>(png_bytes),
112172
static_cast<const uint8_t*>(png_bytes) + len);
113-
OnGtkMain([tray_id, bytes = std::move(bytes)]() mutable {
173+
OnGtkMain([api, tray_id, bytes = std::move(bytes)]() mutable {
114174
std::string path = "/tmp/laufey-tray-" + std::to_string(tray_id) + ".png";
115175
FILE* f = fopen(path.c_str(), "wb");
116176
if (!f) return;
@@ -119,7 +179,7 @@ void SetTrayIconLinux(uint32_t tray_id, const void* png_bytes, size_t len) {
119179
std::lock_guard<std::mutex> lock(LinuxTrayMutex());
120180
auto it = LinuxTrayMap().find(tray_id);
121181
if (it == LinuxTrayMap().end() || !it->second.indicator) return;
122-
app_indicator_set_icon_full(it->second.indicator, path.c_str(), "");
182+
api->set_icon_full(it->second.indicator, path.c_str(), "");
123183
});
124184
}
125185

@@ -138,7 +198,12 @@ void SetTrayTooltipLinux(uint32_t /*tray_id*/,
138198
void SetTrayMenuLinux(uint32_t tray_id, laufey_value_t* menu_template,
139199
const laufey_backend_api_t* api,
140200
laufey_menu_click_fn on_click, void* on_click_data) {
141-
OnGtkMain([tray_id, menu_template, api, on_click, on_click_data] {
201+
const AppIndicatorApi* ind_api = GetAppIndicatorApi();
202+
if (!ind_api) {
203+
if (menu_template && api) api->value_free(menu_template);
204+
return;
205+
}
206+
OnGtkMain([ind_api, tray_id, menu_template, api, on_click, on_click_data] {
142207
// tray_id passed as window_id so the shared click dispatcher routes
143208
// back through on_click with the right tray identifier.
144209
GtkWidget* new_menu = nullptr;
@@ -156,12 +221,12 @@ void SetTrayMenuLinux(uint32_t tray_id, laufey_value_t* menu_template,
156221
return;
157222
}
158223
if (new_menu) {
159-
app_indicator_set_menu(it->second.indicator, GTK_MENU(new_menu));
224+
ind_api->set_menu(it->second.indicator, GTK_MENU(new_menu));
160225
it->second.menu = new_menu;
161226
} else {
162227
GtkWidget* empty = gtk_menu_new();
163228
gtk_widget_show_all(empty);
164-
app_indicator_set_menu(it->second.indicator, GTK_MENU(empty));
229+
ind_api->set_menu(it->second.indicator, GTK_MENU(empty));
165230
it->second.menu = empty;
166231
}
167232
it->second.menu_click_fn = on_click;
@@ -181,20 +246,4 @@ void SetTrayDoubleClickHandlerLinux(uint32_t /*tray_id*/,
181246
// Same: no double-click event from AppIndicator.
182247
}
183248

184-
#else // !LAUFEY_HAVE_APPINDICATOR
185-
186-
uint32_t CreateTrayIconLinux() { return 0; }
187-
void DestroyTrayIconLinux(uint32_t) {}
188-
void SetTrayIconLinux(uint32_t, const void*, size_t) {}
189-
void SetTrayIconDarkLinux(uint32_t, const void*, size_t) {}
190-
void SetTrayTooltipLinux(uint32_t, const char*) {}
191-
void SetTrayMenuLinux(uint32_t, laufey_value_t* tmpl, const laufey_backend_api_t* api,
192-
laufey_menu_click_fn, void*) {
193-
if (tmpl && api) api->value_free(tmpl);
194-
}
195-
void SetTrayClickHandlerLinux(uint32_t, laufey_tray_click_fn, void*) {}
196-
void SetTrayDoubleClickHandlerLinux(uint32_t, laufey_tray_click_fn, void*) {}
197-
198-
#endif // LAUFEY_HAVE_APPINDICATOR
199-
200249
} // namespace laufey_common

cef/CMakeLists.txt

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -227,14 +227,12 @@ elseif(OS_LINUX)
227227
)
228228
endif()
229229

230-
# GTK3 (for GDK/X11 headers) and XI2 (for input event monitoring)
230+
# GTK3 (for GDK/X11 headers) and XI2 (for input event monitoring).
231+
# No appindicator build dependency: the tray in backend-common
232+
# dlopen()s the Ayatana or legacy appindicator library at runtime.
231233
find_package(PkgConfig REQUIRED)
232234
pkg_check_modules(GTK3 REQUIRED gtk+-3.0)
233235
pkg_check_modules(XI2 REQUIRED xi)
234-
pkg_check_modules(APPINDICATOR appindicator3-0.1)
235-
if(NOT APPINDICATOR_FOUND)
236-
pkg_check_modules(APPINDICATOR ayatana-appindicator3-0.1)
237-
endif()
238236

239237
set(LAUFEY_SRCS_COMMON
240238
src/app.cc
@@ -258,22 +256,15 @@ elseif(OS_LINUX)
258256
# not part of Chromium's runtime, so its entry-time canary no longer matches
259257
# at return, tripping __stack_chk_fail. Disable stack protection to bypass.
260258
target_compile_options(${CEF_TARGET} PRIVATE -fno-stack-protector)
261-
target_link_libraries(${CEF_TARGET} libcef_dll_wrapper ${CEF_LIB_RELEASE} ${CEF_STANDARD_LIBS} ${GTK3_LIBRARIES} ${XI2_LIBRARIES} ${APPINDICATOR_LIBRARIES} laufey_backend_common)
262-
target_link_directories(${CEF_TARGET} PRIVATE ${GTK3_LIBRARY_DIRS} ${XI2_LIBRARY_DIRS} ${APPINDICATOR_LIBRARY_DIRS})
259+
target_link_libraries(${CEF_TARGET} libcef_dll_wrapper ${CEF_LIB_RELEASE} ${CEF_STANDARD_LIBS} ${GTK3_LIBRARIES} ${XI2_LIBRARIES} laufey_backend_common)
260+
target_link_directories(${CEF_TARGET} PRIVATE ${GTK3_LIBRARY_DIRS} ${XI2_LIBRARY_DIRS})
263261
target_include_directories(${CEF_TARGET} PRIVATE
264262
"${CMAKE_CURRENT_SOURCE_DIR}/src"
265263
"${LAUFEY_INCLUDE_DIR}"
266264
${GTK3_INCLUDE_DIRS}
267265
${XI2_INCLUDE_DIRS}
268-
${APPINDICATOR_INCLUDE_DIRS}
269266
)
270267

271-
if(APPINDICATOR_FOUND)
272-
target_compile_definitions(${CEF_TARGET} PRIVATE LAUFEY_HAVE_APPINDICATOR=1)
273-
else()
274-
message(STATUS "libappindicator not found — tray icon will be a no-op on this build")
275-
endif()
276-
277268
# Set RPATH so the executable finds libcef.so next to it
278269
set_target_properties(${CEF_TARGET} PROPERTIES
279270
INSTALL_RPATH "$ORIGIN"

cef/src/runtime_loader_linux.cc

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Copyright 2025 Divy Srivastava. All rights reserved. MIT license.
22

3-
// Linux-specific backend implementations: tray (via libappindicator) and
4-
// context menu (via GtkMenu popup). Neither needs a GtkWindow parent, so
5-
// both work alongside CEF Views' raw X11 windows.
3+
// Linux-specific backend implementations: tray (via appindicator, dlopened
4+
// in backend-common) and context menu (via GtkMenu popup). Neither needs a
5+
// GtkWindow parent, so both work alongside CEF Views' raw X11 windows.
66
//
77
// set_application_menu (an in-window menu bar) is NOT implemented here: it
88
// requires packing a GtkMenuBar into a GtkBox above the browser widget,
@@ -27,12 +27,6 @@
2727
#include "laufey.h"
2828
#include "laufey_backend_common.h"
2929

30-
#ifdef LAUFEY_HAVE_APPINDICATOR
31-
extern "C" {
32-
#include <libappindicator/app-indicator.h>
33-
}
34-
#endif
35-
3630
// ---------------------------------------------------------------------------
3731
// GTK lazy init. CEF's Chromium process initializes GTK internally for its
3832
// own dialogs/theming, but we don't rely on that — call gtk_init_check once
@@ -87,7 +81,7 @@ void Backend_ShowContextMenu_Linux(void* data, uint32_t window_id, int /*x*/,
8781
}
8882

8983
// ---------------------------------------------------------------------------
90-
// Tray / status-bar icon (libappindicator)
84+
// Tray / status-bar icon (appindicator)
9185
// ---------------------------------------------------------------------------
9286
//
9387
// Trampolines over backend-common/src/tray_linux.cc, which handles its

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Currently shared:
8787
| **Dock** | `dock_mac.mm` (badge / bounce / visible / dock menu storage / reopen handler) | per-backend (FlashWindowEx) + `title_badge.cc` for the badge | per-backend (gtk_window_set_urgency_hint) + `title_badge.cc` for the badge |
8888
| **Key mapping** | `keymap_mac.mm` (NSEvent → W3C) | `keymap_vk.cc` (VK → W3C; CEF uses on every platform) | `keymap_gdk.cc` (GDK → W3C) |
8989
| **App / context menu** | `menu_mac.mm` (NSMenu) | `capi/include/win32_menu.h` (HMENU + SetMenu / TrackPopupMenu) | `menu_linux.cc` (GtkMenu / GtkMenuBar) |
90-
| **Tray icons** | `tray_mac.mm` (NSStatusItem) | `tray_win.cc` (Shell_NotifyIcon + WIC + HMENU) | `tray_linux.cc` (libappindicator + g_idle_add) |
90+
| **Tray icons** | `tray_mac.mm` (NSStatusItem) | `tray_win.cc` (Shell_NotifyIcon + WIC + HMENU) | `tray_linux.cc` (appindicator via dlopen + g_idle_add) |
9191
| **Option parsing** | `parse_options.cc` (compiled on every platform; bridges `laufey_value_t` → plain structs) | | |
9292
| **Title-prefix badge bookkeeping** | `title_badge.cc` (`ApplyTitlePrefixBadge` — used by CEF Win+Linux and webview Win+Linux for Dock-badge fallback) | | |
9393

0 commit comments

Comments
 (0)