diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a2695a91ba..e0559aeebc 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -634,6 +634,8 @@ jobs: build-appimage: runs-on: ubuntu-24.04 needs: build-linux + permissions: + contents: read steps: - uses: actions/checkout@v6 @@ -664,16 +666,7 @@ jobs: run: | VERSION="${{ steps.ver.outputs.version }}" tar -xzf "stack_wallet-linux-x86_64-${VERSION}.tar.gz" - mkdir -p AppDir - cp -r bundle/* AppDir/ - cp appimage/stack_wallet/AppRun AppDir/AppRun - chmod +x AppDir/AppRun - cp appimage/stack_wallet/stack_wallet.desktop AppDir/ - cp asset_sources/icon/stack_wallet/icon.png AppDir/stack_wallet.png - curl -fsSL -o appimagetool \ - https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage - chmod +x appimagetool - ARCH=x86_64 ./appimagetool --appimage-extract-and-run AppDir \ + appimage/build_appimage.sh stack_wallet bundle \ "stack_wallet-appimage-x86_64-${VERSION}.AppImage" - uses: actions/upload-artifact@v7 @@ -1092,6 +1085,8 @@ jobs: build-campfire-appimage: runs-on: ubuntu-24.04 needs: build-campfire-linux + permissions: + contents: read steps: - uses: actions/checkout@v6 @@ -1122,16 +1117,7 @@ jobs: run: | VERSION="${{ steps.ver.outputs.version }}" tar -xzf "campfire-linux-x86_64-${VERSION}.tar.gz" - mkdir -p AppDir - cp -r bundle/* AppDir/ - cp appimage/campfire/AppRun AppDir/AppRun - chmod +x AppDir/AppRun - cp appimage/campfire/campfire.desktop AppDir/ - cp asset_sources/icon/campfire/icon.png AppDir/campfire.png - curl -fsSL -o appimagetool \ - https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage - chmod +x appimagetool - ARCH=x86_64 ./appimagetool --appimage-extract-and-run AppDir \ + appimage/build_appimage.sh campfire bundle \ "campfire-appimage-x86_64-${VERSION}.AppImage" - uses: actions/upload-artifact@v7 @@ -1552,6 +1538,8 @@ jobs: build-stack-duo-appimage: runs-on: ubuntu-24.04 needs: build-stack-duo-linux + permissions: + contents: read steps: - uses: actions/checkout@v6 @@ -1582,16 +1570,7 @@ jobs: run: | VERSION="${{ steps.ver.outputs.version }}" tar -xzf "stack_duo-linux-x86_64-${VERSION}.tar.gz" - mkdir -p AppDir - cp -r bundle/* AppDir/ - cp appimage/stack_duo/AppRun AppDir/AppRun - chmod +x AppDir/AppRun - cp appimage/stack_duo/stack_duo.desktop AppDir/ - cp asset_sources/icon/stack_duo/icon.png AppDir/stack_duo.png - curl -fsSL -o appimagetool \ - https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage - chmod +x appimagetool - ARCH=x86_64 ./appimagetool --appimage-extract-and-run AppDir \ + appimage/build_appimage.sh stack_duo bundle \ "stack_duo-appimage-x86_64-${VERSION}.AppImage" - uses: actions/upload-artifact@v7 diff --git a/appimage/build_appimage.sh b/appimage/build_appimage.sh new file mode 100755 index 0000000000..dd4097d1a7 --- /dev/null +++ b/appimage/build_appimage.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Build an x86_64 AppImage with pinned, hash-verified AppImage tooling. +# Usage: appimage/build_appimage.sh +set -euo pipefail + +readonly APPIMAGETOOL_VERSION="1.9.1" +readonly APPIMAGETOOL_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0" +readonly APPIMAGE_RUNTIME_VERSION="20251108" +readonly APPIMAGE_RUNTIME_SHA256="2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d" +APPIMAGE_WORKDIR="" + +verify_sha256() { + local file=$1 + local expected=$2 + local actual + actual=$(sha256sum "$file" | cut -d ' ' -f1) + if [ "$actual" != "$expected" ]; then + echo "SHA-256 mismatch for $file: expected $expected, got $actual" >&2 + return 1 + fi +} + +download_verified() { + local url=$1 + local expected=$2 + local destination=$3 + curl --fail --silent --show-error --location --retry 3 \ + --connect-timeout 30 --max-time 300 \ + --proto '=https' --proto-redir '=https' \ + --output "$destination" "$url" + verify_sha256 "$destination" "$expected" +} + +main() { + if [ $# -ne 3 ]; then + echo "usage: $0 " >&2 + return 1 + fi + + local app=$1 + local bundle=$2 + local output=$3 + case "$app" in + stack_wallet|stack_duo|campfire) ;; + *) echo "unknown app: $app" >&2; return 1 ;; + esac + if [ ! -d "$bundle" ]; then + echo "bundle directory not found: $bundle" >&2 + return 1 + fi + + local root + root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) + local output_dir + output_dir=$(dirname -- "$output") + mkdir -p "$output_dir" + output_dir=$(cd -- "$output_dir" && pwd) + output="$output_dir/$(basename -- "$output")" + + local build_root="$root/build/appimage-work" + mkdir -p "$build_root" + APPIMAGE_WORKDIR=$(mktemp -d "$build_root/run.XXXXXX") + trap 'rm -rf -- "$APPIMAGE_WORKDIR"' EXIT + + local appdir="$APPIMAGE_WORKDIR/AppDir" + local appimagetool="$APPIMAGE_WORKDIR/appimagetool-x86_64.AppImage" + local runtime="$APPIMAGE_WORKDIR/runtime-x86_64" + + "$root/appimage/make_appdir.sh" "$app" "$bundle" "$appdir" + download_verified \ + "https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-x86_64.AppImage" \ + "$APPIMAGETOOL_SHA256" "$appimagetool" + download_verified \ + "https://github.com/AppImage/type2-runtime/releases/download/${APPIMAGE_RUNTIME_VERSION}/runtime-x86_64" \ + "$APPIMAGE_RUNTIME_SHA256" "$runtime" + chmod +x "$appimagetool" + + ARCH=x86_64 "$appimagetool" --appimage-extract-and-run \ + --no-appstream --runtime-file "$runtime" \ + "$appdir" "$output" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/appimage/campfire/campfire.desktop b/appimage/campfire/com.cypherstack.campfire.desktop similarity index 64% rename from appimage/campfire/campfire.desktop rename to appimage/campfire/com.cypherstack.campfire.desktop index 71b7b612c0..911bdec976 100644 --- a/appimage/campfire/campfire.desktop +++ b/appimage/campfire/com.cypherstack.campfire.desktop @@ -2,6 +2,7 @@ Name=Campfire Comment=Your privacy. Your wallet. Your Firo. Exec=campfire -Icon=campfire +Icon=com.cypherstack.campfire +StartupWMClass=com.cypherstack.campfire Type=Application Categories=Finance; diff --git a/appimage/make_appdir.sh b/appimage/make_appdir.sh new file mode 100755 index 0000000000..10f0197dc2 --- /dev/null +++ b/appimage/make_appdir.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Assemble an AppDir from a built Linux bundle. +# Usage: appimage/make_appdir.sh +set -euo pipefail + +if [ $# -ne 3 ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +app=$1 +bundle=$2 +appdir=$3 + +case "$app" in + stack_wallet) id=com.cypherstack.stackwallet ;; + stack_duo) id=com.cypherstack.stackduo ;; + campfire) id=com.cypherstack.campfire ;; + *) echo "unknown app: $app" >&2; exit 1 ;; +esac + +root=$(cd "$(dirname "$0")/.." && pwd) +icon="$root/asset_sources/icon/$app/icon.png" + +# hicolor directories are named after the icon size, so read it from the +# PNG header (width and height are big-endian at bytes 16 and 20). +width=$((16#$(od -An -j16 -N4 -tx1 "$icon" | tr -d ' \n'))) +height=$((16#$(od -An -j20 -N4 -tx1 "$icon" | tr -d ' \n'))) +if [ "$width" -ne "$height" ]; then + echo "$icon is ${width}x${height}, expected a square icon" >&2 + exit 1 +fi + +mkdir -p "$appdir" +cp -r "$bundle"/. "$appdir"/ +install -m755 "$root/appimage/$app/AppRun" "$appdir/AppRun" +install -m644 "$root/appimage/$app/$id.desktop" "$appdir/$id.desktop" + +# appimagetool embeds the icon named by Icon= from the AppDir root. File +# managers and integrators (Gear Lever, AppImageLauncher) read .DirIcon. +install -m644 "$icon" "$appdir/$id.png" +ln -sfn "$id.png" "$appdir/.DirIcon" + +# Standard AppDir locations, also read by integrators. +install -Dm644 "$icon" \ + "$appdir/usr/share/icons/hicolor/${width}x${width}/apps/$id.png" +install -Dm644 "$root/appimage/$app/$id.desktop" \ + "$appdir/usr/share/applications/$id.desktop" +# No usr/share/metainfo: appimagetool would validate it with appstreamcli, +# which fetches every and fails the build when one is unreachable. diff --git a/appimage/stack_duo/stack_duo.desktop b/appimage/stack_duo/com.cypherstack.stackduo.desktop similarity index 66% rename from appimage/stack_duo/stack_duo.desktop rename to appimage/stack_duo/com.cypherstack.stackduo.desktop index 64b9f6de44..76160ee5ce 100644 --- a/appimage/stack_duo/stack_duo.desktop +++ b/appimage/stack_duo/com.cypherstack.stackduo.desktop @@ -2,6 +2,7 @@ Name=Stack Duo Comment=An open-source, multicoin wallet for everyone Exec=stack_duo -Icon=stack_duo +Icon=com.cypherstack.stackduo +StartupWMClass=com.cypherstack.stackduo Type=Application Categories=Finance; diff --git a/appimage/stack_wallet/stack_wallet.desktop b/appimage/stack_wallet/com.cypherstack.stackwallet.desktop similarity index 65% rename from appimage/stack_wallet/stack_wallet.desktop rename to appimage/stack_wallet/com.cypherstack.stackwallet.desktop index 19c6fcce56..fb5032664b 100644 --- a/appimage/stack_wallet/stack_wallet.desktop +++ b/appimage/stack_wallet/com.cypherstack.stackwallet.desktop @@ -2,6 +2,7 @@ Name=Stack Wallet Comment=Open-source non-custodial cryptocurrency wallet Exec=stack_wallet -Icon=stack_wallet +Icon=com.cypherstack.stackwallet +StartupWMClass=com.cypherstack.stackwallet Type=Application Categories=Finance; diff --git a/flatpak/com.cypherstack.campfire.metainfo.xml b/flatpak/com.cypherstack.campfire.metainfo.xml index f355750b40..16c6409728 100644 --- a/flatpak/com.cypherstack.campfire.metainfo.xml +++ b/flatpak/com.cypherstack.campfire.metainfo.xml @@ -12,5 +12,6 @@ https://campfireprivacy.com https://github.com/cypherstack/stack_wallet/issues + com.cypherstack.campfire.desktop diff --git a/flatpak/com.cypherstack.stackduo.metainfo.xml b/flatpak/com.cypherstack.stackduo.metainfo.xml index 1e929dec2b..af32d8dc80 100644 --- a/flatpak/com.cypherstack.stackduo.metainfo.xml +++ b/flatpak/com.cypherstack.stackduo.metainfo.xml @@ -12,5 +12,6 @@ https://stackwallet.com https://github.com/cypherstack/stack_wallet/issues + com.cypherstack.stackduo.desktop diff --git a/flatpak/com.cypherstack.stackwallet.metainfo.xml b/flatpak/com.cypherstack.stackwallet.metainfo.xml index abf3b19c93..ba6f0dde23 100644 --- a/flatpak/com.cypherstack.stackwallet.metainfo.xml +++ b/flatpak/com.cypherstack.stackwallet.metainfo.xml @@ -13,5 +13,6 @@ https://stackwallet.com https://github.com/cypherstack/stack_wallet/issues + com.cypherstack.stackwallet.desktop diff --git a/scripts/app_config/templates/linux/CMakeLists.txt b/scripts/app_config/templates/linux/CMakeLists.txt index 1eca6af115..a746cbc967 100644 --- a/scripts/app_config/templates/linux/CMakeLists.txt +++ b/scripts/app_config/templates/linux/CMakeLists.txt @@ -152,6 +152,11 @@ install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR} install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) +# X11 window icon, loaded by my_application.cc. +install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../asset_sources/icon/${BINARY_NAME}/icon.png" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" RENAME "app_icon.png" + COMPONENT Runtime) + if(USE_SYSTEM_SECURE_STORAGE_DEPS) file(GLOB JSONCPP_SO_FILES "${JSONCPP_LIBDIR}/libjsoncpp.so*") install(FILES ${JSONCPP_SO_FILES} DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" diff --git a/scripts/app_config/templates/linux/my_application.cc b/scripts/app_config/templates/linux/my_application.cc index 0954bbc891..7ff84743b7 100644 --- a/scripts/app_config/templates/linux/my_application.cc +++ b/scripts/app_config/templates/linux/my_application.cc @@ -17,6 +17,38 @@ static void first_frame_cb(MyApplication* self, FlView *view) gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); } +// X11 window icon from the PNG that CMake installs into data/. Wayland +// ignores this and takes the icon from the .desktop file matching +// APPLICATION_ID. GTK drops icon lists over 262144 longs, and a single +// 512x512 image is already over, so pass scaled copies. +static void set_window_icon(GtkWindow* window) { + g_autofree gchar* exe = g_file_read_link("/proc/self/exe", nullptr); + if (exe == nullptr) { + return; + } + g_autofree gchar* dir = g_path_get_dirname(exe); + g_autofree gchar* path = + g_build_filename(dir, "data", "app_icon.png", nullptr); + g_autoptr(GError) error = nullptr; + g_autoptr(GdkPixbuf) source = gdk_pixbuf_new_from_file(path, &error); + if (source == nullptr) { + g_debug("No window icon: %s", error->message); + return; + } + + static const int sizes[] = {16, 24, 32, 48, 64, 128, 256}; + GList* icons = nullptr; + for (int size : sizes) { + GdkPixbuf* icon = + gdk_pixbuf_scale_simple(source, size, size, GDK_INTERP_BILINEAR); + if (icon != nullptr) { + icons = g_list_append(icons, icon); + } + } + gtk_window_set_icon_list(window, icons); + g_list_free_full(icons, g_object_unref); +} + // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); @@ -37,6 +69,7 @@ static void my_application_activate(GApplication* application) { gtk_window_set_title(window, "PlaceHolderName"); } + set_window_icon(window); gtk_window_set_default_size(window, 1220, 500); gtk_widget_show(GTK_WIDGET(window)); diff --git a/scripts/linux/tests/test_appimage.py b/scripts/linux/tests/test_appimage.py new file mode 100644 index 0000000000..b41e94041b --- /dev/null +++ b/scripts/linux/tests/test_appimage.py @@ -0,0 +1,126 @@ +"""Offline regression tests for AppImage layout and pinned release tooling.""" + +import hashlib +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[3] +MAKE_APPDIR = ROOT / "appimage" / "make_appdir.sh" +BUILD_APPIMAGE = ROOT / "appimage" / "build_appimage.sh" +BUILD_WORKFLOW = ROOT / ".github" / "workflows" / "build.yaml" + +APP_IDS = { + "stack_wallet": "com.cypherstack.stackwallet", + "stack_duo": "com.cypherstack.stackduo", + "campfire": "com.cypherstack.campfire", +} + + +class AppImageScriptsTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="appimage-test-") + self.addCleanup(self.temp.cleanup) + self.temp_path = Path(self.temp.name) + self.bundle = self.temp_path / "bundle" + self.bundle.mkdir() + (self.bundle / "bundle-marker").write_text("copied\n") + + def test_appdir_layout_for_all_apps(self): + for app, app_id in APP_IDS.items(): + with self.subTest(app=app): + appdir = self.temp_path / f"{app} AppDir" + subprocess.run( + [str(MAKE_APPDIR), app, str(self.bundle), str(appdir)], + check=True, + capture_output=True, + text=True, + ) + self.assertTrue((appdir / "AppRun").stat().st_mode & 0o111) + self.assertTrue((appdir / "bundle-marker").is_file()) + self.assertTrue((appdir / f"{app_id}.desktop").is_file()) + self.assertTrue((appdir / f"{app_id}.png").is_file()) + self.assertEqual((appdir / ".DirIcon").readlink(), Path(f"{app_id}.png")) + self.assertTrue( + (appdir / "usr/share/applications" / f"{app_id}.desktop").is_file() + ) + self.assertTrue( + ( + appdir + / "usr/share/icons/hicolor/512x512/apps" + / f"{app_id}.png" + ).is_file() + ) + self.assertFalse((appdir / "usr/share/metainfo").exists()) + + def test_unknown_app_is_rejected(self): + result = subprocess.run( + [str(MAKE_APPDIR), "unknown", str(self.bundle), str(self.temp_path / "out")], + capture_output=True, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("unknown app", result.stderr) + + def test_release_toolchain_is_immutable_and_explicit(self): + script = BUILD_APPIMAGE.read_text() + self.assertNotIn("/continuous/", script) + self.assertIn("releases/download/${APPIMAGETOOL_VERSION}", script) + self.assertIn("releases/download/${APPIMAGE_RUNTIME_VERSION}", script) + self.assertIn("--proto '=https'", script) + self.assertIn("--proto-redir '=https'", script) + self.assertIn("--connect-timeout 30", script) + self.assertIn("--max-time 300", script) + self.assertIn("--runtime-file", script) + self.assertIn("verify_sha256", script) + self.assertRegex(script, r'APPIMAGETOOL_SHA256="[0-9a-f]{64}"') + self.assertRegex(script, r'APPIMAGE_RUNTIME_SHA256="[0-9a-f]{64}"') + + def test_workflow_uses_one_shared_packaging_entrypoint(self): + workflow = BUILD_WORKFLOW.read_text() + self.assertNotIn("AppImage/appimagetool/releases/download/continuous", workflow) + self.assertEqual(workflow.count("appimage/build_appimage.sh"), 3) + self.assertEqual(workflow.count("build-appimage:\n"), 1) + self.assertEqual(workflow.count("build-campfire-appimage:\n"), 1) + self.assertEqual(workflow.count("build-stack-duo-appimage:\n"), 1) + for job, needs in { + "build-appimage": "build-linux", + "build-campfire-appimage": "build-campfire-linux", + "build-stack-duo-appimage": "build-stack-duo-linux", + }.items(): + self.assertIn( + f" {job}:\n" + " runs-on: ubuntu-24.04\n" + f" needs: {needs}\n" + " permissions:\n contents: read\n steps:\n", + workflow, + ) + for app in APP_IDS: + self.assertIn(f"appimage/build_appimage.sh {app} bundle", workflow) + + def test_checksum_verification_accepts_match_and_rejects_mismatch(self): + payload = self.temp_path / "payload" + payload.write_bytes(b"known AppImage tool fixture\n") + digest = hashlib.sha256(payload.read_bytes()).hexdigest() + command = 'source "$1"; verify_sha256 "$2" "$3"' + + valid = subprocess.run( + ["bash", "-c", command, "bash", str(BUILD_APPIMAGE), str(payload), digest], + capture_output=True, + text=True, + ) + self.assertEqual(valid.returncode, 0, valid.stdout + valid.stderr) + + invalid = subprocess.run( + ["bash", "-c", command, "bash", str(BUILD_APPIMAGE), str(payload), "0" * 64], + capture_output=True, + text=True, + ) + self.assertNotEqual(invalid.returncode, 0) + self.assertIn("SHA-256 mismatch", invalid.stderr) + + +if __name__ == "__main__": + unittest.main()