@@ -25,10 +25,10 @@ import io.github.kdroidfilter.platformtools.getOperatingSystem
2525import kotlinx.coroutines.CoroutineScope
2626import kotlinx.coroutines.Dispatchers
2727import kotlinx.coroutines.SupervisorJob
28+ import kotlinx.coroutines.delay
2829import kotlinx.coroutines.launch
2930import java.util.concurrent.atomic.AtomicBoolean
3031
31-
3232internal class NativeTray {
3333
3434 private val trayScope = CoroutineScope (Dispatchers .IO + SupervisorJob ())
@@ -71,6 +71,78 @@ internal class NativeTray {
7171 }
7272 }
7373
74+ /* *
75+ * New update path: render the composable icon to PNG/ICO with retries and then update/init the tray.
76+ * If rendering keeps failing, we log and **do not create/update** the tray (never crash the app).
77+ */
78+ fun updateComposable (
79+ iconContent : @Composable () -> Unit ,
80+ iconRenderProperties : IconRenderProperties = IconRenderProperties (),
81+ tooltip : String ,
82+ primaryAction : (() -> Unit )? = null,
83+ menuContent : (TrayMenuBuilder .() -> Unit )? = null,
84+ maxAttempts : Int = 3,
85+ backoffMs : Long = 200,
86+ ) {
87+ trayScope.launch {
88+ val rendered = renderIconsWithRetry(iconContent, iconRenderProperties, maxAttempts, backoffMs)
89+ if (rendered == null ) {
90+ errorln { " [NativeTray] Icon rendering failed after $maxAttempts attempts. Tray will not be created/updated." }
91+ return @launch
92+ }
93+
94+ val (pngIconPath, windowsIconPath) = rendered
95+
96+ if (! initialized) {
97+ initializeTray(pngIconPath, windowsIconPath, tooltip, primaryAction, menuContent)
98+ initialized = true
99+ } else {
100+ try {
101+ when (os) {
102+ LINUX -> LinuxTrayInitializer .update(instanceId, pngIconPath, tooltip, primaryAction, menuContent)
103+ WINDOWS -> WindowsTrayInitializer .update(instanceId, windowsIconPath, tooltip, primaryAction, menuContent)
104+ MACOS -> MacTrayInitializer .update(instanceId, pngIconPath, tooltip, primaryAction, menuContent)
105+ UNKNOWN -> {
106+ AwtTrayInitializer .update(pngIconPath, tooltip, primaryAction, menuContent)
107+ awtTrayUsed.set(true )
108+ }
109+ else -> {}
110+ }
111+ } catch (th: Throwable ) {
112+ errorln { " [NativeTray] Error updating tray after successful render: $th " }
113+ }
114+ }
115+ }
116+ }
117+
118+ private suspend fun renderIconsWithRetry (
119+ iconContent : @Composable () -> Unit ,
120+ iconRenderProperties : IconRenderProperties ,
121+ maxAttempts : Int ,
122+ backoffMs : Long ,
123+ ): Pair <String , String >? {
124+ var attempt = 0
125+ while (attempt < maxAttempts) {
126+ try {
127+ // Render the composable to PNG for general platforms
128+ val pngIconPath = ComposableIconUtils .renderComposableToPngFile(iconRenderProperties, iconContent)
129+
130+ // On Windows, also render to ICO; on other OSes reuse PNG path
131+ val windowsIconPath = if (os == WINDOWS ) {
132+ ComposableIconUtils .renderComposableToIcoFile(iconRenderProperties, iconContent)
133+ } else pngIconPath
134+
135+ debugln { " [NativeTray] Rendered tray icons (attempt ${attempt + 1 } /$maxAttempts ): PNG=$pngIconPath , WIN=$windowsIconPath " }
136+ return pngIconPath to windowsIconPath
137+ } catch (e: Throwable ) {
138+ errorln { " [NativeTray] Icon render attempt ${attempt + 1 } failed: ${e.message ? : e::class .simpleName} " }
139+ attempt++
140+ if (attempt < maxAttempts) delay(backoffMs)
141+ }
142+ }
143+ return null
144+ }
145+
74146 fun dispose () {
75147 when (os) {
76148 LINUX -> LinuxTrayInitializer .dispose(instanceId)
@@ -84,7 +156,7 @@ internal class NativeTray {
84156
85157 /* *
86158 * Constructor that accepts file paths for icons
87- * @deprecated Use the constructor with composable icon content instead
159+ * @deprecated Use the Composable-based update path instead
88160 */
89161 @Deprecated(
90162 message = " Use the constructor with composable icon content instead" ,
@@ -107,19 +179,16 @@ internal class NativeTray {
107179 LinuxTrayInitializer .initialize(instanceId, iconPath, tooltip, primaryAction, menuContent)
108180 trayInitialized = true
109181 }
110-
111182 WINDOWS -> {
112183 debugln { " [NativeTray] Initializing Windows tray with icon path: $windowsIconPath " }
113184 WindowsTrayInitializer .initialize(instanceId, windowsIconPath, tooltip, primaryAction, menuContent)
114185 trayInitialized = true
115186 }
116-
117187 MACOS -> {
118188 debugln { " [NativeTray] Initializing macOS tray with icon path: $iconPath " }
119189 MacTrayInitializer .initialize(instanceId, iconPath, tooltip, primaryAction, menuContent)
120190 trayInitialized = true
121191 }
122-
123192 else -> {}
124193 }
125194 } catch (th: Throwable ) {
@@ -144,7 +213,8 @@ internal class NativeTray {
144213 }
145214
146215 /* *
147- * Constructor that accepts a Composable for the icon
216+ * Constructor that accepts a Composable for the icon — kept for backward compatibility.
217+ * Now delegates to the retry-safe render+init/update path.
148218 */
149219 private fun initializeTray (
150220 iconContent : @Composable () -> Unit ,
@@ -153,36 +223,18 @@ internal class NativeTray {
153223 primaryAction : (() -> Unit )? ,
154224 menuContent : (TrayMenuBuilder .() -> Unit )? = null
155225 ) {
156- // Render the composable to PNG file for general use
157- val pngIconPath = ComposableIconUtils .renderComposableToPngFile(iconRenderProperties, iconContent)
158- debugln { " [NativeTray] Generated PNG icon path: $pngIconPath " }
159-
160- // For Windows, we need an ICO file
161- val windowsIconPath = if (getOperatingSystem() == WINDOWS ) {
162- // Create a temporary ICO file
163- ComposableIconUtils .renderComposableToIcoFile(iconRenderProperties, iconContent).also {
164- debugln { " [NativeTray] Generated Windows ICO path: $it " }
165- }
166- } else {
167- pngIconPath
168- }
169-
170- initializeTray(pngIconPath, windowsIconPath, tooltip, primaryAction, menuContent)
226+ updateComposable(
227+ iconContent = iconContent,
228+ iconRenderProperties = iconRenderProperties,
229+ tooltip = tooltip,
230+ primaryAction = primaryAction,
231+ menuContent = menuContent
232+ )
171233 }
172-
173234}
174235
175-
176236/* *
177- * Configures and displays a system tray icon for the application with platform-specific behavior and menu options.
178- *
179- * @param iconPath The file path to the tray icon. This should be a valid image file compatible with the platform's tray requirements.
180- * @param windowsIconPath The file path to the tray icon specifically for Windows. Defaults to the value of `iconPath`.
181- * @param tooltip The tooltip text to be displayed when the user hovers over the tray icon.
182- * @param primaryAction An optional callback to be invoked when the tray icon is clicked (handled only on specific platforms).
183- * @param menuContent A lambda that builds the tray menu using a `TrayMenuBuilder`. Define the menu structure, including items, checkable items, dividers, and submenus.
184- *
185- * @deprecated Use the version with composable icon content instead
237+ * Composable helpers
186238 */
187239@Deprecated(
188240 message = " Use the version with composable icon content instead" ,
@@ -221,16 +273,6 @@ fun ApplicationScope.Tray(
221273 }
222274}
223275
224- /* *
225- * Configures and displays a system tray icon for the application with platform-specific behavior and menu options.
226- * This version accepts a Composable for the icon instead of file paths.
227- *
228- * @param iconContent A Composable function that defines the icon to be displayed in the tray.
229- * @param iconRenderProperties Properties for rendering the icon.
230- * @param tooltip The tooltip text to be displayed when the user hovers over the tray icon.
231- * @param primaryAction An optional callback to be invoked when the tray icon is clicked (handled only on specific platforms).
232- * @param menuContent A lambda that builds the tray menu using a `TrayMenuBuilder`. Define the menu structure, including items, checkable items, dividers, and submenus.
233- */
234276@Composable
235277fun ApplicationScope.Tray (
236278 iconContent : @Composable () -> Unit ,
@@ -241,26 +283,27 @@ fun ApplicationScope.Tray(
241283) {
242284 val isDark = isMenuBarInDarkMode() // Observe menu bar theme to trigger recomposition on changes
243285
244- val os = getOperatingSystem()
245286 // Calculate a hash of the rendered composable content to detect changes, including theme state
246287 val contentHash = ComposableIconUtils .calculateContentHash(iconRenderProperties, iconContent) + isDark.hashCode()
247288
248289 // Calculate a hash of the menu content to detect changes
249290 val menuHash = MenuContentHash .calculateMenuHash(menuContent)
250291
251- val pngIconPath = remember(contentHash) { ComposableIconUtils .renderComposableToPngFile(iconRenderProperties, iconContent) }
252- val windowsIconPath = remember(contentHash) {
253- if (os == WINDOWS ) ComposableIconUtils .renderComposableToIcoFile(iconRenderProperties, iconContent) else pngIconPath
254- }
255-
256292 val tray = remember { NativeTray () }
257293
258- // Update when params change, including contentHash (which incorporates theme)
259- LaunchedEffect (pngIconPath, windowsIconPath, tooltip, primaryAction, menuContent, contentHash, menuHash) {
260- tray.update(pngIconPath, windowsIconPath, tooltip, primaryAction, menuContent)
294+ // On any content/menu change, delegate to retry-safe path
295+ LaunchedEffect (contentHash, tooltip, primaryAction, menuContent, menuHash) {
296+ tray.updateComposable(
297+ iconContent = iconContent,
298+ iconRenderProperties = iconRenderProperties,
299+ tooltip = tooltip,
300+ primaryAction = primaryAction,
301+ menuContent = menuContent,
302+ maxAttempts = 3 ,
303+ backoffMs = 200 ,
304+ )
261305 }
262306
263- // Dispose only when Tray is removed from composition
264307 DisposableEffect (Unit ) {
265308 onDispose {
266309 debugln { " [NativeTray] onDispose" }
@@ -269,16 +312,6 @@ fun ApplicationScope.Tray(
269312 }
270313}
271314
272- /* *
273- * Configures and displays a system tray icon using an ImageVector, with automatic tint adaptation based on menu bar theme.
274- *
275- * @param icon The ImageVector to display as the tray icon.
276- * @param tint Optional tint color for the icon. If null, automatically adapts to white in dark mode and black in light mode.
277- * @param iconRenderProperties Properties for rendering the icon.
278- * @param tooltip The tooltip text to be displayed when the user hovers over the tray icon.
279- * @param primaryAction An optional callback to be invoked when the tray icon is clicked.
280- * @param menuContent A lambda that builds the tray menu.
281- */
282315@Composable
283316fun ApplicationScope.Tray (
284317 icon : ImageVector ,
@@ -303,7 +336,6 @@ fun ApplicationScope.Tray(
303336 )
304337 }
305338
306- val os = getOperatingSystem()
307339 // Calculate menu hash to detect changes
308340 val menuHash = MenuContentHash .calculateMenuHash(menuContent)
309341
@@ -312,21 +344,22 @@ fun ApplicationScope.Tray(
312344 isDark.hashCode() +
313345 isSystemInDarkTheme.hashCode() +
314346 icon.hashCode() +
315- (tint?.hashCode() ? : 0 ) // Include tint if set; 0 as fallback when null
316-
317- val pngIconPath = remember(contentHash) { ComposableIconUtils .renderComposableToPngFile(iconRenderProperties, iconContent) }
318- val windowsIconPath = remember(contentHash) {
319- if (os == WINDOWS ) ComposableIconUtils .renderComposableToIcoFile(iconRenderProperties, iconContent) else pngIconPath
320- }
347+ (tint?.hashCode() ? : 0 )
321348
322349 val tray = remember { NativeTray () }
323350
324- // Update when params change, including contentHash (which incorporates theme/icon/tint)
325- LaunchedEffect (pngIconPath, windowsIconPath, tooltip, primaryAction, menuContent, contentHash, menuHash) {
326- tray.update(pngIconPath, windowsIconPath, tooltip, primaryAction, menuContent)
351+ LaunchedEffect (contentHash, tooltip, primaryAction, menuContent, menuHash) {
352+ tray.updateComposable(
353+ iconContent = iconContent,
354+ iconRenderProperties = iconRenderProperties,
355+ tooltip = tooltip,
356+ primaryAction = primaryAction,
357+ menuContent = menuContent,
358+ maxAttempts = 3 ,
359+ backoffMs = 200 ,
360+ )
327361 }
328362
329- // Dispose only when Tray is removed from composition
330363 DisposableEffect (Unit ) {
331364 onDispose {
332365 debugln { " [NativeTray] onDispose" }
@@ -335,15 +368,6 @@ fun ApplicationScope.Tray(
335368 }
336369}
337370
338- /* *
339- * Configures and displays a system tray icon using a Painter.
340- *
341- * @param icon The Painter to display as the tray icon.
342- * @param iconRenderProperties Properties for rendering the icon.
343- * @param tooltip The tooltip text to be displayed when the user hovers over the tray icon.
344- * @param primaryAction An optional callback to be invoked when the tray icon is clicked.
345- * @param menuContent A lambda that builds the tray menu.
346- */
347371@Composable
348372fun ApplicationScope.Tray (
349373 icon : Painter ,
@@ -363,7 +387,6 @@ fun ApplicationScope.Tray(
363387 )
364388 }
365389
366- val os = getOperatingSystem()
367390 // Calculate menu hash to detect changes
368391 val menuHash = MenuContentHash .calculateMenuHash(menuContent)
369392
@@ -372,40 +395,30 @@ fun ApplicationScope.Tray(
372395 isDark.hashCode() +
373396 icon.hashCode()
374397
375- val pngIconPath = remember(contentHash) { ComposableIconUtils .renderComposableToPngFile(iconRenderProperties, iconContent) }
376- val windowsIconPath = remember(contentHash) {
377- if (os == WINDOWS ) ComposableIconUtils .renderComposableToIcoFile(iconRenderProperties, iconContent) else pngIconPath
378- }
379-
380398 val tray = remember { NativeTray () }
381399
382- // Update when params change, including contentHash (which incorporates theme/icon)
383- LaunchedEffect (pngIconPath, windowsIconPath, tooltip, primaryAction, menuContent, contentHash, menuHash) {
384- tray.update(pngIconPath, windowsIconPath, tooltip, primaryAction, menuContent)
400+ LaunchedEffect (contentHash, tooltip, primaryAction, menuContent, menuHash) {
401+ tray.updateComposable(
402+ iconContent = iconContent,
403+ iconRenderProperties = iconRenderProperties,
404+ tooltip = tooltip,
405+ primaryAction = primaryAction,
406+ menuContent = menuContent,
407+ maxAttempts = 3 ,
408+ backoffMs = 200 ,
409+ )
385410 }
386411
387- // Dispose only when Tray is removed from composition
388412 DisposableEffect (Unit ) {
389413 onDispose {
390414 debugln { " [NativeTray] onDispose" }
391415 tray.dispose()
392416 }
393417 }
394418}
419+
395420/* *
396- * Configures and displays a system tray icon using platform-specific icon types:
397- * - Windows: Uses the provided Painter
398- * - macOS/Linux: Uses the provided ImageVector
399- *
400- * This approach leverages polymorphism to provide the most appropriate icon type for each platform.
401- *
402- * @param windowsIcon The Painter to display as the tray icon on Windows.
403- * @param macLinuxIcon The ImageVector to display as the tray icon on macOS and Linux.
404- * @param tint Optional tint color for the ImageVector icon. If null, automatically adapts to white in dark mode and black in light mode.
405- * @param iconRenderProperties Properties for rendering the icon.
406- * @param tooltip The tooltip text to be displayed when the user hovers over the tray icon.
407- * @param primaryAction An optional callback to be invoked when the tray icon is clicked.
408- * @param menuContent A lambda that builds the tray menu.
421+ * Platform-polymorphic helper
409422 */
410423@Composable
411424fun ApplicationScope.Tray (
@@ -418,7 +431,7 @@ fun ApplicationScope.Tray(
418431 menuContent : (TrayMenuBuilder .() -> Unit )? = null,
419432) {
420433 val os = getOperatingSystem()
421-
434+
422435 if (os == WINDOWS ) {
423436 // Use Painter for Windows
424437 Tray (
@@ -440,9 +453,9 @@ fun ApplicationScope.Tray(
440453 )
441454 }
442455}
456+
443457/* *
444- * Configures and displays a system tray icon using a DrawableResource directly.
445- * This allows calling code like: Tray(icon = Res.drawable.icon, ...)
458+ * DrawableResource helpers
446459 */
447460@Composable
448461fun ApplicationScope.Tray (
0 commit comments