diff --git a/.github/workflows/appimage.yml b/.github/workflows/appimage.yml new file mode 100644 index 000000000..ef8d2bac7 --- /dev/null +++ b/.github/workflows/appimage.yml @@ -0,0 +1,118 @@ +name: Build AppImage + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: write + +jobs: + appimage: + name: AppImage + runs-on: ubuntu-22.04 + + env: + DEBIAN_FRONTEND: noninteractive + + steps: + - name: Check out sources + uses: actions/checkout@v7 + + - name: Install build and runtime packages + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + adwaita-icon-theme \ + ca-certificates \ + curl \ + desktop-file-utils \ + ffmpeg \ + file \ + frei0r-plugins \ + gmic \ + gir1.2-atk-1.0 \ + gir1.2-gdkpixbuf-2.0 \ + gir1.2-freedesktop \ + gir1.2-glib-2.0 \ + gir1.2-harfbuzz-0.0 \ + gir1.2-gtk-3.0 \ + gir1.2-pango-1.0 \ + hicolor-icon-theme \ + libcairo-gobject2 \ + libcairo2 \ + libdatrie1 \ + libfontconfig1 \ + libfreetype6 \ + libfribidi0 \ + libfuse2 \ + libgdk-pixbuf2.0-bin \ + libglib2.0-bin \ + libgraphite2-3 \ + libgtk-3-bin \ + libharfbuzz0b \ + libmlt-data \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libpangoft2-1.0-0 \ + libjpeg-turbo8 \ + libjbig0 \ + libpng16-16 \ + librsvg2-common \ + libthai0 \ + libtiff5 \ + libwayland-client0 \ + libwayland-cursor0 \ + libwayland-egl1 \ + libwebp7 \ + libwebpdemux2 \ + libwebpmux3 \ + libxkbcommon0 \ + python3 \ + python3-distutils \ + python3-gi \ + python3-gi-cairo \ + python3-mlt \ + python3-numpy \ + python3-pil \ + python3-setuptools \ + python3-usb1 \ + shared-mime-info \ + squashfs-tools \ + swh-plugins \ + xauth \ + xvfb \ + zlib1g + + - name: Build AppImage + run: packaging/appimage/build-appimage.sh + + - name: Upload workflow artifact + uses: actions/upload-artifact@v7 + with: + name: flowblade-appimage + path: dist/*.AppImage + if-no-files-found: error + + - name: Smoke test AppImage startup + run: packaging/appimage/smoke-test-appimage.sh + + - name: Smoke test AppImage in base containers + run: packaging/appimage/smoke-test-appimage-containers.sh + + - name: Upload AppImage smoke test log + if: failure() + uses: actions/upload-artifact@v7 + with: + name: flowblade-appimage-smoke-log + path: | + build/appimage-smoke/*.log + build/appimage-smoke-containers/**/*.log + if-no-files-found: ignore + + - name: Attach AppImage to release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.event.release.tag_name }}" dist/*.AppImage --clobber diff --git a/.gitignore b/.gitignore index b16a9ec6e..d51146f7a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ mlt7.py # Flatpak when building from local /flowblade-trunk/.flatpak-builder/ +# AppImage build outputs +/build/appimage/ +/dist/ + # Theme build data /flowblade-trunk/Flowblade/res/css2/sass/gtk-flowblade-dark.css.map /flowblade-trunk/Flowblade/res/css3/sass/gtk-flowblade-dark.css.map diff --git a/flowblade-trunk/Flowblade/src/dialogs/dialogs.py b/flowblade-trunk/Flowblade/src/dialogs/dialogs.py index 83f28b672..7cfa4d2ee 100644 --- a/flowblade-trunk/Flowblade/src/dialogs/dialogs.py +++ b/flowblade-trunk/Flowblade/src/dialogs/dialogs.py @@ -764,6 +764,8 @@ def environment_dialog(parent_window): run_type = _("INSTALLATION") elif editorstate.app_running_from == editorstate.RUNNING_FROM_FLATPAK: run_type = "FLATPAK" + elif editorstate.app_running_from == editorstate.RUNNING_FROM_APPIMAGE: + run_type = "APPIMAGE" else: run_type = _("DEVELOPER VERSION") r4 = guiutils.get_left_justified_box([Gtk.Label(label=_("Running from: ")), Gtk.Label(label=run_type)]) @@ -2070,4 +2072,3 @@ def replace_clip_not_enough_material_info(): primary_txt = _("Cannot replace the clip!") secondary_txt = _("The clip being replaced is longer then the clip being added.") dialogutils.warning_message(primary_txt, secondary_txt, gui.editor_window.window) - \ No newline at end of file diff --git a/flowblade-trunk/Flowblade/src/editorstate.py b/flowblade-trunk/Flowblade/src/editorstate.py index de72a2391..7e3d411f5 100644 --- a/flowblade-trunk/Flowblade/src/editorstate.py +++ b/flowblade-trunk/Flowblade/src/editorstate.py @@ -103,6 +103,7 @@ RUNNING_FROM_INSTALLATION = 0 RUNNING_FROM_DEV_VERSION = 1 RUNNING_FROM_FLATPAK = 2 +RUNNING_FROM_APPIMAGE = 3 app_running_from = RUNNING_FROM_INSTALLATION audio_monitoring_available = False diff --git a/flowblade-trunk/Flowblade/src/launch/launchutils.py b/flowblade-trunk/Flowblade/src/launch/launchutils.py index f10d9301a..d71ae116c 100644 --- a/flowblade-trunk/Flowblade/src/launch/launchutils.py +++ b/flowblade-trunk/Flowblade/src/launch/launchutils.py @@ -10,6 +10,13 @@ def get_modules_path(): def set_app_runtime_type(modules_path): import editorstate + appdir = os.environ.get("APPDIR") + if appdir != None: + appdir = os.path.realpath(appdir) + if os.path.realpath(sys.argv[0]).startswith(appdir + os.sep): + editorstate.app_running_from = editorstate.RUNNING_FROM_APPIMAGE + return + root_dir = modules_path.split("/")[1] # Used to decide which translations from file system are used. @@ -27,4 +34,4 @@ def get_arg_value(key_str): if parts[0] == key_str: return parts[1] - return None \ No newline at end of file + return None diff --git a/flowblade-trunk/Flowblade/src/process/gmicheadless.py b/flowblade-trunk/Flowblade/src/process/gmicheadless.py index d6eb477fa..c29648b79 100644 --- a/flowblade-trunk/Flowblade/src/process/gmicheadless.py +++ b/flowblade-trunk/Flowblade/src/process/gmicheadless.py @@ -96,7 +96,14 @@ def main(root_path, session_id, parent_folder, script, clip_path, range_in, rang # Set paths. respaths.set_paths(root_path) - if os.path.exists("/usr/bin/gmic") == True: # distro install an dev. + appdir = os.environ.get("APPDIR") + appimage_gmic = None + if appdir != None: + appimage_gmic = os.path.join(appdir, "usr", "bin", "gmic") + + if appimage_gmic != None and os.path.exists(appimage_gmic) == True: + editorstate.gmic_path = appimage_gmic + elif os.path.exists("/usr/bin/gmic") == True: # distro install an dev. editorstate.gmic_path = "/usr/bin/gmic" elif os.path.exists("/app/bin/gmic") == True: # Flatpak editorstate.gmic_path = "/app/bin/gmic" diff --git a/flowblade-trunk/Flowblade/src/tools/gmic.py b/flowblade-trunk/Flowblade/src/tools/gmic.py index d87d3a478..c4d437145 100644 --- a/flowblade-trunk/Flowblade/src/tools/gmic.py +++ b/flowblade-trunk/Flowblade/src/tools/gmic.py @@ -112,7 +112,14 @@ def test_availablity(): print("G'MIC NOT found") def set_gmic_path(): - if os.path.exists("/usr/bin/gmic") == True: + appdir = os.environ.get("APPDIR") + appimage_gmic = None + if appdir != None: + appimage_gmic = os.path.join(appdir, "usr", "bin", "gmic") + + if appimage_gmic != None and os.path.exists(appimage_gmic) == True: + editorstate.gmic_path = appimage_gmic + elif os.path.exists("/usr/bin/gmic") == True: editorstate.gmic_path = "/usr/bin/gmic" elif os.path.exists("/app/bin/gmic") == True: # File system and flatpak editorstate.gmic_path = "/app/bin/gmic" diff --git a/flowblade-trunk/Flowblade/src/translations.py b/flowblade-trunk/Flowblade/src/translations.py index 95fd959eb..e8332ee1d 100644 --- a/flowblade-trunk/Flowblade/src/translations.py +++ b/flowblade-trunk/Flowblade/src/translations.py @@ -50,7 +50,10 @@ def init_languages(): if (language): langs += language.split(":") - if editorstate.app_running_from == editorstate.RUNNING_FROM_INSTALLATION or editorstate.app_running_from == editorstate.RUNNING_FROM_FLATPAK: + if editorstate.app_running_from == editorstate.RUNNING_FROM_APPIMAGE: + locale_path = respaths.LOCALE_PATH + print("Running from AppImage, using bundled translations at " + locale_path + ".") + elif editorstate.app_running_from == editorstate.RUNNING_FROM_INSTALLATION or editorstate.app_running_from == editorstate.RUNNING_FROM_FLATPAK: # Use /usr/share/locale first if available and running from installation # Look for installed translation in distro install # Were using Russian as test language @@ -935,4 +938,4 @@ def load_filters_translations(): plugin_editor_groups["Animation"] = _("Animation") plugin_editor_groups["Background"] = _("Background") plugin_editor_groups["Layout"] = _("Layout") - plugin_editor_groups["Fonts"] = _("Fonts") \ No newline at end of file + plugin_editor_groups["Fonts"] = _("Fonts") diff --git a/flowblade-trunk/Flowblade/src/window/editorlayout.py b/flowblade-trunk/Flowblade/src/window/editorlayout.py index 8affaa370..7910a4931 100644 --- a/flowblade-trunk/Flowblade/src/window/editorlayout.py +++ b/flowblade-trunk/Flowblade/src/window/editorlayout.py @@ -195,12 +195,6 @@ def top_level_project_panel(): def init_layout_data(): global _panel_positions, _positions_names, _panels_names, _position_notebooks, PANEL_MINIMUM_SIZES _panel_positions = editorpersistance.prefs.panel_positions - - # New panel needs to be made part of saced postions on firts load of 2.26. - try: - pos = _panel_positions[appconsts.PANEL_DISSOLVE_SELECT] - except: - _panel_positions[appconsts.PANEL_DISSOLVE_SELECT] = appconsts.PANEL_PLACEMENT_BOTTOM_ROW_RIGHT # Use default panels positions if nothing available yet or too small screen, # or default panel position is empty, layout code makes too many assumptions to make that work. @@ -213,6 +207,10 @@ def init_layout_data(): editorpersistance.prefs.panel_positions = _panel_positions editorpersistance.save() + # New panel needs to be made part of saved positions on first load of 2.26. + if appconsts.PANEL_DISSOLVE_SELECT not in _panel_positions: + _panel_positions[appconsts.PANEL_DISSOLVE_SELECT] = appconsts.PANEL_PLACEMENT_BOTTOM_ROW_RIGHT + if editorpersistance.prefs.positions_tabs == None: editorpersistance.prefs.positions_tabs = DEFAULT_TABS_POSITIONS editorpersistance.save() @@ -826,4 +824,3 @@ def apply_layout(layout_dict): gui.editor_window.window.show_all() set_positions_frames_visibility() - diff --git a/flowblade-trunk/flowblade b/flowblade-trunk/flowblade index 4ca4ca130..73badbc6a 100755 --- a/flowblade-trunk/flowblade +++ b/flowblade-trunk/flowblade @@ -29,12 +29,23 @@ print ("---------------------------") # Get launch script dir launch_dir = os.path.dirname(os.path.abspath(sys.argv[0])) +real_launch_dir = os.path.realpath(launch_dir) +appdir = os.environ.get("APPDIR") +appdir_usr_bin = None +if appdir != None: + appdir = os.path.realpath(appdir) + appdir_usr_bin = os.path.join(appdir, "usr", "bin") print ("Launch script dir:", launch_dir) # Update sys.path to include modules. +# - When running in AppImage. +if appdir_usr_bin != None and real_launch_dir == appdir_usr_bin: + print ("Running from AppImage...") + modules_path = appdir + "/usr/share/flowblade/Flowblade/src" + print ("modules path:", modules_path) # - When running on distro. -if os.path.realpath(launch_dir) == "/usr/bin": +elif real_launch_dir == "/usr/bin": print ("Running from installation...") modules_path = "/usr/share/flowblade/Flowblade/src" if not os.path.isdir(modules_path): @@ -77,7 +88,9 @@ try: import app import editorstate - if launch_dir == "/usr/bin": + if appdir_usr_bin != None and real_launch_dir == appdir_usr_bin: + editorstate.app_running_from = editorstate.RUNNING_FROM_APPIMAGE + elif launch_dir == "/usr/bin": editorstate.app_running_from = editorstate.RUNNING_FROM_INSTALLATION elif launch_dir == "/app/bin": editorstate.app_running_from = editorstate.RUNNING_FROM_FLATPAK diff --git a/packaging/appimage/build-appimage.sh b/packaging/appimage/build-appimage.sh new file mode 100755 index 000000000..c71a37c6a --- /dev/null +++ b/packaging/appimage/build-appimage.sh @@ -0,0 +1,449 @@ +#!/usr/bin/env bash + +set -euo pipefail + +APP_ID="io.github.jliljebl.Flowblade" +APP_NAME="Flowblade" +ARCH="${ARCH:-x86_64}" +BUILD_APPIMAGE=1 + +usage() { + cat <<'USAGE' +Usage: packaging/appimage/build-appimage.sh [--appdir-only] + +Build a Flowblade AppImage from an Ubuntu/Debian system with Flowblade runtime +packages installed. Use --appdir-only to validate staging without running +linuxdeploy. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --appdir-only) + BUILD_APPIMAGE=0 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +log() { + printf '==> %s\n' "$*" +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Required command not found: $1" >&2 + exit 1 + fi +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +FLOWBLADE_DIR="$ROOT_DIR/flowblade-trunk" +BUILD_DIR="${BUILD_DIR:-$ROOT_DIR/build/appimage}" +APPDIR="${APPDIR:-$BUILD_DIR/${APP_NAME}.AppDir}" +DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}" +TOOLS_DIR="$BUILD_DIR/tools" +DESKTOP_FILE="$APPDIR/$APP_ID.desktop" +ICON_FILE="$APPDIR/$APP_ID.png" + +detect_version() { + python3 - "$FLOWBLADE_DIR/setup.py" <<'PY' +import ast +import pathlib +import sys + +setup_py = pathlib.Path(sys.argv[1]) +tree = ast.parse(setup_py.read_text()) +for node in ast.walk(tree): + if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "setup": + for keyword in node.keywords: + if keyword.arg == "version": + print(ast.literal_eval(keyword.value)) + raise SystemExit(0) +raise SystemExit("Could not find setup(version=...)") +PY +} + +VERSION="${VERSION:-$(detect_version)}" +APPIMAGE_NAME="${APPIMAGE_NAME:-${APP_NAME}-${VERSION}-${ARCH}.AppImage}" + +# These package payloads contain resources that linuxdeploy cannot infer from +# ELF dependencies alone: Python modules, GI typelibs, MLT plugins/data, +# GDK-Pixbuf loaders, icon theme data, and external tools used by Flowblade. +RUNTIME_PACKAGES=( + adwaita-icon-theme + ffmpeg + frei0r-plugins + gmic + gir1.2-atk-1.0 + gir1.2-gdkpixbuf-2.0 + gir1.2-freedesktop + gir1.2-glib-2.0 + gir1.2-harfbuzz-0.0 + gir1.2-gtk-3.0 + gir1.2-pango-1.0 + hicolor-icon-theme + libcairo-gobject2 + libcairo2 + libdatrie1 + libfontconfig1 + libfreetype6 + libfribidi0 + libgdk-pixbuf-2.0-0 + libgdk-pixbuf2.0-bin + libglib2.0-bin + libgraphite2-3 + libgtk-3-0 + libgtk-3-bin + libharfbuzz0b + libjbig0 + libjpeg-turbo8 + libmlt++7 + libmlt-data + libmlt7 + libpango-1.0-0 + libpangocairo-1.0-0 + libpangoft2-1.0-0 + libpng16-16 + librsvg2-common + libthai0 + libtiff5 + libwayland-client0 + libwayland-cursor0 + libwayland-egl1 + libwebp7 + libwebpdemux2 + libwebpmux3 + libxkbcommon0 + melt + python3 + python3-gi + python3-gi-cairo + python3-mlt + python3-numpy + python3-pil + python3-usb1 + shared-mime-info + swh-plugins + zlib1g +) + +copy_path_from_host() { + local src="$1" + local dst="$APPDIR$src" + + case "$src" in + /|/usr|/usr/bin|/usr/lib|/usr/lib/*|/usr/share|/usr/share/doc|/usr/share/man) + if [ -d "$src" ] && [ ! -L "$src" ]; then + mkdir -p "$dst" + return + fi + ;; + esac + + if [ -d "$src" ] && [ ! -L "$src" ]; then + mkdir -p "$dst" + elif [ -e "$src" ] || [ -L "$src" ]; then + mkdir -p "$(dirname "$dst")" + cp -a "$src" "$dst" + fi +} + +copy_package_payloads() { + if ! command -v dpkg-query >/dev/null 2>&1; then + log "dpkg-query not found; skipping Debian package payload copy" + return + fi + + local package path status + for package in "${RUNTIME_PACKAGES[@]}"; do + status="$(dpkg-query -W -f='${Status}' "$package" 2>/dev/null || true)" + if [ "$status" != "install ok installed" ]; then + log "Skipping package payload not installed: $package" + continue + fi + + while IFS= read -r path; do + case "$path" in + ""|/|/usr/share/doc/*|/usr/share/man/*|/usr/share/lintian/*|/usr/share/bug/*) + continue + ;; + esac + copy_path_from_host "$path" + done < <(dpkg-query -L "$package") + done +} + +remove_host_gdk_pixbuf_caches() { + if [ -d "$APPDIR/usr/lib" ]; then + find "$APPDIR/usr/lib" -path '*/gdk-pixbuf-2.0/2.10.0/loaders.cache' -type f -delete + fi +} + +copy_binary_to_appdir() { + local binary="$1" + local resolved + resolved="$(command -v "$binary" 2>/dev/null || true)" + if [ -z "$resolved" ]; then + return + fi + + mkdir -p "$APPDIR/usr/bin" + cp -aL "$resolved" "$APPDIR/usr/bin/$binary" +} + +stage_python_runtime() { + local python_version + python_version="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" + PYTHON_VERSION="$python_version" + + copy_binary_to_appdir python3 + + if command -v "python$python_version" >/dev/null 2>&1; then + copy_binary_to_appdir "python$python_version" + fi + + mkdir -p "$APPDIR/usr/lib" + for path in "/usr/lib/python$python_version" "/usr/lib/python3" "/usr/local/lib/python$python_version/dist-packages"; do + if [ -d "$path" ]; then + mkdir -p "$APPDIR$(dirname "$path")" + cp -a "$path" "$APPDIR$(dirname "$path")/" + fi + done +} + +stage_flowblade() { + local record_file="$BUILD_DIR/install-record.txt" + local source_parent="$BUILD_DIR/source" + local source_dir="$source_parent/flowblade-trunk" + + log "Staging Flowblade into AppDir" + mkdir -p "$BUILD_DIR" "$APPDIR" "$DIST_DIR" + rm -rf "$source_parent" + mkdir -p "$source_parent" + cp -a "$FLOWBLADE_DIR" "$source_parent/" + + ( + cd "$source_dir" + PYTHONDONTWRITEBYTECODE=1 python3 setup.py install \ + --root="$APPDIR" \ + --prefix=/usr \ + --install-lib=/usr/share/flowblade \ + --install-scripts=/usr/bin \ + --single-version-externally-managed \ + --record="$record_file" \ + --no-compile + ) + + mkdir -p "$APPDIR/usr/share/flowblade/Flowblade" + cp -a "$source_dir/Flowblade/res" "$APPDIR/usr/share/flowblade/Flowblade/" + cp -a "$source_dir/Flowblade/locale" "$APPDIR/usr/share/flowblade/Flowblade/" + + cp "$APPDIR/usr/share/applications/$APP_ID.desktop" "$DESKTOP_FILE" + cp "$APPDIR/usr/share/icons/hicolor/128x128/apps/$APP_ID.png" "$ICON_FILE" + sed -i 's|^Exec=.*|Exec=AppRun %f|' "$DESKTOP_FILE" + ln -sfn "$APP_ID.png" "$APPDIR/.DirIcon" +} + +write_apprun() { + log "Writing AppRun" + cat > "$APPDIR/AppRun" < "\$CACHE_BASE/gdk-pixbuf-loaders.cache" 2>"\$CACHE_BASE/gdk-pixbuf-query-loaders.log"; then + cat "\$CACHE_BASE/gdk-pixbuf-query-loaders.log" >&2 || true + fi + if [ -s "\$CACHE_BASE/gdk-pixbuf-loaders.cache" ] && grep -Fq "png" "\$CACHE_BASE/gdk-pixbuf-loaders.cache"; then + export GDK_PIXBUF_MODULEDIR="\$GDK_PIXBUF_LOADER_DIR" + export GDK_PIXBUF_MODULE_FILE="\$CACHE_BASE/gdk-pixbuf-loaders.cache" + fi +fi + +exec "\$APPDIR/usr/bin/python3" "\$APPDIR/usr/bin/flowblade" "\$@" +EOF + chmod +x "$APPDIR/AppRun" +} + +validate_appdir() { + log "Validating AppDir metadata" + test -x "$APPDIR/AppRun" + test -x "$APPDIR/usr/bin/flowblade" + test -f "$DESKTOP_FILE" + test -f "$ICON_FILE" + test -d "$APPDIR/usr/share/flowblade/Flowblade/res/shortcuts" + test -f "$APPDIR/usr/share/flowblade/Flowblade/res/filters/filters.xml" + test -d "$APPDIR/usr/share/flowblade/Flowblade/locale" + + if command -v desktop-file-validate >/dev/null 2>&1; then + desktop-file-validate "$DESKTOP_FILE" + fi +} + +download_linuxdeploy() { + require_command curl + mkdir -p "$TOOLS_DIR" + + LINUXDEPLOY="${LINUXDEPLOY:-$TOOLS_DIR/linuxdeploy-$ARCH.AppImage}" + if [ ! -x "$LINUXDEPLOY" ]; then + log "Downloading linuxdeploy" + curl -L \ + "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-$ARCH.AppImage" \ + -o "$LINUXDEPLOY" + chmod +x "$LINUXDEPLOY" + fi +} + +run_linuxdeploy() { + local args=( + --appdir "$APPDIR" + --desktop-file "$DESKTOP_FILE" + --icon-file "$ICON_FILE" + --executable "$APPDIR/usr/bin/python3" + ) + + for binary in ffmpeg ffprobe gmic melt melt-7; do + if [ -x "$APPDIR/usr/bin/$binary" ]; then + args+=(--executable "$APPDIR/usr/bin/$binary") + fi + done + + local find_roots=() + for dir in "$APPDIR/usr/lib" "$APPDIR/lib"; do + if [ -d "$dir" ]; then + find_roots+=("$dir") + fi + done + + if [ "${#find_roots[@]}" -gt 0 ]; then + while IFS= read -r -d '' library; do + args+=(--library "$library") + done < <(find "${find_roots[@]}" -type f \( -name '*.so' -o -name '*.so.*' \) -print0) + fi + + mkdir -p "$DIST_DIR" + export APPIMAGE_EXTRACT_AND_RUN=1 + export ARCH + export LDAI_OUTPUT="$DIST_DIR/$APPIMAGE_NAME" + + log "Building $LDAI_OUTPUT" + ( + cd "$BUILD_DIR" + "$LINUXDEPLOY" "${args[@]}" --output appimage + ) + + if [ ! -f "$LDAI_OUTPUT" ]; then + local produced="" + while IFS= read -r candidate; do + produced="$candidate" + break + done < <(find "$BUILD_DIR" "$ROOT_DIR" -maxdepth 1 -type f -name '*.AppImage') + + if [ -z "$produced" ]; then + echo "linuxdeploy finished but no AppImage was found" >&2 + exit 1 + fi + mv "$produced" "$LDAI_OUTPUT" + fi + + chmod +x "$LDAI_OUTPUT" + log "Created $LDAI_OUTPUT" +} + +main() { + require_command python3 + + log "Preparing $APPDIR" + rm -rf "$APPDIR" + mkdir -p "$BUILD_DIR" "$DIST_DIR" + + stage_flowblade + copy_package_payloads + remove_host_gdk_pixbuf_caches + stage_python_runtime + copy_binary_to_appdir ffmpeg + copy_binary_to_appdir ffprobe + copy_binary_to_appdir gmic + copy_binary_to_appdir melt + copy_binary_to_appdir melt-7 + write_apprun + validate_appdir + + if [ "$BUILD_APPIMAGE" -eq 0 ]; then + log "AppDir staged at $APPDIR" + exit 0 + fi + + download_linuxdeploy + run_linuxdeploy +} + +main "$@" diff --git a/packaging/appimage/smoke-test-appimage-containers.sh b/packaging/appimage/smoke-test-appimage-containers.sh new file mode 100755 index 000000000..28d19ba12 --- /dev/null +++ b/packaging/appimage/smoke-test-appimage-containers.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +APPIMAGE="${1:-}" +IMAGES="${APPIMAGE_SMOKE_CONTAINER_IMAGES:-ubuntu:22.04 ubuntu:24.04 debian:12 debian:13 fedora:latest archlinux:latest opensuse/tumbleweed:latest gentoo/stage3:latest}" +CONTAINER_SMOKE_DIR="$ROOT_DIR/build/appimage-smoke-containers" + +if [ -z "$APPIMAGE" ]; then + while IFS= read -r candidate; do + APPIMAGE="$candidate" + break + done < <(find "$ROOT_DIR/dist" -maxdepth 1 -type f -name '*.AppImage' -print | sort) +fi + +if [ -z "$APPIMAGE" ] || [ ! -f "$APPIMAGE" ]; then + echo "No AppImage found to smoke test in containers" >&2 + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "Required command not found: docker" >&2 + exit 1 +fi + +container_install_command() { + local image="$1" + + case "$image" in + debian:*|ubuntu:*) + printf '%s\n' \ + 'export DEBIAN_FRONTEND=noninteractive' \ + 'apt-get update' \ + 'apt-get install -y --no-install-recommends bash ca-certificates coreutils findutils grep xauth xvfb' \ + 'rm -rf /var/lib/apt/lists/*' + ;; + fedora:*) + printf '%s\n' \ + 'dnf -y --setopt=install_weak_deps=False install bash ca-certificates coreutils findutils grep xorg-x11-server-Xvfb xorg-x11-xauth' \ + 'dnf clean all' + ;; + archlinux:*) + printf '%s\n' \ + 'pacman -Sy --noconfirm --needed bash ca-certificates coreutils findutils grep xorg-server-xvfb xorg-xauth' \ + 'pacman -Scc --noconfirm' + ;; + opensuse/*) + printf '%s\n' \ + 'zypper --non-interactive --gpg-auto-import-keys refresh' \ + 'zypper --non-interactive --gpg-auto-import-keys install --no-recommends bash ca-certificates coreutils findutils grep xauth xorg-x11-server-extra' \ + 'zypper clean --all' + ;; + gentoo/*) + printf '%s\n' \ + 'if [ ! -e /var/db/repos/gentoo/profiles/repo_name ]; then emerge-webrsync --quiet; fi' \ + 'FEATURES="${FEATURES:-} getbinpkg -news" emerge --getbinpkgonly --usepkg --binpkg-respect-use=y --jobs=1 --quiet app-shells/bash app-misc/ca-certificates sys-apps/coreutils sys-apps/findutils sys-apps/grep x11-apps/xauth '\''x11-base/xorg-server[xvfb]'\''' + ;; + *) + echo "Unsupported AppImage smoke container image: $image" >&2 + return 1 + ;; + esac +} + +appimage_rel="${APPIMAGE#$ROOT_DIR/}" +mkdir -p "$CONTAINER_SMOKE_DIR" + +read -r -a images <<< "$IMAGES" +for image in "${images[@]}"; do + safe_image_name="${image//\//-}" + safe_image_name="${safe_image_name//:/-}" + smoke_dir="/work/build/appimage-smoke-containers/$safe_image_name" + install_command="$(container_install_command "$image")" + + echo "==> Smoke testing AppImage in $image" + docker run --rm \ + --volume "$ROOT_DIR:/work" \ + --workdir /work \ + --env APPIMAGE_EXTRACT_AND_RUN=1 \ + --env APPIMAGE_SMOKE_TIMEOUT="${APPIMAGE_SMOKE_TIMEOUT:-30s}" \ + --env SMOKE_DIR="$smoke_dir" \ + "$image" \ + bash -lc "$install_command"$'\n'"trap 'chmod -R a+rwX /work/build/appimage-smoke-containers || true' EXIT"$'\n'"packaging/appimage/smoke-test-appimage.sh '$appimage_rel'" +done diff --git a/packaging/appimage/smoke-test-appimage.sh b/packaging/appimage/smoke-test-appimage.sh new file mode 100755 index 000000000..2987bdb16 --- /dev/null +++ b/packaging/appimage/smoke-test-appimage.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +APPIMAGE="${1:-}" +APPDIR="${APPDIR:-$ROOT_DIR/build/appimage/Flowblade.AppDir}" +SMOKE_DIR="${SMOKE_DIR:-$ROOT_DIR/build/appimage-smoke}" +SMOKE_TIMEOUT="${APPIMAGE_SMOKE_TIMEOUT:-30s}" +LOG_FILE="$SMOKE_DIR/flowblade-appimage-smoke.log" + +if [ -z "$APPIMAGE" ]; then + while IFS= read -r candidate; do + APPIMAGE="$candidate" + break + done < <(find "$ROOT_DIR/dist" -maxdepth 1 -type f -name '*.AppImage' -print | sort) +fi + +if [ -z "$APPIMAGE" ] || [ ! -f "$APPIMAGE" ]; then + echo "No AppImage found to smoke test" >&2 + exit 1 +fi + +if ! command -v xvfb-run >/dev/null 2>&1 && ! command -v Xvfb >/dev/null 2>&1; then + echo "Required command not found: xvfb-run or Xvfb" >&2 + exit 1 +fi + +run_with_xvfb() { + local display + local xvfb_pid + local xvfb_log + local ready=0 + + display=":$((100 + (RANDOM % 900)))" + xvfb_log="$SMOKE_DIR/xvfb.log" + + Xvfb "$display" -screen 0 1920x1080x24 >"$xvfb_log" 2>&1 & + xvfb_pid=$! + + for _ in {1..50}; do + if [ -S "/tmp/.X11-unix/X${display#:}" ]; then + ready=1 + break + fi + + if ! kill -0 "$xvfb_pid" 2>/dev/null; then + cat "$xvfb_log" >&2 + echo "Xvfb exited before the smoke test could start" >&2 + return 1 + fi + + sleep 0.1 + done + + if [ "$ready" -ne 1 ]; then + cat "$xvfb_log" >&2 + echo "Xvfb did not create display socket $display" >&2 + kill "$xvfb_pid" 2>/dev/null || true + wait "$xvfb_pid" 2>/dev/null || true + return 1 + fi + + DISPLAY="$display" "$@" + local status=$? + + kill "$xvfb_pid" 2>/dev/null || true + wait "$xvfb_pid" 2>/dev/null || true + + return "$status" +} + +validate_appdir_payload() { + if [ ! -d "$APPDIR" ]; then + echo "==> AppDir not found at $APPDIR, skipping AppDir payload validation" + return + fi + + local missing=0 + local lib_roots=() + local library + local typelib + local root + local required_libraries=( + libcairo-gobject.so.2 + libcairo.so.2 + libdatrie.so.1 + libfontconfig.so.1 + libfreetype.so.6 + libfribidi.so.0 + libgraphite2.so.3 + libharfbuzz.so.0 + libjbig.so.0 + libjpeg.so.8 + libpango-1.0.so.0 + libpangocairo-1.0.so.0 + libpangoft2-1.0.so.0 + libpng16.so.16 + libthai.so.0 + libtiff.so.5 + libwayland-client.so.0 + libwayland-cursor.so.0 + libwayland-egl.so.1 + libwebp.so.7 + libwebpdemux.so.2 + libwebpmux.so.3 + libxkbcommon.so.0 + libz.so.1 + ) + local required_typelibs=( + Atk-1.0 + Gdk-3.0 + GdkPixbuf-2.0 + GdkX11-3.0 + GIRepository-2.0 + Gio-2.0 + GLib-2.0 + GModule-2.0 + GObject-2.0 + Gtk-3.0 + HarfBuzz-0.0 + Pango-1.0 + PangoCairo-1.0 + cairo-1.0 + fontconfig-2.0 + freetype2-2.0 + xlib-2.0 + ) + + echo "==> Validating bundled GI typelibs in $APPDIR" + for typelib in "${required_typelibs[@]}"; do + if [ -z "$(find "$APPDIR/usr/lib" -path "*/girepository-1.0/$typelib.typelib" -type f -print -quit 2>/dev/null)" ]; then + echo "Missing bundled GI typelib: $typelib.typelib" >&2 + missing=1 + fi + done + + for root in "$APPDIR/usr/lib" "$APPDIR/lib"; do + if [ -d "$root" ]; then + lib_roots+=("$root") + fi + done + + echo "==> Validating bundled shared libraries in $APPDIR" + for library in "${required_libraries[@]}"; do + if [ "${#lib_roots[@]}" -eq 0 ] || [ -z "$(find "${lib_roots[@]}" -name "$library" -print -quit 2>/dev/null)" ]; then + echo "Missing bundled shared library: $library" >&2 + missing=1 + fi + done + + if [ "$missing" -ne 0 ]; then + exit 1 + fi +} + +validate_appdir_payload + +mkdir -p "$SMOKE_DIR/home" "$SMOKE_DIR/runtime" "$SMOKE_DIR/config" "$SMOKE_DIR/data" "$SMOKE_DIR/cache" +chmod 700 "$SMOKE_DIR/runtime" +chmod +x "$APPIMAGE" + +echo "==> Smoke testing $APPIMAGE" +app_command=( + env + HOME="$SMOKE_DIR/home" + XDG_CONFIG_HOME="$SMOKE_DIR/config" + XDG_DATA_HOME="$SMOKE_DIR/data" + XDG_CACHE_HOME="$SMOKE_DIR/cache" + XDG_RUNTIME_DIR="$SMOKE_DIR/runtime" + PYTHONUNBUFFERED=1 + NO_AT_BRIDGE=1 + "$APPIMAGE" +) + +set +e +if command -v xvfb-run >/dev/null 2>&1; then + timeout --kill-after=5s "$SMOKE_TIMEOUT" \ + xvfb-run -a -s "-screen 0 1920x1080x24" \ + "${app_command[@]}" >"$LOG_FILE" 2>&1 +else + run_with_xvfb \ + timeout --kill-after=5s "$SMOKE_TIMEOUT" \ + "${app_command[@]}" >"$LOG_FILE" 2>&1 +fi +status=$? +set -e + +cat "$LOG_FILE" + +case "$status" in + 0) + echo "==> AppImage exited cleanly during smoke test" + ;; + 124) + echo "==> AppImage remained running for $SMOKE_TIMEOUT" + ;; + *) + echo "AppImage exited with status $status during smoke test" >&2 + exit "$status" + ;; +esac + +required_markers=( + "Running from AppImage..." + "MLT found, version:" + "Application version:" + "GTK+ version:" +) + +for marker in "${required_markers[@]}"; do + if ! grep -Fq "$marker" "$LOG_FILE"; then + echo "Smoke test log is missing expected marker: $marker" >&2 + exit 1 + fi +done + +fatal_markers=( + "MLT not found, exiting" + "Failed to import module app.py" + "Traceback (most recent call last)" +) + +for marker in "${fatal_markers[@]}"; do + if grep -Fq "$marker" "$LOG_FILE"; then + echo "Smoke test log contains fatal marker: $marker" >&2 + exit 1 + fi +done + +echo "==> AppImage smoke test passed"