314 lines
11 KiB
Python
314 lines
11 KiB
Python
"""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)
|