diff --git a/desktop/packaging/linux.sh b/desktop/packaging/linux.sh index f8e23fec..7196a3ca 100755 --- a/desktop/packaging/linux.sh +++ b/desktop/packaging/linux.sh @@ -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 diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt index 50b154bf..eeb1187c 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt @@ -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) { // 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) { ) } - // 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() diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AppImageIntegration.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AppImageIntegration.kt new file mode 100644 index 00000000..9a8f4428 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AppImageIntegration.kt @@ -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("\\\\", "\\") + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/UriLaunch.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/UriLaunch.kt index fefe8056..c086f4ed 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/UriLaunch.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/UriLaunch.kt @@ -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") diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AppImageIntegrationTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AppImageIntegrationTest.kt new file mode 100644 index 00000000..8ecfa14f --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AppImageIntegrationTest.kt @@ -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")) + } +}