"""Implements the 'bdist_appimage' command (create Linux AppImage format). https://appimage.org/ https://docs.appimage.org/ https://docs.appimage.org/packaging-guide/manual.html#ref-manual """ from __future__ import annotations import os import platform import shutil import stat from ctypes.util import find_library from logging import INFO, WARNING from pathlib import Path from textwrap import dedent from typing import ClassVar from urllib.request import urlretrieve from zipfile import ZipFile from filelock import FileLock from setuptools import Command import cx_Freeze.icons from cx_Freeze.exception import ExecError, PlatformError __all__ = ["bdist_appimage"] ARCH = platform.machine() APPIMAGEKIT_URL = "https://github.com/AppImage/AppImageKit/releases" APPIMAGEKIT_PATH = f"download/continuous/appimagetool-{ARCH}.AppImage" APPIMAGEKIT_TOOL = "~/.local/bin/appimagetool" class bdist_appimage(Command): """Create a Linux AppImage.""" description = "create a Linux AppImage" user_options: ClassVar[list[tuple[str, str | None, str]]] = [ ( "appimagekit=", None, f'path to AppImageKit [default: "{APPIMAGEKIT_TOOL}"]', ), ( "bdist-base=", None, "base directory for creating built distributions", ), ( "build-dir=", "b", "directory of built executables and dependent files", ), ( "dist-dir=", "d", 'directory to put final built distributions in [default: "dist"]', ), ( "skip-build", None, "skip rebuilding everything (for testing/debugging)", ), ("target-name=", None, "name of the file to create"), ("target-version=", None, "version of the file to create"), ("silent", "s", "suppress all output except warnings"), ] boolean_options: ClassVar[list[str]] = [ "skip-build", "silent", ] def initialize_options(self) -> None: self.appimagekit = None self.bdist_base = None self.build_dir = None self.dist_dir = None self.skip_build = None self.target_name = None self.target_version = None self.fullname = None self.silent = None self._warnings = [] def finalize_options(self) -> None: if os.name != "posix": msg = ( "don't know how to create AppImage " f"distributions on platform {os.name}" ) raise PlatformError(msg) # inherit options self.set_undefined_options( "build_exe", ("build_exe", "build_dir"), ("silent", "silent"), ) self.set_undefined_options( "bdist", ("bdist_base", "bdist_base"), ("dist_dir", "dist_dir"), ("skip_build", "skip_build"), ) # for the bdist commands, there is a chance that build_exe has already # been executed, so check skip_build if build_exe have_run if not self.skip_build and self.distribution.have_run.get("build_exe"): self.skip_build = 1 if self.target_name is None: if self.distribution.metadata.name: self.target_name = self.distribution.metadata.name else: executables = self.distribution.executables executable = executables[0] self.warn_delayed( "using the first executable as target_name: " f"{executable.target_name}" ) self.target_name = executable.target_name if self.target_version is None and self.distribution.metadata.version: self.target_version = self.distribution.metadata.version name = self.target_name version = self.target_version name, ext = os.path.splitext(name) if ext == ".AppImage": self.app_name = self.target_name self.fullname = name elif version: self.app_name = f"{name}-{version}-{ARCH}.AppImage" self.fullname = f"{name}-{version}" else: self.app_name = f"{name}-{ARCH}.AppImage" self.fullname = name if self.silent is not None: self.verbose = 0 if self.silent else 2 build_exe = self.distribution.command_obj.get("build_exe") if build_exe: build_exe.silent = self.silent # validate or download appimagekit self._get_appimagekit() def _get_appimagekit(self) -> None: """Fetch AppImageKit from the web if not available locally.""" appimagekit = os.path.expanduser(self.appimagekit or APPIMAGEKIT_TOOL) appimagekit_dir = os.path.dirname(appimagekit) self.mkpath(appimagekit_dir) with FileLock(appimagekit + ".lock"): if not os.path.exists(appimagekit): self.announce( f"download and install AppImageKit from {APPIMAGEKIT_URL}", INFO, ) name = os.path.basename(APPIMAGEKIT_PATH) filename = os.path.join(appimagekit_dir, name) if not os.path.exists(filename): urlretrieve( # noqa: S310 os.path.join(APPIMAGEKIT_URL, APPIMAGEKIT_PATH), filename, ) os.chmod(filename, stat.S_IRWXU) if not os.path.exists(appimagekit): self.execute( os.symlink, (filename, appimagekit), msg=f"linking {appimagekit} -> {filename}", ) self.appimagekit = appimagekit def run(self) -> None: # Create the application bundle if not self.skip_build: self.run_command("build_exe") # Make appimage (by default in dist directory) # Set the full path of appimage to be built self.mkpath(self.dist_dir) output = os.path.abspath(os.path.join(self.dist_dir, self.app_name)) if os.path.exists(output): os.unlink(output) # Make AppDir folder appdir = os.path.join(self.bdist_base, "AppDir") if os.path.exists(appdir): self.execute(shutil.rmtree, (appdir,), msg=f"removing {appdir}") self.mkpath(appdir) # Copy from build_exe self.copy_tree(self.build_dir, appdir, preserve_symlinks=True) # Remove zip file after putting all files in the file system # (appimage is a compressed file, no need of internal zip file) library_data = Path(appdir, "lib", "library.dat") if library_data.exists(): target_lib_dir = library_data.parent filename = target_lib_dir / library_data.read_bytes().decode() with ZipFile(filename) as outfile: outfile.extractall(target_lib_dir) filename.unlink() library_data.unlink() # Add icon, desktop file, entrypoint share_icons = os.path.join("share", "icons") icons_dir = os.path.join(appdir, share_icons) self.mkpath(icons_dir) executables = self.distribution.executables executable = executables[0] if len(executables) > 1: self.warn_delayed( "using the first executable as entrypoint: " f"{executable.target_name}" ) if executable.icon is None: icon_name = "logox128.png" icon_source_dir = os.path.dirname(cx_Freeze.icons.__file__) self.copy_file(os.path.join(icon_source_dir, icon_name), icons_dir) else: icon_name = executable.icon.name self.move_file(os.path.join(appdir, icon_name), icons_dir) relative_reference = os.path.join(share_icons, icon_name) origin = os.path.join(appdir, ".DirIcon") self.execute( os.symlink, (relative_reference, origin), msg=f"linking {origin} -> {relative_reference}", ) desktop_entry = f"""\ [Desktop Entry] Type=Application Name={self.target_name} Exec={executable.target_name} Comment={self.distribution.get_description()} Icon=/{share_icons}/{os.path.splitext(icon_name)[0]} Categories=Development; Terminal=true X-AppImage-Arch={ARCH} X-AppImage-Name={self.target_name} X-AppImage-Version={self.target_version or ''} """ self.save_as_file( dedent(desktop_entry), os.path.join(appdir, f"{self.target_name}.desktop"), ) entrypoint = f"""\ #! /bin/bash # If running from an extracted image, fix APPDIR if [ -z "$APPIMAGE" ]; then self="$(readlink -f -- $0)" export APPDIR="${{self%/*}}" fi # Call the application entry point "$APPDIR/{executable.target_name}" "$@" """ self.save_as_file( dedent(entrypoint), os.path.join(appdir, "AppRun"), mode="x" ) # Build an AppImage from an AppDir os.environ["ARCH"] = ARCH cmd = [self.appimagekit, "--no-appstream", appdir, output] if find_library("fuse") is None: # libfuse.so.2 is not found cmd.insert(1, "--appimage-extract-and-run") with FileLock(self.appimagekit + ".lock"): self.spawn(cmd, search_path=0) if not os.path.exists(output): msg = "Could not build AppImage" raise ExecError(msg) self.warnings() def save_as_file(self, data, outfile, mode="r") -> tuple[str, int]: """Save an input data to a file respecting verbose, dry-run and force flags. """ if not self.force and os.path.exists(outfile): if self.verbose >= 1: self.warn_delayed(f"not creating {outfile} (output exists)") return (outfile, 0) if self.verbose >= 1: self.announce(f"creating {outfile}", INFO) if self.dry_run: return (outfile, 1) if isinstance(data, str): data = data.encode() with open(outfile, "wb") as out: out.write(data) st_mode = stat.S_IRUSR if "w" in mode: st_mode = st_mode | stat.S_IWUSR if "x" in mode: st_mode = st_mode | stat.S_IXUSR os.chmod(outfile, st_mode) return (outfile, 1) def warn_delayed(self, msg) -> None: self._warnings.append(msg) def warnings(self) -> None: for msg in self._warnings: self.announce(f"WARNING: {msg}", WARNING)