diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 72e518ba7..65b2e649b 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). @@ -585,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 4019e6e70..bc2d3df17 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). @@ -755,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 index 97130dfb1..99f08c365 100644 --- a/constructor/briefcase.py +++ b/constructor/briefcase.py @@ -17,8 +17,11 @@ 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 @@ -36,6 +39,12 @@ 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" @@ -375,6 +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 (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() @@ -516,6 +528,18 @@ def write_pyproject_toml(self, root: Path, external: Path) -> None: }, } + # 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 802810da7..013ae8af8 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -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.", "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": { 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/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 diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 72e518ba7..65b2e649b 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). @@ -585,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/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/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/tests/test_briefcase.py b/tests/test_briefcase.py index 5e7343edd..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 ( @@ -27,6 +32,9 @@ "_dists": [], "_platform": cc_platform, "_urls": [], + # Required for auto-generating branding images + "welcome_image_text": "MockInfo", + "header_image_text": "MockInfo", } @@ -1010,3 +1018,70 @@ 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="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" + )