diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2fb1791..3105f6b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,6 +6,7 @@ on: push: tags: - "v*" + workflow_dispatch: {} jobs: build: @@ -38,6 +39,7 @@ jobs: publish: name: Publish to PyPI needs: build + if: github.event_name == 'push' runs-on: ubuntu-latest environment: name: pypi @@ -53,3 +55,124 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + + windows-build: + name: Build Windows executable + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install project and PyInstaller + run: python -m pip install ".[pyinstaller]" + + - name: Build executable + run: pyinstaller packaging/windows/rixsviewer.spec + + - name: Rename executable with version + shell: bash + run: mv dist/rixsviewer.exe "dist/rixsviewer-${{ github.ref_name }}-windows.exe" + + - name: Smoke test executable launches + shell: pwsh + env: + QT_QPA_PLATFORM: offscreen + run: | + $exe = Get-ChildItem dist -Filter "rixsviewer-*-windows.exe" | Select-Object -First 1 + $proc = Start-Process -FilePath $exe.FullName -PassThru + Start-Sleep -Seconds 8 + if ($proc.HasExited) { + Write-Error "Executable exited early with code $($proc.ExitCode)" + exit 1 + } + Write-Host "Executable is running after 8s, smoke test passed" + Stop-Process -Id $proc.Id -Force + + - name: Upload Windows executable artifact + uses: actions/upload-artifact@v4 + with: + name: windows-exe + path: dist/rixsviewer-*-windows.exe + + linux-appimage-build: + name: Build Linux AppImage + runs-on: ubuntu-latest + container: rockylinux:9 + steps: + - name: Install git + run: dnf install -y git + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install system dependencies + run: | + dnf install -y python3.11 python3.11-pip \ + mesa-libGL mesa-libEGL libxkbcommon libxkbcommon-x11 \ + xcb-util-wm xcb-util-image xcb-util-keysyms xcb-util-renderutil \ + dbus-libs fontconfig freetype file + + - name: Install project and PyInstaller + run: python3.11 -m pip install ".[pyinstaller]" + + - name: Build AppImage + run: bash packaging/linux/build-appimage.sh "${{ github.ref_name }}" + + - name: Smoke test AppImage launches + env: + QT_QPA_PLATFORM: offscreen + run: | + APPIMAGE=$(ls dist/rixsviewer-*-x86_64.AppImage) + chmod +x "$APPIMAGE" + timeout 8 "$APPIMAGE" --appimage-extract-and-run > /tmp/run.log 2>&1 & + RPID=$! + sleep 6 + if kill -0 $RPID 2>/dev/null; then + echo "Smoke test passed: AppImage is running after 6s" + kill $RPID + else + wait $RPID || true + echo "Smoke test FAILED: AppImage exited early" + cat /tmp/run.log + exit 1 + fi + + - name: Upload Linux AppImage artifact + uses: actions/upload-artifact@v4 + with: + name: linux-appimage + path: dist/rixsviewer-*-x86_64.AppImage + + github-release: + name: Publish GitHub Release + needs: [windows-build, linux-appimage-build] + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download Windows executable artifact + uses: actions/download-artifact@v4 + with: + name: windows-exe + path: dist/ + + - name: Download Linux AppImage artifact + uses: actions/download-artifact@v4 + with: + name: linux-appimage + path: dist/ + + - name: Create/update GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + files: | + dist/rixsviewer-*-windows.exe + dist/rixsviewer-*-x86_64.AppImage diff --git a/.gitignore b/.gitignore index 82cd58c..12bdd00 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ MANIFEST # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec +!packaging/windows/*.spec +!packaging/linux/*.spec # Installer logs pip-log.txt diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun new file mode 100755 index 0000000..6a1992b --- /dev/null +++ b/packaging/linux/AppRun @@ -0,0 +1,3 @@ +#!/bin/bash +HERE="$(dirname "$(readlink -f "${0}")")" +exec "${HERE}/usr/bin/rixsviewer/rixsviewer" "$@" diff --git a/packaging/linux/build-appimage.sh b/packaging/linux/build-appimage.sh new file mode 100755 index 0000000..8c85594 --- /dev/null +++ b/packaging/linux/build-appimage.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# packaging/linux/build-appimage.sh +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +VERSION="${1:?Usage: build-appimage.sh }" + +rm -rf build dist/rixsviewer dist/AppDir +python3.11 -m PyInstaller packaging/linux/rixsviewer.spec + +mkdir -p dist/AppDir/usr/bin +cp -r dist/rixsviewer dist/AppDir/usr/bin/rixsviewer +install -m 755 packaging/linux/AppRun dist/AppDir/AppRun +cp packaging/linux/rixsviewer.desktop dist/AppDir/rixsviewer.desktop +cp src/rixsviewer/assets/icon.png dist/AppDir/rixsviewer.png + +curl -sL -o dist/appimagetool https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage +chmod +x dist/appimagetool + +dist/appimagetool --appimage-extract-and-run dist/AppDir "dist/rixsviewer-${VERSION}-x86_64.AppImage" diff --git a/packaging/linux/entrypoint.py b/packaging/linux/entrypoint.py new file mode 100644 index 0000000..809b3ee --- /dev/null +++ b/packaging/linux/entrypoint.py @@ -0,0 +1,4 @@ +from rixsviewer.rixsviewer_gui import main + +if __name__ == "__main__": + main() diff --git a/packaging/linux/rixsviewer.desktop b/packaging/linux/rixsviewer.desktop new file mode 100644 index 0000000..6bfb300 --- /dev/null +++ b/packaging/linux/rixsviewer.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Type=Application +Name=RixsViewer +Exec=rixsviewer +Icon=rixsviewer +Categories=Science; +Terminal=false diff --git a/packaging/linux/rixsviewer.spec b/packaging/linux/rixsviewer.spec new file mode 100644 index 0000000..9642e79 --- /dev/null +++ b/packaging/linux/rixsviewer.spec @@ -0,0 +1,50 @@ +# packaging/linux/rixsviewer.spec +# -*- mode: python ; coding: utf-8 -*- +from pathlib import Path + +spec_dir = Path(SPECPATH) +repo_root = spec_dir.parent.parent + +a = Analysis( + [str(spec_dir / 'entrypoint.py')], + pathex=[], + binaries=[], + datas=[ + (str(repo_root / 'src' / 'rixsviewer' / 'assets'), 'rixsviewer/assets'), + ], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=['epics'], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='rixsviewer', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name='rixsviewer', +) diff --git a/packaging/windows/entrypoint.py b/packaging/windows/entrypoint.py new file mode 100644 index 0000000..809b3ee --- /dev/null +++ b/packaging/windows/entrypoint.py @@ -0,0 +1,4 @@ +from rixsviewer.rixsviewer_gui import main + +if __name__ == "__main__": + main() diff --git a/packaging/windows/icon.ico b/packaging/windows/icon.ico new file mode 100644 index 0000000..55396ba Binary files /dev/null and b/packaging/windows/icon.ico differ diff --git a/packaging/windows/rixsviewer.spec b/packaging/windows/rixsviewer.spec new file mode 100644 index 0000000..13b63fb --- /dev/null +++ b/packaging/windows/rixsviewer.spec @@ -0,0 +1,45 @@ +# packaging/windows/rixsviewer.spec +# -*- mode: python ; coding: utf-8 -*- +from pathlib import Path + +spec_dir = Path(SPECPATH) +repo_root = spec_dir.parent.parent + +a = Analysis( + [str(spec_dir / 'entrypoint.py')], + pathex=[], + binaries=[], + datas=[ + (str(repo_root / 'src' / 'rixsviewer' / 'assets'), 'rixsviewer/assets'), + ], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=['epics'], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='rixsviewer', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=str(spec_dir / 'icon.ico'), +) diff --git a/pyproject.toml b/pyproject.toml index da56bce..d18f361 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ dependencies = [ epics = ["pyepics"] dev = ["pytest>=7.0.0", "black>=22.0.0", "flake8>=4.0.0"] docs = ["sphinx>=6.0.0", "sphinx-rtd-theme>=1.0.0", "numpydoc>=1.5.0"] +pyinstaller = ["pyinstaller>=6.21"] [project.scripts] rixsviewer = "rixsviewer.rixsviewer_gui:main" diff --git a/src/rixsviewer/rixsviewer_gui.py b/src/rixsviewer/rixsviewer_gui.py index a929bc4..b2ec1dc 100644 --- a/src/rixsviewer/rixsviewer_gui.py +++ b/src/rixsviewer/rixsviewer_gui.py @@ -772,6 +772,11 @@ def main(): # Create QApplication with remaining arguments app = QApplication([sys.argv[0]] + qt_args) + if sys.platform == "darwin": + pass # keep native macOS style + else: + app.setStyle("Fusion") # Windows / Linux + icon_path = Path(__file__).parent / "assets" / "icon.png" if icon_path.exists(): app.setWindowIcon(QIcon(str(icon_path)))