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 96190cf6e..8c3ad742c 100644
--- a/constructor/nsis/main.nsi.tmpl
+++ b/constructor/nsis/main.nsi.tmpl
@@ -155,6 +155,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 cacdca569..99fe16c02 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('@'):
@@ -332,6 +346,7 @@ def make_nsi(
ppd["has_conda"] = info["_has_conda"]
ppd["custom_welcome"] = info.get("welcome_file", "").endswith(".nsi")
ppd["custom_conclusion"] = info.get("conclusion_file", "").endswith(".nsi")
+ 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':
@@ -373,6 +388,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", ())])),
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 7a0975799..2cf57e124 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
@@ -334,7 +335,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(
@@ -364,6 +365,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):
@@ -464,6 +472,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):
@@ -515,7 +603,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))