diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0f87e02c7..39610000a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -137,6 +137,7 @@ jobs: run: conda list - name: conda config run: conda config --show-sources + - name: Run unit tests run: | pytest -ra -vvv --cov=constructor --cov-branch tests/ -m "not examples" @@ -155,6 +156,7 @@ jobs: AZURE_SIGNTOOL_KEY_VAULT_URL: ${{ secrets.AZURE_SIGNTOOL_KEY_VAULT_URL }} CONSTRUCTOR_EXAMPLES_KEEP_ARTIFACTS: "${{ runner.temp }}/examples_artifacts" CONSTRUCTOR_SIGNTOOL_PATH: "C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x86/signtool.exe" + CONSTRUCTOR_VERBOSE: 0 run: | rm -rf coverage.json pytest -ra -vvv --cov=constructor --cov-branch tests/test_examples.py diff --git a/.gitignore b/.gitignore index 22609e17e..9733f24b1 100644 --- a/.gitignore +++ b/.gitignore @@ -150,8 +150,13 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# VS Code .vscode/ +# macOS +.DS_Store + # Rever rever/ diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 17a71302f..65b2e649b 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -235,6 +235,7 @@ The type of the installer being created. Possible values are: - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS +- `msi`: Windows GUI installer built with Briefcase and WiX The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well @@ -317,8 +318,11 @@ Name of the company/entity responsible for the installer. ### `reverse_domain_identifier` Unique identifier for this package, formatted with reverse domain notation. This is -used internally in the PKG installers to handle future updates and others. If not -provided, it will default to `io.continuum`. (MacOS only) +used internally in the MSI and PKG installers to handle future updates and others. +If not provided, it will default to: + +* In MSI installers: `io.continuum` followed by an ID derived from the `name`. +* In PKG installers: `io.continuum`. ### `uninstall_name` @@ -341,7 +345,8 @@ pre_install/post_install script(s). If you need to include single quotes in your value, you can escape them by replacing each single quote with `'''`. -On Windows, single quotes and double quotes are not supported. +For Windows EXE installers, single quotes and double quotes are not supported. +For Windows MSI installers, single quotes are supported but double quotes are not. Note that the # (hash) character cannot be used as it denotes yaml comments for all platforms. @@ -383,6 +388,9 @@ Path to a post-install script. Some notes: the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`. `%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`/S`), `"0"` otherwise. +- For Windows `.msi` installers, the script must be a `.bat` file. + The same variables as `.exe` installers are available, except + `%INSTALLER_TYPE%` is set to `MSI` and `%INSTALLER_UNATTENDED%` is not available. If necessary, you can activate the installed `base` environment like this: @@ -401,11 +409,11 @@ This option has no effect on `SH` installers. ### `pre_uninstall` -Path to a pre uninstall script. This is only supported on Windows, +Path to a pre uninstall script. This is only supported on Windows (EXE and MSI), and must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. -`%INSTALLER_TYPE%` is set to `EXE`. +`%INSTALLER_TYPE%` is set to `EXE` or `MSI`. If the uninstallation is performed with `conda-standalone`, the following environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to @@ -485,7 +493,9 @@ so the user receives updates after each command executed by the installer. Path to an image in any common image format (`.png`, `.jpg`, `.tif`, etc.) to be used as the welcome image for the Windows and PKG installers. -The image is re-sized to 164 x 314 pixels on Windows and 1227 x 600 on macOS. +The image is re-sized to 164 x 314 pixels for EXE installers, 1227 x 600 on macOS, +and for MSI installers it is scaled to fit a 164-pixel wide side panel (maintaining +aspect ratio) with white padding on the right. By default, an image is automatically generated on Windows. On macOS, Anaconda's logo is shown if this key is not provided. If you don't want a background on PKG installers, set this key to `""` (empty string). @@ -577,7 +587,7 @@ shown before the license information, right after the introduction. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `welcome_file` and `welcome_text` are provided, `welcome_file` takes precedence. -If the installer is for Windows and the welcome file type is nsi, +If the installer is for Windows EXE and the welcome file type is nsi, it will use the nsi script to add in extra pages before the installer begins the installation process. diff --git a/constructor/_schema.py b/constructor/_schema.py index 87945ad03..bc2d3df17 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -40,6 +40,7 @@ class WinSignTools(StrEnum): class InstallerTypes(StrEnum): ALL = "all" EXE = "exe" + MSI = "msi" PKG = "pkg" SH = "sh" @@ -403,6 +404,7 @@ class ConstructorConfiguration(BaseModel): - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS + - `msi`: Windows GUI installer built with Briefcase and WiX The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well @@ -486,8 +488,11 @@ class ConstructorConfiguration(BaseModel): reverse_domain_identifier: NonEmptyStr | None = None """ Unique identifier for this package, formatted with reverse domain notation. This is - used internally in the PKG installers to handle future updates and others. If not - provided, it will default to `io.continuum`. (MacOS only) + used internally in the MSI and PKG installers to handle future updates and others. + If not provided, it will default to: + + * In MSI installers: `io.continuum` followed by an ID derived from the `name`. + * In PKG installers: `io.continuum`. """ uninstall_name: NonEmptyStr | None = None """ @@ -510,7 +515,8 @@ class ConstructorConfiguration(BaseModel): in your value, you can escape them by replacing each single quote with `'''`. - On Windows, single quotes and double quotes are not supported. + For Windows EXE installers, single quotes and double quotes are not supported. + For Windows MSI installers, single quotes are supported but double quotes are not. Note that the # (hash) character cannot be used as it denotes yaml comments for all platforms. @@ -552,6 +558,9 @@ class ConstructorConfiguration(BaseModel): the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`. `%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`/S`), `"0"` otherwise. + - For Windows `.msi` installers, the script must be a `.bat` file. + The same variables as `.exe` installers are available, except + `%INSTALLER_TYPE%` is set to `MSI` and `%INSTALLER_UNATTENDED%` is not available. If necessary, you can activate the installed `base` environment like this: @@ -570,11 +579,11 @@ class ConstructorConfiguration(BaseModel): """ pre_uninstall: NonEmptyStr | None = None """ - Path to a pre uninstall script. This is only supported on Windows, + Path to a pre uninstall script. This is only supported on Windows (EXE and MSI), and must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. - `%INSTALLER_TYPE%` is set to `EXE`. + `%INSTALLER_TYPE%` is set to `EXE` or `MSI`. If the uninstallation is performed with `conda-standalone`, the following environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to @@ -654,7 +663,9 @@ class ConstructorConfiguration(BaseModel): """ Path to an image in any common image format (`.png`, `.jpg`, `.tif`, etc.) to be used as the welcome image for the Windows and PKG installers. - The image is re-sized to 164 x 314 pixels on Windows and 1227 x 600 on macOS. + The image is re-sized to 164 x 314 pixels for EXE installers, 1227 x 600 on macOS, + and for MSI installers it is scaled to fit a 164-pixel wide side panel (maintaining + aspect ratio) with white padding on the right. By default, an image is automatically generated on Windows. On macOS, Anaconda's logo is shown if this key is not provided. If you don't want a background on PKG installers, set this key to `""` (empty string). @@ -746,7 +757,7 @@ class ConstructorConfiguration(BaseModel): File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `welcome_file` and `welcome_text` are provided, `welcome_file` takes precedence. - If the installer is for Windows and the welcome file type is nsi, + If the installer is for Windows EXE and the welcome file type is nsi, it will use the nsi script to add in extra pages before the installer begins the installation process. """ diff --git a/constructor/briefcase.py b/constructor/briefcase.py new file mode 100644 index 000000000..99f08c365 --- /dev/null +++ b/constructor/briefcase.py @@ -0,0 +1,626 @@ +""" +Logic to build installers using Briefcase. +""" + +import functools +import logging +import re +import shutil +import sys +import sysconfig +import tarfile +import tempfile +from dataclasses import dataclass +from pathlib import Path +from subprocess import run + +IS_WINDOWS = sys.platform == "win32" +if IS_WINDOWS: + import tomli_w + + from .imaging import write_images +else: + tomli_w = None # This file is only intended for Windows use + write_images = None # imaging.py requires PIL, which is only available on Windows + +from . import preconda +from .jinja import render_template +from .signing import create_windows_signing_tool +from .utils import ( + DEFAULT_REVERSE_DOMAIN_ID, + bat_echo_esc, + bat_env_var_esc, + copy_conda_exe, + filename_dist, + get_final_channels, + shortcuts_flags, +) + +BRIEFCASE_DIR = Path(__file__).parent / "briefcase" +EXTERNAL_PACKAGE_PATH = "external" + +# MSI Branding Limitations: +# The following EXE branding options are not supported for MSI installers +# because they require modifications to the WiX template in briefcase-windows-app-template: +# - welcome_file / welcome_text (custom welcome page text) +# - conclusion_file / conclusion_text (finish page text) + +# Default to a low version, so that if a valid version is provided in the future, it'll +# be treated as an upgrade. +DEFAULT_VERSION = "0.0.1" + +logger = logging.getLogger(__name__) + + +def get_name_version(info): + if not (name := info.get("name")): + raise ValueError("Name is empty") + if not (version := info.get("version")): + raise ValueError("Version is empty") + + # Briefcase requires version numbers to be in the canonical Python format, and some + # installer types use the version to distinguish between upgrades, downgrades and + # reinstalls. So try to produce a consistent ordering by extracting the last valid + # version from the Constructor version string. + # + # Hyphens aren't allowed in this format, but for compatibility with Miniconda's + # version format, we treat them as dots. + matches = list( + re.finditer( + r"(\d+!)?\d+(\.\d+)*((a|b|rc)\d+)?(\.post\d+)?(\.dev\d+)?", + version.lower().replace("-", "."), + ) + ) + if not matches: + logger.warning( + f"Version {version!r} contains no valid version numbers; " + f"defaulting to {DEFAULT_VERSION}" + ) + return f"{name} {version}", DEFAULT_VERSION + + match = matches[-1] + version = match.group() + + # Treat anything else in the version string as part of the name. + start, end = match.span() + strip_chars = " .-_" + before = info["version"][:start].strip(strip_chars) + after = info["version"][end:].strip(strip_chars) + name = " ".join(s for s in [name, before, after] if s) + + return name, version + + +def make_app_name(name, source): + """Takes an arbitrary string with at least one alphanumeric character, and makes it into a valid Python package name.""" + app_name = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + if not app_name: + raise ValueError(f"{source} contains no alphanumeric characters") + return app_name + + +# Some installer types use the reverse domain ID to detect when the product is already +# installed, so it should be both unique between different products, and stable between +# different versions of a product. +def get_bundle_app_name(info, name): + # If reverse_domain_identifier is provided, use it as-is, + if (rdi := info.get("reverse_domain_identifier")) is not None: + if "." not in rdi: + raise ValueError(f"reverse_domain_identifier {rdi!r} contains no dots") + bundle, app_name = rdi.rsplit(".", 1) + + # Ensure that the last component is a valid Python package name, as Briefcase + # requires. + if not re.fullmatch( + r"[A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9]", app_name, flags=re.IGNORECASE + ): + app_name = make_app_name( + app_name, f"Last component of reverse_domain_identifier {rdi!r}" + ) + + # If reverse_domain_identifier isn't provided, generate it from the name. + else: + bundle = DEFAULT_REVERSE_DOMAIN_ID + app_name = make_app_name(name, f"Name {name!r}") + + return bundle, app_name + + +def get_license(info): + """Retrieve the specified license as a dict or return a placeholder if not set.""" + + if "license_file" in info: + return {"file": info["license_file"]} + + placeholder_license = Path(__file__).parent / "nsis" / "placeholder_license.txt" + return {"file": str(placeholder_license)} # convert to str for TOML serialization + + +def is_bat_file(file_path: Path) -> bool: + return file_path.is_file() and file_path.suffix.lower() == ".bat" + + +def _get_script_env_variables(info: dict) -> dict[str, str]: + """Validate and escape script_env_variables for batch file use. + + Raises ValueError if any key or value contains double quotes. + Returns escaped key-value pairs. + """ + raw_vars = info.get("script_env_variables", {}) + escaped_vars = {} + + for key, val in raw_vars.items(): + if '"' in key or '"' in val: + raise ValueError( + f"script_env_variables entry '{key}' contains double quotes, " + "which are not supported in MSI installers. " + "Use single quotes instead." + ) + escaped_vars[key] = bat_env_var_esc(val) + + return escaped_vars + + +def _setup_envs_commands(info: dict) -> list[dict]: + """Build environment setup data for base and extra_envs. + + Returns a list of dicts, each containing the data needed to install + one environment. Used by the run_installation.bat template. + """ + environments = [] + + # Base environment + environments.append( + { + "name": "base", + "prefix": "%BASE_PATH%", + "lockfile": r"%BASE_PATH%\conda-meta\initial-state.explicit.txt", + "channels": ",".join(get_final_channels(info)), + "shortcuts": shortcuts_flags(info), + } + ) + + # Extra environments + for env_name in info.get("_extra_envs_info", {}): + env_config = info["extra_envs"][env_name] + # Needed for shortcuts_flags function + if "_conda_exe_type" not in env_config: + env_config["_conda_exe_type"] = info.get("_conda_exe_type") + channel_info = { + "channels": env_config.get("channels", info.get("channels", ())), + "channels_remap": env_config.get("channels_remap", info.get("channels_remap", ())), + } + environments.append( + { + "name": env_name, + "prefix": rf"%BASE_PATH%\envs\{env_name}", + "lockfile": rf"%BASE_PATH%\envs\{env_name}\conda-meta\initial-state.explicit.txt", + "channels": ",".join(get_final_channels(channel_info)), + "shortcuts": shortcuts_flags(env_config), + } + ) + + return environments + + +def create_uninstall_options_list(info: dict) -> list[dict]: + """Returns a list of dicts with data formatted for the uninstallation options page.""" + return [ + { + "name": "remove_user_data", + "title": "Remove user data", + "description": "Remove user data associated with this installation.", + "default": False, + }, + { + "name": "remove_caches", + "title": "Remove caches", + "description": "Clear the package cache upon completion.", + "default": False, + }, + { + "name": "remove_config_files", + "title": "Remove configuration files", + "description": "Remove .condarc and other configuration files.", + "default": False, + }, + ] + + +def create_install_options_list(info: dict) -> list[dict]: + """Returns a list of dicts with data formatted for the installation options page.""" + options = [] + + # Register Python (if Python is bundled) + has_python = False + for item in info.get("_dists", []): + if item.startswith("python-"): + components = item.split("-") # python-x.y.z-.suffix + python_version = ".".join(components[1].split(".")[:-1]) # create the string "x.y" + has_python = True + break + + if has_python and info.get("register_python", True): + options.append( + { + "name": "register_python", + "title": f"Register {info['name']} as my default Python {python_version}.", + "description": "Allows other programs, such as VSCode, PyCharm, etc. to automatically " + f"detect {info['name']} as the primary Python {python_version} on the system.", + "default": info.get("register_python_default", False), + } + ) + + # Initialize conda + initialize_conda = info.get("initialize_conda", "classic") + if initialize_conda: + if initialize_conda == "condabin": + description = ( + "Adds condabin, which only contains the 'conda' executables, to PATH. " + "Does not require special shortcuts but activation needs " + "to be performed manually." + ) + else: + description = ( + "NOT recommended. This can lead to conflicts with other applications. " + "Instead, use the Command Prompt and Powershell menus added to the Windows Start Menu." + ) + options.append( + { + "name": "initialize_conda", + "title": "Add installation to my PATH environment variable", + "description": description, + "default": info.get("initialize_by_default", False), + } + ) + + # Keep package option (presented to the user as a negation (clear package cache)) + clear_package_cache = not info.get("keep_pkgs", False) + options.append( + { + "name": "clear_package_cache", + "title": "Clear the package cache upon completion", + "description": "Recommended. Recovers some disk space without harming functionality.", + "default": clear_package_cache, + } + ) + + # Enable shortcuts + if info.get("_enable_shortcuts", False) is True: + options.append( + { + "name": "enable_shortcuts", + "title": "Create shortcuts", + "description": "Create shortcuts (supported packages only).", + "default": True, + } + ) + + # Pre/Post install script + for script_type in ["pre", "post"]: + script_description = info.get(f"{script_type}_install_desc", "") + script = info.get(f"{script_type}_install", "") + if script_description and not script: + raise ValueError( + f"{script_type}_install_desc was set, but {script_type}_install was not!" + ) + + if script: + script_path = Path(script) + if not is_bat_file(script_path): + raise ValueError( + f"Specified {script_type}-install script '{script}' must be an existing '.bat' file." + ) + + # The UI option is only displayed if a description is set + if script_description: + options.append( + { + "name": f"{script_type}_install_script", + "title": f"{script_type.capitalize()}-install script", + "description": script_description, + "default": False, + } + ) + + return options + + +def _get_python_info(info: dict) -> tuple[bool, list[str]]: + """Return (has_python, pyver_components) by inspecting _dists.""" + for dist in info.get("_dists", []): + name, version, _ = filename_dist(dist).rsplit("-", 2) + if name == "python": + return True, version.split(".") + return False, [] + + +@dataclass +class Payload: + """ + This class manages and prepares a payload with a temporary directory. + """ + + info: dict + archive_name: str = "payload.tar.gz" + conda_exe_name: str = "_conda.exe" + + # Enable additional log output during pre/post uninstall/install. + add_debug_logging: bool = False + + @functools.cached_property + def root(self) -> Path: + """Create root upon first access and cache it.""" + return Path(tempfile.mkdtemp(prefix="payload-")) + + def remove(self, *, ignore_errors: bool = True) -> None: + """Remove the root of the payload. + + This function requires some extra care due to the root directory being a cached property. + """ + root = getattr(self, "root", None) + if root is None: + return + shutil.rmtree(root, ignore_errors=ignore_errors) + # Now we drop the cached value so next access will recreate if desired + try: + delattr(self, "root") + except Exception: + # delattr on a cached_property may raise on some versions / edge cases + pass + + def prepare(self) -> None: + """Prepares the payload. + + Directory structure created during preparation: + + / (temporary directory, see :attr:`root`) + └── / (external_dir: contains the payload archive and conda exe) + └── base/ (base_dir: represents the base conda environment) + └── pkgs/ (pkgs_dir: staging area for conda package distributions) + + Note: base_dir and pkgs_dir are removed after archiving. + """ + external_dir = self.root / EXTERNAL_PACKAGE_PATH + external_dir.mkdir(parents=True, exist_ok=True) + + # Generate branding images for MSI installer (only if user provided custom images) + write_images(self.info, external_dir, installer_type="msi") + + # Note that the directory name "base" is also explicitly defined in `run_installation.bat` + base_dir = external_dir / "base" + base_dir.mkdir() + + pkgs_dir = base_dir / "pkgs" + pkgs_dir.mkdir() + + self.render_templates() + self.write_pyproject_toml(self.root, external_dir) + + preconda.write_files(self.info, base_dir) + preconda.copy_extra_files(self.info.get("extra_files", []), base_dir) + # Copy license file to PREFIX, matching behavior of other installer types + if license_file := self.info.get("license_file"): + preconda.copy_extra_files([license_file], base_dir) + self._stage_dists(pkgs_dir) + self._stage_user_scripts(pkgs_dir) + self._stage_conda(external_dir) + + archive_path = self.make_archive(base_dir, external_dir) + if not archive_path.exists(): + raise RuntimeError(f"Unexpected error, failed to create archive: {archive_path}") + + def make_archive(self, src: Path, dst: Path) -> Path: + """Create an archive of the directory 'src'. + The input 'src' must be an existing directory. + If 'dst' does not exist, this function will create it. + The directory specified via 'src' is removed after successful creation. + Returns the path to the archive. + + Example: + payload = Payload(...) + foo = Path('foo') + bar = Path('bar') + targz = payload.make_archive(foo, bar) + This will create the file bar\\ containing 'foo' and all its contents. + + """ + if not src.is_dir(): + raise NotADirectoryError(src) + dst.mkdir(parents=True, exist_ok=True) + + archive_path = dst / self.archive_name + + archive_type = archive_path.suffix[1:] # since suffix starts with '.' + with tarfile.open(archive_path, mode=f"w:{archive_type}", compresslevel=1) as tar: + tar.add(src, arcname=src.name) + + shutil.rmtree(src) + return archive_path + + def render_templates(self) -> list[Path]: + """Render the configured templates under the payload root, + returns a list of Paths to the rendered templates. + """ + templates = { + Path(BRIEFCASE_DIR / "run_installation.bat"): Path(self.root / "run_installation.bat"), + Path(BRIEFCASE_DIR / "pre_uninstall.bat"): Path(self.root / "pre_uninstall.bat"), + } + + has_python, pyver_components = _get_python_info(self.info) + + context: dict = { + "archive_name": self.archive_name, + "conda_exe_name": self.conda_exe_name, + "add_debug": self.add_debug_logging, + "register_envs": str(self.info.get("register_envs", True)).lower(), + # --- has_python / pyver_components --- + "has_python": has_python, + "pyver_components": pyver_components, + # --- OPTION_INITIALIZE_CONDA --- + "initialize_conda": self.info.get("initialize_conda", "classic"), + # --- OPTION_CLEAR_PACKAGE_CACHE / OPTION_ENABLE_SHORTCUTS --- + "no_rcs_arg": self.info.get("_ignore_condarcs_arg", ""), + # --- OPTION_ENABLE_SHORTCUTS --- + # shortcuts_flags returns the appropriate --shortcuts-only=... flags, + # an empty string (all shortcuts), or --no-shortcuts (none). + # In the .bat template this is used in the "shortcuts enabled" branch, + # so passing an empty string here is correct when all shortcuts are wanted. + "shortcuts": shortcuts_flags(self.info), + # --- setup_envs --- + "setup_envs": _setup_envs_commands(self.info), + # --- virtual_specs --- + # virtual_specs: quoted for command-line use + # virtual_specs_debug: unquoted for display + # virtual_specs_debug_bat: escaped for batch echo commands + "virtual_specs": " ".join([f'"{spec}"' for spec in self.info.get("virtual_specs", ())]), + "virtual_specs_debug": " ".join(self.info.get("virtual_specs", ())), + "virtual_specs_debug_bat": bat_echo_esc(" ".join(self.info.get("virtual_specs", ()))), + # --- script_env_variables --- + # User-defined environment variables for pre/post install scripts + "script_env_variables": _get_script_env_variables(self.info), + # --- user scripts --- + # Flags indicating whether user-supplied scripts are present + "has_pre_install": bool(self.info.get("pre_install")), + "has_post_install": bool(self.info.get("post_install")), + "has_pre_uninstall": bool(self.info.get("pre_uninstall")), + # Flags indicating whether scripts are optional (have a description) + "has_pre_install_desc": bool(self.info.get("pre_install_desc")), + "has_post_install_desc": bool(self.info.get("post_install_desc")), + # --- installer metadata --- + # Used by user scripts to identify the installer + "installer_name": self.info.get("name", ""), + "installer_version": self.info.get("version", ""), + "installer_platform": self.info.get("_platform", ""), + } + + # Render the templates now using jinja and the defined context + for src, dst in templates.items(): + if not src.exists(): + raise FileNotFoundError(src) + rendered = render_template(src.read_text(encoding="utf-8"), **context) + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(rendered, encoding="utf-8", newline="\r\n") + + return list(templates.values()) + + def write_pyproject_toml(self, root: Path, external: Path) -> None: + name, version = get_name_version(self.info) + bundle, app_name = get_bundle_app_name(self.info, name) + + config = { + "project_name": name, + "bundle": bundle, + "version": version, + "license": get_license(self.info), + "app": { + app_name: { + "formal_name": f"{self.info['name']} {self.info['version']}", + "description": "", # Required, but not used in the installer. + "external_package_path": str(external), + "use_full_install_path": False, + "install_launcher": False, + "install_option": create_install_options_list(self.info), + "uninstall_option": create_uninstall_options_list(self.info), + "post_install_script": str(root / "run_installation.bat"), + "pre_uninstall_script": str(root / "pre_uninstall.bat"), + } + }, + } + + # Add optional branding images (only if user provided them in construct.yaml) + icon_ico = external / "icon.ico" + if icon_ico.exists(): + # Briefcase expects icon path WITHOUT extension - it appends .ico + config["app"][app_name]["icon"] = str(external / "icon") + welcome_bmp = external / "welcome.bmp" + if welcome_bmp.exists(): + config["app"][app_name]["installer_background"] = str(welcome_bmp) + header_bmp = external / "header.bmp" + if header_bmp.exists(): + config["app"][app_name]["installer_banner"] = str(header_bmp) + + # Add optional content + if "company" in self.info: + config["author"] = self.info["company"] + + # Finalize + (root / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}})) + logger.debug(f"Created TOML file at: {root}") + + def _stage_dists(self, pkgs_dir: Path) -> None: + download_dir = Path(self.info["_download_dir"]) + # Collect dists from base and extra_envs, de-duplicated + dists = set(self.info["_dists"]) + for env_info in self.info.get("_extra_envs_info", {}).values(): + dists.update(env_info.get("_dists", [])) + for dist in sorted(dists): + shutil.copy(download_dir / filename_dist(dist), pkgs_dir) + + def _stage_user_scripts(self, pkgs_dir: Path) -> None: + """Copy user-supplied pre/post install scripts to the pkgs directory.""" + script_mappings = [ + ("pre_install", "user_pre_install.bat"), + ("post_install", "user_post_install.bat"), + ("pre_uninstall", "user_pre_uninstall.bat"), + ] + for key, dest_name in script_mappings: + if script_path := self.info.get(key): + script_path = Path(script_path) + if not is_bat_file(script_path): + raise ValueError( + f"Specified {key} script '{script_path}' must be an existing '.bat' file." + ) + shutil.copy(script_path, pkgs_dir / dest_name) + + def _stage_conda(self, external_dir: Path) -> None: + copy_conda_exe(external_dir, self.conda_exe_name, self.info["_conda_exe"]) + + +def create(info, verbose=False): + if not IS_WINDOWS: + raise OSError(f"Invalid platform '{sys.platform}'. MSI installers require Windows.") + + if not info.get("_conda_exe_supports_logging"): + raise ValueError("MSI installers require conda-standalone with logging support.") + + # Check briefcase exists before doing any work + briefcase = Path(sysconfig.get_path("scripts")) / "briefcase.exe" + if not briefcase.exists(): + raise FileNotFoundError( + f"Dependency 'briefcase' does not seem to be installed.\nTried: {briefcase}" + ) + + # MSI installers always use conda-standalone for uninstallation. + # This ensures proper cleanup of conda init, environments, and shortcuts + # via the `conda constructor uninstall` command. + info["uninstall_with_conda_exe"] = True + + signing_tool = create_windows_signing_tool(info) + + payload = Payload(info) + try: + payload.prepare() + + logger.info("Building MSI installer") + run( + [briefcase, "package"] + (["-v"] if verbose else []), + cwd=payload.root, + check=True, + ) + + dist_dir = payload.root / "dist" + msi_paths = list(dist_dir.glob("*.msi")) + if len(msi_paths) != 1: + raise RuntimeError(f"Found {len(msi_paths)} MSI files in {dist_dir}, expected 1.") + + if signing_tool: + signing_tool.sign(msi_paths[0]) + signing_tool.verify_signature(msi_paths[0]) + + outpath = Path(info["_outpath"]) + outpath.unlink(missing_ok=True) + shutil.move(msi_paths[0], outpath) + finally: + if not info.get("_debug"): + payload.remove() diff --git a/constructor/briefcase/pre_uninstall.bat b/constructor/briefcase/pre_uninstall.bat new file mode 100644 index 000000000..47b31713f --- /dev/null +++ b/constructor/briefcase/pre_uninstall.bat @@ -0,0 +1,171 @@ +@echo {{ 'on' if add_debug else 'off' }} +rem enabledelayedexpansion is required for !VAR! expansion inside for /f loops +rem and for building UNINST_ARGS dynamically. Note that this is NOT inherited +rem from run_pre_uninstall.bat even though it sets enabledelayedexpansion there, +rem because setlocal in this script creates a new scope. +setlocal enabledelayedexpansion + +{% macro error_block(message, code) %} +echo [ERROR] {{ message }} +>> "%LOG%" echo [ERROR] {{ message }} +exit /b {{ code }} +{% endmacro %} + +{%- macro tee(message) -%} +echo {{ message }} +>> "%LOG%" echo {{ message }} +{%- endmacro %} + +rem Assign INSTDIR and normalize the path +set "INSTDIR=%~dp0.." +for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" + +set "BASE_PATH=%INSTDIR%\base" +set "PREFIX=%BASE_PATH%" +set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" +set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" +set "CONDA_ROOT_PREFIX=%BASE_PATH%" +rem Set CONDA_QUIET primarily to disable the spinners +set CONDA_QUIET={{ 0 if add_debug else 1 }} + +rem Get the name of the install directory +for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI" +set "LOG=%INSTDIR%\uninstall.log" + +{%- if script_env_variables %} +rem User-defined environment variables for pre/post install scripts +{%- for key, val in script_env_variables.items() %} +set "{{ key }}={{ val }}" +{%- endfor %} +{%- endif %} + +rem Installer metadata for pre-uninstall script +set "INSTALLER_NAME={{ installer_name }}" +set "INSTALLER_VER={{ installer_version }}" +set "INSTALLER_PLAT={{ installer_platform }}" +set "INSTALLER_TYPE=MSI" +rem INSTALLER_UNATTENDED is not available for MSI installers. +rem Detecting silent mode requires UILevel from WiX, which would need +rem changes to the briefcase-windows-app-template to pass to this script. + +rem Determine install mode from .nonadmin marker file written at install time +if exist "%BASE_PATH%\.nonadmin" ( + set "REG_HIVE=HKCU" +) else ( + set "REG_HIVE=HKLM" +) + +rem Map OPTION_* to UNINSTALLER_* for compatibility with NSIS uninstaller scripts +if "%OPTION_REMOVE_USER_DATA%"=="1" set "UNINSTALLER_REMOVE_USER_DATA=1" +if "%OPTION_REMOVE_CACHES%"=="1" set "UNINSTALLER_REMOVE_CACHES=1" +if "%OPTION_REMOVE_CONFIG_FILES%"=="1" ( + if "%REG_HIVE%"=="HKCU" (set "UNINSTALLER_REMOVE_CONFIG_FILES=user") else (set "UNINSTALLER_REMOVE_CONFIG_FILES=all") +) + +{%- if add_debug %} +>> "%LOG%" echo ==== pre_uninstall start ==== +>> "%LOG%" echo SCRIPT=%~f0 +>> "%LOG%" echo CWD=%CD% +>> "%LOG%" echo INSTDIR=%INSTDIR% +>> "%LOG%" echo BASE_PATH=%BASE_PATH% +>> "%LOG%" echo CONDA_EXE=%CONDA_EXE% +>> "%LOG%" echo PAYLOAD_TAR=%PAYLOAD_TAR% +>> "%LOG%" echo CONDA_ROOT_PREFIX=%CONDA_ROOT_PREFIX% +>> "%LOG%" echo REG_HIVE=%REG_HIVE% +>> "%LOG%" echo ALLUSERS=%ALLUSERS% +>> "%LOG%" echo OPTION_REMOVE_USER_DATA=%OPTION_REMOVE_USER_DATA% +>> "%LOG%" echo OPTION_REMOVE_CACHES=%OPTION_REMOVE_CACHES% +>> "%LOG%" echo OPTION_REMOVE_CONFIG_FILES=%OPTION_REMOVE_CONFIG_FILES% +>> "%LOG%" echo UNINSTALLER_REMOVE_USER_DATA=%UNINSTALLER_REMOVE_USER_DATA% +>> "%LOG%" echo UNINSTALLER_REMOVE_CACHES=%UNINSTALLER_REMOVE_CACHES% +>> "%LOG%" echo UNINSTALLER_REMOVE_CONFIG_FILES=%UNINSTALLER_REMOVE_CONFIG_FILES% +"%CONDA_EXE%" --version >> "%LOG%" 2>&1 +{%- endif %} + +rem Consistency checks +if not exist "%CONDA_EXE%" ( + {{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }} +) +if "%ALLUSERS%"=="0" ( + if not exist "%BASE_PATH%\.nonadmin" ( + {{ error_block('Insufficient permissions. Please re-run the uninstallation as administrator.', 11) }} + ) +) + +rem Recreate an empty payload tar. This file was deleted during installation but the +rem MSI installer expects it to exist. +type nul > "%PAYLOAD_TAR%" +if errorlevel 1 ( + {{ error_block('Failed to create "%PAYLOAD_TAR%"', '%errorlevel%') }} +) + +{%- if has_pre_uninstall %} +rem Run user-supplied pre-uninstall script +{{ tee("Running pre-uninstall script...") }} +call "%BASE_PATH%\pkgs\user_pre_uninstall.bat" +if errorlevel 1 ( exit /b %errorlevel% ) +{%- endif %} + +rem Remove PATH entries only for user-scoped installs (mirrors NSIS .nonadmin check) +{%- set pathflag = "--condabin" if initialize_conda == "condabin" else "--classic" %} +if exist "%BASE_PATH%\.nonadmin" ( + {{ tee("Removing from PATH...") }} + "%CONDA_EXE%" constructor windows path --remove=user --prefix "%BASE_PATH%" {{ pathflag }} --log-file "%LOG%" + if errorlevel 1 ( exit /b %errorlevel% ) +) + +{%- if has_python %} +rem Remove Python registry entries only if InstallPath matches BASE_PATH. +{{ tee("Checking Python registry entries...") }} +call :remove_python_registry "%REG_HIVE%" "%BASE_PATH%" +goto :after_remove_python_registry + +:remove_python_registry +set "REG_HIVE_ARG=%~1" +set "BASE_PATH_ARG=%~2" +rem REG64 forces the 64-bit registry view since the MSI engine runs as a 32-bit process. +set "REG64=/reg:64" +rem Enumerate all subkeys under PythonCore (e.g. 3.11, 3.12, ...) +for /f "tokens=*" %%K in ('reg query "%REG_HIVE_ARG%\Software\Python\PythonCore" %REG64% 2^>nul') do ( + rem Read the InstallPath default value for each subkey + for /f "tokens=2*" %%A in ('reg query "%%K\InstallPath" /ve %REG64% 2^>nul') do ( + rem Only delete if InstallPath matches our installation directory + if /i "%%B"=="%BASE_PATH_ARG%" ( + echo Removing Python registry key: %%K + >> "%LOG%" echo Removing Python registry key: %%K + reg delete "%%K" /f %REG64% >> "%LOG%" 2>&1 + if errorlevel 1 ( exit /b %errorlevel% ) + ) + ) +) +exit /b 0 + +:after_remove_python_registry +{%- endif %} + +rem Run constructor uninstall, conditionally passing optional flags +set "UNINST_ARGS=" +if "%OPTION_REMOVE_USER_DATA%"=="1" ( + set "UNINST_ARGS=!UNINST_ARGS! --remove-user-data" +) +if "%OPTION_REMOVE_CACHES%"=="1" ( + set "UNINST_ARGS=!UNINST_ARGS! --remove-caches" +) +if "%OPTION_REMOVE_CONFIG_FILES%"=="1" ( + rem User installs (.nonadmin marker exists) only remove user config files. + rem Admin installs remove both user and system config files. + if exist "%BASE_PATH%\.nonadmin" ( + set "UNINST_ARGS=!UNINST_ARGS! --remove-config-files=user" + ) else ( + set "UNINST_ARGS=!UNINST_ARGS! --remove-config-files=all" + ) +) +{{ tee("Running constructor uninstall...") }} +"%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%"!UNINST_ARGS! --log-file "%LOG%" +if errorlevel 1 ( exit /b %errorlevel% ) + +rem If we reached this far without any errors, remove any log files. +if exist "%INSTDIR%\install.log" del "%INSTDIR%\install.log" +if exist "%INSTDIR%\uninstall.log" del "%INSTDIR%\uninstall.log" + +exit /b 0 diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat new file mode 100644 index 000000000..9d718ff3c --- /dev/null +++ b/constructor/briefcase/run_installation.bat @@ -0,0 +1,223 @@ +@echo {{ 'on' if add_debug else 'off' }} +rem enabledelayedexpansion is required for !REG_HIVE! expansion when +rem registering Python, and because setlocal creates a new scope that +rem does not inherit enabledelayedexpansion from the calling script. +setlocal enabledelayedexpansion + +{% macro error_block(message, code) %} +echo [ERROR] {{ message }} +>> "%LOG%" echo [ERROR] {{ message }} +exit /b {{ code }} +{% endmacro %} + +{%- macro tee(message) -%} +echo {{ message }} +>> "%LOG%" echo {{ message }} +{%- endmacro %} + +{% macro install_env(env) %} +{{ tee("Setting up " ~ env.name ~ " environment...") }} +set "CONDA_CHANNELS={{ env.channels }}" +if "%OPTION_ENABLE_SHORTCUTS%"=="1" ( + "%CONDA_EXE%" install --offline -yp "{{ env.prefix }}" --file "{{ env.lockfile }}" {{ env.shortcuts }} {{ no_rcs_arg }} --log-file "%LOG%" +) else ( + "%CONDA_EXE%" install --offline -yp "{{ env.prefix }}" --file "{{ env.lockfile }}" --no-shortcuts {{ no_rcs_arg }} --log-file "%LOG%" +) +set "INSTALL_ERRORLEVEL=%errorlevel%" +if %INSTALL_ERRORLEVEL% neq 0 ( exit /b %INSTALL_ERRORLEVEL% ) +{% endmacro %} + +rem Assign INSTDIR and normalize the path +set "INSTDIR=%~dp0.." +for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI" + +set "BASE_PATH=%INSTDIR%\base" +set "PREFIX=%BASE_PATH%" +set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}" +set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}" + +set CONDA_EXTRA_SAFETY_CHECKS=no +set CONDA_PROTECT_FROZEN_ENVS=0 +set CONDA_REGISTER_ENVS={{ register_envs }} +set CONDA_SAFETY_CHECKS=disabled +set "CONDA_ROOT_PREFIX=%BASE_PATH%" +rem Set CONDA_QUIET primarily to disable the spinners +set CONDA_QUIET={{ 0 if add_debug else 1 }} + +rem Get the name of the install directory +for %%I in ("%INSTDIR%") do set "DISTRIBUTION_NAME=%%~nxI" +set MENUINST_DISTRIBUTION_NAME=%DISTRIBUTION_NAME% +set "LOG=%INSTDIR%\install.log" + +{%- if script_env_variables %} +rem User-defined environment variables for pre/post install scripts +{%- for key, val in script_env_variables.items() %} +set "{{ key }}={{ val }}" +{%- endfor %} +{%- endif %} + +rem Installer metadata for pre/post install scripts +set "INSTALLER_NAME={{ installer_name }}" +set "INSTALLER_VER={{ installer_version }}" +set "INSTALLER_PLAT={{ installer_platform }}" +set "INSTALLER_TYPE=MSI" +rem INSTALLER_UNATTENDED is not available for MSI installers. +rem Detecting silent mode requires UILevel from WiX, which would need +rem changes to the briefcase-windows-app-template to pass to this script. + +{%- if add_debug %} +>> "%LOG%" echo ==== run_installation start ==== +>> "%LOG%" echo SCRIPT=%~f0 +>> "%LOG%" echo CWD=%CD% +>> "%LOG%" echo INSTDIR=%INSTDIR% +>> "%LOG%" echo BASE_PATH=%BASE_PATH% +>> "%LOG%" echo CONDA_EXE=%CONDA_EXE% +>> "%LOG%" echo PAYLOAD_TAR=%PAYLOAD_TAR% +>> "%LOG%" echo ALLUSERS=%ALLUSERS% +>> "%LOG%" echo OPTION_ENABLE_SHORTCUTS=%OPTION_ENABLE_SHORTCUTS% +>> "%LOG%" echo OPTION_INITIALIZE_CONDA=%OPTION_INITIALIZE_CONDA% +{%- if has_python %} +>> "%LOG%" echo OPTION_REGISTER_PYTHON=%OPTION_REGISTER_PYTHON% +{%- endif %} +>> "%LOG%" echo OPTION_CLEAR_PACKAGE_CACHE=%OPTION_CLEAR_PACKAGE_CACHE% +{%- endif %} + +rem Consistency checks +if not exist "%CONDA_EXE%" ( + {{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }} +) +if not exist "%PAYLOAD_TAR%" ( + {{ error_block('PAYLOAD_TAR not found: "%PAYLOAD_TAR%"', 11) }} +) + +{{ tee("Unpacking payload...") }} +"%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin --log-file "%LOG%" < "%PAYLOAD_TAR%" +if errorlevel 1 ( exit /b %errorlevel% ) + +if not exist "%BASE_PATH%" ( + {{ error_block('"%BASE_PATH%" not found!', 12) }} +) + +{%- if virtual_specs %} +rem Check virtual specs compatibility before extracting conda packages. +rem This matches the order used by NSIS installers. +rem We need to specify CONDA_SOLVER=classic to work around this bug: +rem https://github.com/conda/conda-libmamba-solver/issues/480 +rem Use a temp pkgs dir to avoid incomplete cache files in base\pkgs (like shell installer does). +set "CONDA_SOLVER=classic" +set "CONDA_PKGS_DIRS=%TEMP%\constructor_vspecs" +echo Checking virtual specs compatibility: {{ virtual_specs_debug_bat }} +>> "%LOG%" echo Checking virtual specs compatibility: {{ virtual_specs_debug_bat }} +"%CONDA_EXE%" create --dry-run --prefix "%BASE_PATH%\envs\_virtual_specs_checks" --offline {{ virtual_specs }} {{ no_rcs_arg }} --log-file "%LOG%" +if errorlevel 1 ( + echo [ERROR] Failed to check virtual specs: {{ virtual_specs_debug_bat }} + >> "%LOG%" echo [ERROR] Failed to check virtual specs: {{ virtual_specs_debug_bat }} + exit /b 13 +) +set "CONDA_SOLVER=" +{%- endif %} + +set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs" + +"%CONDA_EXE%" constructor extract --prefix "%BASE_PATH%" --conda-pkgs --log-file "%LOG%" +if errorlevel 1 ( exit /b %errorlevel% ) + +rem Create .nonadmin marker file for user-scoped installs inside BASE_PATH. +rem This is used by the uninstaller (and menuinst) to determine the install mode. +if "%ALLUSERS%"=="0" ( + echo. > "%BASE_PATH%\.nonadmin" + if errorlevel 1 ( exit /b %errorlevel% ) +) + +rem Persist distribution_name to menuinst.toml before installing packages. +rem This ensures the value is captured even if no packages have shortcuts. +rem Must run before conda install to avoid creating shortcuts twice. +"%CONDA_EXE%" menuinst --install -p "%BASE_PATH%" --root-prefix "%BASE_PATH%" +if errorlevel 1 ( exit /b %errorlevel% ) +if not exist "%BASE_PATH%\Menu\menuinst.toml" ( + {{ error_block('Failed to initialize shortcut configuration', 14) }} +) + +{%- if has_pre_install %} +rem Run user-supplied pre-install script +{%- if has_pre_install_desc %} +if "%OPTION_PRE_INSTALL_SCRIPT%"=="1" ( +{%- endif %} + {{ tee("Running pre-install script...") }} + call "%BASE_PATH%\pkgs\user_pre_install.bat" + if errorlevel 1 ( exit /b %errorlevel% ) +{%- if has_pre_install_desc %} +) +{%- endif %} +{%- endif %} + +rem Install packages for each environment +{%- for env in setup_envs %} +{{ install_env(env) }} +{%- endfor %} + +rem Delete the payload to save disk space. +rem A truncated placeholder of 0 bytes is recreated during uninstall +rem because MSI expects the file to be there to clean the registry. +del "%PAYLOAD_TAR%" +if errorlevel 1 ( exit /b %errorlevel% ) + +rem Add to PATH / run conda init if the option was selected +{%- set pathflag = "--condabin" if initialize_conda == "condabin" else "--classic" %} +if "%OPTION_INITIALIZE_CONDA%"=="1" ( + {{ tee("Adding to PATH...") }} + "%CONDA_EXE%" constructor windows path --prepend=user --prefix "%BASE_PATH%" {{ pathflag }} --log-file "%LOG%" + if errorlevel 1 ( exit /b %errorlevel% ) +) + +{%- if has_python %} +rem Register as system Python if the option was selected +if "%OPTION_REGISTER_PYTHON%"=="1" ( + {{ tee("Registering as system Python...") }} + if "%ALLUSERS%"=="1" ( + set "REG_HIVE=HKLM" + ) else ( + set "REG_HIVE=HKCU" + ) + rem PY_REG is the base registry path for this Python version. + rem /v sets a named value, /ve sets the default (unnamed) value, /d sets the data, + rem /f forces overwrite without prompting. + rem REG64 forces the 64-bit registry view since the MSI engine runs as a 32-bit process. + set "REG64=/reg:64" + set "PY_REG=!REG_HIVE!\Software\Python\PythonCore\{{ pyver_components[:2] | join(".") }}" + reg add "!PY_REG!\Help\Main Python Documentation" /v "Main Python Documentation" /d "%BASE_PATH%\Doc\python{{ pyver_components | join("") }}.chm" /f !REG64! >> "%LOG%" 2>&1 + if errorlevel 1 ( exit /b %errorlevel% ) + reg add "!PY_REG!\InstallPath" /ve /d "%BASE_PATH%" /f !REG64! >> "%LOG%" 2>&1 + if errorlevel 1 ( exit /b %errorlevel% ) + reg add "!PY_REG!\InstallPath" /v "ExecutablePath" /d "%BASE_PATH%\python.exe" /f !REG64! >> "%LOG%" 2>&1 + if errorlevel 1 ( exit /b %errorlevel% ) + reg add "!PY_REG!\InstallPath" /v "InstallGroup" /d "Python {{ pyver_components[:2] | join(".") }}" /f !REG64! >> "%LOG%" 2>&1 + if errorlevel 1 ( exit /b %errorlevel% ) + reg add "!PY_REG!\Modules" /ve /d "" /f !REG64! >> "%LOG%" 2>&1 + if errorlevel 1 ( exit /b %errorlevel% ) + reg add "!PY_REG!\PythonPath" /ve /d "%BASE_PATH%\Lib;%BASE_PATH%\DLLs" /f !REG64! >> "%LOG%" 2>&1 + if errorlevel 1 ( exit /b %errorlevel% ) +) +{%- endif %} + +{%- if has_post_install %} +rem Run user-supplied post-install script +{%- if has_post_install_desc %} +if "%OPTION_POST_INSTALL_SCRIPT%"=="1" ( +{%- endif %} + {{ tee("Running post-install script...") }} + call "%BASE_PATH%\pkgs\user_post_install.bat" + if errorlevel 1 ( exit /b %errorlevel% ) +{%- if has_post_install_desc %} +) +{%- endif %} +{%- endif %} + +rem Clear the package cache if the option was selected +if "%OPTION_CLEAR_PACKAGE_CACHE%"=="1" ( + {{ tee("Clearing package cache...") }} + "%CONDA_EXE%" clean --all --force-pkgs-dirs --yes {{ no_rcs_arg }} --log-file "%LOG%" + if errorlevel 1 ( exit /b %errorlevel% ) +) + +exit /b 0 diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index f0178d738..013ae8af8 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -244,6 +244,7 @@ "enum": [ "all", "exe", + "msi", "pkg", "sh" ], @@ -864,7 +865,7 @@ } ], "default": null, - "description": "The type of the installer being created. Possible values are:\n- `sh`: shell-based installer for Linux or macOS\n- `pkg`: macOS GUI installer built with Apple's `pkgbuild`\n- `exe`: Windows GUI installer built with NSIS\nThe default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well as `sh` on Linux and `exe` on Windows.", + "description": "The type of the installer being created. Possible values are:\n- `sh`: shell-based installer for Linux or macOS\n- `pkg`: macOS GUI installer built with Apple's `pkgbuild`\n- `exe`: Windows GUI installer built with NSIS\n- `msi`: Windows GUI installer built with Briefcase and WiX\nThe default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well as `sh` on Linux and `exe` on Windows.", "title": "Installer Type" }, "keep_pkgs": { @@ -995,7 +996,7 @@ } ], "default": null, - "description": "Path to a post-install script. Some notes:\n- For Unix `.sh` installers, the shebang line is respected if present; otherwise, the script is run by the POSIX shell `sh`. Note that the use of a shebang can reduce the portability of the installer. The installation path is available as `${PREFIX}`. Installer metadata is available in the `${INSTALLER_NAME}`, `${INSTALLER_VER}`, `${INSTALLER_PLAT}` environment variables. `${INSTALLER_TYPE}` is set to `SH`. `${INSTALLER_UNATTENDED}` will be `\"1\"` in batch mode (`-b`), `\"0\"` otherwise.\n- For PKG installers, the shebang line is respected if present; otherwise, `bash` is used. The same variables mentioned for `sh` installers are available here. `${INSTALLER_TYPE}` is set to `PKG`. `${INSTALLER_UNATTENDED}` will be `\"1\"` for command line installs, `\"0\"` otherwise.\n- For Windows `.exe` installers, the script must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`. `%INSTALLER_UNATTENDED%` will be `\"1\"` in silent mode (`/S`), `\"0\"` otherwise.\nIf necessary, you can activate the installed `base` environment like this:\n- Unix: `. \"$PREFIX/etc/profile.d/conda.sh\" && conda activate \"$PREFIX\"`\n- Windows: `call \"%PREFIX%\\Scripts\\activate.bat\"`", + "description": "Path to a post-install script. Some notes:\n- For Unix `.sh` installers, the shebang line is respected if present; otherwise, the script is run by the POSIX shell `sh`. Note that the use of a shebang can reduce the portability of the installer. The installation path is available as `${PREFIX}`. Installer metadata is available in the `${INSTALLER_NAME}`, `${INSTALLER_VER}`, `${INSTALLER_PLAT}` environment variables. `${INSTALLER_TYPE}` is set to `SH`. `${INSTALLER_UNATTENDED}` will be `\"1\"` in batch mode (`-b`), `\"0\"` otherwise.\n- For PKG installers, the shebang line is respected if present; otherwise, `bash` is used. The same variables mentioned for `sh` installers are available here. `${INSTALLER_TYPE}` is set to `PKG`. `${INSTALLER_UNATTENDED}` will be `\"1\"` for command line installs, `\"0\"` otherwise.\n- For Windows `.exe` installers, the script must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`. `%INSTALLER_UNATTENDED%` will be `\"1\"` in silent mode (`/S`), `\"0\"` otherwise.\n- For Windows `.msi` installers, the script must be a `.bat` file. The same variables as `.exe` installers are available, except `%INSTALLER_TYPE%` is set to `MSI` and `%INSTALLER_UNATTENDED%` is not available.\nIf necessary, you can activate the installed `base` environment like this:\n- Unix: `. \"$PREFIX/etc/profile.d/conda.sh\" && conda activate \"$PREFIX\"`\n- Windows: `call \"%PREFIX%\\Scripts\\activate.bat\"`", "title": "Post Install" }, "post_install_desc": { @@ -1072,7 +1073,7 @@ } ], "default": null, - "description": "Path to a pre uninstall script. This is only supported on Windows, and must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`.\nIf the uninstallation is performed with `conda-standalone`, the following environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to `system`, `user`, or `all` if selected), `%UNINSTALLER_REMOVE_USER_DATA%` (set to `1` if set), and `%UNINSTALLER_REMOVE_CACHES%` (set to `1` if set).", + "description": "Path to a pre uninstall script. This is only supported on Windows (EXE and MSI), and must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE` or `MSI`.\nIf the uninstallation is performed with `conda-standalone`, the following environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to `system`, `user`, or `all` if selected), `%UNINSTALLER_REMOVE_USER_DATA%` (set to `1` if set), and `%UNINSTALLER_REMOVE_CACHES%` (set to `1` if set).", "title": "Pre Uninstall" }, "progress_notifications": { @@ -1144,7 +1145,7 @@ } ], "default": null, - "description": "Unique identifier for this package, formatted with reverse domain notation. This is used internally in the PKG installers to handle future updates and others. If not provided, it will default to `io.continuum`. (MacOS only)", + "description": "Unique identifier for this package, formatted with reverse domain notation. This is used internally in the MSI and PKG installers to handle future updates and others. If not provided, it will default to:\n* In MSI installers: `io.continuum` followed by an ID derived from the `name`. * In PKG installers: `io.continuum`.", "title": "Reverse Domain Identifier" }, "script_env_variables": { @@ -1152,7 +1153,7 @@ "type": "string" }, "default": {}, - "description": "Dictionary of additional environment variables to be made available to the pre_install and post_install scripts, in the form of VAR:VALUE pairs. These environment variables are in addition to those in the `post_install` section above and take precedence in the case of name collisions.\nOn Unix the variable values are automatically single quoted, allowing you to supply strings with spaces, without needing to worry about escaping. As a consequence, string interpolation is disabled: if you need string interpolation, you can apply it in the pre_install/post_install script(s). If you need to include single quotes in your value, you can escape them by replacing each single quote with `'''`.\nOn Windows, single quotes and double quotes are not supported.\nNote that the # (hash) character cannot be used as it denotes yaml comments for all platforms.", + "description": "Dictionary of additional environment variables to be made available to the pre_install and post_install scripts, in the form of VAR:VALUE pairs. These environment variables are in addition to those in the `post_install` section above and take precedence in the case of name collisions.\nOn Unix the variable values are automatically single quoted, allowing you to supply strings with spaces, without needing to worry about escaping. As a consequence, string interpolation is disabled: if you need string interpolation, you can apply it in the pre_install/post_install script(s). If you need to include single quotes in your value, you can escape them by replacing each single quote with `'''`.\nFor Windows EXE installers, single quotes and double quotes are not supported. For Windows MSI installers, single quotes are supported but double quotes are not.\nNote that the # (hash) character cannot be used as it denotes yaml comments for all platforms.", "propertyNames": { "minLength": 1 }, @@ -1309,7 +1310,7 @@ } ], "default": null, - "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the introduction. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `welcome_file` and `welcome_text` are provided, `welcome_file` takes precedence.\nIf the installer is for Windows and the welcome file type is nsi, it will use the nsi script to add in extra pages before the installer begins the installation process.", + "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the introduction. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `welcome_file` and `welcome_text` are provided, `welcome_file` takes precedence.\nIf the installer is for Windows EXE and the welcome file type is nsi, it will use the nsi script to add in extra pages before the installer begins the installation process.", "title": "Welcome File" }, "welcome_image": { @@ -1322,7 +1323,7 @@ } ], "default": null, - "description": "Path to an image in any common image format (`.png`, `.jpg`, `.tif`, etc.) to be used as the welcome image for the Windows and PKG installers. The image is re-sized to 164 x 314 pixels on Windows and 1227 x 600 on macOS. By default, an image is automatically generated on Windows. On macOS, Anaconda's logo is shown if this key is not provided. If you don't want a background on PKG installers, set this key to `\"\"` (empty string).", + "description": "Path to an image in any common image format (`.png`, `.jpg`, `.tif`, etc.) to be used as the welcome image for the Windows and PKG installers. The image is re-sized to 164 x 314 pixels for EXE installers, 1227 x 600 on macOS, and for MSI installers it is scaled to fit a 164-pixel wide side panel (maintaining aspect ratio) with white padding on the right. By default, an image is automatically generated on Windows. On macOS, Anaconda's logo is shown if this key is not provided. If you don't want a background on PKG installers, set this key to `\"\"` (empty string).", "title": "Welcome Image" }, "welcome_image_text": { diff --git a/constructor/header.sh b/constructor/header.sh index 82778608a..c50cacb67 100644 --- a/constructor/header.sh +++ b/constructor/header.sh @@ -671,10 +671,6 @@ for env_pkgs in "${PREFIX}"/pkgs/envs/*/; do done {%- endif %} -{%- for condarc in write_condarc %} -{{ condarc }} -{%- endfor %} - POSTCONDA="$PREFIX/postconda.tar.bz2" CONDA_QUIET="$BATCH" \ "$CONDA_EXEC" constructor --prefix "$PREFIX" --extract-tarball < "$POSTCONDA" || exit 1 diff --git a/constructor/imaging.py b/constructor/imaging.py index b6ecd3347..31961ec87 100644 --- a/constructor/imaging.py +++ b/constructor/imaging.py @@ -25,6 +25,12 @@ icon_size = 256, 256 # These are for OSX welcome_size_osx = 1227, 600 +# MSI/WiX image sizes +# WiX WelcomeDlg uses a full-background image with text overlaid on the right side. +# We create a side-panel effect: branding on left 164px, white padding on right. +welcome_size_msi = (493, 312) +welcome_side_panel_width_msi = 164 # Width for branding area (matches EXE welcome width) +header_size_msi = (493, 58) def new_background(size, color, bs=20, boxes=50): @@ -99,19 +105,43 @@ def add_color_info(info): sys.exit("Error: color '%s' not defined" % color_name) -def write_images(info, dir_path, os="windows"): - if os == "windows": +def _resize_for_msi_welcome(image_path): + """Resize image for MSI welcome dialog with side-panel layout. + + WiX WelcomeDlg uses a full-background bitmap with text overlaid on the right. + The user's image is resized to 164x312 and placed on the left, with white + padding on the right for the dialog text. + """ + im = Image.open(image_path) + + # Resize to side panel dimensions (164x312) + panel_size = (welcome_side_panel_width_msi, welcome_size_msi[1]) + im = im.resize(panel_size) + + # Create white canvas (493x312) and paste image on left side + canvas = Image.new("RGB", welcome_size_msi, color=white) + canvas.paste(im, (0, 0)) + return canvas + + +def write_images(info, dir_path, installer_type="exe"): + if installer_type == "exe": instructions = [ ("welcome", welcome_size, mk_welcome_image, ".bmp"), ("header", header_size, mk_header_image, ".bmp"), ("icon", icon_size, mk_icon_image, ".ico"), ] - elif os == "osx": + elif installer_type == "pkg": instructions = [ ("welcome", welcome_size_osx, mk_welcome_image_osx, ".png"), ] + elif installer_type == "msi": + # MSI uses WiX defaults; user-provided images handled separately below + instructions = [] else: - raise ValueError(f"OS {os} not supported. Choose `windows` or `osx`.") + raise ValueError( + f"Installer type '{installer_type}' not supported. Choose 'exe', 'pkg', or 'msi'." + ) for name, size, function, ext in instructions: key = name + "_image" @@ -124,12 +154,17 @@ def write_images(info, dir_path, os="windows"): assert im.size == size im.save(join(dir_path, name + ext)) - -if __name__ == "__main__": - info = { - "name": "test", - "version": "0.3.1", - "default_image_color": "yellow", - "welcome_image": "../examples/miniconda/bird.png", - } - write_images(info, ".") + # MSI: handle custom images if provided (no auto-generation) + if installer_type == "msi": + if info.get("welcome_image"): + im = _resize_for_msi_welcome(info["welcome_image"]) + assert im.size == welcome_size_msi + im.save(join(dir_path, "welcome.bmp")) + if info.get("header_image"): + im = Image.open(info["header_image"]) + im = im.resize(header_size_msi) + im.save(join(dir_path, "header.bmp")) + if info.get("icon_image"): + im = Image.open(info["icon_image"]) + im = im.resize(icon_size) + im.save(join(dir_path, "icon.ico")) diff --git a/constructor/main.py b/constructor/main.py index 5a8ce9af7..80d06d8e7 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -40,7 +40,7 @@ def get_installer_type(info: dict): osname, unused_arch = info["_platform"].split("-") - os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe",)} + os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe", "msi")} all_allowed = set(sum(os_allowed.values(), ("all",))) itype = info.get("installer_type") @@ -48,6 +48,19 @@ def get_installer_type(info: dict): return os_allowed[osname][:1] elif itype == "all": return os_allowed[osname] + elif isinstance(itype, (list, tuple)): + # Handle list of installer types, e.g. [exe, msi] + for t in itype: + if t not in all_allowed: + all_allowed_str = ", ".join(sorted(all_allowed)) + sys.exit("Error: invalid installer type '%s'; allowed: %s" % (t, all_allowed_str)) + if t not in os_allowed[osname]: + os_allowed_str = ", ".join(sorted(os_allowed[osname])) + sys.exit( + "Error: invalid installer type '%s' for %s; allowed: %s" + % (t, osname, os_allowed_str) + ) + return tuple(itype) elif itype not in all_allowed: all_allowed = ", ".join(sorted(all_allowed)) sys.exit("Error: invalid installer type '%s'; allowed: %s" % (itype, all_allowed)) @@ -399,6 +412,10 @@ def main_build( from .winexe import create as winexe_create create = winexe_create + elif itype == "msi": + from .briefcase import create as briefcase_create + + create = briefcase_create info["installer_type"] = itype info["_outpath"] = abspath(join(output_dir, get_output_filename(info))) create(info, verbose=verbose) diff --git a/constructor/nsis/main.nsi.tmpl b/constructor/nsis/main.nsi.tmpl index 10bf6c9f4..c00aead2d 100644 --- a/constructor/nsis/main.nsi.tmpl +++ b/constructor/nsis/main.nsi.tmpl @@ -1648,9 +1648,10 @@ Section "Install" {%- endif %} {%- endfor %} -{%- for condarc in WRITE_CONDARC %} - {{ condarc }} -{%- endfor %} +{%- if condarc_file %} + SetOutPath "$INSTDIR" + File {{ condarc_file }} +{%- endif %} AddSize {{ SIZE }} diff --git a/constructor/osx/run_installation.sh b/constructor/osx/run_installation.sh index a1fdb0b1c..bf066f7c8 100644 --- a/constructor/osx/run_installation.sh +++ b/constructor/osx/run_installation.sh @@ -115,10 +115,6 @@ done # Cleanup! find "$PREFIX/pkgs" -type d -empty -exec rmdir {} \; 2>/dev/null || : -{%- for condarc in write_condarc %} -{{ condarc }} -{%- endfor %} - # This is not needed for the default install to ~, but if the user changes the # install location, the permissions will default to root unless this is done. chown -R "${USER}" "$PREFIX" diff --git a/constructor/osxpkg.py b/constructor/osxpkg.py index 2bccfbf85..3174cb884 100644 --- a/constructor/osxpkg.py +++ b/constructor/osxpkg.py @@ -21,7 +21,7 @@ from .jinja import render_template from .signing import CodeSign from .utils import ( - add_condarc, + DEFAULT_REVERSE_DOMAIN_ID, approx_size_kb, copy_conda_exe, explained_check_call, @@ -150,10 +150,10 @@ def modify_xml(xml_path, info): if not info["welcome_image"]: background_path = None else: - write_images(info, PACKAGES_DIR, os="osx") + write_images(info, PACKAGES_DIR, installer_type="pkg") background_path = os.path.join(PACKAGES_DIR, "welcome.png") elif "welcome_image_text" in info: - write_images(info, PACKAGES_DIR, os="osx") + write_images(info, PACKAGES_DIR, installer_type="pkg") background_path = os.path.join(PACKAGES_DIR, "welcome.png") else: # Default to Anaconda's logo if the keys above were not specified @@ -394,7 +394,6 @@ def move_script(src, dst, info, ensure_shebang=False, user_script_type=None): variables["installer_version"] = info["version"] variables["installer_platform"] = info["_platform"] variables["final_channels"] = get_final_channels(info) - variables["write_condarc"] = list(add_condarc(info)) variables["path_exists_error_text"] = path_exists_error_text variables["progress_notifications"] = info.get("progress_notifications", False) variables["pre_or_post"] = user_script_type or "__PRE_OR_POST__" @@ -433,7 +432,7 @@ def fresh_dir(dir_path): def pkgbuild(name, identifier=None, version=None, install_location=None): "see `man pkgbuild` for the meaning of optional arguments" if identifier is None: - identifier = "io.continuum" + identifier = DEFAULT_REVERSE_DOMAIN_ID args = [ "pkgbuild", "--root", diff --git a/constructor/preconda.py b/constructor/preconda.py index f928213e8..2edd4c2ce 100644 --- a/constructor/preconda.py +++ b/constructor/preconda.py @@ -35,6 +35,7 @@ from .utils import ( ensure_transmuted_ext, filename_dist, + get_condarc_content, get_final_channels, get_final_url, shortcuts_flags, @@ -203,6 +204,9 @@ def write_files(info: dict, workspace: str): # base environment frozen marker files write_frozen(info.get("freeze_base"), join(workspace, "conda-meta")) + # base environment .condarc + write_condarc(info, workspace) + for fn in files: os.chmod(join(workspace, fn), 0o664) @@ -332,6 +336,19 @@ def write_shortcuts_txt(info: dict, dst_dir: str, env_config: dict): f.write(contents) +def write_condarc(info: dict, dst_dir: str): + """Write .condarc file to the workspace if configured. + + The file will be included in the payload tarball and extracted + to the correct location during installation. + """ + condarc = get_condarc_content(info) + if not condarc: + return + with open(join(dst_dir, ".condarc"), "w") as f: + f.write(condarc) + + def copy_extra_files( extra_files: list[os.PathLike | Mapping], workdir: os.PathLike ) -> list[os.PathLike]: diff --git a/constructor/shar.py b/constructor/shar.py index a0b631267..e857aac7f 100644 --- a/constructor/shar.py +++ b/constructor/shar.py @@ -26,7 +26,6 @@ from .preconda import files as preconda_files from .preconda import write_files as preconda_write_files from .utils import ( - add_condarc, approx_size_kb, copy_conda_exe, filename_dist, @@ -96,7 +95,6 @@ def get_header(conda_exec, tarball, info): variables["second_payload_size"] = getsize(tarball) variables["conda_exe_payloads"] = info.get("_conda_exe_payloads", {}) variables["conda_exe_payloads_size"] = info.get("_conda_exe_payloads_size", 0) - variables["write_condarc"] = list(add_condarc(info)) variables["final_channels"] = get_final_channels(info) variables["conclusion_text"] = info.get("conclusion_text", "installation finished.") variables["pycache"] = "__pycache__" @@ -176,6 +174,9 @@ def create(info, verbose=False): if os.path.exists(join(tmp_dir, "conda-meta", "frozen")): post_t.add(join(tmp_dir, "conda-meta", "frozen"), "conda-meta/frozen") + if os.path.exists(join(tmp_dir, ".condarc")): + post_t.add(join(tmp_dir, ".condarc"), ".condarc") + for env_name in info.get("_extra_envs_info", {}): pre_t.addfile(tarinfo=tarfile.TarInfo(f"envs/{env_name}/conda-meta/history")) post_t.add( diff --git a/constructor/signing.py b/constructor/signing.py index 02e7b8925..bf316b9dc 100644 --- a/constructor/signing.py +++ b/constructor/signing.py @@ -67,6 +67,10 @@ def verify_signature(self): """Verify the signed installer.""" raise NotImplementedError("Signature verification not implemented for base class.") + def sign(self, file_path: str | Path): + """Sign the specified file.""" + raise NotImplementedError("Signing not implemented for base class.") + class WindowsSignTool(SigningTool): def __init__(self, certificate_file=None): @@ -75,21 +79,49 @@ def __init__(self, certificate_file=None): certificate_file=certificate_file, ) + def _get_signing_params(self): + """Get signing parameters from environment.""" + return { + "timestamp_server": os.environ.get( + "CONSTRUCTOR_SIGNTOOL_TIMESTAMP_SERVER_URL", "http://timestamp.sectigo.com" + ), + "timestamp_digest": os.environ.get("CONSTRUCTOR_SIGNTOOL_TIMESTAMP_DIGEST", "sha256"), + "file_digest": os.environ.get("CONSTRUCTOR_SIGNTOOL_FILE_DIGEST", "sha256"), + "password": os.environ.get("CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD"), + } + def get_signing_command(self) -> str: - timestamp_server = os.environ.get( - "CONSTRUCTOR_SIGNTOOL_TIMESTAMP_SERVER_URL", "http://timestamp.sectigo.com" - ) - timestamp_digest = os.environ.get("CONSTRUCTOR_SIGNTOOL_TIMESTAMP_DIGEST", "sha256") - file_digest = os.environ.get("CONSTRUCTOR_SIGNTOOL_FILE_DIGEST", "sha256") + params = self._get_signing_params() command = ( f"{win_str_esc(self.executable)} sign /f {win_str_esc(self.certificate_file)} " - f"/tr {win_str_esc(timestamp_server)} /td {timestamp_digest} /fd {file_digest}" + f"/tr {win_str_esc(params['timestamp_server'])} /td {params['timestamp_digest']} " + f"/fd {params['file_digest']}" ) - if "CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD" in os.environ: + if params["password"]: # signtool can get the password from the env var on its own command += ' /p "%CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD%"' return command + def sign(self, file_path: str | Path): + """Sign a file using signtool.""" + params = self._get_signing_params() + command = [ + self.executable, + "sign", + "/f", + str(self.certificate_file), + "/tr", + params["timestamp_server"], + "/td", + params["timestamp_digest"], + "/fd", + params["file_digest"], + ] + if params["password"]: + command.extend(["/p", params["password"]]) + command.append(str(file_path)) + check_call(command) + def verify_signing_tool(self): super()._verify_tool_is_available() if not Path(self.certificate_file).exists(): @@ -120,6 +152,75 @@ class AzureSignTool(SigningTool): def __init__(self): super().__init__(os.environ.get("AZURE_SIGNTOOL_PATH", "AzureSignTool")) + def _get_signing_params(self): + """Get signing parameters from environment.""" + required_env_vars = ( + "AZURE_SIGNTOOL_KEY_VAULT_URL", + "AZURE_SIGNTOOL_KEY_VAULT_CERTIFICATE", + ) + check_required_env_vars(required_env_vars) + + return { + "key_vault_url": os.environ["AZURE_SIGNTOOL_KEY_VAULT_URL"], + "key_vault_certificate": os.environ["AZURE_SIGNTOOL_KEY_VAULT_CERTIFICATE"], + "timestamp_server": os.environ.get( + "AZURE_SIGNTOOL_TIMESTAMP_SERVER_URL", "http://timestamp.sectigo.com" + ), + "timestamp_digest": os.environ.get("AZURE_SIGNTOOL_TIMESTAMP_DIGEST", "sha256"), + "file_digest": os.environ.get("AZURE_SIGNTOOL_FILE_DIGEST", "sha256"), + "access_token": os.environ.get("AZURE_SIGNTOOL_KEY_VAULT_ACCESSTOKEN"), + "secret": os.environ.get("AZURE_SIGNTOOL_KEY_VAULT_SECRET"), + "client_id": os.environ.get("AZURE_SIGNTOOL_KEY_VAULT_CLIENT_ID"), + "tenant_id": os.environ.get("AZURE_SIGNTOOL_KEY_VAULT_TENANT_ID"), + } + + def sign(self, file_path: str | Path): + """Sign a file using AzureSignTool.""" + params = self._get_signing_params() + command = [ + self.executable, + "sign", + "-v", + "-kvu", + params["key_vault_url"], + "-kvc", + params["key_vault_certificate"], + "-tr", + params["timestamp_server"], + "-td", + params["timestamp_digest"], + "-fd", + params["file_digest"], + ] + + if params["access_token"]: + logger.info("AzureSignTool: signing binary using access token.") + command.extend(["-kva", params["access_token"]]) + elif params["secret"]: + logger.info("AzureSignTool: signing binary using secret.") + check_required_env_vars( + ( + "AZURE_SIGNTOOL_KEY_VAULT_CLIENT_ID", + "AZURE_SIGNTOOL_KEY_VAULT_TENANT_ID", + ) + ) + command.extend( + [ + "-kvi", + params["client_id"], + "-kvt", + params["tenant_id"], + "-kvs", + params["secret"], + ] + ) + else: + logger.info("AzureSignTool: signing binary using managed identity.") + command.append("-kvm") + + command.append(str(file_path)) + check_call(command) + def get_signing_command(self) -> str: required_env_vars = ( "AZURE_SIGNTOOL_KEY_VAULT_URL", @@ -261,3 +362,23 @@ def sign_bundle( else: command = self.get_signing_command(bundle, entitlements=entitlements) explained_check_call(command) + + +def create_windows_signing_tool(info: dict) -> SigningTool | None: + """Create a Windows signing tool based on construct.yaml. + + Returns None if no signing is configured. + """ + signing_tool_name = info.get("windows_signing_tool") + if not signing_tool_name: + return None + + if signing_tool_name == "signtool": + signing_tool = WindowsSignTool(certificate_file=info.get("signing_certificate")) + elif signing_tool_name == "azuresigntool": + signing_tool = AzureSignTool() + else: + raise ValueError(f"Unknown signing tool: {signing_tool_name}") + + signing_tool.verify_signing_tool() + return signing_tool diff --git a/constructor/utils.py b/constructor/utils.py index c63329c16..0122c463c 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -26,6 +26,8 @@ from conda.models.version import VersionOrder from ruamel.yaml import YAML +DEFAULT_REVERSE_DOMAIN_ID = "io.continuum" + logger = logging.getLogger(__name__) yaml = YAML(typ="rt") yaml.default_flow_style = False @@ -130,7 +132,14 @@ def if_repl(match): return if_pat.sub(if_repl, data) -def add_condarc(info): +def get_condarc_content(info) -> str | None: + """ + Get the condarc content string from the info dict. + + Returns the condarc content as a YAML string, or None if no condarc should be written. + Handles both the new 'condarc' key (direct content) and the legacy 'write_condarc' + approach (building from channel settings). + """ from .conda_interface import MatchSpec # prevent circular import condarc = info.get("condarc") @@ -144,7 +153,7 @@ def add_condarc(info): if not ( write_condarc and (default_channels or channels or channel_alias or mirrored_channels) ): - return + return None condarc = {} if default_channels: condarc["default_channels"] = default_channels @@ -162,18 +171,7 @@ def add_condarc(info): ) if isinstance(condarc, dict): condarc = yaml_to_string(condarc) - yield "# ----- add condarc" - if info["_platform"].startswith("win"): - yield "Var /Global CONDARC" - yield 'FileOpen $CONDARC "$INSTDIR\\.condarc" w' - for line in condarc.splitlines(): - yield 'FileWrite $CONDARC "%s$\\r$\\n"' % line - yield "FileClose $CONDARC" - else: - yield 'cat <"$PREFIX/.condarc"' - for line in condarc.splitlines(): - yield line - yield "EOF" + return condarc def ensure_transmuted_ext(info, url): @@ -398,6 +396,36 @@ def win_str_esc(s, newlines=True): return '"%s"' % s +def bat_env_var_esc(s: str) -> str: + """Escape a string for use in a Windows batch file SET command. + + For use with: set "VAR=" + + Single quotes are allowed (they're literal in batch). + Double quotes are NOT supported - validate before calling this function. + """ + # ^ and % need special handling: ^ must be first (it's the escape char), + # and % uses %% not ^% + s = s.replace("^", "^^") + s = s.replace("%", "%%") + for c in ("!", "&", "<", ">", "|"): + s = s.replace(c, f"^{c}") + return s + + +def bat_echo_esc(s: str) -> str: + """Escape a string for use in a Windows batch file ECHO command. + + Escapes special shell characters that would otherwise be interpreted + as redirections or command separators. + """ + # ^ must be escaped first since it's the escape character + s = s.replace("^", "^^") + for c in ("&", "<", ">", "|"): + s = s.replace(c, f"^{c}") + return s + + def check_required_env_vars(env_vars): missing_vars = {var for var in env_vars if var not in environ} if missing_vars: diff --git a/constructor/winexe.py b/constructor/winexe.py index caf49f066..41ff9f9bd 100644 --- a/constructor/winexe.py +++ b/constructor/winexe.py @@ -17,15 +17,15 @@ from os.path import abspath, basename, dirname, isfile, join from pathlib import Path from subprocess import check_output, run +from typing import TYPE_CHECKING from .construct import ns_platform from .imaging import write_images from .jinja import render_template from .preconda import copy_extra_files from .preconda import write_files as preconda_write_files -from .signing import AzureSignTool, WindowsSignTool +from .signing import create_windows_signing_tool from .utils import ( - add_condarc, approx_size_kb, copy_conda_exe, filename_dist, @@ -35,6 +35,9 @@ win_str_esc, ) +if TYPE_CHECKING: + from .signing import AzureSignTool, WindowsSignTool + NSIS_DIR = join(abspath(dirname(__file__)), "nsis") MAKENSIS_EXE = abspath(join(sys.prefix, "NSIS", "makensis.exe")) @@ -189,6 +192,7 @@ def make_nsi( "pre_uninstall": "@pre_uninstall.bat", "index_cache": "@" + join("pkgs", "cache"), "repodata_record": "@" + join("pkgs", "repodata_record.json"), + "condarc_file": "@.condarc" if os.path.exists(join(dir_path, ".condarc")) else "", } conclusion_text = info.get("conclusion_text", "") @@ -252,7 +256,7 @@ def make_nsi( variables["initialize_by_default"] = info.get("initialize_by_default", None) variables["check_path_length"] = info.get("check_path_length", False) variables["check_path_spaces"] = info.get("check_path_spaces", True) - variables["keep_pkgs"] = info.get("keep_pkgs") or False + variables["keep_pkgs"] = info.get("keep_pkgs", False) variables["pre_install_exists"] = bool(info.get("pre_install")) variables["post_install_exists"] = bool(info.get("post_install")) variables["with_conclusion_text"] = bool(conclusion_text) @@ -276,7 +280,6 @@ def make_nsi( variables["DISTS"] = [win_str_esc(join(download_dir, dist)) for dist in dists] variables["SIGNTOOL_COMMAND"] = signing_tool.get_signing_command() if signing_tool else "" variables["SETUP_ENVS"] = setup_envs_commands(info, dir_path) - variables["WRITE_CONDARC"] = list(add_condarc(info)) variables["SIZE"] = approx_pkgs_size_kb variables["UNINSTALL_NAME"] = info.get("uninstall_name", default_uninstall_name) variables["EXTRA_FILES"] = get_extra_files(extra_files, dir_path) @@ -358,15 +361,7 @@ def verify_nsis_install(): def create(info, verbose=False): verify_nsis_install() - signing_tool = None - if signing_tool_name := info.get("windows_signing_tool"): - if signing_tool_name == "signtool": - signing_tool = WindowsSignTool(certificate_file=info.get("signing_certificate")) - elif signing_tool_name == "azuresigntool": - signing_tool = AzureSignTool() - else: - raise ValueError(f"Unknown signing tool: {signing_tool_name}") - signing_tool.verify_signing_tool() + signing_tool = create_windows_signing_tool(info) tmp_dir = tempfile.mkdtemp() preconda_write_files(info, tmp_dir) copied_extra_files = copy_extra_files(info.get("extra_files", []), tmp_dir) diff --git a/dev/extra-requirements-windows.txt b/dev/extra-requirements-windows.txt index 1f405685d..d382e69cb 100644 --- a/dev/extra-requirements-windows.txt +++ b/dev/extra-requirements-windows.txt @@ -1 +1,3 @@ +conda-forge::briefcase>=0.3.26 conda-forge::nsis>=3.08=*_log_* +conda-forge::tomli-w>=1.2.0 diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 17a71302f..65b2e649b 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -235,6 +235,7 @@ The type of the installer being created. Possible values are: - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS +- `msi`: Windows GUI installer built with Briefcase and WiX The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well @@ -317,8 +318,11 @@ Name of the company/entity responsible for the installer. ### `reverse_domain_identifier` Unique identifier for this package, formatted with reverse domain notation. This is -used internally in the PKG installers to handle future updates and others. If not -provided, it will default to `io.continuum`. (MacOS only) +used internally in the MSI and PKG installers to handle future updates and others. +If not provided, it will default to: + +* In MSI installers: `io.continuum` followed by an ID derived from the `name`. +* In PKG installers: `io.continuum`. ### `uninstall_name` @@ -341,7 +345,8 @@ pre_install/post_install script(s). If you need to include single quotes in your value, you can escape them by replacing each single quote with `'''`. -On Windows, single quotes and double quotes are not supported. +For Windows EXE installers, single quotes and double quotes are not supported. +For Windows MSI installers, single quotes are supported but double quotes are not. Note that the # (hash) character cannot be used as it denotes yaml comments for all platforms. @@ -383,6 +388,9 @@ Path to a post-install script. Some notes: the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`. `%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`/S`), `"0"` otherwise. +- For Windows `.msi` installers, the script must be a `.bat` file. + The same variables as `.exe` installers are available, except + `%INSTALLER_TYPE%` is set to `MSI` and `%INSTALLER_UNATTENDED%` is not available. If necessary, you can activate the installed `base` environment like this: @@ -401,11 +409,11 @@ This option has no effect on `SH` installers. ### `pre_uninstall` -Path to a pre uninstall script. This is only supported on Windows, +Path to a pre uninstall script. This is only supported on Windows (EXE and MSI), and must be a `.bat` file. Installation path is available as `%PREFIX%`. Metadata about the installer can be found in the `%INSTALLER_NAME%`, `%INSTALLER_VER%`, `%INSTALLER_PLAT%` environment variables. -`%INSTALLER_TYPE%` is set to `EXE`. +`%INSTALLER_TYPE%` is set to `EXE` or `MSI`. If the uninstallation is performed with `conda-standalone`, the following environment variables are available: `%UNINSTALLER_REMOVE_CONFIG_FILES%` (set to @@ -485,7 +493,9 @@ so the user receives updates after each command executed by the installer. Path to an image in any common image format (`.png`, `.jpg`, `.tif`, etc.) to be used as the welcome image for the Windows and PKG installers. -The image is re-sized to 164 x 314 pixels on Windows and 1227 x 600 on macOS. +The image is re-sized to 164 x 314 pixels for EXE installers, 1227 x 600 on macOS, +and for MSI installers it is scaled to fit a 164-pixel wide side panel (maintaining +aspect ratio) with white padding on the right. By default, an image is automatically generated on Windows. On macOS, Anaconda's logo is shown if this key is not provided. If you don't want a background on PKG installers, set this key to `""` (empty string). @@ -577,7 +587,7 @@ shown before the license information, right after the introduction. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `welcome_file` and `welcome_text` are provided, `welcome_file` takes precedence. -If the installer is for Windows and the welcome file type is nsi, +If the installer is for Windows EXE and the welcome file type is nsi, it will use the nsi script to add in extra pages before the installer begins the installation process. diff --git a/docs/source/howto.md b/docs/source/howto.md index 8087a2803..c255b6e84 100644 --- a/docs/source/howto.md +++ b/docs/source/howto.md @@ -7,10 +7,12 @@ which it is running. In other words, if you run constructor on a Windows computer, you can only generate Windows installers. This is largely because OS-native tools are needed to generate the Windows `.exe` files and macOS `.pkg` files. There is a key in `construct.yaml`, `installer_type`, which dictates -the type of installer that gets generated. This is primarily only useful for -macOS, where you can generate either `.pkg` or `.sh` installers. When not set in -`construct.yaml`, this value defaults to `.sh` on Unix platforms, and `.exe` on -Windows. Using this key is generally done with selectors. For example, to +the type of installer that gets generated. This is useful for macOS, where you can +generate either `.pkg` or `.sh` installers, and Windows, where you can generate +either `.exe` or `.msi` installers. + +When not set in`construct.yaml`, this value defaults to `.sh` on Unix platforms, and +`.exe` on Windows. Using this key is generally done with selectors. For example, to build a `.pkg` installer on MacOS, but fall back to default behavior on other platforms: diff --git a/examples/azure_signtool/construct.yaml b/examples/azure_signtool/construct.yaml index 86a6fb983..96498a702 100644 --- a/examples/azure_signtool/construct.yaml +++ b/examples/azure_signtool/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Signed_AzureSignTool -version: X +version: 1.0.0 installer_type: exe channels: - https://repo.anaconda.com/pkgs/main/ diff --git a/examples/custom_nsis_template/construct.yaml b/examples/custom_nsis_template/construct.yaml index 4b8eab0b4..4a59f423a 100644 --- a/examples/custom_nsis_template/construct.yaml +++ b/examples/custom_nsis_template/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: custom -version: X +version: 1.0.0 ignore_duplicate_files: True installer_filename: {{ name }}-installer.exe installer_type: exe diff --git a/examples/customized_welcome_conclusion/construct.yaml b/examples/customized_welcome_conclusion/construct.yaml index 751e1305c..f02e55265 100644 --- a/examples/customized_welcome_conclusion/construct.yaml +++ b/examples/customized_welcome_conclusion/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: CustomizedWelcomeConclusion -version: X +version: 1.0.0 installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ diff --git a/examples/exe_extra_pages/construct.yaml b/examples/exe_extra_pages/construct.yaml index 5452ce126..957e926c3 100644 --- a/examples/exe_extra_pages/construct.yaml +++ b/examples/exe_extra_pages/construct.yaml @@ -7,7 +7,7 @@ {% set name = "extraPageSingle" %} {% endif %} name: {{ name }} -version: X +version: 1.0.0 installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ diff --git a/examples/extra_envs/construct.yaml b/examples/extra_envs/construct.yaml index b74124eeb..74746410e 100644 --- a/examples/extra_envs/construct.yaml +++ b/examples/extra_envs/construct.yaml @@ -2,8 +2,8 @@ "$schema": "../../constructor/data/construct.schema.json" name: ExtraEnvs -version: X -installer_type: all +version: 1.0.0 +installer_type: {{ "exe" if os.name == "nt" else "all" }} channels: - https://conda.anaconda.org/conda-forge specs: diff --git a/examples/extra_files/construct.yaml b/examples/extra_files/construct.yaml index 7b8c9a600..fa82dbfad 100644 --- a/examples/extra_files/construct.yaml +++ b/examples/extra_files/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: ExtraFiles -version: X +version: 1.0.0 installer_type: all license_file: TEST_LICENSE.txt check_path_spaces: False diff --git a/examples/from_env_txt/construct.yaml b/examples/from_env_txt/construct.yaml index ee8412dc7..5cb7ae774 100644 --- a/examples/from_env_txt/construct.yaml +++ b/examples/from_env_txt/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: EnvironmentTXT -version: X +version: 1.0.0 installer_type: all environment_file: env.txt initialize_by_default: false diff --git a/examples/from_env_yaml/construct.yaml b/examples/from_env_yaml/construct.yaml index d86bdeafb..caa5922b8 100644 --- a/examples/from_env_yaml/construct.yaml +++ b/examples/from_env_yaml/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: EnvironmentYAML -version: X +version: 1.0.0 installer_type: all environment_file: env.yaml initialize_by_default: false diff --git a/examples/from_existing_env/construct.yaml b/examples/from_existing_env/construct.yaml index 89df7b411..b45540a0b 100644 --- a/examples/from_existing_env/construct.yaml +++ b/examples/from_existing_env/construct.yaml @@ -1,7 +1,7 @@ # yaml-language-server: $schema=../../constructor/data/construct.schema.json "$schema": "../../constructor/data/construct.schema.json" name: Existing -version: X +version: 1.0.0 installer_type: all environment: {{ os.environ.get("CONSTRUCTOR_TEST_EXISTING_ENV", os.environ["CONDA_PREFIX"]) }} channels: diff --git a/examples/from_explicit/construct.yaml b/examples/from_explicit/construct.yaml index 9137fa8f7..6e07790cd 100644 --- a/examples/from_explicit/construct.yaml +++ b/examples/from_explicit/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Explicit -version: X +version: 1.0.0 installer_type: all environment_file: explicit_linux-64.txt initialize_by_default: false diff --git a/examples/initialization/construct.yaml b/examples/initialization/construct.yaml index 1e980532f..8f38e6631 100644 --- a/examples/initialization/construct.yaml +++ b/examples/initialization/construct.yaml @@ -18,4 +18,4 @@ initialize_by_default: true register_python: false check_path_spaces: true check_path_length: false -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} diff --git a/examples/miniconda/bird.png b/examples/miniconda/bird.png deleted file mode 100644 index 2efe0f30a..000000000 Binary files a/examples/miniconda/bird.png and /dev/null differ diff --git a/examples/mirrored_channels/construct.yaml b/examples/mirrored_channels/construct.yaml index f105c6d0c..6e7ab9d81 100644 --- a/examples/mirrored_channels/construct.yaml +++ b/examples/mirrored_channels/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Mirrors -version: X +version: 1.0.0 channels: - conda-forge diff --git a/examples/noconda/constructor_input.yaml b/examples/noconda/constructor_input.yaml index 0a17e6cb2..b5641de01 100644 --- a/examples/noconda/constructor_input.yaml +++ b/examples/noconda/constructor_input.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: NoConda -version: X +version: 1.0.0 installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ diff --git a/examples/outputs/construct.yaml b/examples/outputs/construct.yaml index 01aa24a0a..67909fcfc 100644 --- a/examples/outputs/construct.yaml +++ b/examples/outputs/construct.yaml @@ -2,9 +2,9 @@ "$schema": "../../constructor/data/construct.schema.json" name: Outputs -version: X +version: 1.0.0 installer_type: sh # [unix] -installer_type: exe # [win] +installer_type: [exe, msi] # [win] channels: - https://conda.anaconda.org/conda-forge specs: diff --git a/examples/protected_base/construct.yaml b/examples/protected_base/construct.yaml index 7e2e09a49..b0c2c84b4 100644 --- a/examples/protected_base/construct.yaml +++ b/examples/protected_base/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: ProtectedBaseEnv -version: X +version: 1.0.0 installer_type: all channels: diff --git a/examples/register_envs/construct.yaml b/examples/register_envs/construct.yaml index 86d621561..760757a05 100644 --- a/examples/register_envs/construct.yaml +++ b/examples/register_envs/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: RegisterEnvs -version: X +version: 1.0.0 installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ diff --git a/examples/scripts/construct.yaml b/examples/scripts/construct.yaml index 33dcfee91..d55057bbd 100644 --- a/examples/scripts/construct.yaml +++ b/examples/scripts/construct.yaml @@ -2,8 +2,8 @@ "$schema": "../../constructor/data/construct.schema.json" name: Scripts -version: X -installer_type: all +version: 1.0.0 +installer_type: {{ "exe" if os.name == "nt" else "all" }} channels: - https://repo.anaconda.com/pkgs/main/ specs: diff --git a/examples/scripts/post_install.bat b/examples/scripts/post_install.bat index bc9103425..2c2c57af5 100644 --- a/examples/scripts/post_install.bat +++ b/examples/scripts/post_install.bat @@ -1,6 +1,6 @@ echo Added by post-install script > "%PREFIX%\post_install_sentinel.txt" if not "%INSTALLER_NAME%" == "Scripts" exit 1 -if not "%INSTALLER_VER%" == "X" exit 1 +if not "%INSTALLER_VER%" == "1.0.0" exit 1 if not "%INSTALLER_PLAT%" == "win-64" exit 1 if not "%INSTALLER_TYPE%" == "EXE" exit 1 if not "%INSTALLER_UNATTENDED%" == "1" exit 1 diff --git a/examples/scripts/post_install.sh b/examples/scripts/post_install.sh index 2158bf640..00a0095e6 100644 --- a/examples/scripts/post_install.sh +++ b/examples/scripts/post_install.sh @@ -15,7 +15,7 @@ echo "CUSTOM_VARIABLE_2=${CUSTOM_VARIABLE_2}" echo "PREFIX=${PREFIX}" test "${INSTALLER_NAME}" = "Scripts" -test "${INSTALLER_VER}" = "X" +test "${INSTALLER_VER}" = "1.0.0" # shellcheck disable=SC2016 # String interpolation disabling is deliberate test "${CUSTOM_VARIABLE_1}" = 'FIR$T-CUSTOM_'\''STRING'\'' WITH SPACES AND @*! "CHARACTERS"' # shellcheck disable=SC2016 # String interpolation disabling is deliberate diff --git a/examples/scripts/pre_install.bat b/examples/scripts/pre_install.bat index ec4fce07c..22a529daa 100644 --- a/examples/scripts/pre_install.bat +++ b/examples/scripts/pre_install.bat @@ -1,5 +1,5 @@ if not "%INSTALLER_NAME%" == "Scripts" exit 1 -if not "%INSTALLER_VER%" == "X" exit 1 +if not "%INSTALLER_VER%" == "1.0.0" exit 1 if not "%INSTALLER_PLAT%" == "win-64" exit 1 if not "%INSTALLER_TYPE%" == "EXE" exit 1 if not "%INSTALLER_UNATTENDED%" == "1" exit 1 diff --git a/examples/scripts/pre_install.sh b/examples/scripts/pre_install.sh index 753db8121..88b111630 100644 --- a/examples/scripts/pre_install.sh +++ b/examples/scripts/pre_install.sh @@ -12,7 +12,7 @@ echo "CUSTOM_VARIABLE_2=${CUSTOM_VARIABLE_2}" echo "PREFIX=${PREFIX}" test "${INSTALLER_NAME}" = "Scripts" -test "${INSTALLER_VER}" = "X" +test "${INSTALLER_VER}" = "1.0.0" # shellcheck disable=SC2016 # String interpolation disabling is deliberate test "${CUSTOM_VARIABLE_1}" = 'FIR$T-CUSTOM_'\''STRING'\'' WITH SPACES AND @*! "CHARACTERS"' # shellcheck disable=SC2016 # String interpolation disabling is deliberate diff --git a/examples/signing/construct.yaml b/examples/signing/construct.yaml index 07bf9685f..0889a3387 100644 --- a/examples/signing/construct.yaml +++ b/examples/signing/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Signed -version: X +version: 1.0.0 installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ diff --git a/news/1235-msi-branding b/news/1235-msi-branding new file mode 100644 index 000000000..40dd95678 --- /dev/null +++ b/news/1235-msi-branding @@ -0,0 +1,19 @@ +### Enhancements + +* MSI: Add branding image support (`welcome_image`, `header_image`, `icon_image`). (#1235) + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* MSI: Document that text branding options (`welcome_file`, `welcome_text`, `readme_file`, `readme_text`, `conclusion_file`, `conclusion_text`) are not supported. (#1235) + +### Other + +* diff --git a/pyproject.toml b/pyproject.toml index f54eaac55..2c457356d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,9 @@ dependencies = [ "ruamel.yaml >=0.11.14,<0.19", "pillow >=3.1 ; platform_system=='Windows' or platform_system=='Darwin'", "jinja2", - "jsonschema >=4" + "jsonschema >=4", + "briefcase >=0.3.26 ; platform_system=='Windows'", + "tomli-w >=1.2.0 ; platform_system=='Windows'", ] [project.optional-dependencies] diff --git a/recipe/meta.yaml b/recipe/meta.yaml index d68af36a1..d550e5b68 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -25,11 +25,13 @@ requirements: - conda >=4.6 - python # >=3.10 - ruamel.yaml >=0.11.14,<0.19 - - conda-standalone >=24.1.2 + - conda-standalone >=24.11.0 - jinja2 - jsonschema >=4 - pillow >=3.1 # [win or osx] - nsis >=3.08 # [win] + - briefcase >=0.3.26 # [win] + - tomli-w >=1.2.0 # [win] run_constrained: # [unix] - nsis >=3.08 # [unix] - conda-libmamba-solver !=24.11.0 diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py new file mode 100644 index 000000000..6eda836d5 --- /dev/null +++ b/tests/test_briefcase.py @@ -0,0 +1,1087 @@ +import sys +import tarfile +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + +import pytest + +from constructor.briefcase import ( + Payload, + _get_python_info, + _get_script_env_variables, + _setup_envs_commands, + create_uninstall_options_list, + get_bundle_app_name, + get_name_version, +) +from constructor.conda_interface import cc_platform + +""" + Here 'mock_info' is simply a 'mock' of the regular 'info' object that is used to create installers. + It contains bare minimum in order to allow simple unit testing. +""" +mock_info = { + "name": "MockInfo", + "version": "1.0.0", + "_conda_exe": str(Path(sys.prefix) / "standalone_conda" / "conda.exe"), + "_download_dir": "", + "_dists": [], + "_platform": cc_platform, + "_urls": [], + # Required for auto-generating branding images + "welcome_image_text": "MockInfo", + "header_image_text": "MockInfo", +} + + +@pytest.mark.parametrize( + "name_in, version_in, name_expected, version_expected", + [ + # Valid versions + ("Miniconda", "1", "Miniconda", "1"), + ("Miniconda", "1.2", "Miniconda", "1.2"), + ("Miniconda", "1.2.3", "Miniconda", "1.2.3"), + ("Miniconda", "1.2a1", "Miniconda", "1.2a1"), + ("Miniconda", "1.2b2", "Miniconda", "1.2b2"), + ("Miniconda", "1.2rc3", "Miniconda", "1.2rc3"), + ("Miniconda", "1.2.post4", "Miniconda", "1.2.post4"), + ("Miniconda", "1.2.dev5", "Miniconda", "1.2.dev5"), + ("Miniconda", "1.2rc3.post4.dev5", "Miniconda", "1.2rc3.post4.dev5"), + # Hyphens are treated as dots + ("Miniconda", "1.2-3", "Miniconda", "1.2.3"), + ("Miniconda", "1.2-3.4-5.6", "Miniconda", "1.2.3.4.5.6"), + # Additional text before and after the last valid version should be treated as + # part of the name. + ("Miniconda", "1.2 3.4 5.6", "Miniconda 1.2 3.4", "5.6"), + ("Miniconda", "1.2_3.4_5.6", "Miniconda 1.2_3.4", "5.6"), + ("Miniconda", "1.2c3", "Miniconda 1.2c", "3"), + ("Miniconda", "1.2rc3.dev5.post4", "Miniconda 1.2rc3.dev5.post", "4"), + ("Miniconda", "py313", "Miniconda py", "313"), + ("Miniconda", "py.313", "Miniconda py", "313"), + ("Miniconda", "py3.13", "Miniconda py", "3.13"), + ("Miniconda", "py313_1.2", "Miniconda py313", "1.2"), + ("Miniconda", "1.2 and more", "Miniconda and more", "1.2"), + ("Miniconda", "1.2! and more", "Miniconda ! and more", "1.2"), + ("Miniconda", "py313 1.2 and more", "Miniconda py313 and more", "1.2"), + # Numbers in the name are not added to the version. + ("Miniconda3", "1", "Miniconda3", "1"), + ], +) +def test_name_version(name_in, version_in, name_expected, version_expected): + name_actual, version_actual = get_name_version( + {"name": name_in, "version": version_in}, + ) + assert (name_actual, version_actual) == (name_expected, version_expected) + + +@pytest.mark.parametrize( + "info", + [ + {}, + {"name": ""}, + ], +) +def test_name_empty(info): + with pytest.raises(ValueError, match="Name is empty"): + get_name_version(info) + + +@pytest.mark.parametrize( + "info", + [ + {"name": "Miniconda"}, + {"name": "Miniconda", "version": ""}, + ], +) +def test_version_empty(info): + with pytest.raises(ValueError, match="Version is empty"): + get_name_version(info) + + +@pytest.mark.parametrize("version_in", ["x", ".", " ", "hello"]) +def test_version_invalid(version_in, caplog): + name_actual, version_actual = get_name_version( + {"name": "Miniconda3", "version": version_in}, + ) + assert name_actual == f"Miniconda3 {version_in}" + assert version_actual == "0.0.1" + assert caplog.messages == [ + f"Version {version_in!r} contains no valid version numbers; defaulting to 0.0.1" + ] + + +@pytest.mark.parametrize( + "rdi, name, bundle_expected, app_name_expected", + [ + # Valid rdi + ("org.conda", "ignored", "org", "conda"), + ("org.Conda", "ignored", "org", "Conda"), + ("org.conda-miniconda", "ignored", "org", "conda-miniconda"), + ("org.conda_miniconda", "ignored", "org", "conda_miniconda"), + ("org-conda.miniconda", "ignored", "org-conda", "miniconda"), + ("org.conda.miniconda", "ignored", "org.conda", "miniconda"), + ("org.conda.1", "ignored", "org.conda", "1"), + # Invalid rdi + ("org.hello-", "Miniconda", "org", "hello"), + ("org.-hello", "Miniconda", "org", "hello"), + ("org.hello world", "Miniconda", "org", "hello-world"), + ("org.hello!world", "Miniconda", "org", "hello-world"), + # Missing rdi + (None, "x", "io.continuum", "x"), + (None, "X", "io.continuum", "x"), + (None, "1", "io.continuum", "1"), + (None, "Miniconda", "io.continuum", "miniconda"), + (None, "Miniconda3", "io.continuum", "miniconda3"), + (None, "Miniconda3 py313", "io.continuum", "miniconda3-py313"), + (None, "Hello, world!", "io.continuum", "hello-world"), + ], +) +def test_bundle_app_name(rdi, name, bundle_expected, app_name_expected): + bundle_actual, app_name_actual = get_bundle_app_name({"reverse_domain_identifier": rdi}, name) + assert (bundle_actual, app_name_actual) == (bundle_expected, app_name_expected) + + +@pytest.mark.parametrize("rdi", ["", "org"]) +def test_rdi_no_dots(rdi): + with pytest.raises(ValueError, match=f"reverse_domain_identifier '{rdi}' contains no dots"): + get_bundle_app_name({"reverse_domain_identifier": rdi}, "ignored") + + +@pytest.mark.parametrize("rdi", ["org.", "org.hello.", "org.hello.-"]) +def test_rdi_invalid_package(rdi): + with pytest.raises( + ValueError, + match=( + f"Last component of reverse_domain_identifier '{rdi}' " + f"contains no alphanumeric characters" + ), + ): + get_bundle_app_name({"reverse_domain_identifier": rdi}, "ignored") + + +@pytest.mark.parametrize("name", ["", " ", "!", "-", "---"]) +def test_name_no_alphanumeric(name): + with pytest.raises(ValueError, match=f"Name '{name}' contains no alphanumeric characters"): + get_bundle_app_name({}, name) + + +@pytest.mark.parametrize( + "dists, has_python_expected, pyver_expected", + [ + # Python present + (["python-3.11.5-0.tar.bz2"], True, ["3", "11", "5"]), + (["python-3.9.7-0.tar.bz2"], True, ["3", "9", "7"]), + # Python present alongside other dists + (["numpy-1.24.0-py311_0.tar.bz2", "python-3.11.5-0.tar.bz2"], True, ["3", "11", "5"]), + # No python dist + (["numpy-1.24.0-py311_0.tar.bz2"], False, []), + # Empty dists + ([], False, []), + ], +) +def test_get_python_info(dists, has_python_expected, pyver_expected): + info = {"_dists": dists} + has_python, pyver_components = _get_python_info(info) + assert has_python == has_python_expected + assert pyver_components == pyver_expected + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_prepare_payload(): + """Test preparing the payload.""" + info = mock_info.copy() + payload = Payload(info) + payload.prepare() + assert payload.root.is_dir() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_payload_layout(): + """Test the layout of the payload and verify that archiving + parts of the payload works as expected. + """ + info = mock_info.copy() + payload = Payload(info) + payload.prepare() + + external_dir = payload.root / "external" + assert external_dir.is_dir() + + base_dir = payload.root / "external" / "base" + pkgs_dir = payload.root / "external" / "base" / "pkgs" + archive_path = external_dir / payload.archive_name + # Since archiving removes the directory 'base_dir' and its contents + assert not base_dir.exists() + assert not pkgs_dir.exists() + assert archive_path.exists() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_payload_archive(tmp_path: Path): + """Test that the payload archive function works as expected.""" + info = mock_info.copy() + payload = Payload(info) + + foo_dir = tmp_path / "foo" + foo_dir.mkdir() + + expected_text = "some test text" + hello_file = foo_dir / "hello.txt" + hello_file.write_text(expected_text, encoding="utf-8") + + archive_path = payload.make_archive(foo_dir, tmp_path) + + with tarfile.open(archive_path, mode="r:gz") as tar: + member = tar.getmember("foo/hello.txt") + f = tar.extractfile(member) + assert f is not None + assert f.read().decode("utf-8") == expected_text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_payload_remove(): + """Test removing the payload.""" + info = mock_info.copy() + payload = Payload(info) + payload.prepare() + root = payload.root + + assert root.is_dir() + payload.remove() + assert not root.is_dir() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_payload_pyproject_toml(): + """Test that the pyproject.toml file is created when the payload is prepared.""" + info = mock_info.copy() + payload = Payload(info) + payload.prepare() + pyproject_toml = payload.root / "pyproject.toml" + assert pyproject_toml.is_file() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_payload_conda_exe(): + """Test that conda-standalone is prepared.""" + info = mock_info.copy() + payload = Payload(info) + payload.prepare() + external_dir = payload.root / "external" + conda_exe = external_dir / "_conda.exe" + assert conda_exe.is_file() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize("debug_logging", [True, False]) +def test_payload_templates_are_rendered(debug_logging): + """Test that templates are rendered when the payload is prepared.""" + info = mock_info.copy() + payload = Payload(info) + payload.add_debug_logging = debug_logging + rendered_templates = payload.render_templates() + assert len(rendered_templates) == 2 # There should be at least two files + for f in rendered_templates: + assert f.is_file() + text = f.read_text(encoding="utf-8") + assert "{{" not in text and "}}" not in text + assert "{%" not in text and "%}" not in text + assert "{#" not in text and "#}" not in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize("debug_logging", [True, False]) +def test_templates_debug_mode(debug_logging): + """Test that debug logging affects template generation.""" + info = mock_info.copy() + payload = Payload(info) + payload.add_debug_logging = debug_logging + rendered_templates = payload.render_templates() + assert len(rendered_templates) == 2 + + for f in rendered_templates: + assert f.is_file() + with open(f) as open_file: + lines = open_file.readlines() + expected = "@echo on\n" if debug_logging else "@echo off\n" + assert lines[0] == expected + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_no_python(): + """Test when no Python dist is present, has_python is False and the + OPTION_REGISTER_PYTHON block should not appear in the rendered output.""" + info = mock_info.copy() + info["_dists"] = [] + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + assert "OPTION_REGISTER_PYTHON" not in text + assert "PythonCore" not in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_with_python(): + """Test when a Python dist is present, has_python is True and the + OPTION_REGISTER_PYTHON block should appear in the rendered output.""" + info = mock_info.copy() + info["_dists"] = ["python-3.11.5-0.tar.bz2"] + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + assert "OPTION_REGISTER_PYTHON" in text + assert "PythonCore" in text + assert "3.11" in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize( + "initialize_conda, expected_flag", + [ + ("condabin", "--condabin"), + ("classic", "--classic"), + ], +) +def test_render_templates_add_to_path_flags(initialize_conda, expected_flag): + """Verify that the correct path flag is rendered based on initialize_conda mode.""" + info = mock_info.copy() + info["initialize_conda"] = initialize_conda + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + assert expected_flag in text + assert "constructor windows path" in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize("no_rcs_arg", ["--no-rc", ""]) +def test_render_templates_no_rcs_arg(no_rcs_arg): + """Verify that no_rcs_arg is rendered into the template correctly.""" + info = mock_info.copy() + info["_ignore_condarcs_arg"] = no_rcs_arg + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + if no_rcs_arg: + assert no_rcs_arg in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_registry_uses_base_path(): + """Test that Python registry entries use BASE_PATH (INSTDIR\\base) and not + INSTDIR directly, since in the MSI layout is different from EXE.""" + info = mock_info.copy() + info["_dists"] = ["python-3.11.5-0.tar.bz2"] + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + assert "%BASE_PATH%\\python.exe" in text + assert "%BASE_PATH%\\Lib;%BASE_PATH%\\DLLs" in text + assert "%BASE_PATH%\\Doc\\" in text + + assert "%INSTDIR%\\python.exe" not in text + assert "%INSTDIR%\\Lib;%INSTDIR%\\DLLs" not in text + assert "%INSTDIR%\\Doc\\" not in text + + +def test_render_templates_path_commands_use_base_path(): + """Verify that the 'constructor windows path' commands use BASE_PATH + (INSTDIR\\base) and not INSTDIR directly, since the conda environment + lives in BASE_PATH in the MSI layout.""" + info = mock_info.copy() + info["initialize_conda"] = "classic" + payload = Payload(info) + rendered_templates = payload.render_templates() + + # Check run_installation.bat uses BASE_PATH for adding to PATH + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + run_text = run_installation.read_text(encoding="utf-8") + + assert 'constructor windows path --prepend=user --prefix "%BASE_PATH%"' in run_text + assert 'constructor windows path --prepend=user --prefix "%INSTDIR%"' not in run_text + + # Check pre_uninstall.bat uses BASE_PATH for removing from PATH + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + pre_text = pre_uninstall.read_text(encoding="utf-8") + + assert 'constructor windows path --remove=user --prefix "%BASE_PATH%"' in pre_text + assert 'constructor windows path --remove=user --prefix "%INSTDIR%"' not in pre_text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_nonadmin_created_for_user_install(): + """Verify that run_installation.bat creates a .nonadmin marker file + when ALLUSERS is 0. This file is used by pre_uninstall.bat to determine + the install mode via REG_HIVE.""" + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + assert ".nonadmin" in text + assert 'ALLUSERS%"=="0"' in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_option_variable_names(): + """Verify that the option variable names in the rendered template match + exactly what run_post_installation.bat sets via positional arguments.""" + info = mock_info.copy() + info["_dists"] = ["python-3.11.5-0.tar.bz2"] + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + assert "OPTION_REGISTER_PYTHON" in text + assert "OPTION_INITIALIZE_CONDA" in text + assert "OPTION_CLEAR_PACKAGE_CACHE" in text + assert "OPTION_ENABLE_SHORTCUTS" in text + + assert "OPTION_REGISTER_SYSTEM_PYTHON" not in text + assert "OPTION_ADD_TO_PATH" not in text + assert "OPTION_CLEAR_PKG_CACHE" not in text + assert "OPTION_CREATE_SHORTCUTS" not in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_uninstall_option_variable_names(): + """Verify that the uninstall option variable names in the rendered template match + exactly what run_pre_uninstall.bat sets via positional arguments.""" + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + assert "OPTION_REMOVE_USER_DATA" in text + assert "OPTION_REMOVE_CACHES" in text + assert "OPTION_REMOVE_CONFIG_FILES" in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_pre_uninstall_delayed_expansion(): + """Verify that pre_uninstall.bat enables delayed expansion explicitly. + This is required because: + 1. setlocal in pre_uninstall.bat creates a new scope, so enabledelayedexpansion + from run_pre_uninstall.bat is NOT inherited. + 2. !VAR! syntax is needed inside for /f loops and for building UNINST_ARGS + dynamically. + """ + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + assert "setlocal enabledelayedexpansion" in text.lower() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_no_python_no_registry(): + """Verify that when no Python dist is present, no registry operations + appear in pre_uninstall.bat.""" + info = mock_info.copy() + info["_dists"] = [] + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + assert "PythonCore" not in text + assert "reg delete" not in text + assert "reg query" not in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize( + "initialize_conda, expected_flag", + [ + ("condabin", "--condabin"), + ("classic", "--classic"), + ], +) +def test_render_templates_path_removal_flags(initialize_conda, expected_flag): + """Verify the correct path flag is rendered in pre_uninstall.bat + based on initialize_conda mode.""" + info = mock_info.copy() + info["initialize_conda"] = initialize_conda + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + assert expected_flag in text + assert "constructor windows path" in text + assert "--remove=user" in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_path_removal_gated_on_nonadmin(): + """Verify that PATH removal in pre_uninstall.bat is gated on + the .nonadmin marker file check, mirroring the NSIS behaviour. PATH was + only ever added for user-scoped installs so should only be removed for + those.""" + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + path_removal_pos = text.find("--remove=user") + assert path_removal_pos != -1, '"--remove=user" not found in pre_uninstall.bat' + + # Search backwards from --remove=user to find the nearest preceding .nonadmin + # guard, confirming it is the direct condition for PATH removal and not some + # other .nonadmin check elsewhere in the file. + preceding_text = text[:path_removal_pos] + nonadmin_check_pos = preceding_text.rfind('if exist "%BASE_PATH%\\.nonadmin"') + assert nonadmin_check_pos != -1, ( + 'No .nonadmin guard found immediately before "--remove=user" in pre_uninstall.bat' + ) + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_render_templates_registry_uses_reg64(): + """Verify that Python registry writes in run_installation.bat use /reg:64 + to force the 64-bit registry view, since the MSI engine runs as a 32-bit + process and would otherwise redirect writes to WOW6432Node.""" + info = mock_info.copy() + info["_dists"] = ["python-3.11.5-0.tar.bz2"] + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + # REG64 variable must be defined and used + assert "REG64" in text + assert "/reg:64" in text + # Must not use the literal flag directly in reg add calls (should use variable) + assert "reg add" in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_pre_uninstall_registry_uses_reg64(): + """Verify that Python registry queries and deletes in pre_uninstall.bat use + /reg:64 to force the 64-bit registry view, since the MSI engine runs as a + 32-bit process and registry entries were written to the 64-bit view at + install time.""" + info = mock_info.copy() + info["_dists"] = ["python-3.11.5-0.tar.bz2"] + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + # REG64 variable must be defined and used in the subroutine + assert "REG64" in text + assert "/reg:64" in text + + # reg query and reg delete must both appear after the subroutine label + subroutine_pos = text.find(":remove_python_registry") + reg_query_pos = text.find("reg query", subroutine_pos) + reg_delete_pos = text.find("reg delete", subroutine_pos) + assert subroutine_pos != -1 + assert reg_query_pos != -1 + assert reg_delete_pos != -1 + + # /reg:64 must appear in both the reg query and reg delete lines + reg_query_line = next( + line for line in text.splitlines() if "reg query" in line and "PythonCore" in line + ) + reg_delete_line = next(line for line in text.splitlines() if "reg delete" in line) + assert "/reg:64" in reg_query_line or "%REG64%" in reg_query_line + assert "/reg:64" in reg_delete_line or "%REG64%" in reg_delete_line + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +def test_pre_uninstall_conda_root_prefix(): + """Verify that CONDA_ROOT_PREFIX is set in pre_uninstall.bat. + This is required for the --rm-menus command to work correctly.""" + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + assert "CONDA_ROOT_PREFIX=%BASE_PATH%" in text + + +def test_create_uninstall_options_list(): + """Test that create_uninstall_options_list returns all expected options.""" + options = create_uninstall_options_list({}) + + option_names = [opt["name"] for opt in options] + assert "remove_user_data" in option_names + assert "remove_caches" in option_names + assert "remove_config_files" in option_names + + +def test_render_templates_with_virtual_specs(): + """Test that virtual_specs check block is rendered when specs are provided.""" + info = mock_info.copy() + info["virtual_specs"] = ["__win>=10", "__cuda>=11"] + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + assert "Checking virtual specs compatibility" in text + assert '"__win>=10" "__cuda>=11"' in text + assert "CONDA_SOLVER=classic" in text + assert "--dry-run" in text + assert "_virtual_specs_checks" in text + + +def test_render_templates_without_virtual_specs(): + """Test that virtual_specs check block is not rendered when specs are empty.""" + info = mock_info.copy() + info["virtual_specs"] = [] + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + assert "Checking virtual specs compatibility" not in text + assert "_virtual_specs_checks" not in text + + +@pytest.mark.parametrize( + "info", + [ + {}, + {"script_env_variables": {}}, + ], +) +def test_get_script_env_variables_empty(info): + """Test that empty dict is returned when no script_env_variables are provided.""" + assert _get_script_env_variables(info) == {} + + +@pytest.mark.parametrize( + "script_env_variables, expected", + [ + # Basic variables pass through + ({"FOO": "bar"}, {"FOO": "bar"}), + ({"FOO": "bar", "BAZ": "qux"}, {"FOO": "bar", "BAZ": "qux"}), + # Single quotes are allowed + ({"MSG": "foo 'bar'"}, {"MSG": "foo 'bar'"}), + # Special batch characters are escaped + ({"PERCENT": "50%"}, {"PERCENT": "50%%"}), + ({"AMP": "a & b"}, {"AMP": "a ^& b"}), + ({"PIPE": "a | b"}, {"PIPE": "a ^| b"}), + ({"REDIR": "a < b > c"}, {"REDIR": "a ^< b ^> c"}), + ({"CARET": "a^b"}, {"CARET": "a^^b"}), + ({"BANG": "foo!"}, {"BANG": "foo^!"}), + ], +) +def test_get_script_env_variables(script_env_variables, expected): + """Test script_env_variables validation and escaping.""" + info = {"script_env_variables": script_env_variables} + assert _get_script_env_variables(info) == expected + + +@pytest.mark.parametrize( + "script_env_variables", + [ + {"BAD": 'foo "bar"'}, # double quotes in value + {'BAD"KEY': "value"}, # double quotes in key + ], +) +def test_get_script_env_variables_double_quotes_rejected(script_env_variables): + """Test that double quotes raise ValueError.""" + info = {"script_env_variables": script_env_variables} + with pytest.raises(ValueError, match="double quotes"): + _get_script_env_variables(info) + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +@pytest.mark.parametrize("template_name", ["run_installation.bat", "pre_uninstall.bat"]) +def test_render_templates_with_script_env_variables(template_name): + """Test that script_env_variables are rendered in both install and uninstall templates.""" + info = mock_info.copy() + info["script_env_variables"] = {"MY_VAR": "my_value", "OTHER": "50%"} + payload = Payload(info) + rendered_templates = payload.render_templates() + + template = next(f for f in rendered_templates if f.name == template_name) + text = template.read_text(encoding="utf-8") + + assert 'set "MY_VAR=my_value"' in text + assert 'set "OTHER=50%%"' in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +@pytest.mark.parametrize("template_name", ["run_installation.bat", "pre_uninstall.bat"]) +def test_render_templates_without_script_env_variables(template_name): + """Test that no script_env_variables block is rendered when not provided.""" + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + template = next(f for f in rendered_templates if f.name == template_name) + text = template.read_text(encoding="utf-8") + + assert "User-defined environment variables" not in text + + +def test_setup_envs_commands_base_only(): + """Test _setup_envs_commands returns only base env when no extra_envs.""" + info = { + "channels": ["conda-forge", "defaults"], + "_extra_envs_info": {}, + } + envs = _setup_envs_commands(info) + + assert len(envs) == 1 + assert envs[0]["name"] == "base" + assert envs[0]["prefix"] == "%BASE_PATH%" + assert envs[0]["lockfile"] == r"%BASE_PATH%\conda-meta\initial-state.explicit.txt" + assert "conda-forge" in envs[0]["channels"] + + +def test_setup_envs_commands_with_extra_envs(): + """Test _setup_envs_commands includes extra_envs.""" + info = { + "channels": ["defaults"], + "_extra_envs_info": { + "py311": {"_dists": []}, + "tools": {"_dists": []}, + }, + "extra_envs": { + "py311": { + "channels": ["conda-forge"], + }, + "tools": { + # No channels specified, should inherit from base + }, + }, + } + envs = _setup_envs_commands(info) + + assert len(envs) == 3 + + # Base env + assert envs[0]["name"] == "base" + assert envs[0]["prefix"] == "%BASE_PATH%" + + # Extra envs + env_names = [e["name"] for e in envs] + assert "py311" in env_names + assert "tools" in env_names + + py311_env = next(e for e in envs if e["name"] == "py311") + assert py311_env["prefix"] == r"%BASE_PATH%\envs\py311" + assert py311_env["lockfile"] == r"%BASE_PATH%\envs\py311\conda-meta\initial-state.explicit.txt" + assert "conda-forge" in py311_env["channels"] + + tools_env = next(e for e in envs if e["name"] == "tools") + assert tools_env["prefix"] == r"%BASE_PATH%\envs\tools" + # tools inherits channels from base + assert "defaults" in tools_env["channels"] + + +def test_setup_envs_commands_shortcuts(): + """Test _setup_envs_commands handles menu_packages/shortcuts correctly.""" + info = { + "channels": ["defaults"], + "menu_packages": ["console_shortcut"], + "_extra_envs_info": { + "myenv": {"_dists": []}, + }, + "extra_envs": { + "myenv": { + "menu_packages": ["other_shortcut"], + }, + }, + } + envs = _setup_envs_commands(info) + + assert len(envs) == 2 + + base_env = envs[0] + assert "console_shortcut" in base_env["shortcuts"] + + myenv = envs[1] + assert "other_shortcut" in myenv["shortcuts"] + + +def test_setup_envs_commands_channels_remap(): + """Test _setup_envs_commands handles channels_remap correctly.""" + info = { + "channels": ["https://foo.bar"], + "channels_remap": [{"src": "https://foo.bar", "dest": "conda-forge"}], + "_extra_envs_info": { + "myenv": {"_dists": []}, + }, + "extra_envs": { + "myenv": { + "channels": ["https://foo.bar"], + "channels_remap": [{"src": "https://foo.bar", "dest": "my-mirror"}], + }, + }, + } + envs = _setup_envs_commands(info) + + base_env = envs[0] + assert "conda-forge" in base_env["channels"] + + myenv = envs[1] + assert "my-mirror" in myenv["channels"] + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +def test_render_templates_with_extra_envs(): + """Test that extra_envs are rendered in the install template.""" + info = mock_info.copy() + info["channels"] = ["defaults"] + info["_extra_envs_info"] = { + "py311": {"_dists": []}, + } + info["extra_envs"] = { + "py311": { + "channels": ["conda-forge"], + }, + } + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + # Both base and py311 environments should be installed + assert "Setting up base environment" in text + assert "Setting up py311 environment" in text + assert r"%BASE_PATH%\envs\py311" in text + + +@pytest.mark.parametrize("template_name", ["run_installation.bat", "pre_uninstall.bat"]) +def test_render_templates_installer_metadata(template_name): + """Test that installer metadata env vars are rendered in both templates.""" + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + template = next(f for f in rendered_templates if f.name == template_name) + text = template.read_text(encoding="utf-8") + + assert 'set "INSTALLER_NAME=MockInfo"' in text + assert 'set "INSTALLER_VER=1.0.0"' in text + assert f'set "INSTALLER_PLAT={cc_platform}"' in text + assert 'set "INSTALLER_TYPE=MSI"' in text + assert "INSTALLER_UNATTENDED is not available" in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +@pytest.mark.parametrize( + "script_type,has_desc", + [ + ("pre_install", False), + ("pre_install", True), + ("post_install", False), + ("post_install", True), + ], +) +def test_render_templates_user_install_scripts(tmp_path, script_type, has_desc): + """Test that user install scripts are rendered correctly. + + - Mandatory scripts (no desc) are always called + - Optional scripts (with desc) are gated by OPTION flag + """ + script = tmp_path / f"{script_type}.bat" + script.write_text(f"@echo {script_type}") + + info = mock_info.copy() + info[script_type] = str(script) + if has_desc: + info[f"{script_type}_desc"] = "Custom script description" + + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + text = run_installation.read_text(encoding="utf-8") + + # Script call should always be present + label = script_type.replace("_", "-") # pre_install -> pre-install + assert f"Running {label} script" in text + assert f"user_{script_type}.bat" in text + + # OPTION check only present for optional scripts + option_var = f"OPTION_{script_type.upper()}_SCRIPT" + if has_desc: + assert option_var in text + else: + assert option_var not in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +def test_render_templates_with_pre_uninstall(tmp_path): + """Test that pre_uninstall script call is rendered.""" + script = tmp_path / "pre_uninstall.bat" + script.write_text("@echo pre_uninstall") + + info = mock_info.copy() + info["pre_uninstall"] = str(script) + payload = Payload(info) + rendered_templates = payload.render_templates() + + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + text = pre_uninstall.read_text(encoding="utf-8") + + assert "Running pre-uninstall script" in text + assert "user_pre_uninstall.bat" in text + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +def test_render_templates_without_user_scripts(): + """Test that no user script blocks are rendered when scripts are not provided.""" + info = mock_info.copy() + payload = Payload(info) + rendered_templates = payload.render_templates() + + run_installation = next(f for f in rendered_templates if f.name == "run_installation.bat") + pre_uninstall = next(f for f in rendered_templates if f.name == "pre_uninstall.bat") + + assert "user_pre_install.bat" not in run_installation.read_text(encoding="utf-8") + assert "user_post_install.bat" not in run_installation.read_text(encoding="utf-8") + assert "user_pre_uninstall.bat" not in pre_uninstall.read_text(encoding="utf-8") + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +@pytest.mark.parametrize( + "script_key,dest_name", + [ + ("pre_install", "user_pre_install.bat"), + ("post_install", "user_post_install.bat"), + ("pre_uninstall", "user_pre_uninstall.bat"), + ], +) +def test_stage_user_scripts(tmp_path, script_key, dest_name): + """Test that user scripts are staged to the correct location.""" + script = tmp_path / f"{script_key}.bat" + script.write_text(f"@echo {script_key}") + + info = mock_info.copy() + info[script_key] = str(script) + payload = Payload(info) + + pkgs_dir = tmp_path / "pkgs" + pkgs_dir.mkdir() + payload._stage_user_scripts(pkgs_dir) + + staged_script = pkgs_dir / dest_name + assert staged_script.is_file() + assert staged_script.read_text() == f"@echo {script_key}" + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only") +def test_stage_user_scripts_validates_bat_extension(tmp_path): + """Test that non-.bat files are rejected.""" + script = tmp_path / "pre_install.sh" + script.write_text("foo") + + info = mock_info.copy() + info["pre_install"] = str(script) + payload = Payload(info) + + pkgs_dir = tmp_path / "pkgs" + pkgs_dir.mkdir() + + with pytest.raises(ValueError, match="must be an existing '.bat' file"): + payload._stage_user_scripts(pkgs_dir) + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +@pytest.mark.parametrize( + "has_user_images", + [ + pytest.param(True, id="user-provided-images"), + pytest.param(False, id="no-user-images"), + ], +) +def test_payload_pyproject_toml_installer_images(tmp_path, has_user_images): + """Test that pyproject.toml contains branding image paths only when user provides them. + + MSI installers only include branding images if user explicitly provides them. + Otherwise, WiX defaults are used. + """ + info = mock_info.copy() + + if has_user_images: + # Use existing test image from examples directory + repo_root = Path(__file__).parent.parent + example_image = ( + repo_root / "examples" / "customized_welcome_conclusion" / "ExtraPagesExampleImg.bmp" + ) + assert example_image.exists(), f"Test image not found: {example_image}" + + info["welcome_image"] = str(example_image) + info["header_image"] = str(example_image) + info["icon_image"] = str(example_image) + + payload = Payload(info) + payload.prepare() + + pyproject_path = payload.root / "pyproject.toml" + assert pyproject_path.is_file() + + with open(pyproject_path, "rb") as f: + config = tomllib.load(f) + + app_config = config["tool"]["briefcase"]["app"] + app_name = list(app_config.keys())[0] + app = app_config[app_name] + + if has_user_images: + # Verify branding paths are present when user provides images + assert "installer_background" in app, "installer_background missing" + assert "installer_banner" in app, "installer_banner missing" + assert "icon" in app, "icon missing from pyproject.toml" + + # Verify paths point to expected files + assert app["installer_background"].endswith("welcome.bmp") + assert app["installer_banner"].endswith("header.bmp") + assert app["icon"].endswith("icon") # No extension for icon + + # Verify the actual image files exist + assert Path(app["installer_background"]).exists() + assert Path(app["installer_banner"]).exists() + assert Path(app["icon"] + ".ico").exists() # Briefcase adds .ico + else: + # No branding images - use WiX defaults + assert "icon" not in app, "icon should not be present without user image" + assert "installer_background" not in app, ( + "installer_background should not be present without user image" + ) + assert "installer_banner" not in app, ( + "installer_banner should not be present without user image" + ) diff --git a/tests/test_examples.py b/tests/test_examples.py index 4ec5c9a64..b7fe4e584 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -10,6 +10,7 @@ import warnings import xml.etree.ElementTree as ET from contextlib import nullcontext +from dataclasses import dataclass from datetime import timedelta from functools import cache from pathlib import Path @@ -22,6 +23,7 @@ from conda.models.version import VersionOrder as Version from ruamel.yaml import YAML +from constructor.construct import parse as parse_construct from constructor.utils import ( StandaloneExe, check_version, @@ -52,6 +54,7 @@ REPO_DIR = Path(__file__).parent.parent ON_CI = bool(os.environ.get("CI")) and os.environ.get("CI") != "0" CONSTRUCTOR_CONDA_EXE = os.environ.get("CONSTRUCTOR_CONDA_EXE") +CONSTRUCTOR_VERBOSE = os.environ.get("CONSTRUCTOR_VERBOSE") CONDA_EXE, CONDA_EXE_VERSION = identify_conda_exe(CONSTRUCTOR_CONDA_EXE) if CONDA_EXE_VERSION is not None: CONDA_EXE_VERSION = Version(CONDA_EXE_VERSION) @@ -337,6 +340,197 @@ def _sentinel_file_checks(example_path, install_dir): ) +def calculate_msi_install_path(config_path: Path) -> Path: + """Calculate the MSI install path from the construct.yaml config. + + MSI installers use ' ' as the install directory name, + matching the formal_name set in briefcase.py. + """ + config = parse_construct(str(config_path), platform="win-64") + dir_name = f"{config['name']} {config['version']}" + local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) + root_dir = Path(local_dir) / "Programs" + root_dir.mkdir(parents=True, exist_ok=True) + + assert root_dir.is_dir() # Consistency check to avoid strange unexpected errors + return Path(root_dir) / dir_name + + +def handle_exception_and_error_out( + failure: InstallationFailure | UninstallationFailure, original_exception: BaseException +) -> None: + """Print failure context (including logs) and re-raise with exception chaining.""" + print(failure.read_text()) + raise failure from original_exception + + +def _read_briefcase_log_tail(path: Path, last_digits: int) -> str: + """Helper function to read logs from installers created with briefcase. + The encoding can vary between the different logs. + """ + if not path or not path.exists(): + return f"(log not found: {path})" + + # Try UTF-16 first (MSI logs), fallback to UTF-8 + try: + text = path.read_text(encoding="utf-16", errors="replace") + except UnicodeError: + text = path.read_text(encoding="utf-8", errors="replace") + + return text[last_digits:] + + +@dataclass +class InstallationFailure(RuntimeError): + cmd: list[str] + returncode: int + msi_log: Path | None = None + post_install_log: Path | None = None + + def read_text(self, last_digits: int = -15000) -> str: + parts = [ + f"Command: {self.cmd}", + f"Return code: {self.returncode}", + ] + + if self.post_install_log: + parts.append( + f"\n=== MSI LOG POST INSTALL: {self.post_install_log} ===\n" + + _read_briefcase_log_tail(self.post_install_log, last_digits) + ) + + if self.msi_log: + parts.append( + f"\n=== MSI LOG: {self.msi_log} ===\n" + + _read_briefcase_log_tail(self.msi_log, last_digits) + ) + + return "\n".join(parts) + + +@dataclass +class UninstallationFailure(RuntimeError): + cmd: list[str] + returncode: int + msi_log: Path | None = None + pre_uninstall_log: Path | None = None + + def read_text(self, last_digits: int = -15000) -> str: + parts = [ + f"Command: {self.cmd}", + f"Return code: {self.returncode}", + ] + + if self.pre_uninstall_log: + parts.append( + f"\n=== MSI LOG PRE UNINSTALL: {self.pre_uninstall_log} ===\n" + + _read_briefcase_log_tail(self.pre_uninstall_log, last_digits) + ) + + if self.msi_log: + parts.append( + f"\n=== MSI LOG: {self.msi_log} ===\n" + + _read_briefcase_log_tail(self.msi_log, last_digits) + ) + + return "\n".join(parts) + + +def _run_installer_msi( + installer: Path, + install_dir: Path, + installer_input=None, + timeout=420, + check=True, + options: list | None = None, +): + """Runs specified MSI Installer via command line in silent mode. This is work in progress.""" + if not sys.platform.startswith("win"): + raise ValueError("Can only run .msi installers on Windows") + + # Currently we only have 1 test that specifies options, so this is a temporary "fix" + if options: + allusers = "/InstallationType=AllUsers" in options + else: + allusers = False + + cmd = [ + "msiexec.exe", + "/i", + str(installer), + "ALLUSERS=1" + if allusers + else "MSIINSTALLPERUSER=1", # For some reason tests fail on the CI system if "ALLUSERS=1" + "/qn", + ] + + # Prepare logging + post_install_log = install_dir / "install.log" + # Logging from MSI engine is handled separately + log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-install.log") + if log_path.exists(): + os.remove(log_path) + cmd.extend(["/L*V", str(log_path)]) + + # Run installer and handle errors/logs if necessary + try: + process = _execute(cmd, installer_input=installer_input, timeout=timeout, check=check) + except subprocess.CalledProcessError as e: + handle_exception_and_error_out( + InstallationFailure( + cmd=cmd, + returncode=e.returncode, + msi_log=log_path, + post_install_log=post_install_log, + ), + original_exception=e, + ) + + return process + + +def _run_uninstaller_msi( + installer: Path, + install_dir: Path, + timeout: int = 420, + check: bool = True, +) -> subprocess.CompletedProcess | None: + cmd = [ + "msiexec.exe", + "/x", + str(installer), + "/qn", + ] + + # Prepare logging + pre_uninstall_log = install_dir / "uninstall.log" + # Logging from MSI engine is handled separately + log_path = Path(os.environ.get("TEMP")) / (install_dir.name + "-uninstall.log") + if log_path.exists(): + os.remove(log_path) + cmd.extend(["/L*V", str(log_path)]) + + try: + process = _execute(cmd, installer_input=None, timeout=timeout, check=check) + except subprocess.CalledProcessError as e: + handle_exception_and_error_out( + UninstallationFailure( + cmd=cmd, + returncode=e.returncode, + msi_log=log_path, + pre_uninstall_log=pre_uninstall_log, + ), + original_exception=e, + ) + + if check: + # TODO: + # Check log and if there are remaining files, similar to the exe installers + pass + + return process + + def _run_installer( example_path: Path, installer: Path, @@ -382,12 +576,27 @@ def _run_installer( timeout=timeout, check=check_subprocess, ) + elif installer.suffix == ".msi": + process = _run_installer_msi( + installer, + install_dir, + installer_input=installer_input, + timeout=timeout, + check=check_subprocess, + options=options, + ) else: raise ValueError(f"Unknown installer type: {installer.suffix}") - if check_sentinels and not (installer.suffix == ".pkg" and ON_CI): + + if installer.suffix == ".msi": + print("sentinel_file_checks for MSI installers not yet implemented") + elif check_sentinels and not (installer.suffix == ".pkg" and ON_CI): _sentinel_file_checks(example_path, install_dir) - if uninstall and installer.suffix == ".exe": - _run_uninstaller_exe(install_dir, timeout=timeout, check=check_subprocess) + if uninstall: + if installer.suffix == ".msi": + _run_uninstaller_msi(installer, install_dir, timeout=timeout, check=check_subprocess) + elif installer.suffix == ".exe": + _run_uninstaller_exe(install_dir, timeout=timeout, check=check_subprocess) return process @@ -407,16 +616,19 @@ def create_installer( output_dir = workspace / "installer" output_dir.mkdir(parents=True, exist_ok=True) - cmd = [ - *COV_CMD, - "constructor", - "-v", + cmd = [*COV_CMD, "constructor"] + # This flag will (if enabled) create a lot of output upon test failures for .exe-installers. + # If debugging generated NSIS templates, it can be worth to enable. + if CONSTRUCTOR_VERBOSE: + cmd.append("-v") + cmd += [ str(input_dir), "--output-dir", str(output_dir), "--config-filename", config_filename, ] + if conda_exe: cmd.extend(["--conda-exe", conda_exe]) if debug: @@ -430,18 +642,21 @@ def create_installer( def _sort_by_extension(path): "Return shell installers first so they are run before the GUI ones" - return {"sh": 1, "pkg": 2, "exe": 3}[path.suffix[1:]], path + return {"sh": 1, "pkg": 2, "exe": 3, "msi": 4}[path.suffix[1:]], path - installers = (p for p in output_dir.iterdir() if p.suffix in (".exe", ".sh", ".pkg")) + installers = (p for p in output_dir.iterdir() if p.suffix in (".exe", ".msi", ".sh", ".pkg")) for installer in sorted(installers, key=_sort_by_extension): if installer.suffix == ".pkg" and ON_CI: install_dir = Path("~").expanduser() / calculate_install_dir( input_dir / config_filename ) + elif installer.suffix == ".msi": + install_dir = calculate_msi_install_path(input_dir / config_filename) else: install_dir = ( workspace / f"{install_dir_prefix}-{installer.stem}-{installer.suffix[1:]}" ) + yield installer, install_dir if KEEP_ARTIFACTS_PATH: try: @@ -529,13 +744,23 @@ def test_example_extra_envs(tmp_path, request): assert "@EXPLICIT" in envtxt.read_text() if sys.platform.startswith("win"): - _run_uninstaller_exe(install_dir=install_dir) + if installer.suffix == ".msi": + _run_uninstaller_msi(installer, install_dir) + else: + _run_uninstaller_exe(install_dir=install_dir) def test_example_extra_files(tmp_path, request): input_path = _example_path("extra_files") for installer, install_dir in create_installer(input_path, tmp_path, with_spaces=True): - _run_installer(input_path, installer, install_dir, request=request) + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_sentinels=CONSTRUCTOR_VERBOSE, + check_subprocess=CONSTRUCTOR_VERBOSE, + ) def test_example_mirrored_channels(tmp_path, request): @@ -612,6 +837,9 @@ def test_example_miniforge(tmp_path, request, example): raise AssertionError("Could not find Start Menu folder for miniforge") _run_uninstaller_exe(install_dir) assert not list(start_menu_dir.glob("Miniforge*.lnk")) + elif installer.suffix == ".msi": + # TODO: Start menus + _run_uninstaller_msi(installer, install_dir) def test_example_noconda(tmp_path, request): @@ -764,6 +992,10 @@ def test_example_shortcuts(tmp_path, request): _run_installer(input_path, installer, install_dir, request=request, uninstall=False) # check that the shortcuts are created if sys.platform == "win32": + # MSI shortcut verification expected to fail due to menuinst bug + # https://github.com/conda/menuinst/issues/453 + if installer.suffix == ".msi": + pytest.xfail("MSI shortcut verification fails due to menuinst#453") for key in ("ProgramData", "AppData"): start_menu = Path(os.environ[key]) / "Microsoft/Windows/Start Menu/Programs" package_1 = start_menu / "Package 1" @@ -779,7 +1011,10 @@ def test_example_shortcuts(tmp_path, request): break else: raise AssertionError("No shortcuts found!") - _run_uninstaller_exe(install_dir) + if installer.suffix == ".msi": + _run_uninstaller_msi(installer, install_dir) + else: + _run_uninstaller_exe(install_dir) assert not (package_1 / "A.lnk").is_file() assert not (package_1 / "B.lnk").is_file() elif sys.platform == "darwin": @@ -794,6 +1029,24 @@ def test_example_shortcuts(tmp_path, request): assert (applications / "package-1_b.desktop").exists() +def _verify_windows_signature(installer: Path): + """Verify a Windows installer has a valid signature.""" + proc = subprocess.run( + [ + "powershell", + "-c", + f"$sig = Get-AuthenticodeSignature -LiteralPath '{installer}';$sig.Status.value__", + ], + capture_output=True, + text=True, + ) + if proc.returncode != 0 or not proc.stdout.strip(): + raise AssertionError(f"Failed to verify signature for {installer}: {proc.stderr}") + status = int(proc.stdout.strip()) + # 0 = Valid, 1 = UnknownError (self-signed certs), >1 = Error/NotSigned + assert status <= 1, f"Signature verification failed for {installer}: status={status}" + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") def test_example_signing(tmp_path, request): input_path = _example_path("signing") @@ -811,6 +1064,7 @@ def test_example_signing(tmp_path, request): CONSTRUCTOR_SIGNING_CERTIFICATE=str(cert_path), CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD=cert_pwd, ): + _verify_windows_signature(installer) _run_installer(input_path, installer, install_dir, request=request) @@ -921,6 +1175,7 @@ def test_example_from_explicit(tmp_path, request): def test_register_envs(tmp_path, request): + """Verify that 'register_envs: False' results in the environment not being registered.""" input_path = _example_path("register_envs") for installer, install_dir in create_installer(input_path, tmp_path): _run_installer(input_path, installer, install_dir, request=request) @@ -983,6 +1238,7 @@ def test_cross_osx_building(tmp_path): ) +@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix only") def test_cross_build_example(tmp_path, platform_conda_exe): platform, conda_exe = platform_conda_exe input_path = _example_path("virtual_specs_ok") @@ -998,6 +1254,7 @@ def test_cross_build_example(tmp_path, platform_conda_exe): def test_virtual_specs_failed(tmp_path, request): + """Verify that virtual packages listed via 'virtual_specs' are satisfied.""" input_path = _example_path("virtual_specs_failed") for installer, install_dir in create_installer(input_path, tmp_path): process = _run_installer( @@ -1013,6 +1270,16 @@ def test_virtual_specs_failed(tmp_path, request): with pytest.raises(AssertionError, match="Failed to check virtual specs"): _check_installer_log(install_dir) continue + elif installer.suffix == ".msi": + # MSI writes errors to install.log in the install directory + msi_post_install_log = install_dir / "install.log" + if msi_post_install_log.exists(): + log_content = msi_post_install_log.read_text(encoding="utf-8", errors="replace") + assert "Failed to check virtual specs" in log_content + else: + # If log doesn't exist, installation failed before post-install script ran + assert process.returncode != 0 + continue elif installer.suffix == ".pkg": if not ON_CI: continue @@ -1075,6 +1342,8 @@ def test_initialization(tmp_path, request, monkeypatch, method): # GHA runs on an admin user account, but AllUsers (admin) installs # do not add to PATH due to CVE-2022-26526, so force single user install options = ["/AddToPath=1", "/InstallationType=JustMe"] + elif installer.suffix == ".msi": + raise NotImplementedError("Test needs to be implemented") else: options = [] _run_installer( @@ -1108,6 +1377,8 @@ def test_initialization(tmp_path, request, monkeypatch, method): finally: _run_uninstaller_exe(install_dir, check=True) + elif installer.suffix == ".msi": + raise NotImplementedError("Test needs to be implemented") else: # GHA's Ubuntu needs interactive, but macOS wants login :shrug: login_flag = "-i" if sys.platform.startswith("linux") else "-l" @@ -1204,6 +1475,8 @@ def _get_dacl_information(filepath: Path) -> dict: input_path = _example_path("miniforge") for installer, install_dir in create_installer(input_path, tmp_path): + if installer.suffix == ".msi": + continue # TODO: Test currently not applicable for MSI installers _run_installer( input_path, installer, @@ -1473,9 +1746,11 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): """Verify the app is in the Installed Apps Menu (or not), based on the CLI arg '/NoRegistry'. If NoRegistry=0, we expect to find the installer in the Menu, otherwise not. """ - input_path = _example_path("extra_files") # The specific example we use here is not important + input_path = _example_path("register_envs") # The specific example we use here is not important options = ["/InstallationType=JustMe", f"/NoRegistry={no_registry}"] for installer, install_dir in create_installer(input_path, tmp_path): + if installer.suffix == ".msi": + continue _run_installer( input_path, installer, @@ -1486,20 +1761,20 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): options=options, ) - # Use the installer file name for the registry search - installer_file_name_parts = Path(installer).name.split("-") - name = installer_file_name_parts[0] - version = installer_file_name_parts[1] - partial_name = f"{name} {version}" + # Use the installer file name for the registry search + installer_file_name_parts = Path(installer).name.split("-") + name = installer_file_name_parts[0] + version = installer_file_name_parts[1] + partial_name = f"{name} {version}" - is_in_installed_apps_menu = _is_program_installed(partial_name) - _run_uninstaller_exe(install_dir) + is_in_installed_apps_menu = _is_program_installed(partial_name) + _run_uninstaller_exe(install_dir) - # If no_registry=0 we expect is_in_installed_apps_menu=True - # If no_registry=1 we expect is_in_installed_apps_menu=False - assert is_in_installed_apps_menu == (no_registry == 0), ( - f"Unable to find program '{partial_name}' in the 'Installed apps' menu" - ) + # If no_registry=0 we expect is_in_installed_apps_menu=True + # If no_registry=1 we expect is_in_installed_apps_menu=False + assert is_in_installed_apps_menu == (no_registry == 0), ( + f"Unable to find program '{partial_name}' in the 'Installed apps' menu" + ) @pytest.mark.xfail( @@ -1531,18 +1806,22 @@ def test_frozen_environment(tmp_path, request, has_conflict): if has_conflict: config.setdefault("extra_files", []).append({"frozen.json": "conda-meta/frozen"}) - with open(input_path / "construct.yaml", "w") as f: - yaml.dump(config, f) + + with open(input_path / "construct.yaml", "w") as f: + yaml.dump(config, f) with context as c: for installer, install_dir in create_installer(input_path, tmp_path): _run_installer(input_path, installer, install_dir, request=request, uninstall=False) + # MSI installers use a 'base' subdirectory for the conda environment + prefix = install_dir / "base" if installer.suffix == ".msi" else install_dir + expected_frozen = { - install_dir / "conda-meta" / "frozen": config["freeze_base"]["conda"], - install_dir / "envs" / "env1" / "conda-meta" / "frozen": config["extra_envs"][ - "env1" - ]["freeze_env"]["conda"], + prefix / "conda-meta" / "frozen": config["freeze_base"]["conda"], + prefix / "envs" / "env1" / "conda-meta" / "frozen": config["extra_envs"]["env1"][ + "freeze_env" + ]["conda"], } for frozen_path, expected_content in expected_frozen.items(): diff --git a/tests/test_main.py b/tests/test_main.py index 896c7d8a6..e17f69f54 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -7,7 +7,7 @@ def test_dry_run(tmp_path): inputfile = dedent( """ name: test_schema_validation - version: X + version: 1.0.0 installer_type: all channels: - https://repo.anaconda.com/pkgs/main/ diff --git a/tests/test_preconda.py b/tests/test_preconda.py new file mode 100644 index 000000000..8d41a3305 --- /dev/null +++ b/tests/test_preconda.py @@ -0,0 +1,58 @@ +from constructor.preconda import write_condarc + + +def test_write_condarc_with_condarc_dict(tmp_path): + """Test that write_condarc creates .condarc file when condarc is a dict.""" + info = {"condarc": {"channels": ["conda-forge"], "ssl_verify": False}} + write_condarc(info, str(tmp_path)) + + condarc_file = tmp_path / ".condarc" + assert condarc_file.exists() + content = condarc_file.read_text() + assert "channels:" in content + assert "conda-forge" in content + assert "ssl_verify:" in content + + +def test_write_condarc_with_condarc_string(tmp_path): + """Test that write_condarc creates .condarc file when condarc is a string.""" + info = {"condarc": "channels:\n - my-channel\nssl_verify: false\n"} + write_condarc(info, str(tmp_path)) + + condarc_file = tmp_path / ".condarc" + assert condarc_file.exists() + content = condarc_file.read_text() + assert "channels:" in content + assert "my-channel" in content + assert "ssl_verify:" in content + + +def test_write_condarc_with_write_condarc_flag(tmp_path): + """Test legacy write_condarc=True approach.""" + info = { + "write_condarc": True, + "channels": ["defaults"], + } + write_condarc(info, str(tmp_path)) + + condarc_file = tmp_path / ".condarc" + assert condarc_file.exists() + assert "defaults" in condarc_file.read_text() + + +def test_write_condarc_no_content_no_file(tmp_path): + """Test that write_condarc does nothing when no condarc is configured.""" + info = {} + write_condarc(info, str(tmp_path)) + + condarc_file = tmp_path / ".condarc" + assert not condarc_file.exists() + + +def test_write_condarc_write_condarc_without_channels(tmp_path): + """Test that write_condarc does nothing when write_condarc=True but no channels.""" + info = {"write_condarc": True} + write_condarc(info, str(tmp_path)) + + condarc_file = tmp_path / ".condarc" + assert not condarc_file.exists() diff --git a/tests/test_utils.py b/tests/test_utils.py index 56f88e4c7..c646589b3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,12 @@ from os import sep -from constructor.utils import make_VIProductVersion, normalize_path +from constructor.utils import ( + bat_echo_esc, + bat_env_var_esc, + get_condarc_content, + make_VIProductVersion, + normalize_path, +) def test_make_VIProductVersion(): @@ -20,3 +26,84 @@ def test_normalize_path(): path = "test///test/test".replace("/", sep) assert normalize_path(path) == "test/test/test".replace("/", sep) + + +def test_bat_env_var_esc(): + """Test escaping strings for Windows batch file SET commands.""" + # Plain strings pass through unchanged + assert bat_env_var_esc("foo") == "foo" + assert bat_env_var_esc("foo bar") == "foo bar" + + # Single quotes are allowed (literal in batch) + assert bat_env_var_esc("foo 'bar'") == "foo 'bar'" + + # Special characters are escaped + assert bat_env_var_esc("50%") == "50%%" + assert bat_env_var_esc("a & b") == "a ^& b" + assert bat_env_var_esc("a | b") == "a ^| b" + assert bat_env_var_esc("a < b > c") == "a ^< b ^> c" + assert bat_env_var_esc("a^b") == "a^^b" + assert bat_env_var_esc("foo!") == "foo^!" + + # Combined special characters + assert bat_env_var_esc("100% & done!") == "100%% ^& done^!" + + +def test_bat_echo_esc(): + """Test escaping strings for Windows batch file ECHO commands.""" + # Plain strings pass through unchanged + assert bat_echo_esc("foo") == "foo" + assert bat_echo_esc("foo bar") == "foo bar" + + # Special characters are escaped + assert bat_echo_esc("a & b") == "a ^& b" + assert bat_echo_esc("a | b") == "a ^| b" + assert bat_echo_esc("a < b > c") == "a ^< b ^> c" + assert bat_echo_esc("a^b") == "a^^b" + + # Percent and exclamation are NOT escaped (unlike bat_env_var_esc) + assert bat_echo_esc("50%") == "50%" + assert bat_echo_esc("foo!") == "foo!" + + # Virtual specs examples + assert bat_echo_esc("__win<0") == "__win^<0" + assert bat_echo_esc("__cuda>=11") == "__cuda^>=11" + assert bat_echo_esc("__win<0 __cuda>=11") == "__win^<0 __cuda^>=11" + + +def test_get_condarc_content_with_write_condarc(): + """Test that get_condarc_content returns YAML content when write_condarc is True.""" + info = { + "write_condarc": True, + "channels": ["conda-forge", "defaults"], + } + content = get_condarc_content(info) + assert content is not None + assert "channels:" in content + assert "conda-forge" in content + assert "defaults" in content + + +def test_get_condarc_content_with_condarc_dict(): + """Test that get_condarc_content returns YAML content when condarc is a dict.""" + info = { + "condarc": { + "channels": ["my-channel"], + "ssl_verify": False, + }, + } + content = get_condarc_content(info) + assert content is not None + assert "channels:" in content + assert "my-channel" in content + assert "ssl_verify:" in content + + +def test_get_condarc_content_returns_none(): + """Test that get_condarc_content returns None when no condarc settings are provided.""" + info = {} + assert get_condarc_content(info) is None + + # write_condarc without channels should also return None + info = {"write_condarc": True} + assert get_condarc_content(info) is None