diff --git a/.github/workflows/build_linux.yml b/.github/workflows/build_linux.yml index 8a926afc..ca073f1f 100644 --- a/.github/workflows/build_linux.yml +++ b/.github/workflows/build_linux.yml @@ -10,15 +10,21 @@ jobs: needs: buildAssets runs-on: ubuntu-latest env: - PYTHON_VERSION: "3.11" - LINUX_TAG: "manylinux_2_28_x86_64" + PYTHON_VERSION: "3.13" + LINUX_TAG: "manylinux_2_28" + LINUX_ARCH: "x86_64" steps: - name: Python Setup uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.13" architecture: x64 + - name: Install Packages (apt) + run: | + sudo apt update + sudo apt install libxcb-cursor0 + - name: Install Packages (pip) run: pip install python-appimage setuptools @@ -35,7 +41,7 @@ jobs: id: build run: | echo "BUILD_VERSION=$(python pkgutils.py version)" >> $GITHUB_OUTPUT - python pkgutils.py build-appimage --linux-tag $LINUX_TAG --python-version $PYTHON_VERSION + python pkgutils.py build-appimage $LINUX_TAG $LINUX_ARCH $PYTHON_VERSION - name: Upload Artifacts uses: actions/upload-artifact@v4 diff --git a/pkgutils.py b/pkgutils.py index fc30b985..beb5fece 100755 --- a/pkgutils.py +++ b/pkgutils.py @@ -272,24 +272,12 @@ if __name__ == "__main__": cmdBuildUbuntu.set_defaults(func=utils.build_debian.launchpad) # Build AppImage - cmdBuildAppImage = parsers.add_parser( - "build-appimage", help=( - "Build an AppImage. " - "Argument --linux-tag defaults manylinux_2_28_x86_64, and --python-version to 3.13." - ) - ) - cmdBuildAppImage.add_argument( - "--linux-tag", - default="manylinux_2_28_x86_64", - help=( - "Linux compatibility tag (e.g. manylinux_2_28_x86_64) " - "see https://python-appimage.readthedocs.io/en/latest/#available-python-appimages " - "and https://github.com/pypa/manylinux for a list of valid tags." - ), - ) - cmdBuildAppImage.add_argument( - "--python-version", default="3.13", help="Python version (e.g. 3.13)" - ) + # See https://github.com/pypa/manylinux + # See https://python-appimage.readthedocs.io/en/latest/#available-python-appimages + cmdBuildAppImage = parsers.add_parser("build-appimage", help="Build an AppImage.") + cmdBuildAppImage.add_argument("linux", help="Manylinux version, e.g. manylinux_2_28.") + cmdBuildAppImage.add_argument("arch", help="Architecture, e.g. x86_64.") + cmdBuildAppImage.add_argument("python", help="Python version, e.g. 3.13.") cmdBuildAppImage.set_defaults(func=utils.build_appimage.appImage) # Build Windows Inno Setup Installer diff --git a/setup/icons/novelwriter.png b/setup/icons/novelwriter.png new file mode 100644 index 00000000..a9ec557a Binary files /dev/null and b/setup/icons/novelwriter.png differ diff --git a/setup/icons/x-novelwriter-project.png b/setup/icons/x-novelwriter-project.png new file mode 100644 index 00000000..9186479b Binary files /dev/null and b/setup/icons/x-novelwriter-project.png differ diff --git a/utils/build_appimage.py b/utils/build_appimage.py index ea784269..57b44413 100644 --- a/utils/build_appimage.py +++ b/utils/build_appimage.py @@ -22,13 +22,15 @@ from __future__ import annotations import argparse import datetime +import os import shutil -import subprocess import sys +from pathlib import Path + from utils.common import ( ROOT_DIR, SETUP_DIR, appdataXml, copyPackageFiles, copySourceCode, - extractVersion, makeCheckSum, toUpload, writeFile + extractVersion, freshFolder, makeCheckSum, systemCall, toUpload, writeFile ) @@ -49,115 +51,83 @@ def appImage(args: argparse.Namespace) -> None: print("") print("Build AppImage") - print("==============") - print("") + print("="*120) - linuxTag = args.linux_tag - pythonVer = args.python_version + mLinux = args.linux + mArch = args.arch + pyVer = args.python # Version Info - # ============ - pkgVers, _, relDate = extractVersion() relDate = datetime.datetime.strptime(relDate, "%Y-%m-%d") - print("") # Set Up Folder - # ============= - bldDir = ROOT_DIR / "dist_appimage" - bldPkg = f"novelwriter_{pkgVers}" + bldPkg = f"novelwriter-{pkgVers}-{mArch}" + bldImg = f"{bldPkg}.AppImage" outDir = bldDir / bldPkg imgDir = bldDir / "appimage" - - # Set Up Folders - # ============== + appDir = bldDir / f"novelWriter-{mArch}" bldDir.mkdir(exist_ok=True) - - if outDir.exists(): - print("Removing old build files ...") - print("") - shutil.rmtree(outDir) - - outDir.mkdir() - - if imgDir.exists(): - print("Removing old build metadata files ...") - print("") - shutil.rmtree(imgDir) - - imgDir.mkdir() + freshFolder(outDir) + freshFolder(imgDir) + freshFolder(appDir) # Remove old AppImages if images := bldDir.glob("*.AppImage"): print("Removing old AppImages") - print("") for image in images: image.unlink() # Copy novelWriter Source - # ======================= - print("Copying novelWriter source ...") - print("") - copySourceCode(outDir) - print("") print("Copying or generating additional files ...") - print("") - copyPackageFiles(outDir) # Write Metadata - # ============== - writeFile(imgDir / "novelwriter.appdata.xml", appdataXml()) - print("Wrote: novelwriter.appdata.xml") - + writeFile(imgDir / "requirements.txt", str(outDir)) writeFile(imgDir / "entrypoint.sh", ( - '#! /bin/bash \n' + f"export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:${{APPDIR}}/usr/lib/{mArch}-linux-gnu/\n" '{{ python-executable }} -sE ${APPDIR}/opt/python{{ python-version }}/bin/novelwriter "$@"' )) - print("Wrote: entrypoint.sh") - - writeFile(imgDir / "requirements.txt", str(outDir)) - print("Wrote: requirements.txt") shutil.copyfile(SETUP_DIR / "data" / "novelwriter.desktop", imgDir / "novelwriter.desktop") print("Copied: novelwriter.desktop") - shutil.copyfile(SETUP_DIR / "icons" / "novelwriter.svg", imgDir / "novelwriter.svg") - print("Copied: novelwriter.svg") - - shutil.copyfile( - SETUP_DIR / "data" / "hicolor" / "256x256" / "apps" / "novelwriter.png", - imgDir / "novelwriter.png" - ) + shutil.copyfile(SETUP_DIR / "icons" / "novelwriter.png", imgDir / "novelwriter.png") print("Copied: novelwriter.png") - # Build AppImage - # ============== + # Build AppDir + systemCall([ + sys.executable, "-m", "python_appimage", "build", "app", "--no-packaging", + "-l", f"{mLinux}_{mArch}", "-p", pyVer, "appimage" + ], cwd=bldDir) - try: - subprocess.call([ - sys.executable, "-m", "python_appimage", "build", "app", - "-l", linuxTag, "-p", pythonVer, "appimage" - ], cwd=bldDir) - except Exception as exc: - print("AppImage build: FAILED") - print("") - print(str(exc)) - print("") - sys.exit(1) + # Copy Libraries + libPath = Path(f"/usr/lib/{mArch}-linux-gnu") + appLibs = appDir / "usr" / "lib" / f"{mArch}-linux-gnu" + appLibs.mkdir(exist_ok=True) + shutil.copyfile(libPath / "libxcb-cursor.so.0", appLibs / "libxcb-cursor.so.0") - bldFile = list(bldDir.glob("*.AppImage"))[0] - outFile = bldDir / f"novelWriter-{pkgVers}.AppImage" - bldFile.rename(outFile) - shaFile = makeCheckSum(outFile.name, cwd=bldDir) + # Build Image + env = os.environ.copy() + env["ARCH"] = mArch + systemCall([ + "appimagetool", "--no-appstream", "--updateinformation", + f"gh-releases-zsync|vkbo|novelwriter|latest|novelwriter-*-{mArch}.AppImage.zsync", + str(appDir), bldImg + ], cwd=bldDir, env=env) - toUpload(outFile) + updFile = bldDir / f"{bldImg}.zsync" + bldFile = bldDir / bldImg + shaFile = makeCheckSum(bldFile.name, cwd=bldDir) + + toUpload(bldFile) + toUpload(updFile) toUpload(shaFile) return diff --git a/utils/common.py b/utils/common.py index 6eda3841..2c986480 100644 --- a/utils/common.py +++ b/utils/common.py @@ -22,6 +22,7 @@ from __future__ import annotations import shutil import subprocess +import sys from pathlib import Path @@ -77,16 +78,16 @@ def copySourceCode(dst: Path) -> None: for item in src.glob("**/*"): relSrc = item.relative_to(ROOT_DIR) if item.suffix in (".pyc", ".pyo"): - print(f"Ignore: {relSrc}") + print("Ignored:", relSrc) continue if item.parent.is_dir() and item.parent.name != "__pycache__": dstDir = dst / relSrc.parent if not dstDir.exists(): dstDir.mkdir(parents=True) - print(f"Folder: {dstDir}") + print("Created:", dstDir.relative_to(ROOT_DIR)) if item.is_file(): shutil.copyfile(item, dst / relSrc) - print(f"Copied: {dst / relSrc}") + print("Copied:", relSrc) return @@ -95,26 +96,23 @@ def copyPackageFiles(dst: Path, setupPy: bool = False) -> None: copyFiles = ["LICENSE.md", "CREDITS.md", "pyproject.toml"] for copyFile in copyFiles: shutil.copyfile(copyFile, dst / copyFile) - print(f"Copied: {copyFile}") + print("Copied:", copyFile) writeFile(dst / "MANIFEST.in", ( "include LICENSE.md\n" "include CREDITS.md\n" "recursive-include novelwriter/assets *\n" )) - print("Wrote: MANIFEST.in") if setupPy: writeFile(dst / "setup.py", ( "import setuptools\n" "setuptools.setup()\n" )) - print("Wrote: setup.py") text = readFile(ROOT_DIR / "pyproject.toml") text = text.replace("setup/description_pypi.md", "data/description_short.txt") writeFile(dst / "pyproject.toml", text) - print("Wrote: pyproject.toml") return @@ -188,4 +186,27 @@ def readFile(file: Path) -> str: def writeFile(file: Path, text: str) -> int: """Write string to file.""" - return file.write_text(text, encoding="utf-8") + result = file.write_text(text, encoding="utf-8") + print("Wrote:", file.relative_to(ROOT_DIR)) + return result + + +def freshFolder(path: Path) -> None: + """Make sure a folder exists and is empty.""" + if path.exists(): + print("Removing:", str(path)) + shutil.rmtree(path) + path.mkdir() + return + + +def systemCall(cmd: list, cwd: Path | str | None = None, env: dict | None = None) -> None: + """Make a system call using subprocess.""" + if isinstance(cwd, Path): + cwd = str(cwd) + try: + subprocess.call([str(c) for c in cmd], cwd=cwd, env=env) + except Exception as exc: + print("ERROR:", str(exc)) + sys.exit(1) + return