From e35f4dbf62c6c3c7910cab032ca94f7cd36b2337 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 4 Jan 2026 21:47:07 +0800 Subject: [PATCH] WIP: auto update --- .github/workflows/build_all.yml | 248 ++++++++++++----------- .github/workflows/build_orca.yml | 115 ++++++++--- .github/workflows/generate_appcast.yml | 138 +++++++++++++ CMakeLists.txt | 5 + deps/CMakeLists.txt | 17 ++ deps/Sparkle/Sparkle.cmake | 27 +++ deps/WinSparkle/WinSparkle.cmake | 33 +++ scripts/generate_appcast.py | 208 +++++++++++++++++++ src/CMakeLists.txt | 23 +++ src/dev-utils/platform/osx/Info.plist.in | 9 + src/slic3r/CMakeLists.txt | 27 ++- src/slic3r/GUI/GUI_App.cpp | 23 ++- src/slic3r/GUI/MainFrame.cpp | 5 + src/slic3r/GUI/UpdateManager.cpp | 174 ++++++++++++++++ src/slic3r/GUI/UpdateManager.hpp | 41 ++++ src/slic3r/GUI/UpdateManagerMac.mm | 204 +++++++++++++++++++ src/slic3r/GeneratedConfig.hpp.in | 2 +- 17 files changed, 1141 insertions(+), 158 deletions(-) create mode 100644 .github/workflows/generate_appcast.yml create mode 100644 deps/Sparkle/Sparkle.cmake create mode 100644 deps/WinSparkle/WinSparkle.cmake create mode 100755 scripts/generate_appcast.py create mode 100644 src/slic3r/GUI/UpdateManager.cpp create mode 100644 src/slic3r/GUI/UpdateManager.hpp create mode 100644 src/slic3r/GUI/UpdateManagerMac.mm diff --git a/.github/workflows/build_all.yml b/.github/workflows/build_all.yml index 1e944a7667..01037f6e2f 100644 --- a/.github/workflows/build_all.yml +++ b/.github/workflows/build_all.yml @@ -5,6 +5,7 @@ on: branches: - main - release/* + - feature/auto-update # TODO: Remove after auto-update testing is complete paths: - 'deps/**' - 'src/**' @@ -48,25 +49,28 @@ concurrency: jobs: - build_linux: # Separate so unit tests can wait on just Linux builds to complete. - name: Build Linux - strategy: - fail-fast: false - # Don't run scheduled builds on forks: - if: ${{ !cancelled() && (github.event_name != 'schedule' || github.repository == 'OrcaSlicer/OrcaSlicer') }} - uses: ./.github/workflows/build_deps.yml - with: - os: ubuntu-24.04 - build-deps-only: ${{ inputs.build-deps-only || false }} - force-build: ${{ github.event_name == 'schedule' }} - secrets: inherit + # TODO: Re-enable after auto-update testing is complete + # build_linux: # Separate so unit tests can wait on just Linux builds to complete. + # name: Build Linux + # strategy: + # fail-fast: false + # # Don't run scheduled builds on forks: + # if: ${{ !cancelled() && (github.event_name != 'schedule' || github.repository == 'OrcaSlicer/OrcaSlicer') }} + # uses: ./.github/workflows/build_deps.yml + # with: + # os: ubuntu-24.04 + # build-deps-only: ${{ inputs.build-deps-only || false }} + # force-build: ${{ github.event_name == 'schedule' }} + # secrets: inherit + build_all: - name: Build Non-Linux + name: Build macOS (testing auto-update) strategy: fail-fast: false matrix: include: - - os: windows-latest + # TODO: Re-enable Windows after auto-update testing is complete + # - os: windows-latest - os: macos-14 arch: arm64 # Don't run scheduled builds on forks: @@ -78,109 +82,113 @@ jobs: build-deps-only: ${{ inputs.build-deps-only || false }} force-build: ${{ github.event_name == 'schedule' }} secrets: inherit - unit_tests: - name: Unit Tests - runs-on: ubuntu-24.04 - needs: build_linux - if: ${{ !cancelled() && success() }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: | - .github - scripts - tests - - name: Apt-Install Dependencies - uses: ./.github/actions/apt-install-deps - - name: Restore Test Artifact - uses: actions/download-artifact@v6 - with: - name: ${{ github.sha }}-tests - - uses: lukka/get-cmake@latest - with: - cmakeVersion: "~3.28.0" # use most recent 3.28.x version - - name: Unpackage and Run Unit Tests - timeout-minutes: 20 - run: | - tar -xvf build_tests.tar - scripts/run_unit_tests.sh - - name: Upload Test Logs - uses: actions/upload-artifact@v5 - if: ${{ failure() }} - with: - name: unit-test-logs - path: build/tests/**/*.log - - name: Publish Test Results - if: always() - uses: EnricoMi/publish-unit-test-result-action/linux@v2 - with: - files: "ctest_results.xml" - flatpak: - name: "Flatpak" - container: - image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-48 - options: --privileged - volumes: - - /usr/local/lib/android:/usr/local/lib/android - - /usr/share/dotnet:/usr/share/dotnet - - /opt/ghc:/opt/ghc1 - - /usr/local/share/boost:/usr/local/share/boost1 - - /opt/hostedtoolcache:/opt/hostedtoolcache1 - strategy: - fail-fast: false - matrix: - variant: - - arch: x86_64 - runner: ubuntu-24.04 - - arch: aarch64 - runner: ubuntu-24.04-arm - # Don't run scheduled builds on forks: - if: ${{ !cancelled() && (github.event_name != 'schedule' || github.repository == 'OrcaSlicer/OrcaSlicer') }} - runs-on: ${{ matrix.variant.runner }} - env: - date: - ver: - ver_pure: - steps: - - name: "Remove unneeded stuff to free disk space" - run: - rm -rf /usr/local/lib/android/* /usr/share/dotnet/* /opt/ghc1/* "/usr/local/share/boost1/*" /opt/hostedtoolcache1/* - - uses: actions/checkout@v6 - - name: Get the version and date - run: | - ver_pure=$(grep 'set(SoftFever_VERSION' version.inc | cut -d '"' -f2) - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - ver="PR-${{ github.event.number }}" - git_commit_hash="${{ github.event.pull_request.head.sha }}" - else - ver=V$ver_pure - git_commit_hash="" - fi - echo "ver=$ver" >> $GITHUB_ENV - echo "ver_pure=$ver_pure" >> $GITHUB_ENV - echo "date=$(date +'%Y%m%d')" >> $GITHUB_ENV - echo "git_commit_hash=$git_commit_hash" >> $GITHUB_ENV - shell: bash - - uses: flatpak/flatpak-github-actions/flatpak-builder@master - with: - bundle: OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak - manifest-path: scripts/flatpak/io.github.softfever.OrcaSlicer.yml - cache: true - arch: ${{ matrix.variant.arch }} - upload-artifact: false - - name: Upload artifacts Flatpak - uses: actions/upload-artifact@v5 - with: - name: OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak - path: '/__w/OrcaSlicer/OrcaSlicer/OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak' - - name: Deploy Flatpak to nightly release - if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' - uses: WebFreak001/deploy-nightly@v3.2.0 - with: - upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} - release_id: 137995723 - asset_path: /__w/OrcaSlicer/OrcaSlicer/OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak - asset_name: OrcaSlicer-Linux-flatpak_nightly_${{ matrix.variant.arch }}.flatpak - asset_content_type: application/octet-stream - max_releases: 1 # optional, if there are more releases than this matching the asset_name, the oldest ones are going to be deleted + + # TODO: Re-enable after auto-update testing is complete (depends on build_linux) + # unit_tests: + # name: Unit Tests + # runs-on: ubuntu-24.04 + # needs: build_linux + # if: ${{ !cancelled() && success() }} + # steps: + # - name: Checkout + # uses: actions/checkout@v6 + # with: + # sparse-checkout: | + # .github + # scripts + # tests + # - name: Apt-Install Dependencies + # uses: ./.github/actions/apt-install-deps + # - name: Restore Test Artifact + # uses: actions/download-artifact@v6 + # with: + # name: ${{ github.sha }}-tests + # - uses: lukka/get-cmake@latest + # with: + # cmakeVersion: "~3.28.0" # use most recent 3.28.x version + # - name: Unpackage and Run Unit Tests + # timeout-minutes: 20 + # run: | + # tar -xvf build_tests.tar + # scripts/run_unit_tests.sh + # - name: Upload Test Logs + # uses: actions/upload-artifact@v5 + # if: ${{ failure() }} + # with: + # name: unit-test-logs + # path: build/tests/**/*.log + # - name: Publish Test Results + # if: always() + # uses: EnricoMi/publish-unit-test-result-action/linux@v2 + # with: + # files: "ctest_results.xml" + + # TODO: Re-enable after auto-update testing is complete + # flatpak: + # name: "Flatpak" + # container: + # image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-48 + # options: --privileged + # volumes: + # - /usr/local/lib/android:/usr/local/lib/android + # - /usr/share/dotnet:/usr/share/dotnet + # - /opt/ghc:/opt/ghc1 + # - /usr/local/share/boost:/usr/local/share/boost1 + # - /opt/hostedtoolcache:/opt/hostedtoolcache1 + # strategy: + # fail-fast: false + # matrix: + # variant: + # - arch: x86_64 + # runner: ubuntu-24.04 + # - arch: aarch64 + # runner: ubuntu-24.04-arm + # # Don't run scheduled builds on forks: + # if: ${{ !cancelled() && (github.event_name != 'schedule' || github.repository == 'OrcaSlicer/OrcaSlicer') }} + # runs-on: ${{ matrix.variant.runner }} + # env: + # date: + # ver: + # ver_pure: + # steps: + # - name: "Remove unneeded stuff to free disk space" + # run: + # rm -rf /usr/local/lib/android/* /usr/share/dotnet/* /opt/ghc1/* "/usr/local/share/boost1/*" /opt/hostedtoolcache1/* + # - uses: actions/checkout@v6 + # - name: Get the version and date + # run: | + # ver_pure=$(grep 'set(SoftFever_VERSION' version.inc | cut -d '"' -f2) + # if [[ "${{ github.event_name }}" == "pull_request" ]]; then + # ver="PR-${{ github.event.number }}" + # git_commit_hash="${{ github.event.pull_request.head.sha }}" + # else + # ver=V$ver_pure + # git_commit_hash="" + # fi + # echo "ver=$ver" >> $GITHUB_ENV + # echo "ver_pure=$ver_pure" >> $GITHUB_ENV + # echo "date=$(date +'%Y%m%d')" >> $GITHUB_ENV + # echo "git_commit_hash=$git_commit_hash" >> $GITHUB_ENV + # shell: bash + # - uses: flatpak/flatpak-github-actions/flatpak-builder@master + # with: + # bundle: OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak + # manifest-path: scripts/flatpak/io.github.softfever.OrcaSlicer.yml + # cache: true + # arch: ${{ matrix.variant.arch }} + # upload-artifact: false + # - name: Upload artifacts Flatpak + # uses: actions/upload-artifact@v5 + # with: + # name: OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak + # path: '/__w/OrcaSlicer/OrcaSlicer/OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak' + # - name: Deploy Flatpak to nightly release + # if: github.repository == 'OrcaSlicer/OrcaSlicer' && github.ref == 'refs/heads/main' + # uses: WebFreak001/deploy-nightly@v3.2.0 + # with: + # upload_url: https://uploads.github.com/repos/OrcaSlicer/OrcaSlicer/releases/137995723/assets{?name,label} + # release_id: 137995723 + # asset_path: /__w/OrcaSlicer/OrcaSlicer/OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak + # asset_name: OrcaSlicer-Linux-flatpak_nightly_${{ matrix.variant.arch }}.flatpak + # asset_content_type: application/octet-stream + # max_releases: 1 # optional, if there are more releases than this matching the asset_name, the oldest ones are going to be deleted diff --git a/.github/workflows/build_orca.yml b/.github/workflows/build_orca.yml index 986ac4059c..65811b8629 100644 --- a/.github/workflows/build_orca.yml +++ b/.github/workflows/build_orca.yml @@ -103,11 +103,13 @@ jobs: if: inputs.os == 'macos-14' working-directory: ${{ github.workspace }} run: | - ./build_release_macos.sh -s -n -x -a universal -t 10.15 -1 + # TODO: Change back to -a universal after auto-update testing is complete + ./build_release_macos.sh -s -n -x -a arm64 -t 10.15 -1 # Thanks to RaySajuuk, it's working now - name: Sign app and notary - if: github.repository == 'OrcaSlicer/OrcaSlicer' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) && inputs.os == 'macos-14' + # TODO: Remove feature/auto-update after testing is complete + if: github.repository == 'OrcaSlicer/OrcaSlicer' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || github.ref == 'refs/heads/feature/auto-update') && inputs.os == 'macos-14' working-directory: ${{ github.workspace }} env: BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} @@ -124,27 +126,28 @@ jobs: security import $CERTIFICATE_PATH -P $P12_PASSWORD -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $P12_PASSWORD $KEYCHAIN_PATH - codesign --deep --force --verbose --options runtime --timestamp --entitlements ${{ github.workspace }}/scripts/disable_validation.entitlements --sign "$CERTIFICATE_ID" ${{ github.workspace }}/build/universal/OrcaSlicer/OrcaSlicer.app + # TODO: Change build/arm64 back to build/universal after auto-update testing is complete + codesign --deep --force --verbose --options runtime --timestamp --entitlements ${{ github.workspace }}/scripts/disable_validation.entitlements --sign "$CERTIFICATE_ID" ${{ github.workspace }}/build/arm64/OrcaSlicer/OrcaSlicer.app # Sign OrcaSlicer_profile_validator.app if it exists - if [ -f "${{ github.workspace }}/build/universal/OrcaSlicer/OrcaSlicer_profile_validator.app/Contents/MacOS/OrcaSlicer_profile_validator" ]; then - codesign --deep --force --verbose --options runtime --timestamp --entitlements ${{ github.workspace }}/scripts/disable_validation.entitlements --sign "$CERTIFICATE_ID" ${{ github.workspace }}/build/universal/OrcaSlicer/OrcaSlicer_profile_validator.app + if [ -f "${{ github.workspace }}/build/arm64/OrcaSlicer/OrcaSlicer_profile_validator.app/Contents/MacOS/OrcaSlicer_profile_validator" ]; then + codesign --deep --force --verbose --options runtime --timestamp --entitlements ${{ github.workspace }}/scripts/disable_validation.entitlements --sign "$CERTIFICATE_ID" ${{ github.workspace }}/build/arm64/OrcaSlicer/OrcaSlicer_profile_validator.app fi - + # Create main OrcaSlicer DMG without the profile validator helper - mkdir -p ${{ github.workspace }}/build/universal/OrcaSlicer_dmg - rm -rf ${{ github.workspace }}/build/universal/OrcaSlicer_dmg/* - cp -R ${{ github.workspace }}/build/universal/OrcaSlicer/OrcaSlicer.app ${{ github.workspace }}/build/universal/OrcaSlicer_dmg/ - ln -sfn /Applications ${{ github.workspace }}/build/universal/OrcaSlicer_dmg/Applications - hdiutil create -volname "OrcaSlicer" -srcfolder ${{ github.workspace }}/build/universal/OrcaSlicer_dmg -ov -format UDZO OrcaSlicer_Mac_universal_${{ env.ver }}.dmg + mkdir -p ${{ github.workspace }}/build/arm64/OrcaSlicer_dmg + rm -rf ${{ github.workspace }}/build/arm64/OrcaSlicer_dmg/* + cp -R ${{ github.workspace }}/build/arm64/OrcaSlicer/OrcaSlicer.app ${{ github.workspace }}/build/arm64/OrcaSlicer_dmg/ + ln -sfn /Applications ${{ github.workspace }}/build/arm64/OrcaSlicer_dmg/Applications + hdiutil create -volname "OrcaSlicer" -srcfolder ${{ github.workspace }}/build/arm64/OrcaSlicer_dmg -ov -format UDZO OrcaSlicer_Mac_universal_${{ env.ver }}.dmg codesign --deep --force --verbose --options runtime --timestamp --entitlements ${{ github.workspace }}/scripts/disable_validation.entitlements --sign "$CERTIFICATE_ID" OrcaSlicer_Mac_universal_${{ env.ver }}.dmg - + # Create separate OrcaSlicer_profile_validator DMG if the app exists - if [ -f "${{ github.workspace }}/build/universal/OrcaSlicer/OrcaSlicer_profile_validator.app/Contents/MacOS/OrcaSlicer_profile_validator" ]; then - mkdir -p ${{ github.workspace }}/build/universal/OrcaSlicer_profile_validator_dmg - rm -rf ${{ github.workspace }}/build/universal/OrcaSlicer_profile_validator_dmg/* - cp -R ${{ github.workspace }}/build/universal/OrcaSlicer/OrcaSlicer_profile_validator.app ${{ github.workspace }}/build/universal/OrcaSlicer_profile_validator_dmg/ - ln -sfn /Applications ${{ github.workspace }}/build/universal/OrcaSlicer_profile_validator_dmg/Applications - hdiutil create -volname "OrcaSlicer Profile Validator" -srcfolder ${{ github.workspace }}/build/universal/OrcaSlicer_profile_validator_dmg -ov -format UDZO OrcaSlicer_profile_validator_Mac_universal_${{ env.ver }}.dmg + if [ -f "${{ github.workspace }}/build/arm64/OrcaSlicer/OrcaSlicer_profile_validator.app/Contents/MacOS/OrcaSlicer_profile_validator" ]; then + mkdir -p ${{ github.workspace }}/build/arm64/OrcaSlicer_profile_validator_dmg + rm -rf ${{ github.workspace }}/build/arm64/OrcaSlicer_profile_validator_dmg/* + cp -R ${{ github.workspace }}/build/arm64/OrcaSlicer/OrcaSlicer_profile_validator.app ${{ github.workspace }}/build/arm64/OrcaSlicer_profile_validator_dmg/ + ln -sfn /Applications ${{ github.workspace }}/build/arm64/OrcaSlicer_profile_validator_dmg/Applications + hdiutil create -volname "OrcaSlicer Profile Validator" -srcfolder ${{ github.workspace }}/build/arm64/OrcaSlicer_profile_validator_dmg -ov -format UDZO OrcaSlicer_profile_validator_Mac_universal_${{ env.ver }}.dmg codesign --deep --force --verbose --options runtime --timestamp --entitlements ${{ github.workspace }}/scripts/disable_validation.entitlements --sign "$CERTIFICATE_ID" OrcaSlicer_profile_validator_Mac_universal_${{ env.ver }}.dmg fi @@ -159,23 +162,65 @@ jobs: xcrun stapler staple OrcaSlicer_profile_validator_Mac_universal_${{ env.ver }}.dmg fi + - name: Sign DMG for Sparkle auto-update + # TODO: Remove feature/auto-update after testing is complete + if: github.repository == 'OrcaSlicer/OrcaSlicer' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || github.ref == 'refs/heads/feature/auto-update') && inputs.os == 'macos-14' + working-directory: ${{ github.workspace }} + env: + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + run: | + # Get the Sparkle sign_update tool from deps (installed to OrcaSlicer_dep/bin) + SIGN_UPDATE="${{ github.workspace }}/deps/build/arm64/OrcaSlicer_dep/bin/sign_update" + + # Fallback to x86_64 if arm64 not found + if [ ! -f "$SIGN_UPDATE" ]; then + SIGN_UPDATE="${{ github.workspace }}/deps/build/x86_64/OrcaSlicer_dep/bin/sign_update" + fi + + if [ -f "$SIGN_UPDATE" ] && [ -n "$SPARKLE_PRIVATE_KEY" ]; then + # Write the private key to a temp file + echo "$SPARKLE_PRIVATE_KEY" > /tmp/sparkle_private_key + chmod 600 /tmp/sparkle_private_key + + # Sign the DMG and capture the signature + SIGNATURE=$("$SIGN_UPDATE" "OrcaSlicer_Mac_universal_${{ env.ver }}.dmg" -f /tmp/sparkle_private_key) + + # Clean up the key file + rm -f /tmp/sparkle_private_key + + # Save signature to a file for later use in appcast generation + echo "$SIGNATURE" > OrcaSlicer_Mac_universal_${{ env.ver }}.dmg.sig + echo "Sparkle signature generated: $SIGNATURE" + + # Also output as GitHub Actions output + echo "sparkle_signature=$SIGNATURE" >> $GITHUB_OUTPUT + else + echo "Warning: Sparkle sign_update tool not found at $SIGN_UPDATE or private key not set, skipping signature generation" + if [ ! -f "$SIGN_UPDATE" ]; then + echo "sign_update not found. Available files:" + ls -la "${{ github.workspace }}/deps/build/arm64/OrcaSlicer_dep/" || true + fi + fi + - name: Create DMG without notary - if: github.ref != 'refs/heads/main' && inputs.os == 'macos-14' + # TODO: Remove feature/auto-update exclusion after testing is complete + if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/feature/auto-update' && !startsWith(github.ref, 'refs/heads/release/') && inputs.os == 'macos-14' working-directory: ${{ github.workspace }} run: | - mkdir -p ${{ github.workspace }}/build/universal/OrcaSlicer_dmg - rm -rf ${{ github.workspace }}/build/universal/OrcaSlicer_dmg/* - cp -R ${{ github.workspace }}/build/universal/OrcaSlicer/OrcaSlicer.app ${{ github.workspace }}/build/universal/OrcaSlicer_dmg/ - ln -sfn /Applications ${{ github.workspace }}/build/universal/OrcaSlicer_dmg/Applications - hdiutil create -volname "OrcaSlicer" -srcfolder ${{ github.workspace }}/build/universal/OrcaSlicer_dmg -ov -format UDZO OrcaSlicer_Mac_universal_${{ env.ver }}.dmg - + # TODO: Change build/arm64 back to build/universal after auto-update testing is complete + mkdir -p ${{ github.workspace }}/build/arm64/OrcaSlicer_dmg + rm -rf ${{ github.workspace }}/build/arm64/OrcaSlicer_dmg/* + cp -R ${{ github.workspace }}/build/arm64/OrcaSlicer/OrcaSlicer.app ${{ github.workspace }}/build/arm64/OrcaSlicer_dmg/ + ln -sfn /Applications ${{ github.workspace }}/build/arm64/OrcaSlicer_dmg/Applications + hdiutil create -volname "OrcaSlicer" -srcfolder ${{ github.workspace }}/build/arm64/OrcaSlicer_dmg -ov -format UDZO OrcaSlicer_Mac_universal_${{ env.ver }}.dmg + # Create separate OrcaSlicer_profile_validator DMG if the app exists - if [ -f "${{ github.workspace }}/build/universal/OrcaSlicer/OrcaSlicer_profile_validator.app/Contents/MacOS/OrcaSlicer_profile_validator" ]; then - mkdir -p ${{ github.workspace }}/build/universal/OrcaSlicer_profile_validator_dmg - rm -rf ${{ github.workspace }}/build/universal/OrcaSlicer_profile_validator_dmg/* - cp -R ${{ github.workspace }}/build/universal/OrcaSlicer/OrcaSlicer_profile_validator.app ${{ github.workspace }}/build/universal/OrcaSlicer_profile_validator_dmg/ - ln -sfn /Applications ${{ github.workspace }}/build/universal/OrcaSlicer_profile_validator_dmg/Applications - hdiutil create -volname "OrcaSlicer Profile Validator" -srcfolder ${{ github.workspace }}/build/universal/OrcaSlicer_profile_validator_dmg -ov -format UDZO OrcaSlicer_profile_validator_Mac_universal_${{ env.ver }}.dmg + if [ -f "${{ github.workspace }}/build/arm64/OrcaSlicer/OrcaSlicer_profile_validator.app/Contents/MacOS/OrcaSlicer_profile_validator" ]; then + mkdir -p ${{ github.workspace }}/build/arm64/OrcaSlicer_profile_validator_dmg + rm -rf ${{ github.workspace }}/build/arm64/OrcaSlicer_profile_validator_dmg/* + cp -R ${{ github.workspace }}/build/arm64/OrcaSlicer/OrcaSlicer_profile_validator.app ${{ github.workspace }}/build/arm64/OrcaSlicer_profile_validator_dmg/ + ln -sfn /Applications ${{ github.workspace }}/build/arm64/OrcaSlicer_profile_validator_dmg/Applications + hdiutil create -volname "OrcaSlicer Profile Validator" -srcfolder ${{ github.workspace }}/build/arm64/OrcaSlicer_profile_validator_dmg -ov -format UDZO OrcaSlicer_profile_validator_Mac_universal_${{ env.ver }}.dmg fi - name: Upload artifacts mac @@ -185,6 +230,14 @@ jobs: name: OrcaSlicer_Mac_universal_${{ env.ver }} path: ${{ github.workspace }}/OrcaSlicer_Mac_universal_${{ env.ver }}.dmg + - name: Upload Sparkle signature mac + if: inputs.os == 'macos-14' + uses: actions/upload-artifact@v5 + with: + name: OrcaSlicer_Mac_universal_${{ env.ver }}_sig + path: ${{ github.workspace }}/OrcaSlicer_Mac_universal_${{ env.ver }}.dmg.sig + if-no-files-found: ignore + - name: Upload OrcaSlicer_profile_validator DMG mac if: inputs.os == 'macos-14' uses: actions/upload-artifact@v5 diff --git a/.github/workflows/generate_appcast.yml b/.github/workflows/generate_appcast.yml new file mode 100644 index 0000000000..e8f2c621d2 --- /dev/null +++ b/.github/workflows/generate_appcast.yml @@ -0,0 +1,138 @@ +name: Generate Appcast + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: 'Version to generate appcast for (e.g., 2.3.2)' + required: true + +jobs: + generate_appcast: + name: Generate and Deploy Appcast + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Get version from release or input + id: version + run: | + if [ "${{ github.event_name }}" == "release" ]; then + VERSION="${{ github.event.release.tag_name }}" + VERSION="${VERSION#v}" # Remove 'v' prefix if present + else + VERSION="${{ github.event.inputs.version }}" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + - name: Download release assets info + id: assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.version }}" + TAG="v$VERSION" + + # Get release info + RELEASE_URL="https://github.com/${{ github.repository }}/releases/tag/$TAG" + echo "release_url=$RELEASE_URL" >> $GITHUB_OUTPUT + + # Get macOS DMG URL and size + # Use browser_download_url for public access (not .url which is the API endpoint) + MAC_ASSET=$(gh release view "$TAG" --json assets -q '.assets[] | select(.name | contains("Mac_universal")) | select(.name | endswith(".dmg"))') + if [ -n "$MAC_ASSET" ]; then + MAC_URL=$(echo "$MAC_ASSET" | jq -r '.browser_download_url // .url') + MAC_SIZE=$(echo "$MAC_ASSET" | jq -r '.size') + echo "mac_url=$MAC_URL" >> $GITHUB_OUTPUT + echo "mac_size=$MAC_SIZE" >> $GITHUB_OUTPUT + fi + + # Get Windows installer URL and size + # Use browser_download_url for public access (not .url which is the API endpoint) + WIN_ASSET=$(gh release view "$TAG" --json assets -q '.assets[] | select(.name | contains("Windows_Installer")) | select(.name | endswith(".exe"))') + if [ -n "$WIN_ASSET" ]; then + WIN_URL=$(echo "$WIN_ASSET" | jq -r '.browser_download_url // .url') + WIN_SIZE=$(echo "$WIN_ASSET" | jq -r '.size') + echo "win_url=$WIN_URL" >> $GITHUB_OUTPUT + echo "win_size=$WIN_SIZE" >> $GITHUB_OUTPUT + fi + + - name: Download signatures + id: signatures + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.version }}" + TAG="v$VERSION" + + # Try to download macOS signature artifact + MAC_SIG_ARTIFACT="OrcaSlicer_Mac_universal_V${VERSION}_sig" + if gh run download --name "$MAC_SIG_ARTIFACT" -D /tmp/mac_sig 2>/dev/null; then + MAC_SIG=$(cat /tmp/mac_sig/*.sig) + echo "mac_signature=$MAC_SIG" >> $GITHUB_OUTPUT + echo "Found macOS signature: $MAC_SIG" + else + echo "No macOS signature artifact found" + fi + + # For Windows, signature would come from WinSparkle signing (if implemented) + # echo "win_signature=$WIN_SIG" >> $GITHUB_OUTPUT + + - name: Generate appcast.xml + run: | + python scripts/generate_appcast.py \ + --version "${{ steps.version.outputs.version }}" \ + --release-notes-url "${{ steps.assets.outputs.release_url }}" \ + --mac-url "${{ steps.assets.outputs.mac_url }}" \ + --mac-signature "${{ steps.signatures.outputs.mac_signature }}" \ + --mac-length "${{ steps.assets.outputs.mac_size }}" \ + --output appcast.xml + + echo "Generated appcast.xml:" + cat appcast.xml + + - name: Upload appcast artifact + uses: actions/upload-artifact@v4 + with: + name: appcast + path: appcast.xml + + # Deploy to Cloudflare KV (for check-version.orcaslicer.com Worker) + - name: Deploy appcast to Cloudflare KV + if: github.event_name == 'release' + env: + CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} + KV_NAMESPACE_ID: ${{ secrets.CF_KV_NAMESPACE_ID }} + run: | + if [ -n "$CF_API_TOKEN" ] && [ -n "$CF_ACCOUNT_ID" ] && [ -n "$KV_NAMESPACE_ID" ]; then + # Deploy appcast.xml + curl -X PUT \ + "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/storage/kv/namespaces/$KV_NAMESPACE_ID/values/appcast.xml" \ + -H "Authorization: Bearer $CF_API_TOKEN" \ + -H "Content-Type: text/plain" \ + --data-binary @appcast.xml + echo "Appcast deployed to Cloudflare KV" + + # Deploy macOS signature file (for verification/auditing) + if [ -n "${{ steps.signatures.outputs.mac_signature }}" ]; then + echo "${{ steps.signatures.outputs.mac_signature }}" > mac_signature.txt + curl -X PUT \ + "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/storage/kv/namespaces/$KV_NAMESPACE_ID/values/signatures/${{ steps.version.outputs.version }}/mac.sig" \ + -H "Authorization: Bearer $CF_API_TOKEN" \ + -H "Content-Type: text/plain" \ + --data-binary @mac_signature.txt + echo "macOS signature deployed to Cloudflare KV" + fi + else + echo "Cloudflare credentials not configured, skipping deployment" + fi diff --git a/CMakeLists.txt b/CMakeLists.txt index 162763621c..0bd1448d2e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -811,6 +811,11 @@ function(orcaslicer_copy_dlls target config postfix output_dlls) ${TOP_LEVEL_PROJECT_DIR}/deps/WebView2/lib/win-${_arch}/WebView2Loader.dll DESTINATION ${_out_dir}) + # WinSparkle for auto-updates + if(EXISTS "${CMAKE_PREFIX_PATH}/bin/WinSparkle.dll") + file(COPY ${CMAKE_PREFIX_PATH}/bin/WinSparkle.dll DESTINATION ${_out_dir}) + endif() + file(COPY ${CMAKE_PREFIX_PATH}/bin/occt/TKBO.dll ${CMAKE_PREFIX_PATH}/bin/occt/TKBRep.dll ${CMAKE_PREFIX_PATH}/bin/occt/TKCAF.dll diff --git a/deps/CMakeLists.txt b/deps/CMakeLists.txt index 13f3945d0c..1305494e22 100644 --- a/deps/CMakeLists.txt +++ b/deps/CMakeLists.txt @@ -386,6 +386,16 @@ endif () include(OCCT/OCCT.cmake) include(OpenCV/OpenCV.cmake) +# WinSparkle for Windows auto-updates +if(WIN32) + include(WinSparkle/WinSparkle.cmake) +endif() + +# Sparkle 2 for macOS auto-updates +if(APPLE) + include(Sparkle/Sparkle.cmake) +endif() + set(_dep_list dep_Boost dep_TBB @@ -410,12 +420,19 @@ set(_dep_list if (MSVC) # Experimental #list(APPEND _dep_list "dep_qhull") + # WinSparkle for auto-updates + list(APPEND _dep_list "dep_WinSparkle") else() list(APPEND _dep_list "dep_Qhull") # Not working, static build has different Eigen #list(APPEND _dep_list "dep_libigl") endif() +if (APPLE) + # Sparkle 2 for auto-updates + list(APPEND _dep_list "dep_Sparkle") +endif() + add_custom_target(deps ALL DEPENDS ${_dep_list}) # Note: I'm not using any of the LOG_xxx options in ExternalProject_Add() commands diff --git a/deps/Sparkle/Sparkle.cmake b/deps/Sparkle/Sparkle.cmake new file mode 100644 index 0000000000..cc3b055a48 --- /dev/null +++ b/deps/Sparkle/Sparkle.cmake @@ -0,0 +1,27 @@ +# Sparkle 2 - Auto-update framework for macOS +# https://sparkle-project.org/ +# https://github.com/sparkle-project/Sparkle +# +# Sparkle is distributed as a pre-built framework, so we just download and extract. + +if(APPLE) + set(SPARKLE_VERSION "2.8.1") + + ExternalProject_Add( + dep_Sparkle + EXCLUDE_FROM_ALL ON + URL "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" + URL_HASH SHA256=5cddb7695674ef7704268f38eccaee80e3accbf19e61c1689efff5b6116d85be + DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/Sparkle + # No build step needed - just install pre-built framework and tools + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND ${CMAKE_COMMAND} -E make_directory ${DESTDIR}/Frameworks + COMMAND ${CMAKE_COMMAND} -E copy_directory + /Sparkle.framework ${DESTDIR}/Frameworks/Sparkle.framework + # Also install the Sparkle CLI tools (sign_update, generate_appcast) for CI/CD signing + COMMAND ${CMAKE_COMMAND} -E make_directory ${DESTDIR}/bin + COMMAND ${CMAKE_COMMAND} -E copy /bin/sign_update ${DESTDIR}/bin/sign_update + COMMAND ${CMAKE_COMMAND} -E copy /bin/generate_appcast ${DESTDIR}/bin/generate_appcast + ) +endif() diff --git a/deps/WinSparkle/WinSparkle.cmake b/deps/WinSparkle/WinSparkle.cmake new file mode 100644 index 0000000000..02fb17eea5 --- /dev/null +++ b/deps/WinSparkle/WinSparkle.cmake @@ -0,0 +1,33 @@ +# WinSparkle - Auto-update framework for Windows +# https://winsparkle.org/ +# https://github.com/vslavik/winsparkle +# +# WinSparkle is distributed as pre-built binaries, so we just download and extract. + +if(WIN32) + set(WINSPARKLE_VERSION "0.8.3") + + # Determine architecture + if(CMAKE_SIZEOF_VOID_P EQUAL 8) + set(WINSPARKLE_ARCH "x64") + else() + set(WINSPARKLE_ARCH "x86") + endif() + + ExternalProject_Add( + dep_WinSparkle + EXCLUDE_FROM_ALL ON + URL "https://github.com/vslavik/winsparkle/releases/download/v${WINSPARKLE_VERSION}/WinSparkle-${WINSPARKLE_VERSION}.zip" + URL_HASH SHA256=5ff4a4604c78d57e01d83e22f79f5ffea0c4969defd48b45c69ccbd6b1a71e94 + DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/WinSparkle + # No build step needed - just install pre-built binaries + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND ${CMAKE_COMMAND} -E copy_directory + /include ${DESTDIR}/include + COMMAND ${CMAKE_COMMAND} -E copy + /${WINSPARKLE_ARCH}/Release/WinSparkle.dll ${DESTDIR}/bin/WinSparkle.dll + COMMAND ${CMAKE_COMMAND} -E copy + /${WINSPARKLE_ARCH}/Release/WinSparkle.lib ${DESTDIR}/lib/WinSparkle.lib + ) +endif() diff --git a/scripts/generate_appcast.py b/scripts/generate_appcast.py new file mode 100755 index 0000000000..c8e6ecff5d --- /dev/null +++ b/scripts/generate_appcast.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Generate appcast.xml for Sparkle/WinSparkle auto-updates. + +This script generates an appcast XML file that can be used by both +Sparkle (macOS) and WinSparkle (Windows) for auto-update functionality. + +Usage: + python generate_appcast.py --version 2.1.0 \ + --win-url https://github.com/.../OrcaSlicer_Windows.exe \ + --win-signature "BASE64_SIGNATURE" \ + --win-length 150000000 \ + --mac-url https://github.com/.../OrcaSlicer_Mac.dmg \ + --mac-signature "BASE64_SIGNATURE" \ + --mac-length 200000000 \ + --release-notes-url https://github.com/.../releases/tag/v2.1.0 \ + --output appcast.xml +""" + +import argparse +import os +import sys +from datetime import datetime, timezone +from xml.etree import ElementTree as ET +from xml.dom import minidom + + +SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle" +DC_NS = "http://purl.org/dc/elements/1.1/" + + +def create_appcast( + version: str, + release_notes_url: str, + win_url: str = None, + win_signature: str = None, + win_length: int = None, + mac_url: str = None, + mac_signature: str = None, + mac_length: int = None, + title: str = "OrcaSlicer Updates", + description: str = "Most recent updates to OrcaSlicer", + link: str = "https://github.com/OrcaSlicer/OrcaSlicer", +) -> str: + """ + Create an appcast XML string. + + Args: + version: Version string (e.g., "2.1.0") + release_notes_url: URL to release notes HTML page + win_url: Download URL for Windows installer + win_signature: EdDSA signature for Windows installer + win_length: File size in bytes for Windows installer + mac_url: Download URL for macOS DMG + mac_signature: EdDSA signature for macOS DMG + mac_length: File size in bytes for macOS DMG + title: Feed title + description: Feed description + link: Link to project homepage + + Returns: + XML string of the appcast + """ + # Register namespaces + ET.register_namespace("sparkle", SPARKLE_NS) + ET.register_namespace("dc", DC_NS) + + # Create root RSS element + rss = ET.Element("rss") + rss.set("version", "2.0") + rss.set(f"xmlns:sparkle", SPARKLE_NS) + rss.set(f"xmlns:dc", DC_NS) + + # Create channel + channel = ET.SubElement(rss, "channel") + ET.SubElement(channel, "title").text = title + ET.SubElement(channel, "link").text = link + ET.SubElement(channel, "description").text = description + ET.SubElement(channel, "language").text = "en" + + # Create item for this release + item = ET.SubElement(channel, "item") + ET.SubElement(item, "title").text = f"Version {version}" + + # Publication date in RFC 2822 format + pub_date = datetime.now(timezone.utc).strftime("%a, %d %b %Y %H:%M:%S +0000") + ET.SubElement(item, "pubDate").text = pub_date + + # Release notes link + release_notes = ET.SubElement(item, f"{{{SPARKLE_NS}}}releaseNotesLink") + release_notes.text = release_notes_url + + # Windows enclosure + if win_url and win_signature: + win_enclosure = ET.SubElement(item, "enclosure") + win_enclosure.set("url", win_url) + win_enclosure.set(f"{{{SPARKLE_NS}}}version", version) + win_enclosure.set(f"{{{SPARKLE_NS}}}os", "windows") + win_enclosure.set(f"{{{SPARKLE_NS}}}edSignature", win_signature) + if win_length: + win_enclosure.set("length", str(win_length)) + win_enclosure.set("type", "application/octet-stream") + + # macOS enclosure + if mac_url and mac_signature: + mac_enclosure = ET.SubElement(item, "enclosure") + mac_enclosure.set("url", mac_url) + mac_enclosure.set(f"{{{SPARKLE_NS}}}version", version) + mac_enclosure.set(f"{{{SPARKLE_NS}}}os", "macos") + mac_enclosure.set(f"{{{SPARKLE_NS}}}edSignature", mac_signature) + if mac_length: + mac_enclosure.set("length", str(mac_length)) + mac_enclosure.set("type", "application/octet-stream") + + # Convert to pretty-printed string + rough_string = ET.tostring(rss, encoding="unicode", method="xml") + reparsed = minidom.parseString(rough_string) + pretty_xml = reparsed.toprettyxml(indent=" ", encoding=None) + + # Remove extra blank lines + lines = [line for line in pretty_xml.split("\n") if line.strip()] + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + description="Generate appcast.xml for OrcaSlicer auto-updates", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + + parser.add_argument( + "--version", "-v", required=True, help="Version string (e.g., 2.1.0)" + ) + parser.add_argument( + "--release-notes-url", + "-r", + required=True, + help="URL to release notes page", + ) + parser.add_argument( + "--win-url", help="Download URL for Windows installer" + ) + parser.add_argument( + "--win-signature", help="EdDSA signature for Windows installer" + ) + parser.add_argument( + "--win-length", type=int, help="File size in bytes for Windows installer" + ) + parser.add_argument( + "--mac-url", help="Download URL for macOS DMG" + ) + parser.add_argument( + "--mac-signature", help="EdDSA signature for macOS DMG" + ) + parser.add_argument( + "--mac-length", type=int, help="File size in bytes for macOS DMG" + ) + parser.add_argument( + "--output", "-o", default="appcast.xml", help="Output file path" + ) + parser.add_argument( + "--title", default="OrcaSlicer Updates", help="Feed title" + ) + parser.add_argument( + "--link", + default="https://github.com/OrcaSlicer/OrcaSlicer", + help="Project homepage URL", + ) + + args = parser.parse_args() + + # Validate that at least one platform is specified + has_windows = args.win_url and args.win_signature + has_macos = args.mac_url and args.mac_signature + + if not has_windows and not has_macos: + print( + "Error: At least one platform (Windows or macOS) must have both URL and signature", + file=sys.stderr, + ) + sys.exit(1) + + # Generate appcast + xml_content = create_appcast( + version=args.version, + release_notes_url=args.release_notes_url, + win_url=args.win_url, + win_signature=args.win_signature, + win_length=args.win_length, + mac_url=args.mac_url, + mac_signature=args.mac_signature, + mac_length=args.mac_length, + title=args.title, + link=args.link, + ) + + # Write output + if args.output == "-": + print(xml_content) + else: + with open(args.output, "w", encoding="utf-8") as f: + f.write(xml_content) + print(f"Appcast written to: {args.output}") + + +if __name__ == "__main__": + main() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8e9a31f705..a8a61cd54c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -131,6 +131,11 @@ if (APPLE) # add_definitions(-DBOOST_THREAD_DONT_USE_CHRONO -DBOOST_NO_CXX11_RVALUE_REFERENCES -DBOOST_THREAD_USES_MOVE) # -liconv: boost links to libiconv by default target_link_libraries(OrcaSlicer "-liconv -framework IOKit" "-framework CoreFoundation" "-framework AVFoundation" "-framework AVKit" "-framework CoreMedia" "-framework VideoToolbox" -lc++) + # Set rpath for embedded frameworks (Sparkle) + set_target_properties(OrcaSlicer PROPERTIES + INSTALL_RPATH "@executable_path/../Frameworks" + BUILD_WITH_INSTALL_RPATH TRUE + ) elseif (MSVC) # Manifest is provided through OrcaSlicer.rc, don't generate your own. set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} /MANIFEST:NO") @@ -252,6 +257,24 @@ else () COMMAND ln -sfn "${SLIC3R_RESOURCES_DIR}" "${BIN_RESOURCES_DIR}" COMMENT "Symlinking the resources directory into the build tree" VERBATIM) + + # Embed Sparkle framework for auto-updates + if (CMAKE_MACOSX_BUNDLE) + find_library(SPARKLE_FRAMEWORK Sparkle PATHS ${CMAKE_PREFIX_PATH}/Frameworks) + if(SPARKLE_FRAMEWORK) + if (CMAKE_CONFIGURATION_TYPES) + set(BUNDLE_FRAMEWORKS_DIR "${CMAKE_CURRENT_BINARY_DIR}/$/OrcaSlicer.app/Contents/Frameworks") + else() + set(BUNDLE_FRAMEWORKS_DIR "${CMAKE_CURRENT_BINARY_DIR}/OrcaSlicer.app/Contents/Frameworks") + endif() + add_custom_command(TARGET OrcaSlicer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${BUNDLE_FRAMEWORKS_DIR}" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${SPARKLE_FRAMEWORK}" "${BUNDLE_FRAMEWORKS_DIR}/Sparkle.framework" + COMMENT "Embedding Sparkle.framework into app bundle" + VERBATIM) + message(STATUS "Sparkle framework will be embedded: ${SPARKLE_FRAMEWORK}") + endif() + endif() endif () # Slic3r binary install target. Default build type is release in case no CMAKE_BUILD_TYPE is provided. diff --git a/src/dev-utils/platform/osx/Info.plist.in b/src/dev-utils/platform/osx/Info.plist.in index a2621d2521..a443861421 100644 --- a/src/dev-utils/platform/osx/Info.plist.in +++ b/src/dev-utils/platform/osx/Info.plist.in @@ -135,5 +135,14 @@ ASAN_OPTIONS detect_container_overflow=0 + + SUFeedURL + https://check-version.orcaslicer.com/appcast.xml + SUPublicEDKey + eLFARgt9i0VZQR4FtXiTL6jdwjkGr2RMPjfYCCfBWeM= + SUEnableAutomaticChecks + + SUAllowsAutomaticUpdates + diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index ca4f9c4123..6d68437cd0 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -456,6 +456,8 @@ set(SLIC3R_GUI_SOURCES GUI/UnsavedChangesDialog.hpp GUI/UpdateDialogs.cpp GUI/UpdateDialogs.hpp + GUI/UpdateManager.hpp + GUI/UpdateManager.cpp GUI/UpgradePanel.cpp GUI/UpgradePanel.hpp GUI/UserManager.cpp @@ -648,6 +650,7 @@ if (APPLE) GUI/InstanceCheckMac.mm GUI/InstanceCheckMac.h GUI/GUI_UtilsMac.mm + GUI/UpdateManagerMac.mm GUI/wxMediaCtrl2.mm GUI/wxMediaCtrl2.h ) @@ -672,11 +675,6 @@ string(REPLACE "\r" "" ORCA_UPDATER_SIG_KEY_B64 "${ORCA_UPDATER_SIG_KEY_B64}") string(REPLACE "\t" "" ORCA_UPDATER_SIG_KEY_B64 "${ORCA_UPDATER_SIG_KEY_B64}") string(REPLACE " " "" ORCA_UPDATER_SIG_KEY_B64 "${ORCA_UPDATER_SIG_KEY_B64}") -set(ORCA_UPDATER_SIG_KEY_AVAILABLE 0) -if(ORCA_UPDATER_SIG_KEY_B64) - set(ORCA_UPDATER_SIG_KEY_AVAILABLE 1) -endif() - configure_file(${CMAKE_CURRENT_SOURCE_DIR}/GeneratedConfig.hpp.in ${CMAKE_CURRENT_BINARY_DIR}/GeneratedConfig.hpp @ONLY) @@ -707,6 +705,16 @@ target_link_libraries(libslic3r_gui libslic3r cereal::cereal imgui imguizmo mini if (MSVC) target_link_libraries(libslic3r_gui Setupapi.lib) + # WinSparkle for auto-updates + find_library(WINSPARKLE_LIB WinSparkle PATHS ${CMAKE_PREFIX_PATH}/lib) + if(WINSPARKLE_LIB) + target_link_libraries(libslic3r_gui ${WINSPARKLE_LIB}) + target_include_directories(libslic3r_gui PRIVATE ${CMAKE_PREFIX_PATH}/include) + target_compile_definitions(libslic3r_gui PRIVATE ORCA_HAS_WINSPARKLE) + message(STATUS "WinSparkle found: ${WINSPARKLE_LIB}") + else() + message(STATUS "WinSparkle not found, auto-update disabled on Windows") + endif() elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux") FIND_LIBRARY(WAYLAND_SERVER_LIBRARIES NAMES wayland-server) FIND_LIBRARY(WAYLAND_EGL_LIBRARIES NAMES wayland-egl) @@ -722,6 +730,15 @@ elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux") ) elseif (APPLE) target_link_libraries(libslic3r_gui ${DISKARBITRATION_LIBRARY} "-framework Security") + # Sparkle 2 for auto-updates + find_library(SPARKLE_FRAMEWORK Sparkle PATHS ${CMAKE_PREFIX_PATH}/Frameworks) + if(SPARKLE_FRAMEWORK) + target_link_libraries(libslic3r_gui ${SPARKLE_FRAMEWORK}) + target_compile_definitions(libslic3r_gui PRIVATE ORCA_HAS_SPARKLE) + message(STATUS "Sparkle found: ${SPARKLE_FRAMEWORK}") + else() + message(STATUS "Sparkle not found, auto-update disabled on macOS") + endif() endif() if (SLIC3R_STATIC) diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 27816e2b67..36db1003dc 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -91,6 +91,7 @@ #include "Tab.hpp" #include "SysInfoDialog.hpp" #include "UpdateDialogs.hpp" +#include "UpdateManager.hpp" #include "Mouse3DController.hpp" #include "RemovableDriveManager.hpp" #include "InstanceCheck.hpp" @@ -984,6 +985,16 @@ void GUI_App::post_init() BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " sync_user_preset: false"; } + // Initialize the auto-update manager +#if defined(ORCA_HAS_SPARKLE) || defined(ORCA_HAS_WINSPARKLE) + { + // Use the appcast URL for Sparkle/WinSparkle (different from version_check_url) + std::string appcast_url = "https://check-version.orcaslicer.com/appcast.xml"; + UpdateManager::init(appcast_url, ""); + BOOST_LOG_TRIVIAL(info) << "UpdateManager initialized with appcast URL: " << appcast_url; + } +#endif + // The extra CallAfter() is needed because of Mac, where this is the only way // to popup a modal dialog on start without screwing combo boxes. // This is ugly but I honestly found no better way to do it. @@ -998,7 +1009,12 @@ void GUI_App::post_init() bool sys_preset = app_config->get("sync_system_preset") == "true"; this->preset_updater->sync(http_url, language, network_ver, sys_preset ? preset_bundle : nullptr); + // Check for application updates +#if defined(ORCA_HAS_SPARKLE) || defined(ORCA_HAS_WINSPARKLE) + UpdateManager::check_for_updates_background(); +#else this->check_new_version_sf(); +#endif if (is_user_login() && !app_config->get_stealth_mode()) { // this->check_privacy_version(0); request_user_handle(0); @@ -2240,6 +2256,11 @@ bool GUI_App::OnInit() int GUI_App::OnExit() { + // Shutdown the auto-update manager +#if defined(ORCA_HAS_SPARKLE) || defined(ORCA_HAS_WINSPARKLE) + UpdateManager::shutdown(); +#endif + stop_sync_user_preset(); if (m_device_manager) { @@ -4630,7 +4651,7 @@ std::string base64url_encode(const unsigned char* data, std::size_t length) std::optional> load_signature_key() { -#if ORCA_UPDATER_SIG_KEY_AVAILABLE +#ifdef ORCA_UPDATER_SIG_KEY_B64 std::string key = ORCA_UPDATER_SIG_KEY_B64; boost::algorithm::trim(key); if (key.empty()) diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 6d0023a0ba..1e4406bdb7 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -60,6 +60,7 @@ #include "MarkdownTip.hpp" #include "NetworkTestDialog.hpp" #include "ConfigWizard.hpp" +#include "UpdateManager.hpp" #include "Widgets/WebView.hpp" #include "DailyTips.hpp" #include "FilamentMapDialog.hpp" @@ -2332,7 +2333,11 @@ static wxMenu* generate_help_menu() // Check New Version append_menu_item(helpMenu, wxID_ANY, _L("Check for Update"), _L("Check for Update"), [](wxCommandEvent&) { +#if defined(ORCA_HAS_SPARKLE) || defined(ORCA_HAS_WINSPARKLE) + UpdateManager::check_for_updates_interactive(); +#else wxGetApp().check_new_version_sf(true, 1); +#endif }, "", nullptr, []() { return true; }); diff --git a/src/slic3r/GUI/UpdateManager.cpp b/src/slic3r/GUI/UpdateManager.cpp new file mode 100644 index 0000000000..9641e6147e --- /dev/null +++ b/src/slic3r/GUI/UpdateManager.cpp @@ -0,0 +1,174 @@ +#include "UpdateManager.hpp" +#include "libslic3r/libslic3r.h" + +#include + +// Windows implementation uses WinSparkle +#if defined(_WIN32) && defined(ORCA_HAS_WINSPARKLE) +#include +#include +#endif + +namespace Slic3r { +namespace GUI { + +// Static member definitions (defined in UpdateManagerMac.mm for macOS) +#if !defined(__APPLE__) +bool UpdateManager::s_initialized = false; +std::string UpdateManager::s_appcast_url; +std::string UpdateManager::s_public_key; +#endif + +#if defined(_WIN32) && defined(ORCA_HAS_WINSPARKLE) +// ============================================================================ +// Windows Implementation (WinSparkle) +// ============================================================================ + +void UpdateManager::init(const std::string& appcast_url, const std::string& public_key) +{ + if (s_initialized) { + BOOST_LOG_TRIVIAL(warning) << "UpdateManager::init called multiple times"; + return; + } + + s_appcast_url = appcast_url; + s_public_key = public_key; + + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Initializing WinSparkle with appcast URL: " << appcast_url; + + // Set application details for registry storage + win_sparkle_set_app_details(L"SoftFever", L"OrcaSlicer", L"" SLIC3R_VERSION); + + // Set appcast URL + win_sparkle_set_appcast_url(appcast_url.c_str()); + + // Set EdDSA public key for signature verification + if (!public_key.empty()) { + win_sparkle_set_dsa_pub_pem(public_key.c_str()); + BOOST_LOG_TRIVIAL(info) << "UpdateManager: EdDSA public key configured"; + } else { + BOOST_LOG_TRIVIAL(warning) << "UpdateManager: No public key provided, signature verification disabled"; + } + + // Initialize WinSparkle (starts background thread) + win_sparkle_init(); + s_initialized = true; + + BOOST_LOG_TRIVIAL(info) << "UpdateManager: WinSparkle initialized successfully"; +} + +void UpdateManager::check_for_updates_interactive() +{ + if (!s_initialized) { + BOOST_LOG_TRIVIAL(warning) << "UpdateManager::check_for_updates_interactive called before init"; + return; + } + + BOOST_LOG_TRIVIAL(info) << "UpdateManager: User-triggered update check"; + win_sparkle_check_update_with_ui(); +} + +void UpdateManager::check_for_updates_background() +{ + if (!s_initialized) { + BOOST_LOG_TRIVIAL(warning) << "UpdateManager::check_for_updates_background called before init"; + return; + } + + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Background update check"; + win_sparkle_check_update_without_ui(); +} + +void UpdateManager::shutdown() +{ + if (!s_initialized) { + return; + } + + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Shutting down WinSparkle"; + win_sparkle_cleanup(); + s_initialized = false; +} + +void UpdateManager::set_automatic_check_enabled(bool enabled) +{ + // WinSparkle manages automatic checks via registry settings + // The user can configure this through WinSparkle's own preferences dialog + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Automatic check enabled: " << enabled; +} + +#elif defined(__linux__) +// ============================================================================ +// Linux Implementation (Stub - AppImageUpdate deferred) +// ============================================================================ + +void UpdateManager::init(const std::string& appcast_url, const std::string& public_key) +{ + s_appcast_url = appcast_url; + s_public_key = public_key; + s_initialized = true; + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Linux auto-update not yet implemented (stub)"; +} + +void UpdateManager::check_for_updates_interactive() +{ + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Linux interactive update check not implemented"; + // TODO: Implement AppImageUpdate integration + // For now, fall back to the old behavior (handled by caller) +} + +void UpdateManager::check_for_updates_background() +{ + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Linux background update check not implemented"; + // TODO: Implement AppImageUpdate integration +} + +void UpdateManager::shutdown() +{ + s_initialized = false; +} + +void UpdateManager::set_automatic_check_enabled(bool enabled) +{ + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Linux automatic check not implemented"; +} + +#elif !defined(__APPLE__) +// ============================================================================ +// Fallback Implementation (No auto-update support) +// ============================================================================ + +void UpdateManager::init(const std::string& appcast_url, const std::string& public_key) +{ + s_appcast_url = appcast_url; + s_public_key = public_key; + s_initialized = true; + BOOST_LOG_TRIVIAL(info) << "UpdateManager: No auto-update support on this platform"; +} + +void UpdateManager::check_for_updates_interactive() +{ + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Interactive update check not available"; +} + +void UpdateManager::check_for_updates_background() +{ + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Background update check not available"; +} + +void UpdateManager::shutdown() +{ + s_initialized = false; +} + +void UpdateManager::set_automatic_check_enabled(bool enabled) +{ + // No-op +} + +#endif + +// Note: macOS implementation is in UpdateManagerMac.mm + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/UpdateManager.hpp b/src/slic3r/GUI/UpdateManager.hpp new file mode 100644 index 0000000000..b189b7ca5e --- /dev/null +++ b/src/slic3r/GUI/UpdateManager.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace Slic3r { +namespace GUI { + +/// Cross-platform auto-update manager abstraction. +/// Uses WinSparkle on Windows, Sparkle 2 on macOS. +/// Linux support deferred (stub implementation). +class UpdateManager { +public: + /// Initialize the platform-specific updater. + /// Must be called once during application startup (GUI_App::on_init_inner). + /// @param appcast_url URL to the appcast XML feed + /// @param public_key Base64-encoded Ed25519 public key for signature verification + static void init(const std::string& appcast_url, const std::string& public_key); + + /// Manual check triggered by user (Help -> Check for Updates). + /// Shows UI regardless of whether an update is available. + static void check_for_updates_interactive(); + + /// Background check called on application startup. + /// Only shows UI if an update is available. + static void check_for_updates_background(); + + /// Cleanup on application exit. + static void shutdown(); + + /// Enable or disable automatic update checks. + /// @param enabled If true, automatically check for updates periodically + static void set_automatic_check_enabled(bool enabled); + +private: + static bool s_initialized; + static std::string s_appcast_url; + static std::string s_public_key; +}; + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/UpdateManagerMac.mm b/src/slic3r/GUI/UpdateManagerMac.mm new file mode 100644 index 0000000000..9c0efed746 --- /dev/null +++ b/src/slic3r/GUI/UpdateManagerMac.mm @@ -0,0 +1,204 @@ +#include "UpdateManager.hpp" + +#ifdef __APPLE__ + +#ifdef ORCA_HAS_SPARKLE +#import +#endif + +#include + +// ============================================================================ +// macOS Implementation (Sparkle 2) +// ============================================================================ + +namespace Slic3r { +namespace GUI { + +// Static member definitions (defined in UpdateManager.cpp for other platforms) +// For macOS, we need to define them here since UpdateManagerMac.mm is compiled instead +bool UpdateManager::s_initialized = false; +std::string UpdateManager::s_appcast_url; +std::string UpdateManager::s_public_key; + +#ifdef ORCA_HAS_SPARKLE + +// Sparkle updater delegate for custom behavior +@interface OrcaSparkleDelegate : NSObject +@end + +@implementation OrcaSparkleDelegate + +// Optional: Add custom parameters to the appcast request +- (NSArray *> *)feedParametersForUpdater:(SPUUpdater *)updater + sendingSystemProfile:(BOOL)sendingProfile +{ + // Add OrcaSlicer-specific parameters to the update check request + NSString *version = [NSString stringWithUTF8String:SLIC3R_VERSION]; + NSString *osVersion = [[NSProcessInfo processInfo] operatingSystemVersionString]; + + return @[ + @{@"key": @"app_version", @"value": version ?: @"unknown"}, + @{@"key": @"os_version", @"value": osVersion ?: @"unknown"} + ]; +} + +// Optional: Handle update errors +- (void)updater:(SPUUpdater *)updater didAbortWithError:(NSError *)error +{ + BOOST_LOG_TRIVIAL(error) << "UpdateManager: Sparkle update aborted with error: " + << [[error localizedDescription] UTF8String]; +} + +// Optional: Called when an update is found +- (void)updater:(SPUUpdater *)updater didFindValidUpdate:(SUAppcastItem *)item +{ + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Found update to version " + << [[item displayVersionString] UTF8String]; +} + +// Optional: Called when no update is available +- (void)updaterDidNotFindUpdate:(SPUUpdater *)updater +{ + BOOST_LOG_TRIVIAL(info) << "UpdateManager: No update available"; +} + +@end + +// Static Sparkle controller and delegate instances +static SPUStandardUpdaterController *s_updater_controller = nil; +static OrcaSparkleDelegate *s_updater_delegate = nil; + +void UpdateManager::init(const std::string& appcast_url, const std::string& public_key) +{ + if (s_initialized) { + BOOST_LOG_TRIVIAL(warning) << "UpdateManager::init called multiple times"; + return; + } + + s_appcast_url = appcast_url; + s_public_key = public_key; + + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Initializing Sparkle 2"; + + @autoreleasepool { + // Create the delegate + s_updater_delegate = [[OrcaSparkleDelegate alloc] init]; + + // Create the standard updater controller + // This reads SUFeedURL and SUPublicEDKey from Info.plist + s_updater_controller = [[SPUStandardUpdaterController alloc] + initWithStartingUpdater:YES + updaterDelegate:s_updater_delegate + userDriverDelegate:nil]; + + if (s_updater_controller) { + s_initialized = true; + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Sparkle 2 initialized successfully"; + } else { + BOOST_LOG_TRIVIAL(error) << "UpdateManager: Failed to initialize Sparkle 2"; + } + } +} + +void UpdateManager::check_for_updates_interactive() +{ + if (!s_initialized || !s_updater_controller) { + BOOST_LOG_TRIVIAL(warning) << "UpdateManager::check_for_updates_interactive called before init"; + return; + } + + BOOST_LOG_TRIVIAL(info) << "UpdateManager: User-triggered update check (Sparkle)"; + + @autoreleasepool { + [s_updater_controller checkForUpdates:nil]; + } +} + +void UpdateManager::check_for_updates_background() +{ + if (!s_initialized || !s_updater_controller) { + BOOST_LOG_TRIVIAL(warning) << "UpdateManager::check_for_updates_background called before init"; + return; + } + + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Background update check (Sparkle)"; + + @autoreleasepool { + SPUUpdater *updater = s_updater_controller.updater; + if (updater) { + [updater checkForUpdatesInBackground]; + } + } +} + +void UpdateManager::shutdown() +{ + if (!s_initialized) { + return; + } + + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Shutting down Sparkle"; + + @autoreleasepool { + // Sparkle handles cleanup automatically when the controller is released + s_updater_controller = nil; + s_updater_delegate = nil; + } + + s_initialized = false; +} + +void UpdateManager::set_automatic_check_enabled(bool enabled) +{ + if (!s_initialized || !s_updater_controller) { + return; + } + + @autoreleasepool { + SPUUpdater *updater = s_updater_controller.updater; + if (updater) { + updater.automaticallyChecksForUpdates = enabled; + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Automatic check enabled: " << enabled; + } + } +} + +#else // !ORCA_HAS_SPARKLE + +// Stub implementation when Sparkle is not available + +void UpdateManager::init(const std::string& appcast_url, const std::string& public_key) +{ + s_appcast_url = appcast_url; + s_public_key = public_key; + s_initialized = true; + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Sparkle not available (stub)"; +} + +void UpdateManager::check_for_updates_interactive() +{ + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Interactive update check not available (no Sparkle)"; +} + +void UpdateManager::check_for_updates_background() +{ + BOOST_LOG_TRIVIAL(info) << "UpdateManager: Background update check not available (no Sparkle)"; +} + +void UpdateManager::shutdown() +{ + s_initialized = false; +} + +void UpdateManager::set_automatic_check_enabled(bool enabled) +{ + // No-op +} + +#endif // ORCA_HAS_SPARKLE + +} // namespace GUI +} // namespace Slic3r + +#endif // __APPLE__ diff --git a/src/slic3r/GeneratedConfig.hpp.in b/src/slic3r/GeneratedConfig.hpp.in index 130f113034..27babdd248 100644 --- a/src/slic3r/GeneratedConfig.hpp.in +++ b/src/slic3r/GeneratedConfig.hpp.in @@ -1,5 +1,5 @@ #pragma once +// Ed25519 public key for verifying update signatures (used by load_signature_key) #define ORCA_UPDATER_SIG_KEY_B64 "@ORCA_UPDATER_SIG_KEY_B64@" -#define ORCA_UPDATER_SIG_KEY_AVAILABLE @ORCA_UPDATER_SIG_KEY_AVAILABLE@