diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index a12bbd91..37178444 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -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: | diff --git a/README.md b/README.md index 510f7874..3beebb39 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,8 @@ flatpak install --user photocraft--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--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 diff --git a/docs/releasing.md b/docs/releasing.md index da9c39f9..1d25c04c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun new file mode 100755 index 00000000..e8543ee2 --- /dev/null +++ b/packaging/linux/AppRun @@ -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" "$@" diff --git a/packaging/linux/apprun-test.sh b/packaging/linux/apprun-test.sh new file mode 100755 index 00000000..0c81a040 --- /dev/null +++ b/packaging/linux/apprun-test.sh @@ -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" diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index abc236a3..2cd7044b 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -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"