Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CONSTRUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 3 additions & 1 deletion constructor/_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
24 changes: 24 additions & 0 deletions constructor/briefcase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"]
Expand Down
2 changes: 1 addition & 1 deletion constructor/data/construct.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
61 changes: 48 additions & 13 deletions constructor/imaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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":
Comment thread
lrandersson marked this conversation as resolved.
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"
Expand All @@ -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"))
4 changes: 2 additions & 2 deletions constructor/osxpkg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/source/construct-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Binary file removed examples/miniconda/bird.png
Binary file not shown.
19 changes: 19 additions & 0 deletions news/1235-msi-branding
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
### Enhancements

* MSI: Add branding image support (`welcome_image`, `header_image`, `icon_image`). (#1235)

### Bug fixes

* <news item>

### Deprecations

* <news item>

### Docs

* MSI: Document that text branding options (`welcome_file`, `welcome_text`, `readme_file`, `readme_text`, `conclusion_file`, `conclusion_text`) are not supported. (#1235)

### Other

* <news item>
75 changes: 75 additions & 0 deletions tests/test_briefcase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -27,6 +32,9 @@
"_dists": [],
"_platform": cc_platform,
"_urls": [],
# Required for auto-generating branding images
"welcome_image_text": "MockInfo",
"header_image_text": "MockInfo",
}


Expand Down Expand Up @@ -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"
)
Loading