diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 16d487cb20..f9534095d6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -63,8 +63,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyShortcut import androidx.compose.ui.unit.dp @@ -216,13 +214,7 @@ fun main() { // on macOS the logo is then wrapped in a squircle so it matches // first-party dock icons. try { - val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes() - val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) - val adapted = - raw?.let { - com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon - .adaptForHost(it) - } + val adapted = com.vitorpamplona.amethyst.desktop.platform.IconResources.adaptedBufferedImage if (adapted != null && java.awt.Taskbar.isTaskbarSupported()) { val taskbar = java.awt.Taskbar.getTaskbar() if (taskbar.isSupported(java.awt.Taskbar.Feature.ICON_IMAGE)) { @@ -297,21 +289,7 @@ fun main() { // Window title-bar / taskbar thumbnail icon. On macOS the source logo // is wrapped in a squircle so it matches every other dock icon; on // other platforms the raw transparent logo is used as-is. - val appIcon = - remember { - val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes() - val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) - val adapted = - com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon - .adaptForHost(raw) - val buf = java.io.ByteArrayOutputStream() - javax.imageio.ImageIO.write(adapted, "png", buf) - val bitmap = - org.jetbrains.skia.Image - .makeFromEncoded(buf.toByteArray()) - .toComposeImageBitmap() - BitmapPainter(bitmap) - } + val appIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.adaptedBitmapPainter Window( onCloseRequest = ::exitApplication, @@ -713,15 +691,7 @@ fun App( val torStatus by torManager.status.collectAsState() val isTorExpected = torSettings.torType != com.vitorpamplona.amethyst.commons.tor.TorType.OFF if (isTorExpected && torStatus !is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Active) { - val splashIcon = - remember { - val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes() - val bitmap = - org.jetbrains.skia.Image - .makeFromEncoded(bytes) - .toComposeImageBitmap() - BitmapPainter(bitmap) - } + val splashIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter androidx.compose.foundation.layout.Box( modifier = androidx.compose.ui.Modifier @@ -984,15 +954,7 @@ fun App( when (accountState) { is AccountState.Loading -> { // Branded loading screen while accounts load from storage - val loadingIcon = - remember { - val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes() - val bitmap = - org.jetbrains.skia.Image - .makeFromEncoded(bytes) - .toComposeImageBitmap() - BitmapPainter(bitmap) - } + val loadingIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/IconResources.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/IconResources.kt new file mode 100644 index 0000000000..dec47bf03f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/IconResources.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.Image +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import javax.imageio.ImageIO + +/** + * Lazily-memoized icon resources shared across all consumers of `/icon.png`. + * + * Before this object existed the desktop launch path read and decoded the + * icon four separate times on the cold-boot critical path (taskbar setup, + * Window icon, Tor splash, account-loading splash). The taskbar and Window + * sites also paid an `ImageIO.read` to obtain a `BufferedImage` before + * either passing it straight to `Taskbar.iconImage` (taskbar) or + * round-tripping it back through `ImageIO.write` so Skia can decode it + * (Window icon). + * + * This object collapses the work to one resource read plus at most one + * decode per output shape. All properties are `lazy { ... }` so the cost + * is paid only when first observed. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 5.1. + */ +object IconResources { + /** Raw PNG bytes read from the `/icon.png` classpath resource. */ + val iconBytes: ByteArray by lazy { + IconResources::class.java.getResourceAsStream("/icon.png")!!.readBytes() + } + + /** Decoded `BufferedImage` for AWT consumers (Taskbar, Window icon source). */ + val rawBufferedImage: BufferedImage by lazy { + ImageIO.read(ByteArrayInputStream(iconBytes)) + } + + /** + * Platform-adapted icon (squircle on macOS, raw transparent PNG elsewhere). + * Returns `null` when adaptation fails or is unsupported — callers should + * fall back to [rawBufferedImage]. + */ + val adaptedBufferedImage: BufferedImage? by lazy { + PlatformAppIcon.adaptForHost(rawBufferedImage) + } + + /** + * Compose `BitmapPainter` for the raw PNG bytes. Used by splash screens + * (Tor connecting, account loading) that paint the un-adapted logo with + * a Material `tint`. + */ + val rawBitmapPainter: BitmapPainter by lazy { + BitmapPainter(Image.makeFromEncoded(iconBytes).toComposeImageBitmap()) + } + + /** + * Compose `BitmapPainter` for the platform-adapted icon. Used by the + * main `Window(icon = …)` parameter so the title-bar / taskbar thumbnail + * matches the dock icon shape on macOS. + */ + val adaptedBitmapPainter: BitmapPainter by lazy { + val adapted = adaptedBufferedImage ?: rawBufferedImage + val buf = ByteArrayOutputStream() + ImageIO.write(adapted, "png", buf) + BitmapPainter(Image.makeFromEncoded(buf.toByteArray()).toComposeImageBitmap()) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/platform/IconResourcesTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/platform/IconResourcesTest.kt new file mode 100644 index 0000000000..a667fbb592 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/platform/IconResourcesTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Phase 5.1 of the launch-optimization plan: pin the memoization invariants + * of [IconResources]. The launch path previously decoded the same `/icon.png` + * resource up to four times during a single cold boot; this object collapses + * the work and the tests below assert that the lazy holders return the same + * cached instance on subsequent accesses. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 5.1. + */ +class IconResourcesTest { + @Test + fun iconBytesAreMemoized() { + val first = IconResources.iconBytes + val second = IconResources.iconBytes + assertSame(first, second, "Raw PNG byte array must be the same instance across calls") + assertTrue(first.isNotEmpty(), "Bundled /icon.png must not be empty") + } + + @Test + fun rawBufferedImageIsMemoized() { + val first = IconResources.rawBufferedImage + val second = IconResources.rawBufferedImage + assertSame(first, second, "Decoded BufferedImage must be the same instance across calls") + assertTrue(first.width > 0 && first.height > 0, "Decoded image must have positive dimensions") + } + + @Test + fun rawBitmapPainterIsMemoized() { + val first = IconResources.rawBitmapPainter + val second = IconResources.rawBitmapPainter + assertSame(first, second, "Raw BitmapPainter must be the same instance across calls") + } + + @Test + fun adaptedBitmapPainterIsMemoized() { + val first = IconResources.adaptedBitmapPainter + val second = IconResources.adaptedBitmapPainter + assertSame(first, second, "Adapted BitmapPainter must be the same instance across calls") + } + + @Test + fun adaptedBufferedImageEitherProducesAValueOrIsNull() { + // On macOS this returns a squircle; on other platforms it may return null + // when PlatformAppIcon.adaptForHost is a no-op. Either is acceptable — we + // just verify the lazy doesn't throw. + val adapted = IconResources.adaptedBufferedImage + // Accessing twice must yield the same instance (or both null). + assertSame(adapted, IconResources.adaptedBufferedImage) + if (adapted != null) { + assertNotNull(adapted, "If adaptation returns non-null, it must be a valid image") + } + } +}