mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
fix(linux): AppImage registers its desktop entry and icon on launch (#617)
* fix(linux): AppImage registers its desktop entry and icon on launch On Wayland the compositor resolves the dock icon from the window's app_id (ai.storyteller.photocraft, already set) against a host-installed .desktop file. An AppImage installs nothing, so every run showed the generic Wayland logo (#593). The app cannot set the icon itself: winit 0.30.13 (egui 0.36.2's pin) has an empty set_window_icon on Wayland; xdg-toplevel-icon-v1 landed on winit's 0.31 line only. - packaging/linux/AppRun is a script instead of a symlink: best-effort integration into $XDG_DATA_HOME before launch (desktop entry with Exec rewritten to the AppImage's path and TryExec dropped, hicolor icons copied), content-compared so an unchanged install costs a few cmp calls, refreshed when the AppImage moves, PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1 opts out, and no failure can block the launch (best-effort steps silent). - packaging/linux/apprun-test.sh: 8-case smoke (passthrough, Exec rewrite with spaces, metadata preserved, idempotence, move refresh, opt-out, unwritable data dir, extracted AppDir). packaging-lint runs it and now shellchecks AppRun. - Docs: releasing.md Linux section; README install paragraph. Fixes #593 * AppRun: skip desktop integration when the AppImage path needs Exec escaping A quote, backquote, dollar, backslash, percent or line break in the path would need desktop-entry escaping (a line break could even add keys), so integration is skipped and the app still launches. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
6 files changed
+192
-3
No files matched your search
@@ -34,7 +34,9 @@ jobs:
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: AppImage AppRun desktop integration
|
||||
run: bash packaging/linux/apprun-test.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
|
||||
@@ -274,6 +274,8 @@ flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-a
|
||||
flatpak run ai.storyteller.photocraft
|
||||
```
|
||||
|
||||
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
|
||||
|
||||
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
|
||||
|
||||
```sh
|
||||
|
||||
+12
-1
@@ -156,12 +156,23 @@ plus a scalable SVG, AppStream metainfo, and a shared-mime-info file for `.pcraf
|
||||
|
||||
Why these formats:
|
||||
|
||||
- **AppImage** runs on any distribution without installing anything. It's the
|
||||
- **AppImage** runs on any distribution without installing anything. The
|
||||
download-and-go option and the fallback for distros the packages don't cover. It's built
|
||||
with the maintained `AppImage/appimagetool`, whose static runtime doesn't need libfuse2.
|
||||
Each AppImage embeds update information (`gh-releases-zsync|…|latest|…`, #349) and ships
|
||||
with a `.zsync` beside it, so AppImageUpdate, AppImageLauncher and similar tools can find
|
||||
the next release and download only the blocks that changed.
|
||||
Its `AppRun` (`packaging/linux/AppRun`) also integrates with the desktop on launch: on
|
||||
Wayland the dock icon comes from the compositor resolving the window's app_id against an
|
||||
*installed* `.desktop` file, which an AppImage alone never provides — without this every
|
||||
AppImage run shows the generic Wayland logo (#593). Best effort: it installs
|
||||
`~/.local/share/applications/ai.storyteller.photocraft.desktop` with `Exec` rewritten to
|
||||
the AppImage's path (`TryExec` dropped, the binary isn't on the host PATH) and the
|
||||
hicolor icons beside it, rewriting only when the content changed, and any failure leaves
|
||||
the launch untouched. `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` opts out; to uninstall, `rm
|
||||
~/.local/share/applications/ai.storyteller.photocraft.desktop` and `find
|
||||
~/.local/share/icons/hicolor -name 'ai.storyteller.photocraft*' -delete`. The script is
|
||||
covered by `packaging/linux/apprun-test.sh` in the packaging-lint workflow.
|
||||
- **.deb** covers Debian, Ubuntu, Mint, Pop!_OS and elementary. **.rpm** covers Fedora, RHEL
|
||||
and its clones, and openSUSE. Both integrate with the menu, MIME and icon caches (the
|
||||
`postinst.sh` hook) and uninstall cleanly. Both are built by
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/bin/sh
|
||||
# PhotoCraft AppImage entry point (packaging/linux/AppRun; installed by package.sh).
|
||||
#
|
||||
# On Wayland the compositor resolves a window's dock icon by taking the window's
|
||||
# app_id (ai.storyteller.photocraft) and looking up an installed .desktop file.
|
||||
# An AppImage installs nothing, so every AppImage run would otherwise show the
|
||||
# generic "Wayland" icon (#593). Best-effort desktop integration: on first run
|
||||
# (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. Opt out with PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1.
|
||||
|
||||
set -u
|
||||
|
||||
APPDIR="$(dirname "$(readlink -f "$0")")"
|
||||
DESK_ID=ai.storyteller.photocraft
|
||||
|
||||
integrate() {
|
||||
case "${PHOTOCRAFT_NO_DESKTOP_INTEGRATION:-}" in
|
||||
1 | true | yes) return 0 ;;
|
||||
esac
|
||||
# The AppImage runtime exports $APPIMAGE; without it (an extracted AppDir) there
|
||||
# is no path to register and nothing to do.
|
||||
appimage="${APPIMAGE:-}"
|
||||
[ -n "$appimage" ] && [ -f "$appimage" ] || return 0
|
||||
appimage="$(readlink -f "$appimage")" || return 0
|
||||
# Exec= is parsed by the desktop: a path holding a quote, backquote, dollar, backslash,
|
||||
# percent or a line break would need escaping (or could add lines), so skip integration.
|
||||
case "$appimage" in
|
||||
*[\"\`\$\\%]* | *'
|
||||
'*) return 0 ;;
|
||||
esac
|
||||
home="${HOME:-}"
|
||||
[ -n "$home" ] || return 0
|
||||
data="${XDG_DATA_HOME:-$home/.local/share}"
|
||||
src_desktop="$APPDIR/usr/share/applications/$DESK_ID.desktop"
|
||||
[ -f "$src_desktop" ] || return 0
|
||||
|
||||
# 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')"
|
||||
if sed -e "s|^Exec=.*|Exec=\"$esc\" %F|" -e '/^TryExec=/d' "$src_desktop" 2>/dev/null >"$tmp"; then
|
||||
if cmp -s "$tmp" "$apps/$DESK_ID.desktop"; then
|
||||
rm -f "$tmp"
|
||||
else
|
||||
if mv -f "$tmp" "$apps/$DESK_ID.desktop" 2>/dev/null; then
|
||||
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Icons: per-file compare so an unchanged install costs a handful of cmp calls.
|
||||
src_icons="$APPDIR/usr/share/icons/hicolor"
|
||||
[ -d "$src_icons" ] || return 0
|
||||
icons="$data/icons/hicolor"
|
||||
(cd "$src_icons" && find . -type f) | while IFS= read -r rel; do
|
||||
mkdir -p "$icons/$(dirname "$rel")" 2>/dev/null || continue
|
||||
if ! cmp -s "$src_icons/$rel" "$icons/$rel"; then
|
||||
cp "$src_icons/$rel" "$icons/$rel" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
integrate
|
||||
exec "$APPDIR/usr/bin/photocraft" "$@"
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke test for packaging/linux/AppRun: the AppImage's desktop integration writes
|
||||
# and refreshes the host .desktop entry and icons, is idempotent, honours the opt-out
|
||||
# and an unwritable HOME, and always launches the app (#593).
|
||||
#
|
||||
# Runs in seconds with no build (CI: .github/workflows/packaging-lint.yml).
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$HERE/../.." && pwd)"
|
||||
APP_ID=ai.storyteller.photocraft
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# Fake AppDir with the real AppRun, .desktop template, icons, and a stub binary.
|
||||
APPDIR="$TMP/PhotoCraft.AppDir"
|
||||
mkdir -p "$APPDIR/usr/bin"
|
||||
install -Dm755 "$HERE/AppRun" "$APPDIR/AppRun"
|
||||
install -Dm644 "$HERE/$APP_ID.desktop" "$APPDIR/usr/share/applications/$APP_ID.desktop"
|
||||
mkdir -p "$APPDIR/usr/share/icons"
|
||||
cp -R "$ROOT/assets/app-icon/hicolor" "$APPDIR/usr/share/icons/"
|
||||
printf '#!/bin/sh\necho "stub: $*"\n' >"$APPDIR/usr/bin/photocraft"
|
||||
chmod +x "$APPDIR/usr/bin/photocraft"
|
||||
|
||||
APPIMAGE="$TMP/photocraft test.AppImage" # spaces on purpose: Exec must stay quoted
|
||||
: >"$APPIMAGE"
|
||||
export APPIMAGE
|
||||
export HOME="$TMP/home"
|
||||
export XDG_DATA_HOME="$HOME/.local/share"
|
||||
mkdir -p "$HOME"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 1. Launch works and passes arguments through.
|
||||
out="$("$APPDIR/AppRun" open file.png)" || fail "AppRun exited non-zero"
|
||||
[ "$out" = "stub: open file.png" ] || fail "arguments not forwarded: $out"
|
||||
|
||||
DESKTOP="$XDG_DATA_HOME/applications/$APP_ID.desktop"
|
||||
[ -f "$DESKTOP" ] || fail "desktop entry not installed"
|
||||
|
||||
# 2. Exec rewritten to the AppImage (quoted, spaces intact), TryExec dropped,
|
||||
# everything else preserved from the packaged entry.
|
||||
expected_exec="Exec=\"$(readlink -f "$APPIMAGE")\" %F"
|
||||
grep -Fxq "$expected_exec" "$DESKTOP" || fail "Exec not rewritten: $(grep '^Exec=' "$DESKTOP")"
|
||||
grep -q '^TryExec=' "$DESKTOP" && fail "TryExec kept, the entry would be hidden"
|
||||
grep -Fxq "Icon=$APP_ID" "$DESKTOP" || fail "Icon line lost"
|
||||
grep -Fxq 'Categories=Graphics;2DGraphics;RasterGraphics;Photography;' "$DESKTOP" || fail "metadata lines lost"
|
||||
if command -v desktop-file-validate >/dev/null; then
|
||||
desktop-file-validate "$DESKTOP" || fail "generated desktop entry is invalid"
|
||||
fi
|
||||
|
||||
# 3. Icons landed under the name the .desktop entry references.
|
||||
for f in "16x16/apps/$APP_ID.png" "256x256/apps/$APP_ID.png" "scalable/apps/$APP_ID.svg"; do
|
||||
[ -f "$XDG_DATA_HOME/icons/hicolor/$f" ] || fail "icon missing: $f"
|
||||
done
|
||||
|
||||
# 4. Idempotent: a second launch must not rewrite anything (inode unchanged).
|
||||
before="$(stat -c %i "$DESKTOP")"
|
||||
out="$("$APPDIR/AppRun")" || fail "second launch exited non-zero"
|
||||
[ "$out" = "stub: " ] || fail "second launch output: $out"
|
||||
after="$(stat -c %i "$DESKTOP")"
|
||||
[ "$before" = "$after" ] || fail "desktop entry rewritten although unchanged"
|
||||
|
||||
# 5. Moving the AppImage refreshes Exec.
|
||||
mv "$APPIMAGE" "$TMP/moved.AppImage"
|
||||
export APPIMAGE="$TMP/moved.AppImage"
|
||||
"$APPDIR/AppRun" >/dev/null || fail "launch after move exited non-zero"
|
||||
grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\" %F" "$DESKTOP" || fail "Exec not refreshed after move"
|
||||
|
||||
# 6. Opt-out installs nothing.
|
||||
rm -rf "$XDG_DATA_HOME"
|
||||
PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1 "$APPDIR/AppRun" >/dev/null || fail "opt-out launch exited non-zero"
|
||||
[ ! -e "$XDG_DATA_HOME" ] || fail "opt-out still integrated"
|
||||
|
||||
# 7. An unwritable data dir never blocks the launch.
|
||||
mkdir -p "$TMP/ro" && chmod a-w "$TMP/ro"
|
||||
out="$(XDG_DATA_HOME="$TMP/ro/data" "$APPDIR/AppRun" out.txt)" || fail "launch blocked by unwritable data dir"
|
||||
[ "$out" = "stub: out.txt" ] || fail "stub not run against unwritable data dir: $out"
|
||||
chmod u+w "$TMP/ro"
|
||||
|
||||
# 8. No $APPIMAGE (extracted AppDir): launch only, nothing installed.
|
||||
rm -rf "$XDG_DATA_HOME"
|
||||
env -u APPIMAGE "$APPDIR/AppRun" >/dev/null || fail "extracted-AppDir launch exited non-zero"
|
||||
[ ! -e "$XDG_DATA_HOME" ] || fail "integrated without $APPIMAGE"
|
||||
|
||||
# 9. A path that would need Exec escaping (here a `$`): launch only, nothing installed.
|
||||
rm -rf "$XDG_DATA_HOME"
|
||||
odd="$TMP/odd\$(id).AppImage"
|
||||
: >"$odd"
|
||||
out="$(APPIMAGE="$odd" "$APPDIR/AppRun" x)" || fail "launch with an odd path exited non-zero"
|
||||
[ "$out" = "stub: x" ] || fail "odd path: $out"
|
||||
[ ! -e "$XDG_DATA_HOME" ] || fail "integrated an AppImage path that needs escaping"
|
||||
|
||||
echo "AppRun desktop integration ok"
|
||||
@@ -97,7 +97,11 @@ if has appimage; then
|
||||
APPDIR="$WORK/PhotoCraft.AppDir"
|
||||
cp -R "$STAGE" "$APPDIR"
|
||||
mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
|
||||
ln -s usr/bin/photocraft "$APPDIR/AppRun"
|
||||
# AppRun is a script, not a symlink: it installs the .desktop entry and icons into
|
||||
# $XDG_DATA_HOME before launching, so Wayland compositors can resolve the window's
|
||||
# app_id to our icon instead of the generic Wayland logo (#593; opt-out env in the
|
||||
# script header). Logic is covered by packaging/linux/apprun-test.sh.
|
||||
install -Dm755 "$HERE/AppRun" "$APPDIR/AppRun"
|
||||
cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop"
|
||||
cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png"
|
||||
ln -s "$APP_ID.png" "$APPDIR/.DirIcon"
|
||||
|
||||
Reference in new issue
Block a user