Files
mail/scripts/build-msix.py
T

152 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""Build the Microsoft Store packages from the staged Windows release files.
`pack` turns one architecture's portable layout (target/package/windows) into
an unsigned .msix with the manifest and logos from platform/windows/msix.
`bundle` combines the architectures' packages into one .msixbundle, the file
uploaded to Partner Center. The Store signs both, so neither is signed here.
The tools come from the newest installed Windows SDK. The MAKEAPPX and
MAKEPRI environment variables can name them explicitly.
"""
import argparse
import os
import platform
import re
import shutil
import subprocess
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
PROJECT = Path(__file__).resolve().parent.parent
MSIX = PROJECT / "platform/windows/msix"
ARCHITECTURES = ("x64", "arm64")
MANIFEST = "{http://schemas.microsoft.com/appx/manifest/foundation/windows10}"
# The manifest ships with this publisher until the Partner Center identity
# replaces it; Partner Center rejects packages that still carry it.
PLACEHOLDER_PUBLISHER = "CN=00000000-0000-0000-0000-000000000000"
def sdk_tool(name):
"""The tool from the newest Windows SDK, preferring native Arm64 builds on Arm64."""
override = os.environ.get(Path(name).stem.upper())
if override:
return override
kits = Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / "Windows Kits/10/bin"
hosts = ("arm64", "x64", "x86") if platform.machine().upper() == "ARM64" else ("x64", "x86")
versions = [path for path in kits.glob("10.*") if re.fullmatch(r"[0-9.]+", path.name)]
for version in sorted(versions, key=lambda path: tuple(map(int, path.name.split("."))), reverse=True):
for host in hosts:
if (tool := version / host / name).is_file():
return str(tool)
raise FileNotFoundError(f"{name} was not found in a Windows SDK under {kits}")
def run(*command):
print("+", " ".join(map(str, command)), flush=True)
subprocess.run([str(part) for part in command], check=True)
def check_version(version):
if not re.fullmatch(r"[1-9][0-9]{0,4}\.[0-9]{1,5}\.[0-9]{1,5}\.0", version) or any(
int(part) > 65535 for part in version.split(".")):
raise ValueError(f"Store versions are Major.Minor.Build.0 with parts up to 65535, not {version}")
def manifest(version, arch):
check_version(version)
if arch not in ARCHITECTURES:
raise ValueError(f"Unsupported Store package architecture: {arch}")
text = (MSIX / "AppxManifest.xml.in").read_text(encoding="utf-8")
for placeholder, value in (("@VERSION@", version), ("@ARCHITECTURE@", arch)):
if text.count(placeholder) != 1:
raise ValueError(f"The manifest template must contain {placeholder} once")
text = text.replace(placeholder, value)
return text
def warn_placeholder_identity(text):
publisher = ET.fromstring(text).find(f"{MANIFEST}Identity").get("Publisher")
if publisher == PLACEHOLDER_PUBLISHER:
message = ("The Store package still has the placeholder identity. "
"See docs/microsoft-store.md before uploading it to Partner Center.")
prefix = "::warning title=Placeholder Store identity::" if os.environ.get("GITHUB_ACTIONS") else "warning: "
print(prefix + message, flush=True)
def pri_config(makepri, directory):
"""makepri's default configuration without automatic resource packages,
which only apply to bundles that split out their resources."""
config = directory / "priconfig.xml"
run(makepri, "createconfig", "/cf", config, "/dq", "en-US", "/pv", "10.0.0", "/o")
tree = ET.parse(config)
for packaging in tree.getroot().findall("packaging"):
tree.getroot().remove(packaging)
tree.write(config, encoding="utf-8", xml_declaration=True)
return config
def pack(arch, version, layout_source, output):
for name in ("flectar-mail.exe", "pdfium.dll"):
if not (layout_source / name).is_file():
raise FileNotFoundError(f"{layout_source / name} is missing; stage the Windows package first")
text = manifest(version, arch)
makeappx, makepri = sdk_tool("makeappx.exe"), sdk_tool("makepri.exe")
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="flectar-msix-") as directory:
directory = Path(directory)
layout = directory / "layout"
shutil.copytree(layout_source, layout)
(layout / "AppxManifest.xml").write_text(text, encoding="utf-8")
# Index only the logos: Windows resolves their scale-, targetsize- and
# altform- variants through resources.pri.
indexed = directory / "pri"
shutil.copytree(MSIX / "Assets", indexed / "Assets")
shutil.copytree(MSIX / "Assets", layout / "Assets")
run(makepri, "new", "/pr", indexed, "/cf", pri_config(makepri, directory),
"/mn", layout / "AppxManifest.xml", "/of", layout / "resources.pri", "/o")
run(makeappx, "pack", "/o", "/h", "SHA256", "/d", layout, "/p", output)
warn_placeholder_identity(text)
print(f"Built {output}")
def bundle(version, packages, output):
check_version(version)
if len({package.name for package in packages}) != len(packages):
raise ValueError("Bundled packages need distinct file names")
makeappx = sdk_tool("makeappx.exe")
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="flectar-msixbundle-") as directory:
for package in packages:
shutil.copyfile(package, Path(directory) / package.name)
run(makeappx, "bundle", "/o", "/bv", version, "/d", directory, "/p", output)
print(f"Built {output}")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
commands = parser.add_subparsers(dest="command", required=True)
single = commands.add_parser("pack", help="package one architecture")
single.add_argument("--arch", choices=ARCHITECTURES, required=True)
single.add_argument("--version", required=True, help="Store version, from release-metadata.py")
single.add_argument("layout", type=Path, help="staged Windows release files")
single.add_argument("output", type=Path, help=".msix to write")
combined = commands.add_parser("bundle", help="bundle the architectures' packages")
combined.add_argument("--version", required=True, help="Store version, from release-metadata.py")
combined.add_argument("output", type=Path, help=".msixbundle to write")
combined.add_argument("packages", type=Path, nargs="+", help=".msix packages")
args = parser.parse_args()
try:
if args.command == "pack":
pack(args.arch, args.version, args.layout, args.output)
else:
bundle(args.version, args.packages, args.output)
except (OSError, ValueError, subprocess.CalledProcessError) as error:
raise SystemExit(f"error: {error}") from error
if __name__ == "__main__":
main()