diff --git a/.github/workflows/cura-all-installers.yml b/.github/workflows/cura-all-installers.yml index 34ccffe54b..b024d035a5 100644 --- a/.github/workflows/cura-all-installers.yml +++ b/.github/workflows/cura-all-installers.yml @@ -50,7 +50,7 @@ on: required: true type: boolean build_macos: - description: 'Build for MacOs' + description: 'Build for MacOS' default: true required: true type: boolean diff --git a/.github/workflows/cura-installer.yml b/.github/workflows/cura-installer.yml index 30ec9bb199..fef4547891 100644 --- a/.github/workflows/cura-installer.yml +++ b/.github/workflows/cura-installer.yml @@ -62,6 +62,7 @@ env: MAC_NOTARIZE_USER: ${{ secrets.MAC_NOTARIZE_USER }} MAC_NOTARIZE_PASS: ${{ secrets.MAC_NOTARIZE_PASS }} MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }} + MACOS_CERT_INSTALLER_P12: ${{ secrets.MACOS_CERT_INSTALLER_P12 }} MACOS_CERT_PASS: ${{ secrets.MACOS_CERT_PASS }} MACOS_CERT_USER: ${{ secrets.MACOS_CERT_USER }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} @@ -119,7 +120,7 @@ jobs: - name: Install MacOS system requirements if: ${{ runner.os == 'Macos' }} - run: brew install autoconf automake ninja create-dmg + run: brew install autoconf automake ninja - name: Install Linux system requirements if: ${{ runner.os == 'Linux' }} @@ -152,14 +153,25 @@ jobs: if: ${{ runner.os == 'Linux' }} run: echo -n "$GPG_PRIVATE_KEY" | base64 --decode | gpg --import - - name: Configure Macos keychain (Bash) - id: macos-keychain + - name: Configure Macos keychain Developer Cert(Bash) + id: macos-keychain-developer-cert if: ${{ runner.os == 'Macos' }} uses: apple-actions/import-codesign-certs@v1 with: + keychain-password: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} p12-file-base64: ${{ secrets.MACOS_CERT_P12 }} p12-password: ${{ secrets.MACOS_CERT_PASSPHRASE }} + - name: Configure Macos keychain Installer Cert (Bash) + id: macos-keychain-installer-cert + if: ${{ runner.os == 'Macos' }} + uses: apple-actions/import-codesign-certs@v1 + with: + keychain-password: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} + create-keychain: false # keychain is created in previous use of action. + p12-file-base64: ${{ secrets.MACOS_CERT_INSTALLER_P12 }} + p12-password: ${{ secrets.MACOS_CERT_PASSPHRASE }} + - name: Create PFX certificate from BASE64_PFX_CONTENT secret if: ${{ runner.os == 'Windows' }} id: create-pfx @@ -204,7 +216,7 @@ jobs: if: ${{ runner.os == 'Macos' }} run: security unlock -p $TEMP_KEYCHAIN_PASSWORD signing_temp.keychain env: - TEMP_KEYCHAIN_PASSWORD: ${{ steps.macos-keychain.outputs.keychain-password }} + TEMP_KEYCHAIN_PASSWORD: ${{ steps.macos-keychain-developer-cert.outputs.keychain-password }} # FIXME: This is a workaround to ensure that we use and pack a shared library for OpenSSL 1.1.1l. We currently compile # OpenSSL statically for CPython, but our Python Dependenies (such as PyQt6) require a shared library. @@ -239,7 +251,7 @@ jobs: if "${{ runner.os }}" == "Windows": installer_ext = "msi" if "${{ inputs.msi_installer }}" == "true" else "exe" elif "${{ runner.os }}" == "macOS": - installer_ext = "dmg" + installer_ext = "pkg" else: installer_ext = "AppImage" output_env = os.environ["GITHUB_OUTPUT"] @@ -296,9 +308,9 @@ jobs: run: python ../cura_inst/packaging/AppImage/create_appimage.py ./UltiMaker-Cura $CURA_VERSION_FULL "${{ steps.filename.outputs.FULL_INSTALLER_FILENAME }}" working-directory: dist - - name: Create the MacOS dmg (Bash) - if: ${{ inputs.installer && runner.os == 'Macos' }} - run: python ../cura_inst/packaging/dmg/dmg_sign_noterize.py ../cura_inst . "${{ steps.filename.outputs.FULL_INSTALLER_FILENAME }}" + - name: Create the MacOS pkg (Bash) + if: ${{ github.event.inputs.installer == 'true' && runner.os == 'Macos' }} + run: python ../cura_inst/packaging/MacOS/build_macos.py . "${{ steps.filename.outputs.FULL_INSTALLER_FILENAME }}" working-directory: dist - name: Upload the artifacts diff --git a/UltiMaker-Cura.spec.jinja b/UltiMaker-Cura.spec.jinja index 5eb01845c1..ba5b6e11c2 100644 --- a/UltiMaker-Cura.spec.jinja +++ b/UltiMaker-Cura.spec.jinja @@ -141,6 +141,7 @@ class UMBUNDLE(BUNDLE): "CFBundleIconFile": os.path.basename(self.icon), "CFBundleInfoDictionaryVersion": "6.0", "CFBundlePackageType": "APPL", + "CFBundleVersionString": self.version, "CFBundleShortVersionString": self.version, } diff --git a/conanfile.py b/conanfile.py index 0105490acf..9a2c1773ed 100644 --- a/conanfile.py +++ b/conanfile.py @@ -317,7 +317,7 @@ class CuraConan(ConanFile): self._generate_cura_version(Path(self.source_folder, "cura")) if self.options.devtools: - entitlements_file = "'{}'".format(Path(self.source_folder, "packaging", "dmg", "cura.entitlements")) + entitlements_file = "'{}'".format(Path(self.source_folder, "packaging", "MacOS", "cura.entitlements")) self._generate_pyinstaller_spec(location = self.generators_folder, entrypoint_location = "'{}'".format(Path(self.source_folder, self._um_data()["runinfo"]["entrypoint"])).replace("\\", "\\\\"), icon_path = "'{}'".format(Path(self.source_folder, "packaging", self._um_data()["pyinstaller"]["icon"][str(self.settings.os)])).replace("\\", "\\\\"), @@ -445,7 +445,7 @@ echo "CURA_APP_NAME={{ cura_app_name }}" >> ${{ env_prefix }}GITHUB_ENV self._generate_cura_version(Path(self._site_packages, "cura")) - entitlements_file = "'{}'".format(Path(self.cpp_info.res_paths[2], "dmg", "cura.entitlements")) + entitlements_file = "'{}'".format(Path(self.cpp_info.res_paths[2], "MacOS", "cura.entitlements")) self._generate_pyinstaller_spec(location = self._base_dir, entrypoint_location = "'{}'".format(Path(self.cpp_info.bin_paths[0], self._um_data()["runinfo"]["entrypoint"])).replace("\\", "\\\\"), icon_path = "'{}'".format(Path(self.cpp_info.res_paths[2], self._um_data()["pyinstaller"]["icon"][str(self.settings.os)])).replace("\\", "\\\\"), diff --git a/packaging/MacOS/build_macos.py b/packaging/MacOS/build_macos.py new file mode 100644 index 0000000000..06880cf9b7 --- /dev/null +++ b/packaging/MacOS/build_macos.py @@ -0,0 +1,110 @@ +# Copyright (c) 2023 UltiMaker +# Cura is released under the terms of the LGPLv3 or higher. + + +import os +import argparse # Command line arguments parsing and help. +import subprocess +from pathlib import Path + +ULTIMAKER_CURA_DOMAIN = os.environ.get("ULTIMAKER_CURA_DOMAIN", "nl.ultimaker.cura") + +def build_pkg(dist_path: str, app_filename: str, component_filename: str, installer_filename: str) -> None: + """ Builds and signs the pkg installer. + + @param dist_path: Path to put output pkg in + @param app_filename: name of the .app file to bundle inside the pkg + @param component_filename: Name of the pkg component package to bundle the app in + @param installer_filename: Name of the installer that contains the component package + """ + pkg_build_executable = os.environ.get("PKG_BUILD_EXECUTABLE", "pkgbuild") + product_build_executable = os.environ.get("PRODUCT_BUILD_EXECUTABLE", "productbuild") + codesign_identity = os.environ.get("CODESIGN_IDENTITY") + + # This builds the component package that contains UltiMaker-Cura.app. This component package will be bundled in a distribution package. + pkg_build_arguments = [ + pkg_build_executable, + "--component", + Path(dist_path, app_filename), + Path(dist_path, component_filename), + "--install-location", "/Applications", + ] + + if codesign_identity: + pkg_build_arguments.extend(["--sign", codesign_identity]) + else: + print("CODESIGN_IDENTITY missing. The installer is not being signed") + + subprocess.run(pkg_build_arguments) + + # This automatically generates a distribution.xml file that is used to build the installer. + # If you want to make any changes to how the installer functions, this file should be changed to do that. + # TODO: Use --product {property_list_file} to pull keys out of file for distribution.xml. This can be used to set min requirements + distribution_creation_arguments = [ + product_build_executable, + "--synthesize", + "--package", Path(dist_path, component_filename), # Package that will be inside installer + Path(dist_path, "distribution.xml"), # Output location for sythesized distributions file + ] + subprocess.run(distribution_creation_arguments) + + # This creates the distributable package (Installer) + installer_creation_arguments = [ + product_build_executable, + "--distribution", Path(dist_path, "distribution.xml"), + "--package-path", dist_path, # Where to find the component packages mentioned in distribution.xml (UltiMaker-Cura.pkg) + Path(dist_path, installer_filename), + ] + + if codesign_identity: + installer_creation_arguments.extend(["--sign", codesign_identity]) + + subprocess.run(installer_creation_arguments) + + +def notarize_file(dist_path: str, filename: str) -> None: + """ Notarize a file. This takes 5+ minutes, there is indication that this step is successful.""" + notarize_user = os.environ.get("MAC_NOTARIZE_USER") + notarize_password = os.environ.get("MAC_NOTARIZE_PASS") + altool_executable = os.environ.get("ALTOOL_EXECUTABLE", "altool") + + notarize_arguments = [ + "xcrun", altool_executable, + "--notarize-app", + "--primary-bundle-id", ULTIMAKER_CURA_DOMAIN, + "--username", notarize_user, + "--password", notarize_password, + "--file", Path(dist_path, filename) + ] + + subprocess.run(notarize_arguments) + + +def create_pkg_installer(filename: str, dist_path: str) -> None: + """ Creates a pkg installer from {filename}.app called {filename}-Installer.pkg + + The final package structure is UltiMaker-Cura-XXX-Installer.pkg[UltiMaker-Cura.pkg[UltiMaker-Cura.app]]. The outer + pkg file is a distributable pkg (Installer). Inside the distributable pkg there is a component pkg. The component + pkg contains the .app file that will be installed in the users Applications folder. + + @param filename: The name of the app file and the app component package file without the extension + @param dist_path: The location to read the app from and save the pkg to + """ + + filename_stem = Path(filename).stem + cura_component_package_name = f"{filename_stem}-Component.pkg" # This is a component package that is nested inside the installer, it contains the UltiMaker-Cura.app file + app_name = "UltiMaker-Cura.app" # This is the app file that will end up in your applications folder + + build_pkg(dist_path, app_name, cura_component_package_name, filename) + + notarize = bool(os.environ.get("NOTARIZE_INSTALLER", "FALSE")) + if notarize: + notarize_file(dist_path, filename) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description = "Create installer for Cura.") + parser.add_argument("dist_path", type = str, help="Path to Pyinstaller dist folder") + parser.add_argument("filename", type = str, help = "Filename of the pkg (e.g. 'UltiMaker-Cura-5.1.0-beta-Macos-X64.pkg')") + args = parser.parse_args() + + create_pkg_installer(args.filename, args.dist_path) diff --git a/packaging/dmg/cura.entitlements b/packaging/MacOS/cura.entitlements similarity index 100% rename from packaging/dmg/cura.entitlements rename to packaging/MacOS/cura.entitlements diff --git a/packaging/dmg/cura_background_dmg.png b/packaging/MacOS/cura_background_dmg.png similarity index 100% rename from packaging/dmg/cura_background_dmg.png rename to packaging/MacOS/cura_background_dmg.png diff --git a/packaging/dmg/dmg_sign_noterize.py b/packaging/dmg/dmg_sign_noterize.py deleted file mode 100644 index fcda1e378e..0000000000 --- a/packaging/dmg/dmg_sign_noterize.py +++ /dev/null @@ -1,71 +0,0 @@ -# Copyright (c) 2022 UltiMaker -# Cura is released under the terms of the LGPLv3 or higher. - - -import os -import argparse # Command line arguments parsing and help. -import subprocess - -ULTIMAKER_CURA_DOMAIN = os.environ.get("ULTIMAKER_CURA_DOMAIN", "nl.ultimaker.cura") - - -def build_dmg(source_path: str, dist_path: str, filename: str) -> None: - create_dmg_executable = os.environ.get("CREATE_DMG_EXECUTABLE", "create-dmg") - - arguments = [create_dmg_executable, - "--window-pos", "640", "360", - "--window-size", "690", "503", - "--app-drop-link", "520", "272", - "--volicon", f"{source_path}/packaging/icons/VolumeIcons_Cura.icns", - "--icon-size", "90", - "--icon", "UltiMaker-Cura.app", "169", "272", - "--eula", f"{source_path}/packaging/cura_license.txt", - "--background", f"{source_path}/packaging/dmg/cura_background_dmg.png", - f"{dist_path}/{filename}", - f"{dist_path}/UltiMaker-Cura.app"] - - subprocess.run(arguments) - - -def sign(dist_path: str, filename: str) -> None: - codesign_executable = os.environ.get("CODESIGN", "codesign") - codesign_identity = os.environ.get("CODESIGN_IDENTITY") - - arguments = [codesign_executable, - "-s", codesign_identity, - "--timestamp", - "-i", f"{ULTIMAKER_CURA_DOMAIN}.dmg", # TODO: check if this really should have the extra dmg. We seem to be doing this also in the old Rundeck scripts - f"{dist_path}/{filename}"] - - subprocess.run(arguments) - - -def notarize(dist_path: str, filename: str) -> None: - notarize_user = os.environ.get("MAC_NOTARIZE_USER") - notarize_password = os.environ.get("MAC_NOTARIZE_PASS") - altool_executable = os.environ.get("ALTOOL_EXECUTABLE", "altool") - - arguments = [ - "xcrun", altool_executable, - "--notarize-app", - "--primary-bundle-id", ULTIMAKER_CURA_DOMAIN, - "--username", notarize_user, - "--password", notarize_password, - "--file", f"{dist_path}/{filename}" - ] - - subprocess.run(arguments) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description = "Create dmg of Cura.") - parser.add_argument("source_path", type=str, help="Path to Conan install Cura folder.") - parser.add_argument("dist_path", type=str, help="Path to Pyinstaller dist folder") - parser.add_argument("filename", type = str, help = "Filename of the dmg (e.g. 'UltiMaker-Cura-5.1.0-beta-Linux-X64.dmg')") - args = parser.parse_args() - build_dmg(args.source_path, args.dist_path, args.filename) - sign(args.dist_path, args.filename) - - notarize_dmg = bool(os.environ.get("NOTARIZE_DMG", "TRUE")) - if notarize_dmg: - notarize(args.dist_path, args.filename)