mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-06 03:18:23 +00:00
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:
co-authored by
Claude Opus 5.5
parent
f0837089c9
commit
9455b711d4
@@ -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()
|
||||
|
||||
|
||||
+73
@@ -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")
|
||||
|
||||
+36
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user