feat(windows): package for the Microsoft Store as an MSIX bundle

The Store's unpackaged EXE path would require Authenticode-signing the
installer and every PE file inside it. MSIX submissions are re-signed by the
Store instead, so this route needs no code-signing certificate. build-msix.ps1
mirrors build-installer.ps1 and consumes the same per-architecture build
artifacts, leaving the installer, portable archives and WinSparkle appcast
untouched.

One template generates the manifest for both architectures, carrying the
identity reserved in Partner Center. check_windows_msix.py recomputes the
package family name from the publisher DN, so a mistyped identity fails CI
rather than a submission, and it parses the script rather than running it
because root CI is Linux. Qualified logo assets are indexed into
resources.pri; without the altform-unplated variants the shell draws the
taskbar icon on an accent-coloured plate.

PlatformDetector.isPackagedInstall gates the in-app updater and the Liberapay
tile, which the read-only package directory and Store commerce policy
respectively rule out. Gating at runtime keeps one Windows build feeding both
the installer and the Store package.
This commit is contained in:
edde746
2026-07-30 20:03:04 +02:00
parent 834895486b
commit 944a8d89f5
36 changed files with 1050 additions and 7 deletions
+15
View File
@@ -528,6 +528,11 @@ jobs:
- name: Build installer and portables - name: Build installer and portables
run: .\windows\build-installer.ps1 -X64BuildDir "build-x64" -Arm64BuildDir "build-arm64" -Version "${{ steps.version.outputs.version }}" run: .\windows\build-installer.ps1 -X64BuildDir "build-x64" -Arm64BuildDir "build-arm64" -Version "${{ steps.version.outputs.version }}"
# Unsigned on purpose: the Store re-signs the bundle during
# certification. See windows/build-msix.ps1.
- name: Build Store package (MSIX)
run: .\windows\build-msix.ps1 -X64BuildDir "build-x64" -Arm64BuildDir "build-arm64" -Version "${{ steps.version.outputs.version }}"
- name: Sign installer for WinSparkle (EdDSA) - name: Sign installer for WinSparkle (EdDSA)
if: env.SPARKLE_PRIVATE_KEY != '' if: env.SPARKLE_PRIVATE_KEY != ''
env: env:
@@ -558,6 +563,7 @@ jobs:
plezy-windows-x64-portable.7z plezy-windows-x64-portable.7z
plezy-windows-arm64-portable.7z plezy-windows-arm64-portable.7z
plezy-windows-installer.exe plezy-windows-installer.exe
plezy-windows.msixbundle
- name: Upload x64 portable - name: Upload x64 portable
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -580,6 +586,15 @@ jobs:
win-ed-signature.txt win-ed-signature.txt
win-installer-size.txt win-installer-size.txt
# Deliberately not attached to the GitHub release in create-release: a
# Store-identity package cannot be installed without the Store's
# certificate, so it is only useful as a Partner Center upload.
- name: Upload Store package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: windows-msix
path: plezy-windows.msixbundle
build-linux: build-linux:
needs: validate-trusted-ref needs: validate-trusted-ref
name: Build Linux (${{ matrix.arch }}) name: Build Linux (${{ matrix.arch }})
+5
View File
@@ -34,6 +34,11 @@ migrate_working_dir/
/build/ /build/
/debug-info/ /debug-info/
# Windows Store packaging output (windows/build-msix.ps1)
/staging-msix/
/plezy-windows.msixbundle
/AppxManifest.*.xml
# tvOS: build artifacts and generated assets (regenerated by xcode_appletv.sh) # tvOS: build artifacts and generated assets (regenerated by xcode_appletv.sh)
tvos/build/ tvos/build/
tvos/Pods/ tvos/Pods/
+1 -1
View File
@@ -203,7 +203,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
_buildAdvancedSection(), _buildAdvancedSection(),
if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()], if (UpdateService.isUpdateCheckAvailable) ...[_buildUpdateSection()],
// Hidden on Android TV / tvOS (no document picker); desktop in // Hidden on Android TV / tvOS (no document picker); desktop in
// force-TV mode keeps it — FilePickerService works there. // force-TV mode keeps it — FilePickerService works there.
+7 -1
View File
@@ -1,7 +1,13 @@
import 'package:plezy/utils/platform_detector.dart';
class DonationService { class DonationService {
static const String donationUrl = 'https://liberapay.com/edde746'; static const String donationUrl = 'https://liberapay.com/edde746';
/// Suppressed inside a packaged (MSIX/Store) install: Microsoft Store policy
/// treats a link that solicits payment outside the Store as a commerce
/// mechanism, so the tile is a certification risk there. Every other Windows
/// build shape and every other platform keeps it.
static bool get isEnabled { static bool get isEnabled {
return const bool.fromEnvironment('ENABLE_DONATIONS', defaultValue: false); return const bool.fromEnvironment('ENABLE_DONATIONS', defaultValue: false) && !PlatformDetector.isPackagedInstall();
} }
} }
+10 -2
View File
@@ -5,6 +5,7 @@ import 'package:auto_updater/auto_updater.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:plezy/utils/app_logger.dart'; import 'package:plezy/utils/app_logger.dart';
import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'base_shared_preferences_service.dart'; import 'base_shared_preferences_service.dart';
/// Service to check for new versions on GitHub /// Service to check for new versions on GitHub
@@ -30,10 +31,17 @@ class UpdateService {
return const bool.fromEnvironment('ENABLE_UPDATE_CHECK', defaultValue: false); return const bool.fromEnvironment('ENABLE_UPDATE_CHECK', defaultValue: false);
} }
/// Whether any in-app update path applies to this install.
/// False inside a packaged (MSIX/Store) install: the Store owns updates and
/// the package directory is read-only, so neither WinSparkle nor the GitHub
/// fallback dialog has anything it can do. Gates the settings entry too, so
/// no dead affordance ships.
static bool get isUpdateCheckAvailable => isUpdateCheckEnabled && !PlatformDetector.isPackagedInstall();
/// Whether the native auto_updater (Sparkle/WinSparkle) should be used. /// Whether the native auto_updater (Sparkle/WinSparkle) should be used.
/// True on macOS (non-Homebrew) and installed Windows (has uninstaller). /// True on macOS (non-Homebrew) and installed Windows (has uninstaller).
static bool get useNativeUpdater { static bool get useNativeUpdater {
if (!isUpdateCheckEnabled) return false; if (!isUpdateCheckAvailable) return false;
if (Platform.isMacOS) return !_isHomebrewInstall(); if (Platform.isMacOS) return !_isHomebrewInstall();
if (Platform.isWindows) return _isInstalledApp() && !_isWingetInstall(); if (Platform.isWindows) return _isInstalledApp() && !_isWingetInstall();
return false; return false;
@@ -140,7 +148,7 @@ class UpdateService {
MediaServerHttpClient? client, MediaServerHttpClient? client,
bool forceEnabled = false, bool forceEnabled = false,
}) async { }) async {
if (!forceEnabled && !isUpdateCheckEnabled) { if (!forceEnabled && !isUpdateCheckAvailable) {
return null; return null;
} }
+24
View File
@@ -5,6 +5,7 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'app_logger.dart';
import 'async_singleton.dart'; import 'async_singleton.dart';
import 'device_channel.dart'; import 'device_channel.dart';
@@ -275,6 +276,29 @@ class PlatformDetector {
_debugIsDesktopOSOverride = value; _debugIsDesktopOSOverride = value;
} }
/// Whether an executable path belongs to a packaged (MSIX/Store) install.
/// Packaged apps run from C:\Program Files\WindowsApps\<package>\, matched
/// case-insensitively because a casing difference would silently re-enable
/// the paths a read-only package cannot support.
@visibleForTesting
static bool isPackagedExecutablePath(String exePath) {
return exePath.toLowerCase().contains('\\windowsapps\\');
}
/// True inside a packaged (MSIX/Microsoft Store) install. The Store owns
/// updates and the package directory is read-only, and Store policy treats an
/// external donation link as a commerce mechanism, so both of those
/// affordances are suppressed there.
static bool isPackagedInstall() {
try {
if (!Platform.isWindows) return false;
return isPackagedExecutablePath(Platform.resolvedExecutable);
} catch (error, stackTrace) {
appLogger.e('Failed to determine packaged install status', error: error, stackTrace: stackTrace);
return false;
}
}
static bool supportsExternalPlayers() { static bool supportsExternalPlayers() {
if (isAppleTV()) return false; if (isAppleTV()) return false;
return Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux || Platform.isWindows; return Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux || Platform.isWindows;
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""Guard the generated MSIX manifest behind Microsoft Store submissions.
windows/build-msix.ps1 generates AppxManifest.xml per architecture at package
time, so there is no manifest in the tree to review. These checks read the
script as text and validate the template it returns - they never invoke
PowerShell, because root CI runs on Linux where pwsh cannot be assumed, and
-EmitManifestOnly is the Windows-side developer loop instead.
The invariants pinned here are the ones makeappx, the shell and Store
certification enforce: the schema's fixed child order, identity strings that
satisfy their pattern constraints and match the reserved product, a four-part
version whose revision field is the 0 the Store reserves, one template shared by
both architectures, assets that actually exist in the tree, and the resource
index without which the unplated taskbar icons never resolve.
"""
import hashlib
from pathlib import Path
import re
import sys
from xml.etree import ElementTree
from pubspec_version import parse_pubspec_version
from workflow_yaml import job_block
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SCRIPT = ROOT / "windows/build-msix.ps1"
if len(sys.argv) > 2:
raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [build-msix-path]")
SCRIPT = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else DEFAULT_SCRIPT
ASSETS = ROOT / "windows/msix/assets"
CMAKE = ROOT / "windows/CMakeLists.txt"
PUBSPEC = ROOT / "pubspec.yaml"
WORKFLOW = ROOT / ".github/workflows/build.yml"
MSIX_STEP = "Build Store package (MSIX)"
BUNDLE = "plezy-windows.msixbundle"
# The identity reserved in Partner Center. All three are pinned here because a
# character of drift in any of them fails Store validation, and the first two
# derive the package family name edde746.Plezy_13q3sv6jzathm that installed
# copies are keyed by.
IDENTITY_NAME = "edde746.Plezy"
PUBLISHER = "CN=AA9C53CB-AD3C-48DA-B3E3-D1E8986D4E25"
PUBLISHER_DISPLAY_NAME = "edde746"
PACKAGE_FAMILY_SUFFIX = "13q3sv6jzathm"
FOUNDATION = "http://schemas.microsoft.com/appx/manifest/foundation/windows10"
ASSET_REFERENCE = re.compile(r"assets\\([A-Za-z0-9._-]+\.png)")
# Package's child order is fixed by the foundation schema. A manifest may use a
# subset of these, but never a different order.
SCHEMA_ORDER = (
"Identity",
"PhoneIdentity",
"PublisherInfo",
"Properties",
"Resources",
"Dependencies",
"Capabilities",
"Applications",
"Extensions",
)
REQUIRED_ELEMENTS = (
"Identity",
"Properties",
"Resources",
"Dependencies",
"Capabilities",
"Applications",
)
REQUIRED_CAPABILITIES = ("runFullTrust", "internetClient", "privateNetworkClientServer")
# Certification requires these three. The optional tile and splash assets are
# only checked once referenced, since dropping them is a legitimate choice.
REQUIRED_ASSETS = ("StoreLogo.png", "Square150x150Logo.png", "Square44x44Logo.png")
# Normalized so that every pattern below can anchor on \n and $ regardless of
# whether this checkout stores the PowerShell scripts with CRLF endings.
text = SCRIPT.read_text(encoding="utf-8").replace("\r\n", "\n")
errors: list[str] = []
def require(condition: bool, message: str) -> None:
if not condition:
errors.append(message)
def local_name(tag: str) -> str:
return tag.rpartition("}")[2]
def declarations() -> str:
"""New-AppxManifest above its here-string: params and identity strings."""
match = re.search(r'(?ms)^function New-AppxManifest \{\n(.*?)^ return @"$', text)
require(match is not None, "New-AppxManifest must return a here-string template")
return match.group(1) if match else ""
def template() -> str:
"""The AppxManifest.xml emitted by New-AppxManifest."""
match = re.search(r'(?ms)^ return @"\n(.*?)\n"@\n', text)
require(match is not None, "New-AppxManifest must return a single here-string template")
return match.group(1) if match else ""
def substitute(manifest: str, values: dict[str, str]) -> str:
"""Fill the template's PowerShell interpolations with their real values.
Longest name first: $PublisherDisplayName also starts with $Publisher, and
PowerShell resolves the longer name.
"""
for name in sorted(values, key=len, reverse=True):
manifest = manifest.replace(f"${name}", values[name])
unresolved = sorted(set(re.findall(r"\$\w+", manifest)))
require(
not unresolved,
"the template interpolates values this checker cannot resolve: "
f"{', '.join(unresolved)}",
)
return manifest
def package_family_suffix(publisher: str) -> str:
"""The 13-character hash Windows derives from a publisher distinguished name.
SHA-256 over the UTF-16LE publisher, first 8 bytes, padded to 65 bits and
base32-encoded with the alphabet Windows uses for this (i, l, o and u are
omitted).
"""
digest = hashlib.sha256(publisher.encode("utf-16-le")).digest()[:8]
bits = f"{int.from_bytes(digest, 'big'):064b}0"
alphabet = "0123456789abcdefghjkmnpqrstvwxyz"
return "".join(alphabet[int(bits[index : index + 5], 2)] for index in range(0, 65, 5))
def named_step(block: str, name: str) -> str:
match = re.search(rf"(?ms)^ - name: {re.escape(name)}\n.*?(?=^ - |\Z)", block)
require(match is not None, f"missing '{name}' step in package-windows")
return match.group(0) if match else ""
require(
"function New-AppxManifest" in text,
"the manifest must be built by New-AppxManifest so every architecture shares one template",
)
prelude = declarations()
manifest_template = template()
# build-installer.ps1 once carried one whole .iss per architecture shape and the
# copies drifted apart. Anything that appears twice here has drifted too.
for once in (
r'^ return @"$',
r"^<Package ",
r"^ <Identity ",
r"^ <Capabilities>$",
r"^ <Applications>$",
):
require(
len(re.findall(once, text, re.MULTILINE)) == 1,
f"{once} must match exactly one line; a second copy of the template will drift",
)
# The two values the caller supplies, plus every identity string the function
# declares. Together they must cover the whole template, so an interpolation
# added there has to be declared here before it can pass.
for parameter in ("MsixVersion", "Architecture"):
require(
f"[Parameter(Mandatory)][string]${parameter}" in prelude,
f"New-AppxManifest must take ${parameter} as a mandatory parameter",
)
pubspec_version, _ = parse_pubspec_version(PUBSPEC.read_text(encoding="utf-8"))
values = {"MsixVersion": f"{pubspec_version}.0", "Architecture": "x64"}
values.update(re.findall(r'(?m)^ \$(\w+) = "([^"]*)"$', prelude))
substituted = substitute(manifest_template, values)
package = None
try:
package = ElementTree.fromstring(substituted)
except ElementTree.ParseError as error:
require(False, f"the substituted manifest must be well-formed XML: {error}")
if package is not None:
require(
package.tag == f"{{{FOUNDATION}}}Package",
"the root element must be Package in the appx foundation namespace",
)
present = [local_name(child.tag) for child in package]
missing = [element for element in REQUIRED_ELEMENTS if element not in present]
require(not missing, f"the manifest must declare {', '.join(missing)}")
require(
[element for element in present if element in SCHEMA_ORDER]
== [element for element in SCHEMA_ORDER if element in present],
"Package children must keep schema order "
f"({', '.join(SCHEMA_ORDER)}); makeappx rejects a reordered manifest",
)
require(len(present) == len(set(present)), "no Package child may be declared twice")
identity = package.find(f"{{{FOUNDATION}}}Identity")
if identity is None:
require(False, "the manifest must declare an Identity element")
else:
# Both patterns are schema constraints: makeappx refuses the package
# before reading a single payload file when either is violated.
require(
re.fullmatch(r"[-.A-Za-z0-9]{3,50}", identity.get("Name") or "") is not None,
"Identity/@Name must match the schema's [-.A-Za-z0-9]+ pattern; an underscore "
"is rejected by makeappx before it reads any payload",
)
require(
(identity.get("Publisher") or "").startswith("CN="),
"Identity/@Publisher must be the full distinguished name, starting with CN=",
)
require(
identity.get("Name") == IDENTITY_NAME
and identity.get("Publisher") == PUBLISHER
and package.findtext(
f"{{{FOUNDATION}}}Properties/{{{FOUNDATION}}}PublisherDisplayName"
)
== PUBLISHER_DISPLAY_NAME,
"the manifest must carry the identity reserved in Partner Center "
f"({IDENTITY_NAME}, {PUBLISHER}, {PUBLISHER_DISPLAY_NAME}); any drift fails "
"Store validation",
)
require(
identity.get("Version") == values["MsixVersion"],
"Identity/@Version must be the pubspec version plus the Store-reserved "
f"revision field: expected {values['MsixVersion']}",
)
capabilities = [
child.get("Name") for child in package.findall(f"{{{FOUNDATION}}}Capabilities/*")
]
for capability in REQUIRED_CAPABILITIES:
require(
capability in capabilities,
f"the manifest must declare the {capability} capability",
)
require(
"Windows.Desktop"
in [
child.get("Name")
for child in package.findall(
f"{{{FOUNDATION}}}Dependencies/{{{FOUNDATION}}}TargetDeviceFamily"
)
],
"Dependencies must target Windows.Desktop; packaging fails without a device family",
)
application = package.find(f"{{{FOUNDATION}}}Applications/{{{FOUNDATION}}}Application")
binary_name = re.search(
r'(?m)^set\(BINARY_NAME "([^"]+)"\)$', CMAKE.read_text(encoding="utf-8")
)
require(binary_name is not None, "windows/CMakeLists.txt must set BINARY_NAME")
if application is None:
require(False, "the manifest must declare an Application element")
elif binary_name is not None:
require(
application.get("Executable") == f"{binary_name.group(1)}.exe",
"Application/@Executable must be the executable windows/CMakeLists.txt builds, "
f"{binary_name.group(1)}.exe",
)
require(
application.get("EntryPoint") == "Windows.FullTrustApplication",
"a packaged Win32 app must enter through Windows.FullTrustApplication",
)
# Assets are named in attributes (the tile logos) and in element text (the
# Properties/Logo), so both are scanned.
referenced = {
match.group(1)
for element in package.iter()
for value in (*element.attrib.values(), element.text or "")
if (match := ASSET_REFERENCE.fullmatch(value.strip()))
}
for asset in REQUIRED_ASSETS:
require(asset in referenced, f"the manifest must reference {asset}")
for asset in sorted(referenced):
require(
(ASSETS / asset).is_file(),
f"windows/msix/assets/{asset} is referenced by the manifest but is not in the "
"tree; packaging fails on a missing asset",
)
# Independent of the manifest: Partner Center reports the package family name,
# so recomputing it from the pinned publisher proves that string is byte-exact.
# A mistyped publisher otherwise only surfaces as a rejected upload.
require(
package_family_suffix(PUBLISHER) == PACKAGE_FAMILY_SUFFIX,
f"the pinned publisher must hash to the package family name reported by Partner "
f"Center, {IDENTITY_NAME}_{PACKAGE_FAMILY_SUFFIX}",
)
require(
'Version="$MsixVersion"' in manifest_template,
"Identity/@Version must come from the single version variable, not a literal",
)
require(
'ProcessorArchitecture="$Architecture"' in manifest_template,
"Identity/@ProcessorArchitecture must be interpolated, not pinned to one architecture",
)
require(
len(re.findall(r"(?m)^\$MsixVersion = ", text)) == 1,
"the MSIX version must be derived once, so the bundle and both packages agree",
)
require(
re.search(r'(?m)^ return "\$\w+\.0"$', text) is not None,
"the MSIX version must append the Store-reserved revision field as 0",
)
require(
r"($Version -split '\+')[0]" in text,
"pubspec's +build metadata must be stripped; the Store reserves the fourth field",
)
require(
r"'^\d+\.\d+\.\d+$'" in text,
"a version that is not major.minor.patch must be rejected instead of packaged",
)
# Native exit codes are invisible to $ErrorActionPreference, so the one
# invocation that checks $LASTEXITCODE has to be the only one.
require(
len(re.findall(r"(?m)^ & \$Tool @Arguments$", text)) == 1
and "& $MakeAppx" not in text
and "& $MakePri" not in text
and "$LASTEXITCODE -ne 0" in text,
"every SDK tool call must go through the single invocation that checks $LASTEXITCODE",
)
# The taskbar, task view and Alt-Tab draw the small logo on a plate filled with
# BackgroundColor, and the manifest's transparent background leaves the shell
# painting the user's accent colour behind the icon. Only an altform-unplated
# variant suppresses that plate, and qualified variants resolve solely through
# resources.pri - as plain payload files they are inert.
for form in ("altform-unplated", "altform-lightunplated"):
require(
any(ASSETS.glob(f"Square44x44Logo.targetsize-*_{form}.png")),
f"the small logo needs targetsize {form} variants, or the shell draws the taskbar "
"icon on an accent-coloured plate",
)
require(
'"new", "/pr"' in text and "resources.pri" in text,
"the staged payload must be indexed into resources.pri, or every qualified logo "
"variant is inert payload",
)
require(
"RemoveChild($Packaging)" in text,
"autoResourcePackage must be stripped from the PRI config; it moves the qualified "
"logos into resource-package indexes that a per-architecture .msix never carries",
)
require(
"signtool" not in text.lower(),
"the bundle must stay unsigned; the Store re-signs it, and any other certificate "
"fails publisher-identity validation",
)
# The version the manifest carries is passed in by the workflow, so the link
# back to pubspec.yaml lives there rather than in the script.
workflow = WORKFLOW.read_text(encoding="utf-8")
package_windows = job_block(workflow, "package-windows")
require(bool(package_windows), "missing package-windows job")
msix_step = named_step(package_windows, MSIX_STEP)
for argument in (
".\\windows\\build-msix.ps1",
'-X64BuildDir "build-x64"',
'-Arm64BuildDir "build-arm64"',
'-Version "${{ steps.version.outputs.version }}"',
):
require(
argument in msix_step,
f"the MSIX step must reuse the existing packaging inputs: {argument}",
)
require(
"pubspec.yaml" in package_windows,
"the version handed to both packaging scripts must be read from pubspec.yaml",
)
require(
package_windows.count(BUNDLE) == 2,
f"{BUNDLE} must be attested and uploaded, exactly once each",
)
require(
BUNDLE not in job_block(workflow, "create-release"),
f"{BUNDLE} must not be attached to the GitHub release; a Store-identity package "
"cannot be installed without the Store certificate",
)
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
sys.exit(1)
print("windows msix manifest checks passed")
+2 -1
View File
@@ -24,7 +24,8 @@ for checker in \
scripts/check_workflow_action_pins.py \ scripts/check_workflow_action_pins.py \
scripts/check_container_image_pins.py \ scripts/check_container_image_pins.py \
scripts/check_update_packages_workflow.py \ scripts/check_update_packages_workflow.py \
scripts/check_windows_installer.py; do scripts/check_windows_installer.py \
scripts/check_windows_msix.py; do
python3 "$checker" python3 "$checker"
done done
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env pwsh
# Windows MSIX Asset Generator
# Renders the tile, store and splash PNGs the Store manifest references from
# assets/plezy.png, for windows/msix/assets. The results are committed and
# windows/build-msix.ps1 only copies them, so packaging stays runnable without
# image tooling; regenerating is only needed when the app icon changes.
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = Split-Path -Parent $ScriptDir
Set-Location $ProjectRoot
Add-Type -AssemblyName System.Drawing
$Source = Resolve-Path "assets\plezy.png"
$OutputDir = Join-Path $ProjectRoot "windows\msix\assets"
# Sizes and names come from the uap:VisualElements attributes in the manifest
# template; a rename here has to be made there too. The two non-square targets
# letterbox the square icon on transparent padding rather than stretching it.
$Targets = @(
@{ Name = "Square44x44Logo.png"; Width = 44; Height = 44 }
@{ Name = "Square150x150Logo.png"; Width = 150; Height = 150 }
@{ Name = "StoreLogo.png"; Width = 50; Height = 50 }
@{ Name = "Wide310x150Logo.png"; Width = 310; Height = 150 }
@{ Name = "Square310x310Logo.png"; Width = 310; Height = 310 }
@{ Name = "SplashScreen.png"; Width = 620; Height = 300 }
# The base logos are scale-100. Without a 200 the shell upscales them on the
# high-DPI displays most laptops ship with.
@{ Name = "Square44x44Logo.scale-200.png"; Width = 88; Height = 88 }
@{ Name = "Square150x150Logo.scale-200.png"; Width = 300; Height = 300 }
)
# The taskbar, task view and Alt-Tab draw the small logo on a plate filled with
# BackgroundColor, and a transparent background leaves the shell painting the
# user's accent colour behind the icon. An altform-unplated variant is the only
# way to suppress that plate; lightunplated is its light-theme counterpart.
# These resolve through resources.pri, which windows/build-msix.ps1 builds - as
# plain payload files they are inert.
foreach ($Size in 16, 24, 32, 48, 256) {
foreach ($Form in "", "_altform-unplated", "_altform-lightunplated") {
$Targets += @{ Name = "Square44x44Logo.targetsize-${Size}${Form}.png"; Width = $Size; Height = $Size }
}
}
# Regenerate from scratch so a renamed target cannot leave an orphan behind.
if (Test-Path $OutputDir) { Remove-Item (Join-Path $OutputDir "*.png") -Force }
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
Write-Host "Generating MSIX assets from $Source..." -ForegroundColor Cyan
$SourceImage = [System.Drawing.Image]::FromFile($Source)
try {
foreach ($Target in $Targets) {
$Canvas = New-Object System.Drawing.Bitmap(
$Target.Width, $Target.Height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
try {
$Graphics = [System.Drawing.Graphics]::FromImage($Canvas)
try {
# SourceCopy keeps the icon's own alpha instead of blending it
# into the canvas; the manifest declares a transparent tile
# background and expects the padding to stay transparent.
$Graphics.CompositingMode = [System.Drawing.Drawing2D.CompositingMode]::SourceCopy
$Graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$Graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$Edge = [Math]::Min($Target.Width, $Target.Height)
$Left = [int](($Target.Width - $Edge) / 2)
$Top = [int](($Target.Height - $Edge) / 2)
$Graphics.DrawImage(
$SourceImage, (New-Object System.Drawing.Rectangle($Left, $Top, $Edge, $Edge)))
} finally {
$Graphics.Dispose()
}
$Path = Join-Path $OutputDir $Target.Name
$Canvas.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
Write-Host " $($Target.Name) ($($Target.Width)x$($Target.Height))" -ForegroundColor Green
} finally {
$Canvas.Dispose()
}
}
} finally {
$SourceImage.Dispose()
}
Write-Host "`nWrote $($Targets.Count) asset(s) to $OutputDir" -ForegroundColor White
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Behavior tests for the Windows MSIX manifest guard."""
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
CHECKER = ROOT / "scripts/check_windows_msix.py"
SCRIPT = ROOT / "windows/build-msix.ps1"
class WindowsMsixGuardTest(unittest.TestCase):
def _run(self, script: str) -> subprocess.CompletedProcess[str]:
with tempfile.TemporaryDirectory(prefix="plezy-windows-msix-test-") as directory:
fixture = Path(directory) / "build-msix.ps1"
fixture.write_text(script, encoding="utf-8")
return subprocess.run(
[sys.executable, str(CHECKER), str(fixture)],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
)
def _script(self) -> str:
return SCRIPT.read_text(encoding="utf-8")
def _mutate(self, old: str, new: str) -> str:
script = self._script().replace(old, new, 1)
self.assertNotEqual(script, self._script(), f"fixture mutation no longer matches: {old!r}")
return script
def test_current_script_passes(self) -> None:
result = self._run(self._script())
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("msix manifest checks passed", result.stdout)
def test_second_template_copy_is_rejected(self) -> None:
# The regression build-installer.ps1 already had: one whole manifest per
# architecture shape, drifting apart.
marker = " <Capabilities>\n"
script = self._mutate(marker, marker + marker)
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("a second copy of the template will drift", result.stderr)
def test_schema_element_order_is_rejected_when_tidied(self) -> None:
# Resources before Dependencies looks wrong next to the old Visual
# Studio UWP templates, but the foundation schema requires it.
resources = (
" <Resources>\n"
' <Resource Language="en-us" />\n'
" </Resources>\n"
"\n"
)
dependencies = (
" <Dependencies>\n"
' <TargetDeviceFamily Name="Windows.Desktop"\n'
' MinVersion="10.0.17763.0"\n'
' MaxVersionTested="10.0.26100.0" />\n'
" </Dependencies>\n"
"\n"
)
script = self._mutate(resources + dependencies, dependencies + resources)
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("must keep schema order", result.stderr)
def test_hard_coded_architecture_is_rejected(self) -> None:
script = self._mutate(
'ProcessorArchitecture="$Architecture"', 'ProcessorArchitecture="x64"'
)
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("must be interpolated", result.stderr)
def test_underscored_identity_name_is_rejected(self) -> None:
# makeappx enforces the schema pattern on Identity/@Name before it reads
# any payload, so an underscore makes the package unbuildable.
script = self._mutate('$IdentityName = "edde746.Plezy"', '$IdentityName = "edde746_Plezy"')
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("[-.A-Za-z0-9]+ pattern", result.stderr)
def test_identity_drift_from_partner_center_is_rejected(self) -> None:
# The reserved identity is what Store validation matches the upload
# against, and what installed copies are keyed by.
script = self._mutate(
'$PublisherDisplayName = "edde746"', '$PublisherDisplayName = "someone else"'
)
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("reserved in Partner Center", result.stderr)
def test_three_part_version_is_rejected(self) -> None:
script = self._mutate(' return "$Semver.0"', ' return "$Semver"')
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("Store-reserved revision field", result.stderr)
def test_unvalidated_version_input_is_rejected(self) -> None:
script = self._mutate(r"if ($Semver -notmatch '^\d+\.\d+\.\d+$') {", "if ($false) {")
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("must be rejected instead of packaged", result.stderr)
def test_dropping_a_capability_is_rejected(self) -> None:
script = self._mutate(' <rescap:Capability Name="runFullTrust" />\n', "")
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("runFullTrust capability", result.stderr)
def test_asset_reference_without_a_file_is_rejected(self) -> None:
script = self._mutate("Square150x150Logo.png", "Square150x150Logo-renamed.png")
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("packaging fails on a missing asset", result.stderr)
def test_unresolved_interpolation_is_rejected(self) -> None:
# A new variable in the template has to be declared where the checker
# can see it, or the manifest it validates is not the one that ships.
script = self._mutate("<DisplayName>Plezy</DisplayName>", "<DisplayName>$AppName</DisplayName>")
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("cannot resolve", result.stderr)
def test_malformed_manifest_is_rejected(self) -> None:
script = self._mutate("</Applications>", "</Application>")
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("well-formed XML", result.stderr)
def test_unchecked_makeappx_invocation_is_rejected(self) -> None:
script = self._mutate(
'Write-Host "`nBuild complete!" -ForegroundColor Green',
'& $MakeAppx bundle /d packages /p extra.msixbundle',
)
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("checks $LASTEXITCODE", result.stderr)
def test_dropping_the_resource_index_is_rejected(self) -> None:
# Without resources.pri the unplated logo variants are inert payload and
# the shell plates the taskbar icon with the accent colour.
script = self._mutate('"new", "/pr"', '"version", "/pr"')
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("inert payload", result.stderr)
def test_keeping_auto_resource_packages_is_rejected(self) -> None:
script = self._mutate(
"$PriConfigXml.resources.RemoveChild($Packaging)", "$PriConfigXml.resources.AppendChild($Packaging)"
)
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("autoResourcePackage must be stripped", result.stderr)
def test_signing_the_bundle_is_rejected(self) -> None:
script = self._mutate(
"# Clean up staging",
'& signtool sign /fd SHA256 /f release.pfx $Bundle\n\n# Clean up staging',
)
result = self._run(script)
self.assertNotEqual(result.returncode, 0)
self.assertIn("must stay unsigned", result.stderr)
if __name__ == "__main__":
unittest.main()
@@ -135,7 +135,7 @@ void main() {
_MigratedRow( _MigratedRow(
title: t.settings.checkForUpdates, title: t.settings.checkForUpdates,
focusLabel: 'settings_check_for_updates', focusLabel: 'settings_check_for_updates',
isVisible: UpdateService.isUpdateCheckEnabled && UpdateService.useNativeUpdater, isVisible: UpdateService.isUpdateCheckAvailable && UpdateService.useNativeUpdater,
hasSubtitle: false, hasSubtitle: false,
), ),
]; ];
@@ -237,7 +237,7 @@ void main() {
await _pumpUi(tester); await _pumpUi(tester);
} }
if (!UpdateService.isUpdateCheckEnabled) { if (!UpdateService.isUpdateCheckAvailable) {
expect(find.text(t.settings.checkForUpdates), findsNothing); expect(find.text(t.settings.checkForUpdates), findsNothing);
return; return;
} }
+35
View File
@@ -141,4 +141,39 @@ void main() {
expect(allowed(host: false, automotive: true), isFalse); expect(allowed(host: false, automotive: true), isFalse);
}); });
}); });
group('isPackagedExecutablePath', () {
test('a WindowsApps executable path is a packaged install', () {
expect(
PlatformDetector.isPackagedExecutablePath(
r'C:\Program Files\WindowsApps\edde746.Plezy_2.11.0.0_x64__13q3sv6jzathm\plezy.exe',
),
isTrue,
);
});
test('the package directory is matched however it is cased', () {
expect(
PlatformDetector.isPackagedExecutablePath(
r'c:\program files\windowsapps\edde746.Plezy_2.11.0.0_x64__13q3sv6jzathm\plezy.exe',
),
isTrue,
reason:
'Windows paths are case-insensitive; a casing difference must not restore '
'the updater and donation link inside a read-only package',
);
});
test('installed and portable executable paths are not packaged installs', () {
expect(PlatformDetector.isPackagedExecutablePath(r'C:\Program Files\Plezy\plezy.exe'), isFalse);
expect(PlatformDetector.isPackagedExecutablePath(r'D:\portable\plezy-windows-x64\plezy.exe'), isFalse);
});
test('a directory whose name merely starts the same is not a package', () {
expect(
PlatformDetector.isPackagedExecutablePath(r'C:\Users\someone\Downloads\WindowsApps-backup\plezy.exe'),
isFalse,
);
});
});
} }
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env pwsh
# Windows Store (MSIX) Build Script
# Packs the per-arch Flutter Release output into one dual-architecture
# .msixbundle for Microsoft Store submission. Purely additive: the Inno Setup
# installer, the portable archives and the WinSparkle appcast are untouched.
#
# Nothing here signs the bundle. The Store re-signs after certification, and a
# package signed with any other certificate fails publisher-identity validation.
param(
[string]$OutputDir = ".",
[string]$Version = "1.0.0",
[string]$X64BuildDir,
[string]$Arm64BuildDir,
# Write the generated AppxManifest.xml files and stop, so they can be
# inspected without the Windows SDK or a populated Flutter build output.
# scripts/check_windows_msix.py parses the template rather than running
# this: root CI is Linux, where PowerShell cannot be assumed.
[switch]$EmitManifestOnly
)
function ConvertTo-MsixVersion {
param([Parameter(Mandatory)][string]$Version)
# The Store reserves the fourth (revision) field, so pubspec's
# major.minor.patch+build maps to major.minor.patch.0 and the build number
# is dropped. Two releases differing only by build number therefore collide
# in the Store: every submission needs a semver bump.
$Semver = ($Version -split '\+')[0]
if ($Semver -notmatch '^\d+\.\d+\.\d+$') {
throw "Version must be major.minor.patch, optionally with +build metadata; got '$Version'"
}
return "$Semver.0"
}
function New-AppxManifest {
param(
[Parameter(Mandatory)][string]$MsixVersion,
[Parameter(Mandatory)][string]$Architecture
)
# Copied verbatim from the reserved product's identity page in Partner
# Center; Store validation rejects the upload if any of the three differs by
# a single character. Together they yield package family name
# edde746.Plezy_13q3sv6jzathm. Identity/@Name is also constrained to
# '[-.A-Za-z0-9]+' by the schema, so it can never carry an underscore.
$IdentityName = "edde746.Plezy"
$Publisher = "CN=AA9C53CB-AD3C-48DA-B3E3-D1E8986D4E25"
$PublisherDisplayName = "edde746"
# One template for both architectures, which can therefore not drift apart;
# ProcessorArchitecture is the only difference between them.
#
# Child element order is fixed by the foundation schema and makeappx
# rejects a reordered manifest: Identity, Properties, Resources,
# Dependencies, Capabilities, Applications. Resources sitting before
# Dependencies is the opposite of what older Visual Studio UWP templates
# used - do not tidy it.
#
# runFullTrust is restricted (hence rescap) and standard for a packaged
# Win32 app. privateNetworkClientServer is what covers LAN media servers:
# a full-trust package is not AppContainer-isolated, but certification
# looks for declared network intent.
return @"
<?xml version="1.0" encoding="utf-8"?>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity Name="$IdentityName"
Publisher="$Publisher"
Version="$MsixVersion"
ProcessorArchitecture="$Architecture" />
<Properties>
<DisplayName>Plezy</DisplayName>
<PublisherDisplayName>$PublisherDisplayName</PublisherDisplayName>
<Logo>assets\StoreLogo.png</Logo>
</Properties>
<Resources>
<Resource Language="en-us" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop"
MinVersion="10.0.17763.0"
MaxVersionTested="10.0.26100.0" />
</Dependencies>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<Capability Name="internetClient" />
<Capability Name="privateNetworkClientServer" />
</Capabilities>
<Applications>
<Application Id="Plezy" Executable="plezy.exe" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements DisplayName="Plezy"
Description="A modern client for Plex and Jellyfin"
BackgroundColor="transparent"
Square150x150Logo="assets\Square150x150Logo.png"
Square44x44Logo="assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="assets\Wide310x150Logo.png"
Square310x310Logo="assets\Square310x310Logo.png" />
<uap:SplashScreen Image="assets\SplashScreen.png" />
</uap:VisualElements>
</Application>
</Applications>
</Package>
"@
}
function Write-Utf8File {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$Content
)
# The manifest declares encoding="utf-8", and Out-File -Encoding utf8
# writes a BOM on Windows PowerShell but not on pwsh 7. Write the bytes
# directly so the manifest is identical whichever host runs this script.
[System.IO.File]::WriteAllText($Path, $Content, (New-Object System.Text.UTF8Encoding($false)))
}
function Invoke-SdkTool {
param(
[Parameter(Mandatory)][string]$Tool,
[Parameter(Mandatory)][string[]]$Arguments
)
# $ErrorActionPreference does not apply to native executables, so every SDK
# tool call goes through this one checked invocation.
& $Tool @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$(Split-Path -Leaf $Tool) $($Arguments -join ' ') failed with exit code $LASTEXITCODE"
}
}
$ErrorActionPreference = "Stop"
Write-Host "Building Windows Store package..." -ForegroundColor Cyan
# Ensure we're in the project root
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ProjectRoot = Split-Path -Parent $ScriptDir
Set-Location $ProjectRoot
$ResolvedOutput = Resolve-Path $OutputDir
$MsixVersion = ConvertTo-MsixVersion -Version $Version
Write-Host "Package version: $MsixVersion" -ForegroundColor Green
# Unlike the installer's setup.iss, the manifest does not vary with which
# architectures were built, so the inspection path always emits both.
if ($EmitManifestOnly) {
Write-Host "`nGenerating manifests only..." -ForegroundColor Cyan
foreach ($Architecture in @("x64", "arm64")) {
$EmittedManifest = Join-Path $ResolvedOutput "AppxManifest.$Architecture.xml"
Write-Utf8File -Path $EmittedManifest `
-Content (New-AppxManifest -MsixVersion $MsixVersion -Architecture $Architecture)
Write-Host "Created: $EmittedManifest" -ForegroundColor Green
}
exit 0
}
# Auto-detect build dirs from default Flutter output paths if not provided
if (-not $X64BuildDir -and (Test-Path "build\windows\x64\runner\Release")) {
$X64BuildDir = "build\windows\x64\runner\Release"
}
if (-not $Arm64BuildDir -and (Test-Path "build\windows\arm64\runner\Release")) {
$Arm64BuildDir = "build\windows\arm64\runner\Release"
}
$BuildDirs = [ordered]@{}
if ($X64BuildDir -and (Test-Path $X64BuildDir)) { $BuildDirs["x64"] = $X64BuildDir }
if ($Arm64BuildDir -and (Test-Path $Arm64BuildDir)) { $BuildDirs["arm64"] = $Arm64BuildDir }
if ($BuildDirs.Count -eq 0) {
Write-Error "No build directories found. Provide -X64BuildDir and/or -Arm64BuildDir, or run 'flutter build windows --release' first."
exit 1
}
Write-Host "Architectures found:" -ForegroundColor Green
foreach ($Architecture in $BuildDirs.Keys) {
Write-Host " $($Architecture): $($BuildDirs[$Architecture])"
}
# Locate the SDK tools. The newest kit that actually ships both wins; older
# installed kits are frequently partial.
$SdkRoot = "C:\Program Files (x86)\Windows Kits\10\bin"
$SdkBin = $null
if (Test-Path $SdkRoot) {
$SdkBin = Get-ChildItem $SdkRoot -Directory |
Where-Object { $_.Name -as [version] } |
Sort-Object { [version]$_.Name } -Descending |
ForEach-Object { Join-Path $_.FullName "x64" } |
Where-Object { (Test-Path (Join-Path $_ "makeappx.exe")) -and (Test-Path (Join-Path $_ "makepri.exe")) } |
Select-Object -First 1
}
if (-not $SdkBin) {
Write-Error "makeappx.exe and makepri.exe not found under $SdkRoot. Install the Windows 10/11 SDK, or run with -EmitManifestOnly to inspect the manifests."
exit 1
}
$MakeAppx = Join-Path $SdkBin "makeappx.exe"
$MakePri = Join-Path $SdkBin "makepri.exe"
Write-Host "`nUsing $SdkBin" -ForegroundColor Cyan
# Stage payload, assets and manifest per architecture. The .msix packages are
# collected in a sibling directory because makeappx bundle treats every file
# under its /d directory as a package to bundle.
$StagingRoot = Join-Path $ProjectRoot "staging-msix"
$PackageDir = Join-Path $StagingRoot "packages"
if (Test-Path $StagingRoot) { Remove-Item $StagingRoot -Recurse -Force }
New-Item -ItemType Directory -Path $PackageDir -Force | Out-Null
# The resource index that makes the qualified assets resolvable. Without it the
# targetsize/altform-unplated logos are inert payload, and the shell plates the
# taskbar icon with the user's accent colour. Kept outside the staged payload so
# it is not packed. autoResourcePackage would move qualified assets out into
# resource-package indexes that a per-architecture .msix never carries, so the
# shell would stop finding them; strip that.
$PriConfig = Join-Path $StagingRoot "priconfig.xml"
Invoke-SdkTool -Tool $MakePri -Arguments @("createconfig", "/cf", $PriConfig, "/dq", "en-US", "/o")
$PriConfigXml = [xml](Get-Content $PriConfig)
$Packaging = $PriConfigXml.resources.packaging
if ($Packaging) {
$PriConfigXml.resources.RemoveChild($Packaging) | Out-Null
$PriConfigXml.Save($PriConfig)
}
foreach ($Architecture in $BuildDirs.Keys) {
Write-Host "`nStaging $Architecture payload..." -ForegroundColor Cyan
$Staging = Join-Path $StagingRoot $Architecture
New-Item -ItemType Directory -Path $Staging -Force | Out-Null
Copy-Item -Path "$($BuildDirs[$Architecture])\*" -Destination $Staging -Recurse
Copy-Item -Path "windows\msix\assets" -Destination $Staging -Recurse
Write-Utf8File -Path (Join-Path $Staging "AppxManifest.xml") `
-Content (New-AppxManifest -MsixVersion $MsixVersion -Architecture $Architecture)
# Indexed after the manifest is in place: makepri reads the package identity
# from it.
Write-Host "Indexing $Architecture resources..." -ForegroundColor Cyan
Invoke-SdkTool -Tool $MakePri -Arguments @(
"new", "/pr", $Staging, "/cf", $PriConfig, "/of", (Join-Path $Staging "resources.pri"), "/o"
)
Write-Host "Packing $Architecture..." -ForegroundColor Cyan
Invoke-SdkTool -Tool $MakeAppx -Arguments @(
"pack", "/d", $Staging, "/p", (Join-Path $PackageDir "plezy-$Architecture.msix"), "/o"
)
}
$Bundle = Join-Path $ResolvedOutput "plezy-windows.msixbundle"
Write-Host "`nBundling $($BuildDirs.Count) package(s)..." -ForegroundColor Cyan
Invoke-SdkTool -Tool $MakeAppx -Arguments @(
"bundle", "/d", $PackageDir, "/p", $Bundle, "/bv", $MsixVersion, "/o"
)
# Clean up staging
Remove-Item $StagingRoot -Recurse -Force -ErrorAction SilentlyContinue
# Summary
Write-Host "`nBuild complete!" -ForegroundColor Green
Write-Host "Architectures: $($BuildDirs.Keys -join ', ')" -ForegroundColor White
Write-Host "Store package: $Bundle" -ForegroundColor White
Write-Host "The Store signs this bundle during certification; it is unsigned here." -ForegroundColor White
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB