The manifest the Store route needs, and a real package built from it. Packaging/Package.appxmanifest is a full-trust packagedClassicApp: EntryPoint Windows.FullTrustApplication, uap10:TrustLevel=mediumIL, uap10:RuntimeBehavior=packagedClassicApp, the rescap runFullTrust capability, and webcam + microphone DeviceCapabilities. That combination is what lets the packaged app launch the bundled ffmpeg as a child process and write to the user's profile; an appContainer package would virtualise the recordings folder instead. x64 only, en-US only (policy 10.7). Packaging/Assets/ carries the four tiles the packer requires, generated from the app's own Assets/llamacasty-logo.jpg by scripts/make-store-logos.py - listing art and app art cannot drift. scripts/package-msix.sh publishes Release win-x64 self-contained into artifacts/msix-stage (a RID-less publish would copy every RID's ONNX native), stages the manifest as AppxManifest.xml + Assets, and runs the Windows SDK's makeappx. MSIX_PFX/MSIX_PFX_PASSWORD sign a package for a local test install; the Store signs submissions. artifacts/ is gitignored. First real package: artifacts/LlamaCasty.msix, 159 MB, 509 files, 'Package creation succeeded'. Schema traps found by packing (makeappx reports one error at a time): Square310x310Logo and Wide310x150Logo are NOT uap:VisualElements attributes - they live on uap:DefaultTile. Guard: StorePackagingTests.The_package_manifest_declares_full_trust_camera_and_ microphone - identity/architecture, the full-trust application element, the three capabilities, no appContainer VALUE (not the file text: the manifest's own comment says the word), en-US only, and every referenced logo present. Full suite 438/438, 0 warnings. Cold-start smoke test after the Velopack removal: MainWindow loaded, no exceptions.
141 lines
7.5 KiB
C#
141 lines
7.5 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using System.Xml.Linq;
|
|
using Xunit;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>TASK 48, slice 3 — the creator's ruling (2026-09-27): the app ships as a
|
|
/// **Microsoft Store MSIX** and the **Store updates it**. The self-update subsystem that
|
|
/// existed for the abandoned self-hosted route (a Velopack bootstrap plus the DigitalOcean
|
|
/// update-URL droplet) is therefore deleted, not parked: a second updater inside a
|
|
/// Store-installed package fights the Store's own updater.
|
|
///
|
|
/// <para>This test is the guard: the app project references no updater package and no app
|
|
/// source file uses the updater API — so the deleted subsystem cannot creep back in, and
|
|
/// the "the Store updates it" decision stays the only update path.</para></summary>
|
|
public class StorePackagingTests
|
|
{
|
|
/// <summary>Known self-update packages. If one of these comes back, the Store route has
|
|
/// been quietly reversed.</summary>
|
|
private static readonly string[] UpdaterPackages =
|
|
{
|
|
"Velopack", "Squirrel", "NetSparkle", "AutoUpdater", "WinSparkle"
|
|
};
|
|
|
|
[Fact]
|
|
public void The_app_ships_no_self_update_subsystem()
|
|
{
|
|
// 1. The app project references no updater package.
|
|
var project = XDocument.Load(SourceAssert.RepoPath("ytLive.csproj"));
|
|
var packages = project.Descendants()
|
|
.Where(e => e.Name.LocalName == "PackageReference")
|
|
.Select(e => (string?)e.Attribute("Include"))
|
|
.Where(name => name != null)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var updater in UpdaterPackages)
|
|
Assert.DoesNotContain(updater, packages);
|
|
|
|
// 2. No app source file uses the updater API. Matched as CODE (a using directive,
|
|
// a type usage), not as a bare word — a file's prose is allowed to name what it
|
|
// deliberately does not do.
|
|
var root = Path.GetDirectoryName(SourceAssert.RepoPath("ytLive.csproj"))!;
|
|
foreach (var file in AppSourceFiles(root))
|
|
{
|
|
var text = File.ReadAllText(file);
|
|
Assert.False(
|
|
Regex.IsMatch(text, @"^\s*using\s+Velopack\s*;", RegexOptions.Multiline),
|
|
$"{Path.GetFileName(file)} still imports the Velopack updater — the Store updates this app.");
|
|
Assert.False(
|
|
Regex.IsMatch(text, @"\bVelopackApp\b"),
|
|
$"{Path.GetFileName(file)} still calls the Velopack updater — the Store updates this app.");
|
|
}
|
|
}
|
|
|
|
/// <summary>Every C# file of the app project (not the test project, not build output).</summary>
|
|
private static IEnumerable<string> AppSourceFiles(string root)
|
|
{
|
|
return Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)
|
|
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}ytLive.Tests{Path.DirectorySeparatorChar}"))
|
|
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}"))
|
|
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}"));
|
|
}
|
|
|
|
private static readonly XNamespace Foundation =
|
|
"http://schemas.microsoft.com/appx/manifest/foundation/windows10";
|
|
private static readonly XNamespace Uap =
|
|
"http://schemas.microsoft.com/appx/manifest/uap/windows10";
|
|
private static readonly XNamespace Uap10 =
|
|
"http://schemas.microsoft.com/appx/manifest/uap/windows10/10";
|
|
private static readonly XNamespace Rescap =
|
|
"http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities";
|
|
|
|
/// <summary>TASK 48, slice 2 — the MSIX manifest. This is the file that decides whether
|
|
/// recording, capturing and the bundled ffmpeg still work once the app is packaged:
|
|
/// a full-trust packagedClassicApp at mediumIL can launch ffmpeg as a child process and
|
|
/// write to the user's profile; an AppContainer cannot, and recording would silently
|
|
/// land in a virtualised folder. So the guard pins every property that makes that true,
|
|
/// plus the x64/en-US decisions and the logo files the packer requires.</summary>
|
|
[Fact]
|
|
public void The_package_manifest_declares_full_trust_camera_and_microphone()
|
|
{
|
|
var manifestPath = SourceAssert.RepoPath(Path.Combine("Packaging", "Package.appxmanifest"));
|
|
Assert.True(File.Exists(manifestPath), "Packaging/Package.appxmanifest is missing.");
|
|
var manifest = XDocument.Load(manifestPath);
|
|
var package = manifest.Root!;
|
|
Assert.Equal(Foundation + "Package", package.Name);
|
|
|
|
// 1. Identity: x64 only (creator ruling 2026-10-09), and the title the Store expects.
|
|
var identity = package.Element(Foundation + "Identity")!;
|
|
Assert.Equal("LlamaCasty", (string?)identity.Attribute("Name"));
|
|
Assert.Equal("x64", (string?)identity.Attribute("ProcessorArchitecture"));
|
|
|
|
// 2. Full trust: packagedClassicApp + mediumIL + the restricted runFullTrust capability.
|
|
var application = package.Element(Foundation + "Applications")!
|
|
.Element(Foundation + "Application")!;
|
|
Assert.Equal("Windows.FullTrustApplication", (string?)application.Attribute("EntryPoint"));
|
|
Assert.Equal("ytLive.exe", (string?)application.Attribute("Executable"));
|
|
Assert.Equal("mediumIL", (string?)application.Attribute(Uap10 + "TrustLevel"));
|
|
Assert.Equal("packagedClassicApp", (string?)application.Attribute(Uap10 + "RuntimeBehavior"));
|
|
|
|
var capabilities = package.Element(Foundation + "Capabilities")!;
|
|
Assert.Contains(capabilities.Elements(Rescap + "Capability"),
|
|
element => (string?)element.Attribute("Name") == "runFullTrust");
|
|
var devices = capabilities.Elements(Foundation + "DeviceCapability")
|
|
.Select(element => (string?)element.Attribute("Name"))
|
|
.ToArray();
|
|
Assert.Contains("webcam", devices);
|
|
Assert.Contains("microphone", devices);
|
|
|
|
// 3. ⛔ Never appContainer — that is the change that breaks recording (item 6).
|
|
// Matched on ATTRIBUTE VALUES, not the file text: the manifest's own comment
|
|
// explains why it must not be appContainer, and prose is allowed to say so.
|
|
Assert.DoesNotContain(
|
|
manifest.Descendants()
|
|
.SelectMany(element => element.Attributes())
|
|
.Select(attribute => attribute.Value),
|
|
value => string.Equals(value, "appContainer", StringComparison.OrdinalIgnoreCase));
|
|
|
|
// 4. English only (policy 10.7): a second language forces the listing to be localised.
|
|
var languages = package.Element(Foundation + "Resources")!
|
|
.Elements(Foundation + "Resource")
|
|
.Select(element => (string?)element.Attribute("Language"))
|
|
.ToArray();
|
|
Assert.Equal(new[] { "en-US" }, languages);
|
|
|
|
// 5. Every image the manifest references is actually in the package source tree —
|
|
// makeappx refuses to pack otherwise.
|
|
var packaging = Path.GetDirectoryName(manifestPath)!;
|
|
foreach (var logo in manifest.Descendants()
|
|
.SelectMany(element => element.Attributes())
|
|
.Where(attribute => attribute.Name.LocalName.EndsWith("Logo", StringComparison.Ordinal))
|
|
.Select(attribute => attribute.Value.Replace('\\', Path.DirectorySeparatorChar)))
|
|
{
|
|
Assert.True(File.Exists(Path.Combine(packaging, logo)), $"manifest references a missing logo: {logo}");
|
|
}
|
|
}
|
|
}
|