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.
@@ -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 }})
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 817 B |
|
After Width: | Height: | Size: 817 B |
|
After Width: | Height: | Size: 817 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 11 KiB |