diff --git a/.github/workflows/build_linux.yml b/.github/workflows/build_linux.yml index 8a926afc..cbca61c8 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 @@ -34,8 +40,11 @@ jobs: - name: Build AppImage id: build run: | + wget https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$LINUX_ARCH.AppImage + chmod +x appimagetool-$LINUX_ARCH.AppImage + export APPIMAGE_TOOL_EXEC="$(pwd)/appimagetool-$LINUX_ARCH.AppImage" 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/pyproject.toml b/pyproject.toml index ece574a8..0fb42595 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "novelWriter" authors = [ {name = "Veronica Berglyd Olsen", email = "code@vkbo.net"}, ] -description = "A markdown-like text editor for planning and writing novels" +description = "A plain text editor for planning and writing novels" readme = {file = "setup/description_pypi.md", content-type = "text/markdown"} license = {text = "GNU General Public License v3"} classifiers = [ diff --git a/setup/appimage_launcher.sh b/setup/appimage_launcher.sh index fc0aa9e9..81bd9b63 100755 --- a/setup/appimage_launcher.sh +++ b/setup/appimage_launcher.sh @@ -24,7 +24,7 @@ cat > $HOME/.local/share/applications/novelWriter.desktop <= 3.10 Package: novelwriter Architecture: all Depends: ${misc:Depends}, ${python3:Depends}, python3 (>=3.10), python3-pyqt6 (>= 6.4), python3-pyqt6.qtsvg (>= 6.4), python3-enchant (>= 2.0) -Description: A markdown-like text editor for planning and writing novels - novelWriter is a plain text editor designed for writing novels assembled from - many smaller text documents. It uses a minimal formatting syntax inspired by - Markdown, and adds a meta data syntax for comments, synopsis, and - cross-referencing. It's designed to be a simple text editor that allows for - easy organisation of text and notes, using human readable text files as - storage for robustness. +Description: A plain text editor for planning and writing novels 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/setup/macos/Info.plist.template b/setup/macos/Info.plist.template index d239a050..348acff7 100644 --- a/setup/macos/Info.plist.template +++ b/setup/macos/Info.plist.template @@ -11,7 +11,7 @@ CFBundleExecutable novelWriter CFBundleGetInfoString - novelWriter: A markdown-like text editor for planning and writing novels. + novelWriter: A plain text editor for planning and writing novels. CFBundleIconFile novelwriter.icns CFBundleIdentifier diff --git a/setup/macos/build.sh b/setup/macos/build.sh index 878a09b0..ca475fa1 100755 --- a/setup/macos/build.sh +++ b/setup/macos/build.sh @@ -174,7 +174,7 @@ rm lib/python3.*/site-packages/PyQt6/QtWebEngine* || true rm -r lib/python3.*/site-packages/PyQt6/Qt6/translations/qtwebengine* || true rm -r lib/python3.*/site-packages/PyQt6/Qt6/plugins/webview/libqtwebview* || true -# Remove unneeded QtQuick/Decaritive components +# Remove unneeded QtQuick/Declarative components rm lib/python3.*/site-packages/PyQt6/QtQml* || true rm lib/python3.*/site-packages/PyQt6/QtQuick* || true rm lib/python3.*/site-packages/PyQt6/WebChannel* || true @@ -200,16 +200,16 @@ mkdir -p $RLS_DIR # --- Create DMG -------------------------------------------------------------------------------- # # Generate .dmg -echo "Packageing DMG ..." +echo "Packaging DMG ..." brew install create-dmg create-dmg --volname "novelWriter $VERSION" --volicon $SRC_DIR/setup/macos/novelwriter.icns \ --window-pos 200 120 --window-size 800 400 --icon-size 100 \ --icon novelWriter.app 200 190 --hide-extension novelWriter.app \ - --app-drop-link 600 185 $RLS_DIR/novelWriter-"${VERSION}"-$ARCH.dmg "$BUILD_DIR"/ + --app-drop-link 600 185 $RLS_DIR/novelwriter-"${VERSION}"-$ARCH.dmg "$BUILD_DIR"/ pushd $RLS_DIR || exit 1 -shasum -a 256 novelWriter-"${VERSION}"-$ARCH.dmg | tee novelWriter-"${VERSION}"-$ARCH.dmg.sha256 +shasum -a 256 novelwriter-"${VERSION}"-$ARCH.dmg | tee novelwriter-"${VERSION}"-$ARCH.dmg.sha256 popd || exit 1 rm -r $CONDA_PATH diff --git a/utils/build_appimage.py b/utils/build_appimage.py index ea784269..d32a377e 100644 --- a/utils/build_appimage.py +++ b/utils/build_appimage.py @@ -22,13 +22,16 @@ 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, removeRedundantQt, systemCall, + toUpload, writeFile ) @@ -49,115 +52,87 @@ 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") + siteDir = appDir / "opt" / f"python{pyVer}" / "lib" / f"python{pyVer}" / "site-packages" + qt6Lib = siteDir / "PyQt6" / "Qt6" / "lib" + shutil.copyfile(libPath / "libxcb-cursor.so.0", qt6Lib / "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) + # Remove Redundant + removeRedundantQt(siteDir) - toUpload(outFile) + # Build Image + appToolExec = os.environ.get("APPIMAGE_TOOL_EXEC", "appimagetool") + env = os.environ.copy() + env["ARCH"] = mArch + systemCall([ + appToolExec, "--no-appstream", "--updateinformation", + f"gh-releases-zsync|vkbo|novelwriter|latest|novelwriter-*-{mArch}.AppImage.zsync", + str(appDir), bldImg + ], cwd=bldDir, env=env) + + 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/build_windows.py b/utils/build_windows.py index a4f0e866..98d5e8c2 100644 --- a/utils/build_windows.py +++ b/utils/build_windows.py @@ -30,7 +30,10 @@ import zipfile from pathlib import Path -from utils.common import ROOT_DIR, SETUP_DIR, copySourceCode, extractVersion, readFile, writeFile +from utils.common import ( + ROOT_DIR, SETUP_DIR, copySourceCode, extractVersion, readFile, + removeRedundantQt, writeFile +) def prepareCode(outDir: Path) -> None: @@ -103,85 +106,6 @@ def installRequirements(libDir: Path) -> None: return -def removeRedundantQt(libDir: Path) -> None: - """Delete Qt files that are not needed""" - - def unlinkIfFound(file: Path) -> None: - if file.is_file(): - file.unlink() - print(f"Deleted: {file}") - - def deleteFolder(folder: Path) -> None: - if folder.is_dir(): - shutil.rmtree(folder) - print(f"Deleted: {folder}") - - def unlinkIfPrefix(folder: Path, prefix: tuple[str, ...]) -> None: - if folder.is_dir(): - for item in folder.iterdir(): - if item.name.startswith(prefix): - if item.is_file(): - unlinkIfFound(item) - elif item.is_dir(): - deleteFolder(item) - - print("Deleting Redundant Files") - print("========================") - print("") - - pyQt6Dir = libDir / "PyQt6" - bindDir = libDir / "PyQt6" / "bindings" - qt6Dir = libDir / "PyQt6" / "Qt6" - binDir = libDir / "PyQt6" / "Qt6" / "bin" - plugDir = libDir / "PyQt6" / "Qt6" / "plugins" - qmDir = libDir / "PyQt6" / "Qt6" / "translations" - dictDir = libDir / "enchant" / "data" / "mingw64" / "share" / "enchant" / "hunspell" - - for item in dictDir.iterdir(): - if not item.name.startswith(("en_GB", "en_US")): - unlinkIfFound(item) - - for item in qmDir.iterdir(): - if not item.name.startswith("qtbase"): - unlinkIfFound(item) - - bulkDel = ("QtQml", "Qt6Qml", "QtQuick", "Qt6Quick") - unlinkIfPrefix(pyQt6Dir, bulkDel) - unlinkIfPrefix(bindDir, bulkDel) - unlinkIfPrefix(binDir, bulkDel) - - delQt6 = [ - "Qt6Bluetooth", "Qt6DBus", "Qt6Designer", "Qt6Help", "Qt6Multimedia", - "Qt6MultimediaWidgets", "Qt6Network", "Qt6Nfc", "Qt6OpenGL", "Qt6Positioning", - "Qt6PositioningQuick", "Qt6Sensors", "Qt6SerialPort", "Qt6Sql", "Qt6Test", - "Qt6TextToSpeech", "Qt6WebChannel", "Qt6WebSockets", "Qt6Xml", - ] - for item in delQt6: - qtItem = item.replace("Qt6", "Qt") - unlinkIfFound(binDir / f"{item}.dll") - unlinkIfFound(pyQt6Dir / f"{qtItem}.pyd") - unlinkIfFound(pyQt6Dir / f"{qtItem}.pyi") - deleteFolder(bindDir / qtItem) - - delList = [ - binDir / "opengl32sw.dll", - qt6Dir / "qml", - plugDir / "renderers", - plugDir / "sensors", - plugDir / "sqldrivers", - plugDir / "texttospeech", - plugDir / "webview", - ] - for item in delList: - unlinkIfFound(item) - deleteFolder(item) - - print("Done") - print("") - - return - - def main(args: argparse.Namespace) -> None: """Set up a package with embedded Python and dependencies for Windows installation. diff --git a/utils/common.py b/utils/common.py index 6eda3841..4ba82e00 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,93 @@ 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 + + +def removeRedundantQt(qtBase: Path) -> None: + """Delete Qt files that are not needed""" + + def unlinkIfFound(file: Path) -> None: + if file.is_file(): + file.unlink() + print("Deleted:", file.relative_to(ROOT_DIR)) + + def deleteFolder(folder: Path) -> None: + if folder.is_dir(): + shutil.rmtree(folder) + print("Deleted:", folder.relative_to(ROOT_DIR)) + + def unlinkIfPrefix(folder: Path, prefix: tuple[str, ...]) -> None: + if folder.is_dir(): + for item in folder.iterdir(): + if item.name.startswith(prefix): + if item.is_file(): + unlinkIfFound(item) + elif item.is_dir(): + deleteFolder(item) + + print("Deleting redundant files ...") + + pyQt6Dir = qtBase / "PyQt6" + bindDir = qtBase / "PyQt6" / "bindings" + qt6Dir = qtBase / "PyQt6" / "Qt6" + binDir = qtBase / "PyQt6" / "Qt6" / "bin" + libDir = qtBase / "PyQt6" / "Qt6" / "lib" + plugDir = qtBase / "PyQt6" / "Qt6" / "plugins" + qmDir = qtBase / "PyQt6" / "Qt6" / "translations" + dictDir = qtBase / "enchant" / "data" / "mingw64" / "share" / "enchant" / "hunspell" + + # Prune Dictionaries + if dictDir.exists(): + for item in dictDir.iterdir(): + if not item.name.startswith(("en_GB", "en_US")): + unlinkIfFound(item) + + # Prune Translations + for item in qmDir.iterdir(): + if not item.name.startswith("qtbase"): + unlinkIfFound(item) + + # Delete Modules + modules = [ + "Qt6Qml", "Qt6Quick", "Qt6Bluetooth", "Qt6Nfc", + "Qt6Sensors", "Qt6SerialPort", "Qt6Test", + ] + modules.extend([x.replace("Qt6", "Qt") for x in modules]) + modules.extend([f"lib{x}" for x in modules]) + modules = tuple(modules) + + unlinkIfPrefix(pyQt6Dir, modules) + unlinkIfPrefix(bindDir, modules) + unlinkIfPrefix(binDir, modules) + unlinkIfPrefix(libDir, modules) + + # Other Files + deleteFolder(qt6Dir / "qml") + deleteFolder(plugDir / "qmlls") + deleteFolder(plugDir / "qmllint") + + return