fix(packaging): no second menu entry when an AppImage manager already lists the launcher (#474, #394) (#1032)

* fix(packaging): declare the launcher's real window class in its .desktop file (#1026)

The window sets its app id (Wayland) and WM_CLASS (X11) to
ai.storyteller.artcraft-launcher, but the entry said
StartupWMClass=artcraft-launcher. Desktops that find the entry by its
file name were fine; an entry under another name (AppImageLauncher,
Gear Lever, a hand-made one) never matched, so the dock showed a
generic icon and the launcher could not be pinned.

* fix(packaging): no second menu entry when an AppImage manager already lists the launcher (#474, #394)

appimaged, AppImageLauncher and Gear Lever write their own entry for the
AppImage under another file name. AppRun added
ai.storyteller.artcraft-launcher.desktop next to it on every start, so the
launcher showed up twice in the app menu, and deleting the extra entry only
lasted until the next launch.

AppRun now installs nothing while another entry's Exec runs this AppImage,
and removes the entry it wrote earlier when own_entry says it is still
unchanged. own_entry also accepts entries that carry the old
StartupWMClass=artcraft-launcher, since that line is the only difference
from what earlier releases wrote.

* fix(packaging): a hidden manager entry doesn't stand in for ours

Menu editors hide an entry with NoDisplay=true or Hidden=true. When a user
hid the manager's duplicate that way, AppRun still counted it and removed
our entry, leaving the launcher out of the menu. Such an entry no longer
counts. Test 15e covers both keys.

* fix(packaging): a manager entry stands in for ours only with our window class

Read only the [Desktop Entry] group, so an Exec in a [Desktop Action] group
doesn't count, and require StartupWMClass=ai.storyteller.artcraft-launcher.
Gear Lever doesn't refresh its entry when the launcher replaces the AppImage
in place, so its entry can keep the old class, and without ours GNOME would
have nothing to match the window against.
This commit is contained in:
VoiceLessQ authored and GitHub committed 2026-11-02 22:43:36 -05:00
1 parent afe9bf3162
commit dd21313655
2 files changed
+100 -3

No files matched your search

+40 -3
View File
@@ -8,7 +8,8 @@
# (and whenever the AppImage's path changes) install the .desktop entry and the
# hicolor icons into $XDG_DATA_HOME, then launch. Every failure here must leave
# the launch untouched. AppImage managers can opt out with DESKTOPINTEGRATION=1,
# and users with ARTCRAFT_LAUNCHER_NO_DESKTOP_INTEGRATION=1.
# and users with ARTCRAFT_LAUNCHER_NO_DESKTOP_INTEGRATION=1. Nothing is installed
# while another entry (appimaged, AppImageLauncher, Gear Lever) runs this AppImage.
set -u
@@ -38,10 +39,17 @@ integrate() {
data="${XDG_DATA_HOME:-$home/.local/share}"
src_desktop="$APPDIR/usr/share/applications/$DESK_ID.desktop"
[ -f "$src_desktop" ] || return 0
apps="$data/applications"
# A second entry would list the app twice; drop the one we wrote if it is unchanged.
if other_entry_runs "$apps" "$appimage"; then
if own_entry "$apps/$DESK_ID.desktop" && rm -f "$apps/$DESK_ID.desktop"; then
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1
fi
return 0
fi
# Rewrite Exec to this AppImage (the binary is not on the host PATH) and drop
# TryExec, which would hide the entry; everything else is shipped as packaged.
apps="$data/applications"
mkdir -p "$apps" 2>/dev/null || return 0
tmp="$apps/.$DESK_ID.tmp.$$"
esc="$(printf '%s' "$appimage" | sed 's/[\\&|]/\\&/g')"
@@ -97,7 +105,36 @@ own_entry() {
[ "$existing_exec" = "$generated_exec" ] || return 1
expected_body="$(sed -e '/^Exec=/d' -e '/^TryExec=/d' "$src_desktop")" || return 1
existing_body="$(sed -e '/^Exec=/d' -e '/^X-ArtCraft-Launcher-Generated-Exec=/d' "$1")" || return 1
[ "$expected_body" = "$existing_body" ]
# Entries written before the StartupWMClass fix differ only in that line.
old_body="$(printf '%s\n' "$expected_body" | sed 's/^StartupWMClass=.*/StartupWMClass=artcraft-launcher/')"
[ "$expected_body" = "$existing_body" ] || [ "$old_body" = "$existing_body" ]
}
# True when a visible entry in $1 other than ours runs the AppImage $2 and has our window class.
other_entry_runs() {
grep -lF -e "$2" "$1"/*.desktop 2>/dev/null | (
while IFS= read -r f; do
[ "$f" = "$1/$DESK_ID.desktop" ] && continue
main=false runs=false class=false
while IFS= read -r line || [ -n "$line" ]; do
# Only the [Desktop Entry] group; an action's Exec isn't what the entry launches.
case "$line" in
"[Desktop Entry]") main=true && continue ;;
"["*) main=false ;;
esac
[ "$main" = true ] || continue
case "$line" in
# Menu editors hide an entry this way; it lists nothing.
Hidden=true | NoDisplay=true) runs=false && break ;;
# A class from before an in-place update can't match the window, so ours has to stay.
"StartupWMClass=$DESK_ID") class=true ;;
Exec=*"$2" | Exec=*"$2 "* | Exec=*"$2\""* | Exec=*"$2'"*) runs=true ;;
esac
done <"$f"
[ "$runs" = true ] && [ "$class" = true ] && exit 0
done
exit 1
)
}
integrate
+60
View File
@@ -181,4 +181,64 @@ ln -s "$TMP/manager-entry" "$DESKTOP"
"$APPDIR/AppRun" >/dev/null || fail "launch with desktop symlink failed"
[[ -L "$DESKTOP" ]] || fail "desktop symlink replaced"
# 15. appimaged lists this AppImage under its own name: drop our entry, install nothing more.
rm -rf "$XDG_DATA_HOME"
"$APPDIR/AppRun" >/dev/null || fail "launch before appimaged failed"
apps="$XDG_DATA_HOME/applications"
real="$(readlink -f "$APPIMAGE")"
printf '[Desktop Entry]\nName=ArtCraft Launcher\nExec="/usr/bin/appimaged" wrap "%s" %%F\nStartupWMClass=%s\nActions=Trash;\n\n[Desktop Action Trash]\nName=Move to Trash\nExec=gio trash "%s"\n' "$real" "$APP_ID" "$real" >"$apps/appimagekit_0123abcd.desktop"
rm -rf "$XDG_DATA_HOME/icons"
out="$("$APPDIR/AppRun" managed)" || fail "launch beside appimaged failed"
[[ "$out" = "stub: managed" ]] || fail "launch beside appimaged lost arguments"
[[ ! -e "$DESKTOP" ]] || fail "own entry kept beside appimaged's"
[[ ! -e "$XDG_DATA_HOME/icons" ]] || fail "icons installed beside appimaged's entry"
# 15a. Gear Lever's entry (single-quoted path, no trailing newline) counts too.
rm -rf "$XDG_DATA_HOME" && mkdir -p "$apps"
spaced="$TMP/Art Craft.AppImage"
: >"$spaced"
printf "[Desktop Entry]\nName=ArtCraft Launcher\nStartupWMClass=%s\nExec=env DESKTOPINTEGRATION=1 '%s'" "$APP_ID" "$(readlink -f "$spaced")" >"$apps/artcraft_launcher.desktop"
APPIMAGE="$spaced" "$APPDIR/AppRun" >/dev/null || fail "launch beside Gear Lever failed"
[[ ! -e "$DESKTOP" ]] || fail "integrated beside Gear Lever's entry"
# 15b. Our entry with user changes stays beside a manager's entry.
cp "$apps/artcraft_launcher.desktop" "$TMP/gear-lever-entry"
sed "s|^Exec=.*|Exec=\"$(readlink -f "$APPIMAGE")\"|" "$TMP/gear-lever-entry" >"$apps/artcraft_launcher.desktop"
sed 's/^Name=.*/Name=My Launcher/' "$TMP/generated-entry" >"$DESKTOP"
cp "$DESKTOP" "$TMP/renamed-entry"
"$APPDIR/AppRun" >/dev/null || fail "launch with renamed entry failed"
cmp -s "$DESKTOP" "$TMP/renamed-entry" || fail "user-changed entry removed"
# 15c. An entry for another file that shares our path as a prefix doesn't count.
rm -rf "$XDG_DATA_HOME" && mkdir -p "$apps"
printf '[Desktop Entry]\nStartupWMClass=%s\nExec="%s.old"\n' "$APP_ID" "$(readlink -f "$APPIMAGE")" >"$apps/old-copy.desktop"
"$APPDIR/AppRun" >/dev/null || fail "launch beside an older copy's entry failed"
[[ -f "$DESKTOP" ]] || fail "an entry for another file blocked integration"
# 15d. An entry from a release with the old StartupWMClass is ours: refreshed, and dropped beside appimaged's.
sed 's/^StartupWMClass=.*/StartupWMClass=artcraft-launcher/' "$TMP/generated-entry" >"$DESKTOP"
"$APPDIR/AppRun" >/dev/null || fail "launch over an older release's entry failed"
cmp -s "$DESKTOP" "$TMP/generated-entry" || fail "older release's entry not refreshed"
sed 's/^StartupWMClass=.*/StartupWMClass=artcraft-launcher/' "$TMP/generated-entry" >"$DESKTOP"
printf '[Desktop Entry]\nExec="/usr/bin/appimaged" wrap "%s" %%F\nStartupWMClass=%s\n' "$real" "$APP_ID" >"$apps/appimagekit_0123abcd.desktop"
"$APPDIR/AppRun" >/dev/null || fail "launch over an older release's entry beside appimaged failed"
[[ ! -e "$DESKTOP" ]] || fail "older release's entry kept beside appimaged's"
# 15e. A manager's entry hidden with a menu editor lists nothing: ours is installed and kept.
for key in NoDisplay Hidden; do
printf '[Desktop Entry]\nExec="/usr/bin/appimaged" wrap "%s" %%F\nStartupWMClass=%s\n%s=true\n' "$real" "$APP_ID" "$key" >"$apps/appimagekit_0123abcd.desktop"
"$APPDIR/AppRun" >/dev/null || fail "launch beside a hidden entry failed"
[[ -f "$DESKTOP" ]] || fail "no entry of ours beside a $key=true manager entry"
done
# 15f. A manager's entry left with the old window class by an in-place update: ours stays.
printf '[Desktop Entry]\nExec="/usr/bin/appimaged" wrap "%s" %%F\nStartupWMClass=artcraft-launcher\n' "$real" >"$apps/appimagekit_0123abcd.desktop"
"$APPDIR/AppRun" >/dev/null || fail "launch beside an old-class manager entry failed"
[[ -f "$DESKTOP" ]] || fail "own entry dropped beside a manager entry with the old class"
# 15g. Only the [Desktop Entry] group counts: an action that runs this AppImage doesn't.
printf '[Desktop Entry]\nExec=other-app\nStartupWMClass=%s\nActions=Launcher;\n\n[Desktop Action Launcher]\nExec="%s"\n' "$APP_ID" "$real" >"$apps/appimagekit_0123abcd.desktop"
"$APPDIR/AppRun" >/dev/null || fail "launch beside an entry that runs this AppImage from an action failed"
[[ -f "$DESKTOP" ]] || fail "own entry dropped beside an entry that runs this AppImage only from an action"
echo "AppRun desktop integration ok"