diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c07679a30..d2be7c2ca 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -166,7 +166,7 @@ jobs: git diff --exit-code - name: Upload the example installers as artifacts if: github.event_name == 'pull_request' && matrix.python-version == '3.9' - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 + uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 with: name: installers-${{ runner.os }}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} path: "${{ runner.temp }}/examples_artifacts" diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml index b11957a38..0164674b1 100644 --- a/.github/workflows/update.yml +++ b/.github/workflows/update.yml @@ -80,7 +80,7 @@ jobs: - if: github.event.comment.body != '@conda-bot render' id: create # no-op if no commits were made - uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0 + uses: peter-evans/create-pull-request@8867c4aba1b742c39f8d0ba35429c2dfa4b6cb20 # v7.0.1 with: push-to-fork: ${{ env.FORK }} token: ${{ secrets.SYNC_TOKEN }} diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 33be341f1..92d93b572 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -777,6 +777,20 @@ If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether if you set this key to `""` (empty string). (MacOS only). +### `post_install_pages` + +_required:_ no
+_types:_ list, string
+ +Adds extra pages to the installers to be shown after installation. + +For PKG installers, these can be compiled `installer` plug-ins or +directories containing an Xcode project. In the latter case, +constructor will try and compile the project file using `xcodebuild`. + +For Windows, the extra pages must be `.nsi` files. +They will be inserted as-is before the conclusion page. + ### `conclusion_file` _required:_ no
@@ -788,9 +802,7 @@ plain text (.txt), rich text (.rtf) or HTML (.html). If both `conclusion_file` and `conclusion_text` are provided, `conclusion_file` takes precedence. (MacOS only). -If the installer is for windows and conclusion file type is nsi, -it will use the nsi script to add in extra pages and the conclusion file -at the end of the installer. +If the installer is for Windows, the file type must be nsi. ### `conclusion_text` diff --git a/constructor/construct.py b/constructor/construct.py index 19b09d52d..1a3a2b9e7 100644 --- a/constructor/construct.py +++ b/constructor/construct.py @@ -569,6 +569,17 @@ If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether if you set this key to `""` (empty string). (MacOS only). +'''), + + ('post_install_pages', False, (list, str), ''' +Adds extra pages to the installers to be shown after installation. + +For PKG installers, these can be compiled `installer` plug-ins or +directories containing an Xcode project. In the latter case, +constructor will try and compile the project file using `xcodebuild`. + +For Windows, the extra pages must be `.nsi` files. +They will be inserted as-is before the conclusion page. '''), ('conclusion_file', False, str, ''' @@ -578,9 +589,7 @@ `conclusion_file` and `conclusion_text` are provided, `conclusion_file` takes precedence. (MacOS only). -If the installer is for windows and conclusion file type is nsi, -it will use the nsi script to add in extra pages and the conclusion file -at the end of the installer. +If the installer is for Windows, the file type must be nsi. '''), ('conclusion_text', False, str, ''' diff --git a/constructor/main.py b/constructor/main.py index 78d860bfb..98e6a080c 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -105,9 +105,12 @@ def main_build(dir_path, output_dir='.', platform=cc_platform, for key in ('license_file', 'welcome_image', 'header_image', 'icon_image', 'pre_install', 'post_install', 'pre_uninstall', 'environment_file', 'nsis_template', 'welcome_file', 'readme_file', 'conclusion_file', - 'signing_certificate'): - if info.get(key): # only join if there's a truthy value set - info[key] = abspath(join(dir_path, info[key])) + 'signing_certificate', 'post_install_pages'): + if value := info.get(key): # only join if there's a truthy value set + if isinstance(value, str): + info[key] = abspath(join(dir_path, info[key])) + elif isinstance(value, list): + info[key] = [abspath(join(dir_path, val)) for val in value] # Normalize name and set default value if info.get("windows_signing_tool"): diff --git a/constructor/nsis/main.nsi.tmpl b/constructor/nsis/main.nsi.tmpl index e681b0b29..d83311a1e 100644 --- a/constructor/nsis/main.nsi.tmpl +++ b/constructor/nsis/main.nsi.tmpl @@ -188,6 +188,11 @@ Page Custom InstModePage_Create InstModePage_Leave # Custom options now differ depending on installation mode. Page Custom mui_AnaCustomOptions_Show !insertmacro MUI_PAGE_INSTFILES + +#if post_install_pages is True +@POST_INSTALL_PAGES@ +#endif + #if with_conclusion_text is True !define MUI_FINISHPAGE_TITLE __CONCLUSION_TITLE__ !define MUI_FINISHPAGE_TITLE_3LINES diff --git a/constructor/osxpkg.py b/constructor/osxpkg.py index faebf490b..a2280adf2 100644 --- a/constructor/osxpkg.py +++ b/constructor/osxpkg.py @@ -2,17 +2,20 @@ import os import shlex import shutil +import subprocess import sys import xml.etree.ElementTree as ET from os.path import abspath, dirname, exists, isdir, join from pathlib import Path from plistlib import dump as plist_dump from tempfile import NamedTemporaryFile +from typing import List from . import preconda from .conda_interface import conda_context from .construct import ns_platform, parse from .imaging import write_images +from .signing import CodeSign from .utils import ( add_condarc, approx_size_kb, @@ -423,6 +426,80 @@ def pkgbuild_prepare_installation(info): shutil.rmtree(f"{pkg}.expanded") +def create_plugins(pages: list = None, codesigner: CodeSign = None): + def _build_xcode_projects(xcodeporj_dirs: List[Path]): + xcodebuild = shutil.which("xcodebuild") + if not xcodebuild: + raise RuntimeError( + "Plugin directory contains an uncompiled project," + " but xcodebuild is not available." + ) + try: + subprocess.run([xcodebuild, "--help"], check=True, capture_output=True) + except subprocess.CalledSubprocessError: + raise RuntimeError( + "Plugin directory contains an uncompiled project, " + "but xcodebuild requires XCode to compile plugins." + ) + for xcodeproj in xcodeproj_dirs: + build_cmd = [ + xcodebuild, + "-project", + str(xcodeproj), + f"CONFIGURATION_BUILD_DIR={PLUGINS_DIR}", + # do not create dSYM debug symbols directory + "DEBUG_INFORMATION_FORMAT=", + ] + explained_check_call(build_cmd) + + if not pages: + return + elif isinstance(pages, str): + pages = [pages] + + fresh_dir(PLUGINS_DIR) + + for page in pages: + xcodeproj_dirs = [ + file.resolve() + for file in Path(page).iterdir() + if file.suffix == ".xcodeproj" + ] + if xcodeproj_dirs: + _build_xcode_projects(xcodeproj_dirs) + else: + plugin_name = os.path.basename(page) + page_in_plugins = join(PLUGINS_DIR, plugin_name) + shutil.copytree(page, page_in_plugins) + + if codesigner: + with NamedTemporaryFile(suffix=".plist", delete=False) as entitlements: + plist = { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": True, + } + plist_dump(plist, entitlements) + + for path in Path(PLUGINS_DIR).iterdir(): + codesigner.sign_bundle(path, entitlements=entitlements.name) + os.unlink(entitlements.name) + + plugins = [file.name for file in Path(PLUGINS_DIR).iterdir()] + with open(join(PLUGINS_DIR, "InstallerSections.plist"), "wb") as f: + plist = { + "SectionOrder": [ + "Introduction", + "ReadMe", + "License", + "Target", + "PackageSelection", + "Install", + *plugins, + ] + } + plist_dump(plist, f) + + def pkgbuild_script(name, info, src, dst='postinstall', **kwargs): fresh_dir(SCRIPTS_DIR) fresh_dir(PACKAGE_ROOT) @@ -446,12 +523,13 @@ def create(info, verbose=False): "installation! Aborting!" ) - global CACHE_DIR, PACKAGE_ROOT, PACKAGES_DIR, SCRIPTS_DIR + global CACHE_DIR, PACKAGE_ROOT, PACKAGES_DIR, PLUGINS_DIR, SCRIPTS_DIR CACHE_DIR = info['_download_dir'] SCRIPTS_DIR = join(CACHE_DIR, "scripts") PACKAGE_ROOT = join(CACHE_DIR, "package_root") PACKAGES_DIR = join(CACHE_DIR, "built_pkgs") + PLUGINS_DIR = join(CACHE_DIR, "plugins") fresh_dir(PACKAGES_DIR) prefix = join(PACKAGE_ROOT, info.get("pkg_name", info['name']).lower()) @@ -499,31 +577,20 @@ def create(info, verbose=False): shutil.copyfile(info['_conda_exe'], join(prefix, "_conda")) # Sign conda-standalone so it can pass notarization - notarization_identity_name = info.get('notarization_identity_name') - if notarization_identity_name: - with NamedTemporaryFile(suffix=".plist", delete=False) as f: - plist = { + codesigner = None + if notarization_identity_name := info.get('notarization_identity_name'): + codesigner = CodeSign( + notarization_identity_name, + prefix=info.get("reverse_domain_identifier", info['name']) + ) + entitlements = { "com.apple.security.cs.allow-jit": True, "com.apple.security.cs.allow-unsigned-executable-memory": True, "com.apple.security.cs.disable-executable-page-protection": True, "com.apple.security.cs.disable-library-validation": True, "com.apple.security.cs.allow-dyld-environment-variables": True, - } - plist_dump(plist, f) - explained_check_call( - [ - # hardcode to system location to avoid accidental clobber in PATH - "/usr/bin/codesign", - "--verbose", - '--sign', notarization_identity_name, - "--prefix", info.get("reverse_domain_identifier", info['name']), - "--options", "runtime", - "--force", - "--entitlements", f.name, - join(prefix, "_conda"), - ] - ) - os.unlink(f.name) + } + codesigner.sign_bundle(join(prefix, "_conda"), entitlements=entitlements) # This script checks to see if the install location already exists and/or contains spaces # Not to be confused with the user-provided pre_install! @@ -579,14 +646,20 @@ def create(info, verbose=False): explained_check_call(args) modify_xml(xml_path, info) + if plugins := info.get("post_install_pages"): + create_plugins(plugins, codesigner=codesigner) + identity_name = info.get('signing_identity_name') - explained_check_call([ + build_cmd = [ "/usr/bin/productbuild", "--distribution", xml_path, "--package-path", PACKAGES_DIR, "--identifier", info.get("reverse_domain_identifier", info['name']), - "tmp.pkg" if identity_name else info['_outpath'] - ]) + ] + if plugins: + build_cmd.extend(["--plugins", PLUGINS_DIR]) + build_cmd.append("tmp.pkg" if identity_name else info['_outpath']) + explained_check_call(build_cmd) if identity_name: explained_check_call([ # hardcode to system location to avoid accidental clobber in PATH diff --git a/constructor/signing.py b/constructor/signing.py index 31e4e488f..b939f9615 100644 --- a/constructor/signing.py +++ b/constructor/signing.py @@ -2,10 +2,12 @@ import os import shutil from pathlib import Path +from plistlib import dump as plist_dump from subprocess import PIPE, STDOUT, check_call, run +from tempfile import NamedTemporaryFile from typing import Union -from .utils import check_required_env_vars, win_str_esc +from .utils import check_required_env_vars, explained_check_call, win_str_esc logger = logging.getLogger(__name__) @@ -218,3 +220,52 @@ def verify_signature(self, installer_file: Union[str, Path]): except ValueError: # Something else is in the output raise RuntimeError(f"Unexpected signature verification output: {proc.stdout}") + + +class CodeSign(SigningTool): + def __init__( + self, + identity_name: str, + prefix: str = None, + ): + # hardcode to system location to avoid accidental clobber in PATH + super().__init__("/usr/bin/codesign") + self.identity_name = identity_name + self.prefix = prefix + + def get_signing_command( + self, + bundle: Union[str, Path], + entitlements: Union[str, Path] = None, + ) -> list: + command = [ + self.executable, + "--sign", + self.identity_name, + "--force", + "--options", + "runtime", + ] + if self.prefix: + command.extend(["--prefix", self.prefix]) + if entitlements: + command.extend(["--entitlements", str(entitlements)]) + if logger.getEffectiveLevel() == logging.DEBUG: + command.append("--verbose") + command.append(str(bundle)) + return command + + def sign_bundle( + self, + bundle: Union[str, Path], + entitlements: Union[str, Path, dict] = None, + ): + if isinstance(entitlements, dict): + with NamedTemporaryFile(suffix=".plist", delete=False) as ent_file: + plist_dump(entitlements, ent_file) + command = self.get_signing_command(bundle, entitlements=ent_file.name) + explained_check_call(command) + os.unlink(ent_file.name) + else: + command = self.get_signing_command(bundle, entitlements=entitlements) + explained_check_call(command) diff --git a/constructor/winexe.py b/constructor/winexe.py index 02ae28a58..8d6d7fd4c 100644 --- a/constructor/winexe.py +++ b/constructor/winexe.py @@ -302,14 +302,28 @@ def make_nsi( # for the newlines business replace['CONCLUSION_TEXT'] = "\r\n".join(conclusion_lines[1:]) - for key in ['welcome_file', 'conclusion_file']: + for key in ['welcome_file', 'conclusion_file', 'post_install_pages']: value = info.get(key, "") - if value and not value.endswith(".nsi"): + if not value: + continue + if isinstance(value, str) and not value.endswith(".nsi"): logger.warning( - "On Windows, %s must be a .nsi file; %s will be ignored.", + "On Windows, %s must be an .nsi file; %s will be ignored.", key, value, ) + elif isinstance(value, list): + valid_values = [] + for val in value: + if val.endswith(".nsi"): + valid_values.append(val) + else: + logger.warning( + "On Windows, %s must be .nsi files; %s will be ignored.", + key, + val, + ) + info[key] = valid_values for key, value in replace.items(): if value.startswith('@'): @@ -333,6 +347,8 @@ def make_nsi( ppd["custom_welcome"] = info.get("welcome_file", "").endswith(".nsi") ppd["custom_conclusion"] = info.get("conclusion_file", "").endswith(".nsi") ppd["has_license"] = bool(info.get("license_file")) + ppd["post_install_pages"] = bool(info.get("post_install_pages")) + data = preprocess(data, ppd) data = fill_template(data, replace, exceptions=nsis_predefines) if info['_platform'].startswith("win") and sys.platform != 'win32': @@ -374,6 +390,12 @@ def make_nsi( if ppd['custom_conclusion'] else '' ), + ( + '@POST_INSTALL_PAGES@', + '\n'.join( + custom_nsi_insert_from_file(file) for file in info.get('post_install_pages', []) + ), + ), ('@TEMP_EXTRA_FILES@', '\n '.join(insert_tempfiles_commands(temp_extra_files))), ('@VIRTUAL_SPECS@', " ".join([f'"{spec}"' for spec in info.get("virtual_specs", ())])), # This is the same but without quotes so we can print it fine diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 33be341f1..92d93b572 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -777,6 +777,20 @@ If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether if you set this key to `""` (empty string). (MacOS only). +### `post_install_pages` + +_required:_ no
+_types:_ list, string
+ +Adds extra pages to the installers to be shown after installation. + +For PKG installers, these can be compiled `installer` plug-ins or +directories containing an Xcode project. In the latter case, +constructor will try and compile the project file using `xcodebuild`. + +For Windows, the extra pages must be `.nsi` files. +They will be inserted as-is before the conclusion page. + ### `conclusion_file` _required:_ no
@@ -788,9 +802,7 @@ plain text (.txt), rich text (.rtf) or HTML (.html). If both `conclusion_file` and `conclusion_text` are provided, `conclusion_file` takes precedence. (MacOS only). -If the installer is for windows and conclusion file type is nsi, -it will use the nsi script to add in extra pages and the conclusion file -at the end of the installer. +If the installer is for Windows, the file type must be nsi. ### `conclusion_text` diff --git a/docs/source/howto.md b/docs/source/howto.md index a5a8e54d4..c6fca5056 100644 --- a/docs/source/howto.md +++ b/docs/source/howto.md @@ -93,9 +93,17 @@ If neither `AZURE_SIGNTOOL_KEY_VAULT_ACCESSTOKEN` nor `AZURE_SIGNTOOL_KEY_VAULT_ In the case of macOS, users might get warnings for PKGs if the installers are not signed _and_ notarized. However, once these two requirements are fulfilled, the warnings disappear instantly. `constructor` offers some configuration options to help you in this process: -You will need to provide two identity names. One for the PKG signature (via [`signing_identity_name`](construct-yaml.md#signing_identity_name)), and one to pass the notarization (via [`notarization_identity_name`](construct-yaml.md#notarization_identity_name)). These can be obtained in the [Apple Developer portal](https://developer.apple.com/account/). +You will need to provide two identity names: +* the installer certificate identity (via [`signing_identity_name`](construct-yaml.md#signing_identity_name)) to sign the pkg installer, +* the application certificate identity to pass the notarization (via [`notarization_identity_name`](construct-yaml.md#notarization_identity_name)); + this certificate is used to sign binaries and plugins inside the pkg installer. +These can be obtained in the [Apple Developer portal](https://developer.apple.com/account/). Once signed, you can notarize your PKG with Apple's `notarytool`. +:::{note} + +To sign a pkg installer, the keychain containing the identity names must be unlocked and in the keychain search list. + ## Create shortcuts On Windows, `conda` supports `menuinst 1.x` shortcuts. If a package provides a certain JSON file diff --git a/examples/exe_extra_pages/construct.yaml b/examples/exe_extra_pages/construct.yaml new file mode 100644 index 000000000..95b649bf4 --- /dev/null +++ b/examples/exe_extra_pages/construct.yaml @@ -0,0 +1,10 @@ +name: extraPages +version: X +installer_type: all +channels: + - http://repo.anaconda.com/pkgs/main/ +specs: + - python +post_install_pages: + - extra_page_1.nsi + - extra_page_2.nsi diff --git a/examples/exe_extra_pages/extra_page_1.nsi b/examples/exe_extra_pages/extra_page_1.nsi new file mode 100644 index 000000000..c3c502d98 --- /dev/null +++ b/examples/exe_extra_pages/extra_page_1.nsi @@ -0,0 +1,12 @@ +Function extraPage1 +!insertmacro MUI_HEADER_TEXT "Extra Page 1" "This is extra page number 1" + +nsDialogs::Create 1018 +${NSD_CreateLabel} 0 0 100% 12u "Content of extra page 1" + +${NSD_CreateText} 0 13u 100% "Lorem ipsum dolor sit amet" + +nsDialogs::Show +FunctionEnd + +Page custom extraPage1 diff --git a/examples/exe_extra_pages/extra_page_2.nsi b/examples/exe_extra_pages/extra_page_2.nsi new file mode 100644 index 000000000..a59054222 --- /dev/null +++ b/examples/exe_extra_pages/extra_page_2.nsi @@ -0,0 +1,12 @@ +Function extraPage2 +!insertmacro MUI_HEADER_TEXT "Extra Page 2" "This is extra page number 2" + +nsDialogs::Create 1018 +${NSD_CreateLabel} 0 0 100% 12u "Content of extra page 2" + +${NSD_CreateText} 0 13u 100% "consectetur adipiscing elit." + +nsDialogs::Show +FunctionEnd + +Page custom extraPage2 diff --git a/examples/osxpkg_extra_pages/construct.yaml b/examples/osxpkg_extra_pages/construct.yaml new file mode 100644 index 000000000..36f47f00d --- /dev/null +++ b/examples/osxpkg_extra_pages/construct.yaml @@ -0,0 +1,73 @@ +name: osxpkgtest +version: 1.2.3 + +# This config will result in a default install path: +# "~/Library/osx-pkg-test" (spaces not allowed because 'conda' is in 'specs') +default_location_pkg: Library +pkg_name: "osx-pkg-test" + +channels: + - http://repo.anaconda.com/pkgs/main/ + +attempt_hardlinks: True + +specs: + - conda + - openssl + +installer_type: pkg # [osx] + +reverse_domain_identifier: org.website.my + +## You can provide `welcome_image` to a 1200x600 PNG file +## Ideally, transparent background with your logo on the +## bottom left corner. +# welcome_image: ../../constructor/osx/MacInstaller.png +## If you don't want any logo, disable it explicitly: +welcome_image: "" +## If you want an autogenerated logo with the package name +## and version, you need to be explicit about it: +welcome_image_text: osxpkgtest +## Note that if both `welcome_image` and `welcome_image_text` +## are provided, `welcome_image` takes precedence. + +# First screen (labeled introduction) text +# If not provided, defaults to standard message +# welcome_text: | +# something for the users +welcome_text: "" +# You can also pass a file (plain or rich text) +# welcome_file: "path/to/text.rtf" + +# Shown before the license; if not provided, it defaults +# to Anaconda's message. Set it to "" (empty string) to disable it. +# readme_text: | +# something for the users +readme_text: "" +# You can also pass a file (plain or rich text) +# readme_file: "path/to/text.rtf" + +# This will be shown at the end of the wizard +conclusion_text: | + Thanks for installing osxpkgtest v1.2.3! + +# Disable with "" (empty string) to default to the system message: +# conclusion_text: "" + +# You can also pass a file (plain or rich text) +# conclusion_file: "path/to/text.rtf" + +# signing_identity_name: "Developer ID Installer: XXX XXXX XXX (XXXXXX)" +# notarization_identity_name: "Developer ID Application: XXX XXXX XXX (XXXXXX)" + +install_path_exists_error_text: > + {CHOSEN_PATH} exists! Please update using our in-app mechanisms or + relaunch the installer and choose a different location. + +initialize_by_default: false +register_python: False + +# Examples for how to write these plugins: +# https://preserve.mactech.com/articles/mactech/Vol.25/25.06/InstallerPlugins/index.html +# http://s.sudre.free.fr/Stuff/Installer/Installer_Plugins/index.html +post_install_pages: "plugins/ExtraPage" diff --git a/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage.xcodeproj/project.pbxproj b/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage.xcodeproj/project.pbxproj new file mode 100644 index 000000000..466b1d950 --- /dev/null +++ b/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage.xcodeproj/project.pbxproj @@ -0,0 +1,345 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + ABACDD4F2C73F6BD002DA78F /* ExtraPage.m in Sources */ = {isa = PBXBuildFile; fileRef = ABACDD4E2C73F6BD002DA78F /* ExtraPage.m */; }; + ABACDD522C73F6BD002DA78F /* ExtraPage.xib in Resources */ = {isa = PBXBuildFile; fileRef = ABACDD502C73F6BD002DA78F /* ExtraPage.xib */; }; + ABACDD552C73F6BD002DA78F /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = ABACDD532C73F6BD002DA78F /* Localizable.strings */; }; + ABACDD5B2C73F6BD002DA78F /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = ABACDD592C73F6BD002DA78F /* InfoPlist.strings */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + ABACDD4A2C73F6BD002DA78F /* ExtraPage.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExtraPage.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; + ABACDD4D2C73F6BD002DA78F /* ExtraPage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ExtraPage.h; sourceTree = ""; }; + ABACDD4E2C73F6BD002DA78F /* ExtraPage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ExtraPage.m; sourceTree = ""; }; + ABACDD512C73F6BD002DA78F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/ExtraPage.xib; sourceTree = ""; }; + ABACDD582C73F6BD002DA78F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + ABACDD472C73F6BD002DA78F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + ABACDD412C73F6BD002DA78F = { + isa = PBXGroup; + children = ( + ABACDD4C2C73F6BD002DA78F /* ExtraPage */, + ABACDD4B2C73F6BD002DA78F /* Products */, + ); + sourceTree = ""; + }; + ABACDD4B2C73F6BD002DA78F /* Products */ = { + isa = PBXGroup; + children = ( + ABACDD4A2C73F6BD002DA78F /* ExtraPage.bundle */, + ); + name = Products; + sourceTree = ""; + }; + ABACDD4C2C73F6BD002DA78F /* ExtraPage */ = { + isa = PBXGroup; + children = ( + ABACDD4D2C73F6BD002DA78F /* ExtraPage.h */, + ABACDD4E2C73F6BD002DA78F /* ExtraPage.m */, + ABACDD502C73F6BD002DA78F /* ExtraPage.xib */, + ABACDD532C73F6BD002DA78F /* Localizable.strings */, + ABACDD582C73F6BD002DA78F /* Info.plist */, + ABACDD592C73F6BD002DA78F /* InfoPlist.strings */, + ); + path = ExtraPage; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + ABACDD492C73F6BD002DA78F /* ExtraPage */ = { + isa = PBXNativeTarget; + buildConfigurationList = ABACDD5E2C73F6BD002DA78F /* Build configuration list for PBXNativeTarget "ExtraPage" */; + buildPhases = ( + ABACDD462C73F6BD002DA78F /* Sources */, + ABACDD472C73F6BD002DA78F /* Frameworks */, + ABACDD482C73F6BD002DA78F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ExtraPage; + productName = ExtraPage; + productReference = ABACDD4A2C73F6BD002DA78F /* ExtraPage.bundle */; + productType = "com.apple.product-type.bundle"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + ABACDD422C73F6BD002DA78F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastUpgradeCheck = 1430; + TargetAttributes = { + ABACDD492C73F6BD002DA78F = { + CreatedOnToolsVersion = 14.3; + }; + }; + }; + buildConfigurationList = ABACDD452C73F6BD002DA78F /* Build configuration list for PBXProject "ExtraPage" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = ABACDD412C73F6BD002DA78F; + productRefGroup = ABACDD4B2C73F6BD002DA78F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + ABACDD492C73F6BD002DA78F /* ExtraPage */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + ABACDD482C73F6BD002DA78F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ABACDD522C73F6BD002DA78F /* ExtraPage.xib in Resources */, + ABACDD5B2C73F6BD002DA78F /* InfoPlist.strings in Resources */, + ABACDD552C73F6BD002DA78F /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + ABACDD462C73F6BD002DA78F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ABACDD4F2C73F6BD002DA78F /* ExtraPage.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + ABACDD502C73F6BD002DA78F /* ExtraPage.xib */ = { + isa = PBXVariantGroup; + children = ( + ABACDD512C73F6BD002DA78F /* Base */, + ); + name = ExtraPage.xib; + sourceTree = ""; + }; + ABACDD532C73F6BD002DA78F /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + ABACDD542C73F6BD002DA78F /* en */, + ); + name = Localizable.strings; + sourceTree = ""; + }; + ABACDD592C73F6BD002DA78F /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + ABACDD5A2C73F6BD002DA78F /* en */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + ABACDD5C2C73F6BD002DA78F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.3; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + }; + name = Debug; + }; + ABACDD5D2C73F6BD002DA78F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.3; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + }; + name = Release; + }; + ABACDD5F2C73F6BD002DA78F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ExtraPage/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMainNibFile = ExtraPage; + INFOPLIST_KEY_NSPrincipalClass = InstallerSection; + INSTALL_PATH = "$(HOME)/Library/Bundles"; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.website.my.ExtraPane; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_EMIT_LOC_STRINGS = YES; + WRAPPER_EXTENSION = bundle; + }; + name = Debug; + }; + ABACDD602C73F6BD002DA78F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ExtraPage/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMainNibFile = ExtraPage; + INFOPLIST_KEY_NSPrincipalClass = InstallerSection; + INSTALL_PATH = "$(HOME)/Library/Bundles"; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.website.my.ExtraPane; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_EMIT_LOC_STRINGS = YES; + WRAPPER_EXTENSION = bundle; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + ABACDD452C73F6BD002DA78F /* Build configuration list for PBXProject "ExtraPage" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + ABACDD5C2C73F6BD002DA78F /* Debug */, + ABACDD5D2C73F6BD002DA78F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + ABACDD5E2C73F6BD002DA78F /* Build configuration list for PBXNativeTarget "ExtraPage" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + ABACDD5F2C73F6BD002DA78F /* Debug */, + ABACDD602C73F6BD002DA78F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = ABACDD422C73F6BD002DA78F /* Project object */; +} diff --git a/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage/Base.lproj/ExtraPage.xib b/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage/Base.lproj/ExtraPage.xib new file mode 100644 index 000000000..4c0b759f6 --- /dev/null +++ b/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage/Base.lproj/ExtraPage.xib @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage/ExtraPage.h b/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage/ExtraPage.h new file mode 100644 index 000000000..8e640f065 --- /dev/null +++ b/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage/ExtraPage.h @@ -0,0 +1,5 @@ +#import + +@interface ExtraPage : InstallerPane + +@end diff --git a/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage/ExtraPage.m b/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage/ExtraPage.m new file mode 100644 index 000000000..7ccd91aff --- /dev/null +++ b/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage/ExtraPage.m @@ -0,0 +1,10 @@ +#import "ExtraPage.h" + +@implementation ExtraPage + +- (NSString *)title +{ + return [[NSBundle bundleForClass:[self class]] localizedStringForKey:@"Extra Page" value:nil table:nil]; +} + +@end diff --git a/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage/Info.plist b/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage/Info.plist new file mode 100644 index 000000000..7ab3ae791 --- /dev/null +++ b/examples/osxpkg_extra_pages/plugins/ExtraPage/ExtraPage/Info.plist @@ -0,0 +1,8 @@ + + + + + InstallerSectionTitle + Extra Page + + diff --git a/news/852-pkg-extra-pages b/news/852-pkg-extra-pages new file mode 100644 index 000000000..6cbcd512d --- /dev/null +++ b/news/852-pkg-extra-pages @@ -0,0 +1,19 @@ +### Enhancements + +* Add capability to add extra post-install pages to pkg installers. (#852) + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +* diff --git a/scripts/create_self_signed_certificates_macos.sh b/scripts/create_self_signed_certificates_macos.sh new file mode 100755 index 000000000..02e2e067b --- /dev/null +++ b/scripts/create_self_signed_certificates_macos.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +set -e + +if [[ -z "${ROOT_DIR}" ]]; then + ROOT_DIR=$(mktemp -d) +else + mkdir -p "${ROOT_DIR}" +fi + +# Array assignment may leave the first element empty, so run cut twice +openssl_lib=$(openssl version | cut -d' ' -f1) +openssl_version=$(openssl version | cut -d' ' -f2) +if [[ "${openssl_lib}" == "OpenSSL" ]] && [[ "${openssl_version}" == 3.* ]]; then + legacy=-legacy +fi + +APPLICATION_SIGNING_ID=${APPLICATION_SIGNING_ID:-${APPLICATION_ROOT}} + +KEYCHAIN_PATH="${KEYCHAIN_PATH:-"${ROOT_DIR}/constructor.keychain"}" +security create-keychain -p "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" +security set-keychain-settings -lut 3600 "${KEYCHAIN_PATH}" +security unlock-keychain -p "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" + +# Originally, this code contained code for creating certificates for installer signing: +# https://github.com/conda/constructor/blob/555eccb19ab4c3ed8cf5384bf66348b6d9613fd1/scripts/create_self_signed_certificates_macos.sh +# However, installer certificates must be trusted. Adding a trusted certificate to any +# keychain requires authentication, which is interactive and causes the run to hang. +APPLICATION_ROOT="application" +keyusage="codeSigning" +certtype="1.2.840.113635.100.6.1.13" +commonname="${APPLICATION_SIGNING_ID}" +password="${APPLICATION_SIGNING_PASSWORD}" +keyfile="${ROOT_DIR}/application.key" +p12file="${ROOT_DIR}/application.p12" +crtfile="${ROOT_DIR}/application.crt" + +openssl genrsa -out "${keyfile}" 2048 +openssl req -x509 -new -key "${keyfile}"\ + -out "${crtfile}"\ + -sha256\ + -days 1\ + -subj "/C=XX/ST=State/L=City/O=Company/OU=Org/CN=${commonname}/emailAddress=somebody@somewhere.com"\ + -addext "basicConstraints=critical,CA:FALSE"\ + -addext "extendedKeyUsage=critical,${keyusage}"\ + -addext "keyUsage=critical,digitalSignature"\ + -addext "${certtype}=critical,DER:0500" + +# shellcheck disable=SC2086 +openssl pkcs12 -export\ + -out "${p12file}"\ + -inkey "${keyfile}"\ + -in "${crtfile}"\ + -passout pass:"${password}"\ + ${legacy} + +security import "${p12file}" -P "${password}" -t cert -f pkcs12 -k "${KEYCHAIN_PATH}" -A +# shellcheck disable=SC2046 +security list-keychains -d user -s "${KEYCHAIN_PATH}" $(security list-keychains -d user | xargs) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..169c21a65 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,39 @@ +import os +import subprocess +from pathlib import Path + +import pytest + +REPO_DIR = Path(__file__).parent.parent + + +@pytest.fixture +def self_signed_application_certificate_macos(tmp_path): + p = subprocess.run( + ["security", "list-keychains", "-d", "user"], + capture_output=True, + text=True, + ) + current_keychains = [keychain.strip(' "') for keychain in p.stdout.split("\n") if keychain] + cert_root = tmp_path / "certs" + cert_root.mkdir(parents=True, exist_ok=True) + notarization_identity = "testapplication" + notarization_identity_password = "5678" + keychain_password = "abcd" + env = os.environ.copy() + env.update({ + "APPLICATION_SIGNING_ID": notarization_identity, + "APPLICATION_SIGNING_PASSWORD": notarization_identity_password, + "KEYCHAIN_PASSWORD": keychain_password, + "ROOT_DIR": str(cert_root), + }) + p = subprocess.run( + ["bash", REPO_DIR / "scripts" / "create_self_signed_certificates_macos.sh"], + env=env, + capture_output=True, + text=True, + check=True, + ) + yield notarization_identity + # Clean up + subprocess.run(["security", "list-keychains", "-d", "user", "-s", *current_keychains]) diff --git a/tests/test_examples.py b/tests/test_examples.py index a3157b813..380d68f6f 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -10,6 +10,7 @@ from datetime import timedelta from functools import lru_cache from pathlib import Path +from plistlib import load as plist_load from typing import Generator, Iterable, Optional, Tuple import pytest @@ -340,7 +341,7 @@ def _sort_by_extension(path): @lru_cache(maxsize=None) -def _self_signed_certificate(path: str, password: str = None): +def _self_signed_certificate_windows(path: str, password: str = None): if not sys.platform.startswith("win"): return return _execute( @@ -370,6 +371,13 @@ def test_example_customized_welcome_conclusion(tmp_path, request): _run_installer(input_path, installer, install_dir, request=request) +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_example_extra_pages_win(tmp_path, request): + input_path = _example_path("exe_extra_pages") + for installer, install_dir in create_installer(input_path, tmp_path): + _run_installer(input_path, installer, install_dir, request=request) + + def test_example_extra_envs(tmp_path, request): input_path = _example_path("extra_envs") for installer, install_dir in create_installer(input_path, tmp_path): @@ -470,6 +478,86 @@ def test_example_osxpkg(tmp_path, request): assert expected == found +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") +@pytest.mark.skipif(not shutil.which("xcodebuild"), reason="requires xcodebuild") +def test_example_osxpkg_extra_pages(tmp_path): + try: + subprocess.run(["xcodebuild", "--help"], check=True, capture_output=True) + except subprocess.CalledProcessError: + pytest.skip("xcodebuild requires XCode to compile extra pages.") + recipe_path = _example_path("osxpkg_extra_pages") + input_path = tmp_path / "input" + output_path = tmp_path / "output" + shutil.copytree(str(recipe_path), str(input_path)) + installer, install_dir = next(create_installer(input_path, output_path)) + # expand-full is an undocumented option that extracts all archives, + # including binary archives like the PlugIns file + cmd = ["pkgutil", "--expand-full", installer, output_path / "expanded"] + _execute(cmd) + installer_sections = output_path / "expanded" / "PlugIns" / "InstallerSections.plist" + assert installer_sections.exists() + + with open(installer_sections, "rb") as f: + plist = plist_load(f) + expected = { + "SectionOrder": [ + "Introduction", + "ReadMe", + "License", + "Target", + "PackageSelection", + "Install", + "ExtraPage.bundle", + ] + } + assert plist == expected + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") +@pytest.mark.skipif(not shutil.which("xcodebuild"), reason="requires xcodebuild") +@pytest.mark.skipif("CI" not in os.environ, reason="CI only") +def test_macos_signing(tmp_path, self_signed_application_certificate_macos): + try: + subprocess.run(["xcodebuild", "--help"], check=True, capture_output=True) + except subprocess.CalledProcessError: + pytest.skip("xcodebuild requires XCode to compile extra pages.") + input_path = tmp_path / "input" + recipe_path = _example_path("osxpkg_extra_pages") + shutil.copytree(str(recipe_path), str(input_path)) + with open(input_path / "construct.yaml", "a") as f: + f.write(f"notarization_identity_name: {self_signed_application_certificate_macos}\n") + output_path = tmp_path / "output" + installer, install_dir = next(create_installer(input_path, output_path)) + + # Check component signatures + expanded_path = output_path / "expanded" + # expand-full is an undocumented option that extracts all archives, + # including binary archives like the PlugIns file + cmd = ["pkgutil", "--expand-full", installer, expanded_path] + _execute(cmd) + components = [ + Path(expanded_path, "prepare_installation.pkg", "Payload", "osx-pkg-test", "_conda"), + Path(expanded_path, "Plugins", "ExtraPage.bundle"), + ] + validated_signatures = [] + for component in components: + p = subprocess.run( + ["/usr/bin/codesign", "--verify", str(component), "--verbose=4"], + check=True, + text=True, + capture_output=True, + ) + # codesign --verify outputs to stderr + lines = p.stderr.split("\n")[:-1] + if ( + len(lines) == 2 + and lines[0] == f"{component}: valid on disk" + and lines[1] == f"{component}: satisfies its Designated Requirement" + ): + validated_signatures.append(component) + assert validated_signatures == components + + def test_example_scripts(tmp_path, request): input_path = _example_path("scripts") for installer, install_dir in create_installer(input_path, tmp_path, with_spaces=True): @@ -521,7 +609,7 @@ def test_example_signing(tmp_path, request): input_path = _example_path("signing") cert_path = tmp_path / "self-signed-cert.pfx" cert_pwd = "1234" - _self_signed_certificate(path=cert_path, password=cert_pwd) + _self_signed_certificate_windows(path=cert_path, password=cert_pwd) assert cert_path.exists() certificate_in_input_dir = input_path / "certificate.pfx" shutil.copy(str(cert_path), str(certificate_in_input_dir))