diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c5220d2c..440f60d5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -528,6 +528,11 @@ jobs: - name: Build installer and portables 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) if: env.SPARKLE_PRIVATE_KEY != '' env: @@ -558,6 +563,7 @@ jobs: plezy-windows-x64-portable.7z plezy-windows-arm64-portable.7z plezy-windows-installer.exe + plezy-windows.msixbundle - name: Upload x64 portable uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -580,6 +586,15 @@ jobs: win-ed-signature.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: needs: validate-trusted-ref name: Build Linux (${{ matrix.arch }}) diff --git a/.gitignore b/.gitignore index 64274250..99f47ebb 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,11 @@ migrate_working_dir/ /build/ /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/ tvos/Pods/ diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index ad85370a..ca71065b 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -203,7 +203,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun _buildAdvancedSection(), - if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()], + if (UpdateService.isUpdateCheckAvailable) ...[_buildUpdateSection()], // Hidden on Android TV / tvOS (no document picker); desktop in // force-TV mode keeps it — FilePickerService works there. diff --git a/lib/services/donation_service.dart b/lib/services/donation_service.dart index b1799ad0..e5f6ad7a 100644 --- a/lib/services/donation_service.dart +++ b/lib/services/donation_service.dart @@ -1,7 +1,13 @@ +import 'package:plezy/utils/platform_detector.dart'; + class DonationService { 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 { - return const bool.fromEnvironment('ENABLE_DONATIONS', defaultValue: false); + return const bool.fromEnvironment('ENABLE_DONATIONS', defaultValue: false) && !PlatformDetector.isPackagedInstall(); } } diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index 2b913f72..a100b8a5 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -5,6 +5,7 @@ import 'package:auto_updater/auto_updater.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:plezy/utils/app_logger.dart'; import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:plezy/utils/platform_detector.dart'; import 'base_shared_preferences_service.dart'; /// Service to check for new versions on GitHub @@ -30,10 +31,17 @@ class UpdateService { 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. /// True on macOS (non-Homebrew) and installed Windows (has uninstaller). static bool get useNativeUpdater { - if (!isUpdateCheckEnabled) return false; + if (!isUpdateCheckAvailable) return false; if (Platform.isMacOS) return !_isHomebrewInstall(); if (Platform.isWindows) return _isInstalledApp() && !_isWingetInstall(); return false; @@ -140,7 +148,7 @@ class UpdateService { MediaServerHttpClient? client, bool forceEnabled = false, }) async { - if (!forceEnabled && !isUpdateCheckEnabled) { + if (!forceEnabled && !isUpdateCheckAvailable) { return null; } diff --git a/lib/utils/platform_detector.dart b/lib/utils/platform_detector.dart index 20275c74..f719c2c7 100644 --- a/lib/utils/platform_detector.dart +++ b/lib/utils/platform_detector.dart @@ -5,6 +5,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'app_logger.dart'; import 'async_singleton.dart'; import 'device_channel.dart'; @@ -275,6 +276,29 @@ class PlatformDetector { _debugIsDesktopOSOverride = value; } + /// Whether an executable path belongs to a packaged (MSIX/Store) install. + /// Packaged apps run from C:\Program Files\WindowsApps\\, 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() { if (isAppleTV()) return false; return Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux || Platform.isWindows; diff --git a/scripts/check_windows_msix.py b/scripts/check_windows_msix.py new file mode 100644 index 00000000..81052483 --- /dev/null +++ b/scripts/check_windows_msix.py @@ -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"^$", + r"^ $", +): + 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") diff --git a/scripts/ci_guard_checks.sh b/scripts/ci_guard_checks.sh index f8e80488..00687054 100644 --- a/scripts/ci_guard_checks.sh +++ b/scripts/ci_guard_checks.sh @@ -24,7 +24,8 @@ for checker in \ scripts/check_workflow_action_pins.py \ scripts/check_container_image_pins.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" done diff --git a/scripts/generate_windows_msix_assets.ps1 b/scripts/generate_windows_msix_assets.ps1 new file mode 100644 index 00000000..8074d5bf --- /dev/null +++ b/scripts/generate_windows_msix_assets.ps1 @@ -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 diff --git a/scripts/test_check_windows_msix.py b/scripts/test_check_windows_msix.py new file mode 100644 index 00000000..dee286cc --- /dev/null +++ b/scripts/test_check_windows_msix.py @@ -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 = " \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 = ( + " \n" + ' \n' + " \n" + "\n" + ) + dependencies = ( + " \n" + ' \n' + " \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(' \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("Plezy", "$AppName") + + 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("", "") + + 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() diff --git a/test/screens/settings/settings_screen_test.dart b/test/screens/settings/settings_screen_test.dart index 209cd5a7..34c7d384 100644 --- a/test/screens/settings/settings_screen_test.dart +++ b/test/screens/settings/settings_screen_test.dart @@ -135,7 +135,7 @@ void main() { _MigratedRow( title: t.settings.checkForUpdates, focusLabel: 'settings_check_for_updates', - isVisible: UpdateService.isUpdateCheckEnabled && UpdateService.useNativeUpdater, + isVisible: UpdateService.isUpdateCheckAvailable && UpdateService.useNativeUpdater, hasSubtitle: false, ), ]; @@ -237,7 +237,7 @@ void main() { await _pumpUi(tester); } - if (!UpdateService.isUpdateCheckEnabled) { + if (!UpdateService.isUpdateCheckAvailable) { expect(find.text(t.settings.checkForUpdates), findsNothing); return; } diff --git a/test/utils/platform_detector_test.dart b/test/utils/platform_detector_test.dart index a89255f3..79c02bd1 100644 --- a/test/utils/platform_detector_test.dart +++ b/test/utils/platform_detector_test.dart @@ -141,4 +141,39 @@ void main() { 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, + ); + }); + }); } diff --git a/windows/build-msix.ps1 b/windows/build-msix.ps1 new file mode 100644 index 00000000..56fb4015 --- /dev/null +++ b/windows/build-msix.ps1 @@ -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 @" + + + + + + + Plezy + $PublisherDisplayName + assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + +"@ +} + +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 diff --git a/windows/msix/assets/SplashScreen.png b/windows/msix/assets/SplashScreen.png new file mode 100644 index 00000000..b465e417 Binary files /dev/null and b/windows/msix/assets/SplashScreen.png differ diff --git a/windows/msix/assets/Square150x150Logo.png b/windows/msix/assets/Square150x150Logo.png new file mode 100644 index 00000000..d6c4aca2 Binary files /dev/null and b/windows/msix/assets/Square150x150Logo.png differ diff --git a/windows/msix/assets/Square150x150Logo.scale-200.png b/windows/msix/assets/Square150x150Logo.scale-200.png new file mode 100644 index 00000000..c0f4ffb4 Binary files /dev/null and b/windows/msix/assets/Square150x150Logo.scale-200.png differ diff --git a/windows/msix/assets/Square310x310Logo.png b/windows/msix/assets/Square310x310Logo.png new file mode 100644 index 00000000..2ff1fbbd Binary files /dev/null and b/windows/msix/assets/Square310x310Logo.png differ diff --git a/windows/msix/assets/Square44x44Logo.png b/windows/msix/assets/Square44x44Logo.png new file mode 100644 index 00000000..d718d4e5 Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.png differ diff --git a/windows/msix/assets/Square44x44Logo.scale-200.png b/windows/msix/assets/Square44x44Logo.scale-200.png new file mode 100644 index 00000000..c754e652 Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.scale-200.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-16.png b/windows/msix/assets/Square44x44Logo.targetsize-16.png new file mode 100644 index 00000000..cbd9742d Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-16.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-16_altform-lightunplated.png b/windows/msix/assets/Square44x44Logo.targetsize-16_altform-lightunplated.png new file mode 100644 index 00000000..cbd9742d Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-16_altform-lightunplated.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-16_altform-unplated.png b/windows/msix/assets/Square44x44Logo.targetsize-16_altform-unplated.png new file mode 100644 index 00000000..cbd9742d Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-16_altform-unplated.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-24.png b/windows/msix/assets/Square44x44Logo.targetsize-24.png new file mode 100644 index 00000000..67c4c1fe Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-24.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-24_altform-lightunplated.png b/windows/msix/assets/Square44x44Logo.targetsize-24_altform-lightunplated.png new file mode 100644 index 00000000..67c4c1fe Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-24_altform-lightunplated.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-24_altform-unplated.png b/windows/msix/assets/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 00000000..67c4c1fe Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-256.png b/windows/msix/assets/Square44x44Logo.targetsize-256.png new file mode 100644 index 00000000..8e9afb63 Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-256.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-256_altform-lightunplated.png b/windows/msix/assets/Square44x44Logo.targetsize-256_altform-lightunplated.png new file mode 100644 index 00000000..8e9afb63 Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-256_altform-lightunplated.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-256_altform-unplated.png b/windows/msix/assets/Square44x44Logo.targetsize-256_altform-unplated.png new file mode 100644 index 00000000..8e9afb63 Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-256_altform-unplated.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-32.png b/windows/msix/assets/Square44x44Logo.targetsize-32.png new file mode 100644 index 00000000..8d24ca99 Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-32.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-32_altform-lightunplated.png b/windows/msix/assets/Square44x44Logo.targetsize-32_altform-lightunplated.png new file mode 100644 index 00000000..8d24ca99 Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-32_altform-lightunplated.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-32_altform-unplated.png b/windows/msix/assets/Square44x44Logo.targetsize-32_altform-unplated.png new file mode 100644 index 00000000..8d24ca99 Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-32_altform-unplated.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-48.png b/windows/msix/assets/Square44x44Logo.targetsize-48.png new file mode 100644 index 00000000..0ed9c519 Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-48.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-48_altform-lightunplated.png b/windows/msix/assets/Square44x44Logo.targetsize-48_altform-lightunplated.png new file mode 100644 index 00000000..0ed9c519 Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-48_altform-lightunplated.png differ diff --git a/windows/msix/assets/Square44x44Logo.targetsize-48_altform-unplated.png b/windows/msix/assets/Square44x44Logo.targetsize-48_altform-unplated.png new file mode 100644 index 00000000..0ed9c519 Binary files /dev/null and b/windows/msix/assets/Square44x44Logo.targetsize-48_altform-unplated.png differ diff --git a/windows/msix/assets/StoreLogo.png b/windows/msix/assets/StoreLogo.png new file mode 100644 index 00000000..3516e565 Binary files /dev/null and b/windows/msix/assets/StoreLogo.png differ diff --git a/windows/msix/assets/Wide310x150Logo.png b/windows/msix/assets/Wide310x150Logo.png new file mode 100644 index 00000000..54e85f0d Binary files /dev/null and b/windows/msix/assets/Wide310x150Logo.png differ