desktop: register the AppImage in the app menu

An AppImage has no menu entry until a tool like AppImageLauncher integrates
it, so docks showed a generic icon and Amber wasn't in the app launcher.
On every launch from an AppImage, write amber-appimage.desktop (Exec=$APPIMAGE,
StartupWMClass=amber-Amber) and the icon to the user's XDG data dirs. Launched
any other way, remove the entry once its AppImage is gone. The entry has its
own name so it never shadows the .deb/.rpm amber-Amber.desktop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
greenart7c3
2026-10-02 08:37:48 -03:00
co-authored by Claude Opus 5.5
parent f0837089c9
commit 9455b711d4
5 changed files with 115 additions and 9 deletions
+2 -2
View File
@@ -31,8 +31,8 @@ mkdir -p "$appdir/usr/lib"
cp -a "$image" "$appdir/usr/lib/amber"
cp "$desktop/src/main/resources/icon.png" "$appdir/amber.png"
ln -s amber.png "$appdir/.DirIcon"
# StartupWMClass matches the X11 class Main.kt sets (LINUX_WINDOW_CLASS), so
# docks show this icon.
# StartupWMClass matches the X11 class the app sets
# (AppImageIntegration.WINDOW_CLASS), so docks show this icon.
cat > "$appdir/amber.desktop" << 'EOF'
[Desktop Entry]
Type=Application
@@ -23,6 +23,7 @@ import com.greenart7c3.nostrsigner.desktop.core.AccountManager
import com.greenart7c3.nostrsigner.desktop.core.AccountsStore
import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop
import com.greenart7c3.nostrsigner.desktop.core.AmberLogger
import com.greenart7c3.nostrsigner.desktop.core.AppImageIntegration
import com.greenart7c3.nostrsigner.desktop.core.AutoStart
import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount
import com.greenart7c3.nostrsigner.desktop.core.NetworkConnectivity
@@ -137,8 +138,6 @@ private object DesktopTray {
* before the first window is created; needs
* `--add-opens java.desktop/sun.awt.X11=ALL-UNNAMED`.
*/
private const val LINUX_WINDOW_CLASS = "amber-Amber"
private fun setX11WindowClass(name: String) {
runCatching {
val toolkit = java.awt.Toolkit.getDefaultToolkit()
@@ -171,6 +170,7 @@ fun main(args: Array<String>) {
// a second instance would linger invisibly instead of exiting.
UriLaunch.installMacOpenUriHandler()
UriLaunch.registerSchemeHandler()
AppImageIntegration.sync()
UriLaunch.startIpcServer()
}
if (launchUri != null) UriLaunch.pending.value = launchUri
@@ -199,10 +199,7 @@ fun main(args: Array<String>) {
)
}
// The .deb/.rpm desktop entry jpackage generates is amber-Amber.desktop
// with no StartupWMClass, so docks match it by window class == file name.
// The AppImage entry declares StartupWMClass=amber-Amber to match.
if (DesktopTray.isLinux) setX11WindowClass(LINUX_WINDOW_CLASS)
if (DesktopTray.isLinux) setX11WindowClass(AppImageIntegration.WINDOW_CLASS)
Session.boot()
@@ -0,0 +1,73 @@
package com.greenart7c3.nostrsigner.desktop.core
import java.io.File
/**
* Integrates a running AppImage with the desktop the way AppImageLauncher /
* Gear Lever do: a menu entry plus icon in the user's XDG data dirs, so Amber
* shows up in the app launcher and docks match its window (StartupWMClass) to
* Amber's icon instead of a generic one.
*
* Re-run on every launch so a moved AppImage is picked up. When Amber runs
* from anything else and the AppImage the entry points at is gone, the entry
* and icon are removed. The entry has its own name so it never shadows the
* .deb/.rpm amber-Amber.desktop.
*/
object AppImageIntegration {
/**
* X11 class Main.kt gives the window: jpackage's .deb/.rpm entry is
* amber-Amber.desktop with no StartupWMClass, so docks match it by window
* class == desktop file name. The AppImage entries declare it explicitly.
*/
const val WINDOW_CLASS = "amber-Amber"
internal const val DESKTOP_FILE = "amber-appimage.desktop"
private const val ICON_FILE = "amber.png"
fun sync() {
if (!AutoStart.isLinux) return
runCatching {
val entry = File(UriLaunch.xdgAppsDir(), DESKTOP_FILE)
val icon = File(AppDirs.dataDir, ICON_FILE)
val appImage = System.getenv("APPIMAGE")?.takeIf { it.isNotBlank() }?.let(::File)?.takeIf { it.isFile }
if (appImage == null) {
val target = if (entry.exists()) execTarget(entry.readText()) else null
if (target != null && !File(target).isFile) {
entry.delete()
icon.delete()
AmberLogger.d("AppImageIntegration", "Removed the entry for deleted AppImage $target")
}
return
}
val iconBytes = AppImageIntegration::class.java.getResourceAsStream("/icon.png")?.use { it.readBytes() } ?: return
if (!icon.exists() || !icon.readBytes().contentEquals(iconBytes)) icon.writeBytes(iconBytes)
entry.parentFile.mkdirs()
val content = desktopEntry(appImage.absolutePath, icon.absolutePath)
if (!entry.exists() || entry.readText() != content) {
entry.writeText(content)
runCatching { ProcessBuilder("update-desktop-database", entry.parentFile.absolutePath).start().waitFor() }
}
AmberLogger.d("AppImageIntegration", "Menu entry $entry -> $appImage")
}.onFailure { AmberLogger.d("AppImageIntegration", "AppImage integration failed: ${it.message}") }
}
internal fun desktopEntry(appImagePath: String, iconPath: String): String = """
[Desktop Entry]
Type=Application
Name=Amber
Comment=Nostr event signer
Exec=${UriLaunch.quoteForDesktopEntry(appImagePath)} %u
Icon=$iconPath
Categories=Network;
Terminal=false
StartupWMClass=$WINDOW_CLASS
""".trimIndent() + "\n"
/** The program an entry written by [desktopEntry] launches, unquoted. */
internal fun execTarget(entry: String): String? {
val exec = entry.lineSequence().firstOrNull { it.startsWith("Exec=") }?.removePrefix("Exec=")?.removeSuffix(" %u") ?: return null
if (!exec.startsWith("\"")) return exec
return exec.removeSurrounding("\"").replace("\\\"", "\"").replace("\\\\", "\\")
}
}
@@ -217,7 +217,7 @@ object UriLaunch {
private fun shellQuote(value: String): String = "'" + value.replace("'", "'\\''") + "'"
private fun xdgAppsDir(): File {
internal fun xdgAppsDir(): File {
val dataHome = System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() }
?: File(File(System.getProperty("user.home"), ".local"), "share").absolutePath
return File(dataHome, "applications")
@@ -0,0 +1,36 @@
package com.greenart7c3.nostrsigner.desktop
import com.greenart7c3.nostrsigner.desktop.core.AppImageIntegration
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AppImageIntegrationTest {
@Test
fun desktopEntryLaunchesTheAppImageAndMatchesTheWindow() {
val entry = AppImageIntegration.desktopEntry("/home/u/Apps/Amber.AppImage", "/home/u/.local/share/amber/amber.png")
assertTrue(entry.startsWith("[Desktop Entry]"))
assertTrue(entry.contains("Exec=/home/u/Apps/Amber.AppImage %u"))
assertTrue(entry.contains("Icon=/home/u/.local/share/amber/amber.png"))
assertTrue(entry.contains("StartupWMClass=${AppImageIntegration.WINDOW_CLASS}"))
// Shown in the app launcher, unlike the nostrconnect:// handler entry.
assertTrue(!entry.contains("NoDisplay"))
}
@Test
fun execTargetRoundTripsQuotedPaths() {
listOf(
"/home/u/Amber.AppImage",
"/home/u/My Apps/Amber.AppImage",
"/home/u/a\"b\\c/Amber.AppImage",
).forEach { path ->
assertEquals(path, AppImageIntegration.execTarget(AppImageIntegration.desktopEntry(path, "/icon.png")))
}
}
@Test
fun execTargetIsNullWithoutAnExecLine() {
assertNull(AppImageIntegration.execTarget("[Desktop Entry]\nName=Amber\n"))
}
}