Skip to content

Commit f9d0300

Browse files
committed
Refactor icon rendering logic with retry mechanism and improve tray update safety
- Added retry-safe `updateComposable` path for rendering and updating tray icons with backoff on failures. - Unified platform-specific tray update logic in `NativeTray`. - Improved resource cleanup and error logging. - Deprecated older initialization paths in favor of retry-safe implementations. Signed-off-by: Elie G. <elyahou.hadass@gmail.com>
1 parent acfe824 commit f9d0300

2 files changed

Lines changed: 122 additions & 115 deletions

File tree

src/commonMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt

Lines changed: 121 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ import io.github.kdroidfilter.platformtools.getOperatingSystem
2525
import kotlinx.coroutines.CoroutineScope
2626
import kotlinx.coroutines.Dispatchers
2727
import kotlinx.coroutines.SupervisorJob
28+
import kotlinx.coroutines.delay
2829
import kotlinx.coroutines.launch
2930
import java.util.concurrent.atomic.AtomicBoolean
3031

31-
3232
internal 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
235277
fun 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
283316
fun 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
348372
fun 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
411424
fun 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
448461
fun ApplicationScope.Tray(

0 commit comments

Comments
 (0)