From 3127731933d19676624078d94f47cdab52986125 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 14 May 2026 13:09:10 -0400 Subject: [PATCH 01/12] Update write_images and add tests --- constructor/imaging.py | 22 ++++++++++++++++++---- tests/test_imaging.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/constructor/imaging.py b/constructor/imaging.py index b6ecd3347..2fe1cb4f6 100644 --- a/constructor/imaging.py +++ b/constructor/imaging.py @@ -25,6 +25,11 @@ icon_size = 256, 256 # These are for OSX welcome_size_osx = 1227, 600 +# MSI/WiX image sizes +# TODO: MSI welcome/header have different aspect ratios than EXE (landscape vs portrait). +# Auto-resize will stretch images. May need to revisit scaling strategy if results are poor. +welcome_size_msi = (493, 312) +header_size_msi = (493, 58) def new_background(size, color, bs=20, boxes=50): @@ -99,19 +104,28 @@ 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 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": + instructions = [ + ("welcome", welcome_size_msi, mk_welcome_image, ".bmp"), + ("header", header_size_msi, mk_header_image, ".bmp"), + ("icon", icon_size, mk_icon_image, ".ico"), + ] 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" diff --git a/tests/test_imaging.py b/tests/test_imaging.py index 23046e683..890de2013 100644 --- a/tests/test_imaging.py +++ b/tests/test_imaging.py @@ -1,3 +1,4 @@ +import os import shutil import sys import tempfile @@ -5,6 +6,8 @@ import pytest if sys.platform == "win32" or sys.platform == "darwin": + from PIL import Image + from constructor.imaging import write_images @@ -25,5 +28,37 @@ def test_write_images(): shutil.rmtree(tmp_dir) +@pytest.mark.skipif( + sys.platform != "win32" and sys.platform != "darwin", + reason="imaging only available on Windows and MacOS", +) +def test_write_images_msi(tmp_path): + """Test that write_images generates correct MSI branding images.""" + info = {"name": "test", "version": "0.3.1"} + for key in ("welcome_image_text", "header_image_text"): + if key not in info: + info[key] = info["name"] + + write_images(info, str(tmp_path), installer_type="msi") + + # Verify welcome.bmp exists with correct dimensions (493x312) + welcome_path = tmp_path / "welcome.bmp" + assert welcome_path.exists(), "welcome.bmp not created" + with Image.open(welcome_path) as img: + assert img.size == (493, 312), f"welcome.bmp wrong size: {img.size}" + + # Verify header.bmp exists with correct dimensions (493x58) + header_path = tmp_path / "header.bmp" + assert header_path.exists(), "header.bmp not created" + with Image.open(header_path) as img: + assert img.size == (493, 58), f"header.bmp wrong size: {img.size}" + + # Verify icon.ico exists with correct dimensions (256x256) + icon_path = tmp_path / "icon.ico" + assert icon_path.exists(), "icon.ico not created" + with Image.open(icon_path) as img: + assert img.size == (256, 256), f"icon.ico wrong size: {img.size}" + + if __name__ == "__main__": test_write_images() From f86250b0ecef22d66b5084290f5fc470af156c17 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 14 May 2026 13:10:42 -0400 Subject: [PATCH 02/12] Update calls to write_images --- constructor/osxpkg.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/constructor/osxpkg.py b/constructor/osxpkg.py index e2ed9bdef..3174cb884 100644 --- a/constructor/osxpkg.py +++ b/constructor/osxpkg.py @@ -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 From c99574bb5a5be59b5a6782556f663698f2cbedbe Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 14 May 2026 13:16:09 -0400 Subject: [PATCH 03/12] Add second test --- tests/test_briefcase.py | 61 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 5e7343edd..0f931cdf8 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -1010,3 +1010,64 @@ def test_stage_user_scripts_validates_bat_extension(tmp_path): 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="auto-generated-images"), + ], +) +def test_payload_pyproject_toml_installer_images(tmp_path, has_user_images): + """Test that pyproject.toml contains branding image paths. + + Both user-provided and auto-generated images should result in + installer_background, installer_banner, and icon keys being present. + """ + import tomllib + + 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) + else: + # Ensure text options are set for auto-generation + info["welcome_image_text"] = "Test" + info["header_image_text"] = "Test" + + 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] + + # Verify branding paths are present + assert "installer_background" in app, "installer_background missing from pyproject.toml" + assert "installer_banner" in app, "installer_banner missing from pyproject.toml" + assert "icon" in app, "icon missing from pyproject.toml" + + # Verify paths are absolute and 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 From 651d9700bef567e685c57a3ff3483fb87b3f3550 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 14 May 2026 13:31:25 -0400 Subject: [PATCH 04/12] Add image generation to MSI installers --- constructor/briefcase.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 97130dfb1..d95bee38d 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -21,6 +21,7 @@ tomli_w = None # This file is only intended for Windows use from . import preconda +from .imaging import write_images from .jinja import render_template from .signing import create_windows_signing_tool from .utils import ( @@ -36,6 +37,13 @@ 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) +# - readme_file / readme_text (readme page) +# - 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" @@ -375,6 +383,15 @@ def prepare(self) -> None: external_dir = self.root / EXTERNAL_PACKAGE_PATH external_dir.mkdir(parents=True, exist_ok=True) + # Generate branding images for MSI installer + write_images(self.info, external_dir, installer_type="msi") + + # Verify all branding images were generated + for image_name in ("welcome.bmp", "header.bmp", "icon.ico"): + image_path = external_dir / image_name + if not image_path.exists(): + raise RuntimeError(f"Failed to generate branding image: {image_path}") + # Note that the directory name "base" is also explicitly defined in `run_installation.bat` base_dir = external_dir / "base" base_dir.mkdir() @@ -512,6 +529,11 @@ def write_pyproject_toml(self, root: Path, external: Path) -> None: "uninstall_option": create_uninstall_options_list(self.info), "post_install_script": str(root / "run_installation.bat"), "pre_uninstall_script": str(root / "pre_uninstall.bat"), + # Branding images (generated by write_images in prepare()) + "installer_background": str(external / "welcome.bmp"), + "installer_banner": str(external / "header.bmp"), + # Briefcase expects icon path WITHOUT extension - it appends .ico + "icon": str(external / "icon"), } }, } From 3ceb47dda8a505521b333703706c2a444dc7179d Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 14 May 2026 13:32:14 -0400 Subject: [PATCH 05/12] Remove obsolete old code --- constructor/imaging.py | 10 ---------- examples/miniconda/bird.png | Bin 12203 -> 0 bytes 2 files changed, 10 deletions(-) delete mode 100644 examples/miniconda/bird.png diff --git a/constructor/imaging.py b/constructor/imaging.py index 2fe1cb4f6..982f3678d 100644 --- a/constructor/imaging.py +++ b/constructor/imaging.py @@ -137,13 +137,3 @@ def write_images(info, dir_path, installer_type="exe"): im = function(info) 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, ".") diff --git a/examples/miniconda/bird.png b/examples/miniconda/bird.png deleted file mode 100644 index 2efe0f30aa3e3de5d76f1ca9165ae8c30bbf0866..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12203 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 Date: Thu, 14 May 2026 13:33:59 -0400 Subject: [PATCH 06/12] Update docs --- CONSTRUCT.md | 12 ++++++++---- constructor/_schema.py | 12 ++++++++---- docs/source/construct-yaml.md | 12 ++++++++---- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 72e518ba7..fca734859 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -585,9 +585,9 @@ 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. +begins the installation process. (Not supported for MSI installers.) ### `welcome_text` @@ -595,7 +595,7 @@ If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the introduction. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether so it defaults to the system message -if you set this key to `""` (empty string). +if you set this key to `""` (empty string). (Not supported for MSI installers.) ### `readme_file` @@ -603,6 +603,7 @@ If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `readme_file` and `readme_text` are provided, `readme_file` takes precedence. +(Not supported for MSI installers.) ### `readme_text` @@ -610,6 +611,7 @@ If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether if you set this key to `""` (empty string). +(Not supported for MSI installers.) ### `post_install_pages` @@ -630,7 +632,8 @@ plain text (.txt), rich text (.rtf) or HTML (.html). If both `conclusion_file` and `conclusion_text` are provided, `conclusion_file` takes precedence. -If the installer is for Windows, the file type must be nsi. +If the installer is for Windows EXE, the file type must be nsi. +(Not supported for MSI installers.) ### `conclusion_text` @@ -640,6 +643,7 @@ The behaviour is slightly different across installer types: You can disable it altogether so it defaults to the system message if you set this key to `""` (empty string). - EXE: The first line will be used as a title. The following lines will be used as text. +- MSI: Not supported. ### `extra_files` diff --git a/constructor/_schema.py b/constructor/_schema.py index 4019e6e70..52b922f0b 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -755,9 +755,9 @@ 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. + begins the installation process. (Not supported for MSI installers.) """ welcome_text: str | None = None """ @@ -765,7 +765,7 @@ class ConstructorConfiguration(BaseModel): shown before the license information, right after the introduction. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether so it defaults to the system message - if you set this key to `""` (empty string). + if you set this key to `""` (empty string). (Not supported for MSI installers.) """ readme_file: NonEmptyStr | None = None """ @@ -773,6 +773,7 @@ class ConstructorConfiguration(BaseModel): shown before the license information, right after the welcome screen. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `readme_file` and `readme_text` are provided, `readme_file` takes precedence. + (Not supported for MSI installers.) """ readme_text: str | None = None """ @@ -780,6 +781,7 @@ class ConstructorConfiguration(BaseModel): shown before the license information, right after the welcome screen. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether if you set this key to `""` (empty string). + (Not supported for MSI installers.) """ post_install_pages: NonEmptyStr | list[NonEmptyStr] | None = None """ @@ -800,7 +802,8 @@ class ConstructorConfiguration(BaseModel): `conclusion_file` and `conclusion_text` are provided, `conclusion_file` takes precedence. - If the installer is for Windows, the file type must be nsi. + If the installer is for Windows EXE, the file type must be nsi. + (Not supported for MSI installers.) """ conclusion_text: str | None = None """ @@ -810,6 +813,7 @@ class ConstructorConfiguration(BaseModel): You can disable it altogether so it defaults to the system message if you set this key to `""` (empty string). - EXE: The first line will be used as a title. The following lines will be used as text. + - MSI: Not supported. """ extra_files: list[NonEmptyStr | dict[NonEmptyStr, NonEmptyStr]] = [] """ diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 72e518ba7..fca734859 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -585,9 +585,9 @@ 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. +begins the installation process. (Not supported for MSI installers.) ### `welcome_text` @@ -595,7 +595,7 @@ If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the introduction. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether so it defaults to the system message -if you set this key to `""` (empty string). +if you set this key to `""` (empty string). (Not supported for MSI installers.) ### `readme_file` @@ -603,6 +603,7 @@ If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `readme_file` and `readme_text` are provided, `readme_file` takes precedence. +(Not supported for MSI installers.) ### `readme_text` @@ -610,6 +611,7 @@ If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether if you set this key to `""` (empty string). +(Not supported for MSI installers.) ### `post_install_pages` @@ -630,7 +632,8 @@ plain text (.txt), rich text (.rtf) or HTML (.html). If both `conclusion_file` and `conclusion_text` are provided, `conclusion_file` takes precedence. -If the installer is for Windows, the file type must be nsi. +If the installer is for Windows EXE, the file type must be nsi. +(Not supported for MSI installers.) ### `conclusion_text` @@ -640,6 +643,7 @@ The behaviour is slightly different across installer types: You can disable it altogether so it defaults to the system message if you set this key to `""` (empty string). - EXE: The first line will be used as a title. The following lines will be used as text. +- MSI: Not supported. ### `extra_files` From 1082e19baae4dda6004628109a4c076fb4ca7980 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 14 May 2026 13:39:35 -0400 Subject: [PATCH 07/12] Update formatting and add comment --- constructor/briefcase.py | 4 +++- constructor/imaging.py | 3 +-- tests/test_briefcase.py | 4 +++- tests/test_imaging.py | 1 - 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/constructor/briefcase.py b/constructor/briefcase.py index d95bee38d..7d2e3595b 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -17,11 +17,13 @@ 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 .imaging import write_images from .jinja import render_template from .signing import create_windows_signing_tool from .utils import ( diff --git a/constructor/imaging.py b/constructor/imaging.py index 982f3678d..40f18bc7c 100644 --- a/constructor/imaging.py +++ b/constructor/imaging.py @@ -123,8 +123,7 @@ def write_images(info, dir_path, installer_type="exe"): ] else: raise ValueError( - f"Installer type '{installer_type}' not supported. " - "Choose 'exe', 'pkg', or 'msi'." + f"Installer type '{installer_type}' not supported. Choose 'exe', 'pkg', or 'msi'." ) for name, size, function, ext in instructions: diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 0f931cdf8..0f3dcd062 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -1033,7 +1033,9 @@ def test_payload_pyproject_toml_installer_images(tmp_path, has_user_images): 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" + 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) diff --git a/tests/test_imaging.py b/tests/test_imaging.py index 890de2013..13bd6892d 100644 --- a/tests/test_imaging.py +++ b/tests/test_imaging.py @@ -1,4 +1,3 @@ -import os import shutil import sys import tempfile From 36d912cdedf0781bc7df5a45a2e600ee2c51b4b2 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 14 May 2026 13:45:12 -0400 Subject: [PATCH 08/12] Add news --- news/1235-msi-branding | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 news/1235-msi-branding 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 + +* From 52b5eb4e92c914a78acad284f73100ae49455cee Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 14 May 2026 14:24:03 -0400 Subject: [PATCH 09/12] Make fixes for issues visible from tests --- constructor/data/construct.schema.json | 12 ++++++------ constructor/imaging.py | 12 ++++++------ tests/test_briefcase.py | 8 +++++++- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index 802810da7..9e3d968ea 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -513,7 +513,7 @@ } ], "default": null, - "description": "If `installer_type` is `pkg` on macOS, this message will be shown at the end of the installer upon success. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `conclusion_file` and `conclusion_text` are provided, `conclusion_file` takes precedence.\nIf the installer is for Windows, the file type must be nsi.", + "description": "If `installer_type` is `pkg` on macOS, this message will be shown at the end of the installer upon success. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `conclusion_file` and `conclusion_text` are provided, `conclusion_file` takes precedence.\nIf the installer is for Windows EXE, the file type must be nsi. (Not supported for MSI installers.)", "title": "Conclusion File" }, "conclusion_text": { @@ -526,7 +526,7 @@ } ], "default": null, - "description": "A message that will be shown at the end of the installer upon success. The behaviour is slightly different across installer types:\n- PKG: If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether so it defaults to the system message if you set this key to `\"\"` (empty string).\n- EXE: The first line will be used as a title. The following lines will be used as text.", + "description": "A message that will be shown at the end of the installer upon success. The behaviour is slightly different across installer types:\n- PKG: If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether so it defaults to the system message if you set this key to `\"\"` (empty string).\n- EXE: The first line will be used as a title. The following lines will be used as text.\n- MSI: Not supported.", "title": "Conclusion Text" }, "conda_channel_alias": { @@ -1093,7 +1093,7 @@ } ], "default": null, - "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `readme_file` and `readme_text` are provided, `readme_file` takes precedence.", + "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `readme_file` and `readme_text` are provided, `readme_file` takes precedence. (Not supported for MSI installers.)", "title": "Readme File" }, "readme_text": { @@ -1106,7 +1106,7 @@ } ], "default": null, - "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether if you set this key to `\"\"` (empty string).", + "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether if you set this key to `\"\"` (empty string). (Not supported for MSI installers.)", "title": "Readme Text" }, "register_envs": { @@ -1310,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. (Not supported for MSI installers.)", "title": "Welcome File" }, "welcome_image": { @@ -1350,7 +1350,7 @@ } ], "default": null, - "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the introduction. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether so it defaults to the system message if you set this key to `\"\"` (empty string).", + "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the introduction. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether so it defaults to the system message if you set this key to `\"\"` (empty string). (Not supported for MSI installers.)", "title": "Welcome Text" }, "windows_signing_tool": { diff --git a/constructor/imaging.py b/constructor/imaging.py index 40f18bc7c..a24bd7734 100644 --- a/constructor/imaging.py +++ b/constructor/imaging.py @@ -56,9 +56,9 @@ def add_text(im, xy, text, min_lines, line_height, font, color): return d -def mk_welcome_image(info): +def mk_welcome_image(info, size=welcome_size): font = ImageFont.truetype(BytesIO(ttf_bytes), 20) - im = new_background(welcome_size, info["_color"]) + im = new_background(size, info["_color"]) text = "\n".join([info["welcome_image_text"], info["version"]]) add_text(im, (20, 100), text, 2, 30, font, white) return im @@ -73,9 +73,9 @@ def mk_welcome_image_osx(info): return im -def mk_header_image(info): +def mk_header_image(info, size=header_size): font = ImageFont.truetype(BytesIO(ttf_bytes), 20) - im = Image.new("RGB", header_size, color=white) + im = Image.new("RGB", size, color=white) text = info["header_image_text"] color = info["_color"] add_text(im, (20, 15), text, 1, 20, font, color) @@ -117,8 +117,8 @@ def write_images(info, dir_path, installer_type="exe"): ] elif installer_type == "msi": instructions = [ - ("welcome", welcome_size_msi, mk_welcome_image, ".bmp"), - ("header", header_size_msi, mk_header_image, ".bmp"), + ("welcome", welcome_size_msi, lambda info: mk_welcome_image(info, welcome_size_msi), ".bmp"), + ("header", header_size_msi, lambda info: mk_header_image(info, header_size_msi), ".bmp"), ("icon", icon_size, mk_icon_image, ".ico"), ] else: diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 0f3dcd062..57a44b771 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -27,6 +27,9 @@ "_dists": [], "_platform": cc_platform, "_urls": [], + # Required for auto-generating branding images + "welcome_image_text": "MockInfo", + "header_image_text": "MockInfo", } @@ -1026,7 +1029,10 @@ def test_payload_pyproject_toml_installer_images(tmp_path, has_user_images): Both user-provided and auto-generated images should result in installer_background, installer_banner, and icon keys being present. """ - import tomllib + try: + import tomllib + except ModuleNotFoundError: + import tomli as tomllib info = mock_info.copy() From 9db0b081c528880ae5c600af78fbb4b48e658753 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 14 May 2026 14:24:23 -0400 Subject: [PATCH 10/12] pre-commit fix --- constructor/imaging.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/constructor/imaging.py b/constructor/imaging.py index a24bd7734..5b1f3457e 100644 --- a/constructor/imaging.py +++ b/constructor/imaging.py @@ -117,8 +117,18 @@ def write_images(info, dir_path, installer_type="exe"): ] elif installer_type == "msi": instructions = [ - ("welcome", welcome_size_msi, lambda info: mk_welcome_image(info, welcome_size_msi), ".bmp"), - ("header", header_size_msi, lambda info: mk_header_image(info, header_size_msi), ".bmp"), + ( + "welcome", + welcome_size_msi, + lambda info: mk_welcome_image(info, welcome_size_msi), + ".bmp", + ), + ( + "header", + header_size_msi, + lambda info: mk_header_image(info, header_size_msi), + ".bmp", + ), ("icon", icon_size, mk_icon_image, ".ico"), ] else: From a886adda949c70559e5a6b1462817cd4d2efbd38 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 18 May 2026 09:46:12 -0400 Subject: [PATCH 11/12] review fixes --- CONSTRUCT.md | 12 ++++-------- constructor/_schema.py | 12 ++++-------- constructor/briefcase.py | 1 - docs/source/construct-yaml.md | 12 ++++-------- 4 files changed, 12 insertions(+), 25 deletions(-) diff --git a/CONSTRUCT.md b/CONSTRUCT.md index fca734859..72e518ba7 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -585,9 +585,9 @@ 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 EXE and the welcome file type is nsi, +If 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. (Not supported for MSI installers.) +begins the installation process. ### `welcome_text` @@ -595,7 +595,7 @@ If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the introduction. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether so it defaults to the system message -if you set this key to `""` (empty string). (Not supported for MSI installers.) +if you set this key to `""` (empty string). ### `readme_file` @@ -603,7 +603,6 @@ If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `readme_file` and `readme_text` are provided, `readme_file` takes precedence. -(Not supported for MSI installers.) ### `readme_text` @@ -611,7 +610,6 @@ If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether if you set this key to `""` (empty string). -(Not supported for MSI installers.) ### `post_install_pages` @@ -632,8 +630,7 @@ plain text (.txt), rich text (.rtf) or HTML (.html). If both `conclusion_file` and `conclusion_text` are provided, `conclusion_file` takes precedence. -If the installer is for Windows EXE, the file type must be nsi. -(Not supported for MSI installers.) +If the installer is for Windows, the file type must be nsi. ### `conclusion_text` @@ -643,7 +640,6 @@ The behaviour is slightly different across installer types: You can disable it altogether so it defaults to the system message if you set this key to `""` (empty string). - EXE: The first line will be used as a title. The following lines will be used as text. -- MSI: Not supported. ### `extra_files` diff --git a/constructor/_schema.py b/constructor/_schema.py index 52b922f0b..4019e6e70 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -755,9 +755,9 @@ 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 EXE and the welcome file type is nsi, + If 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. (Not supported for MSI installers.) + begins the installation process. """ welcome_text: str | None = None """ @@ -765,7 +765,7 @@ class ConstructorConfiguration(BaseModel): shown before the license information, right after the introduction. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether so it defaults to the system message - if you set this key to `""` (empty string). (Not supported for MSI installers.) + if you set this key to `""` (empty string). """ readme_file: NonEmptyStr | None = None """ @@ -773,7 +773,6 @@ class ConstructorConfiguration(BaseModel): shown before the license information, right after the welcome screen. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `readme_file` and `readme_text` are provided, `readme_file` takes precedence. - (Not supported for MSI installers.) """ readme_text: str | None = None """ @@ -781,7 +780,6 @@ class ConstructorConfiguration(BaseModel): shown before the license information, right after the welcome screen. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether if you set this key to `""` (empty string). - (Not supported for MSI installers.) """ post_install_pages: NonEmptyStr | list[NonEmptyStr] | None = None """ @@ -802,8 +800,7 @@ class ConstructorConfiguration(BaseModel): `conclusion_file` and `conclusion_text` are provided, `conclusion_file` takes precedence. - If the installer is for Windows EXE, the file type must be nsi. - (Not supported for MSI installers.) + If the installer is for Windows, the file type must be nsi. """ conclusion_text: str | None = None """ @@ -813,7 +810,6 @@ class ConstructorConfiguration(BaseModel): You can disable it altogether so it defaults to the system message if you set this key to `""` (empty string). - EXE: The first line will be used as a title. The following lines will be used as text. - - MSI: Not supported. """ extra_files: list[NonEmptyStr | dict[NonEmptyStr, NonEmptyStr]] = [] """ diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 7d2e3595b..55ef16256 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -43,7 +43,6 @@ # 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) -# - readme_file / readme_text (readme page) # - 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 diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index fca734859..72e518ba7 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -585,9 +585,9 @@ 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 EXE and the welcome file type is nsi, +If 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. (Not supported for MSI installers.) +begins the installation process. ### `welcome_text` @@ -595,7 +595,7 @@ If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the introduction. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether so it defaults to the system message -if you set this key to `""` (empty string). (Not supported for MSI installers.) +if you set this key to `""` (empty string). ### `readme_file` @@ -603,7 +603,6 @@ If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `readme_file` and `readme_text` are provided, `readme_file` takes precedence. -(Not supported for MSI installers.) ### `readme_text` @@ -611,7 +610,6 @@ If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether if you set this key to `""` (empty string). -(Not supported for MSI installers.) ### `post_install_pages` @@ -632,8 +630,7 @@ plain text (.txt), rich text (.rtf) or HTML (.html). If both `conclusion_file` and `conclusion_text` are provided, `conclusion_file` takes precedence. -If the installer is for Windows EXE, the file type must be nsi. -(Not supported for MSI installers.) +If the installer is for Windows, the file type must be nsi. ### `conclusion_text` @@ -643,7 +640,6 @@ The behaviour is slightly different across installer types: You can disable it altogether so it defaults to the system message if you set this key to `""` (empty string). - EXE: The first line will be used as a title. The following lines will be used as text. -- MSI: Not supported. ### `extra_files` From 3af4b48206dfe5765b0dc8198128d97f3d617e8e Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 18 May 2026 15:09:28 -0400 Subject: [PATCH 12/12] Review fixes, update docs, remove invalid test --- CONSTRUCT.md | 4 +- constructor/_schema.py | 4 +- constructor/briefcase.py | 25 +++++----- constructor/data/construct.schema.json | 14 +++--- constructor/imaging.py | 64 +++++++++++++++++--------- docs/source/construct-yaml.md | 4 +- tests/test_briefcase.py | 60 +++++++++++++----------- tests/test_imaging.py | 34 -------------- 8 files changed, 105 insertions(+), 104 deletions(-) diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 72e518ba7..15c92123d 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -493,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). diff --git a/constructor/_schema.py b/constructor/_schema.py index 4019e6e70..2066b99f3 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -663,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). diff --git a/constructor/briefcase.py b/constructor/briefcase.py index 55ef16256..99f08c365 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -384,15 +384,9 @@ def prepare(self) -> None: external_dir = self.root / EXTERNAL_PACKAGE_PATH external_dir.mkdir(parents=True, exist_ok=True) - # Generate branding images for MSI installer + # Generate branding images for MSI installer (only if user provided custom images) write_images(self.info, external_dir, installer_type="msi") - # Verify all branding images were generated - for image_name in ("welcome.bmp", "header.bmp", "icon.ico"): - image_path = external_dir / image_name - if not image_path.exists(): - raise RuntimeError(f"Failed to generate branding image: {image_path}") - # Note that the directory name "base" is also explicitly defined in `run_installation.bat` base_dir = external_dir / "base" base_dir.mkdir() @@ -530,15 +524,22 @@ def write_pyproject_toml(self, root: Path, external: Path) -> None: "uninstall_option": create_uninstall_options_list(self.info), "post_install_script": str(root / "run_installation.bat"), "pre_uninstall_script": str(root / "pre_uninstall.bat"), - # Branding images (generated by write_images in prepare()) - "installer_background": str(external / "welcome.bmp"), - "installer_banner": str(external / "header.bmp"), - # Briefcase expects icon path WITHOUT extension - it appends .ico - "icon": str(external / "icon"), } }, } + # 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"] diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index 9e3d968ea..63ffbda0e 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -513,7 +513,7 @@ } ], "default": null, - "description": "If `installer_type` is `pkg` on macOS, this message will be shown at the end of the installer upon success. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `conclusion_file` and `conclusion_text` are provided, `conclusion_file` takes precedence.\nIf the installer is for Windows EXE, the file type must be nsi. (Not supported for MSI installers.)", + "description": "If `installer_type` is `pkg` on macOS, this message will be shown at the end of the installer upon success. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `conclusion_file` and `conclusion_text` are provided, `conclusion_file` takes precedence.\nIf the installer is for Windows, the file type must be nsi.", "title": "Conclusion File" }, "conclusion_text": { @@ -526,7 +526,7 @@ } ], "default": null, - "description": "A message that will be shown at the end of the installer upon success. The behaviour is slightly different across installer types:\n- PKG: If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether so it defaults to the system message if you set this key to `\"\"` (empty string).\n- EXE: The first line will be used as a title. The following lines will be used as text.\n- MSI: Not supported.", + "description": "A message that will be shown at the end of the installer upon success. The behaviour is slightly different across installer types:\n- PKG: If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether so it defaults to the system message if you set this key to `\"\"` (empty string).\n- EXE: The first line will be used as a title. The following lines will be used as text.", "title": "Conclusion Text" }, "conda_channel_alias": { @@ -1093,7 +1093,7 @@ } ], "default": null, - "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `readme_file` and `readme_text` are provided, `readme_file` takes precedence. (Not supported for MSI installers.)", + "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. File can be plain text (.txt), rich text (.rtf) or HTML (.html). If both `readme_file` and `readme_text` are provided, `readme_file` takes precedence.", "title": "Readme File" }, "readme_text": { @@ -1106,7 +1106,7 @@ } ], "default": null, - "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether if you set this key to `\"\"` (empty string). (Not supported for MSI installers.)", + "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the welcome screen. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether if you set this key to `\"\"` (empty string).", "title": "Readme Text" }, "register_envs": { @@ -1310,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 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. (Not supported for MSI installers.)", + "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.", "title": "Welcome File" }, "welcome_image": { @@ -1323,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": { @@ -1350,7 +1350,7 @@ } ], "default": null, - "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the introduction. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether so it defaults to the system message if you set this key to `\"\"` (empty string). (Not supported for MSI installers.)", + "description": "If `installer_type` is `pkg` on macOS, this message will be shown before the license information, right after the introduction. If this key is missing, it defaults to a message about Anaconda Cloud. You can disable it altogether so it defaults to the system message if you set this key to `\"\"` (empty string).", "title": "Welcome Text" }, "windows_signing_tool": { diff --git a/constructor/imaging.py b/constructor/imaging.py index 5b1f3457e..31961ec87 100644 --- a/constructor/imaging.py +++ b/constructor/imaging.py @@ -26,9 +26,10 @@ # These are for OSX welcome_size_osx = 1227, 600 # MSI/WiX image sizes -# TODO: MSI welcome/header have different aspect ratios than EXE (landscape vs portrait). -# Auto-resize will stretch images. May need to revisit scaling strategy if results are poor. +# 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) @@ -56,9 +57,9 @@ def add_text(im, xy, text, min_lines, line_height, font, color): return d -def mk_welcome_image(info, size=welcome_size): +def mk_welcome_image(info): font = ImageFont.truetype(BytesIO(ttf_bytes), 20) - im = new_background(size, info["_color"]) + im = new_background(welcome_size, info["_color"]) text = "\n".join([info["welcome_image_text"], info["version"]]) add_text(im, (20, 100), text, 2, 30, font, white) return im @@ -73,9 +74,9 @@ def mk_welcome_image_osx(info): return im -def mk_header_image(info, size=header_size): +def mk_header_image(info): font = ImageFont.truetype(BytesIO(ttf_bytes), 20) - im = Image.new("RGB", size, color=white) + im = Image.new("RGB", header_size, color=white) text = info["header_image_text"] color = info["_color"] add_text(im, (20, 15), text, 1, 20, font, color) @@ -104,6 +105,25 @@ def add_color_info(info): sys.exit("Error: color '%s' not defined" % color_name) +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 = [ @@ -116,21 +136,8 @@ def write_images(info, dir_path, installer_type="exe"): ("welcome", welcome_size_osx, mk_welcome_image_osx, ".png"), ] elif installer_type == "msi": - instructions = [ - ( - "welcome", - welcome_size_msi, - lambda info: mk_welcome_image(info, welcome_size_msi), - ".bmp", - ), - ( - "header", - header_size_msi, - lambda info: mk_header_image(info, header_size_msi), - ".bmp", - ), - ("icon", icon_size, mk_icon_image, ".ico"), - ] + # MSI uses WiX defaults; user-provided images handled separately below + instructions = [] else: raise ValueError( f"Installer type '{installer_type}' not supported. Choose 'exe', 'pkg', or 'msi'." @@ -146,3 +153,18 @@ def write_images(info, dir_path, installer_type="exe"): im = function(info) assert im.size == size im.save(join(dir_path, name + ext)) + + # 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/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 72e518ba7..15c92123d 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -493,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). diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py index 57a44b771..6eda836d5 100644 --- a/tests/test_briefcase.py +++ b/tests/test_briefcase.py @@ -2,6 +2,11 @@ import tarfile from pathlib import Path +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + import pytest from constructor.briefcase import ( @@ -1020,20 +1025,15 @@ def test_stage_user_scripts_validates_bat_extension(tmp_path): "has_user_images", [ pytest.param(True, id="user-provided-images"), - pytest.param(False, id="auto-generated-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. + """Test that pyproject.toml contains branding image paths only when user provides them. - Both user-provided and auto-generated images should result in - installer_background, installer_banner, and icon keys being present. + MSI installers only include branding images if user explicitly provides them. + Otherwise, WiX defaults are used. """ - try: - import tomllib - except ModuleNotFoundError: - import tomli as tomllib - info = mock_info.copy() if has_user_images: @@ -1047,10 +1047,6 @@ def test_payload_pyproject_toml_installer_images(tmp_path, has_user_images): info["welcome_image"] = str(example_image) info["header_image"] = str(example_image) info["icon_image"] = str(example_image) - else: - # Ensure text options are set for auto-generation - info["welcome_image_text"] = "Test" - info["header_image_text"] = "Test" payload = Payload(info) payload.prepare() @@ -1065,17 +1061,27 @@ def test_payload_pyproject_toml_installer_images(tmp_path, has_user_images): app_name = list(app_config.keys())[0] app = app_config[app_name] - # Verify branding paths are present - assert "installer_background" in app, "installer_background missing from pyproject.toml" - assert "installer_banner" in app, "installer_banner missing from pyproject.toml" - assert "icon" in app, "icon missing from pyproject.toml" - - # Verify paths are absolute and 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 + 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_imaging.py b/tests/test_imaging.py index 13bd6892d..23046e683 100644 --- a/tests/test_imaging.py +++ b/tests/test_imaging.py @@ -5,8 +5,6 @@ import pytest if sys.platform == "win32" or sys.platform == "darwin": - from PIL import Image - from constructor.imaging import write_images @@ -27,37 +25,5 @@ def test_write_images(): shutil.rmtree(tmp_dir) -@pytest.mark.skipif( - sys.platform != "win32" and sys.platform != "darwin", - reason="imaging only available on Windows and MacOS", -) -def test_write_images_msi(tmp_path): - """Test that write_images generates correct MSI branding images.""" - info = {"name": "test", "version": "0.3.1"} - for key in ("welcome_image_text", "header_image_text"): - if key not in info: - info[key] = info["name"] - - write_images(info, str(tmp_path), installer_type="msi") - - # Verify welcome.bmp exists with correct dimensions (493x312) - welcome_path = tmp_path / "welcome.bmp" - assert welcome_path.exists(), "welcome.bmp not created" - with Image.open(welcome_path) as img: - assert img.size == (493, 312), f"welcome.bmp wrong size: {img.size}" - - # Verify header.bmp exists with correct dimensions (493x58) - header_path = tmp_path / "header.bmp" - assert header_path.exists(), "header.bmp not created" - with Image.open(header_path) as img: - assert img.size == (493, 58), f"header.bmp wrong size: {img.size}" - - # Verify icon.ico exists with correct dimensions (256x256) - icon_path = tmp_path / "icon.ico" - assert icon_path.exists(), "icon.ico not created" - with Image.open(icon_path) as img: - assert img.size == (256, 256), f"icon.ico wrong size: {img.size}" - - if __name__ == "__main__": test_write_images()