diff --git a/.gitignore b/.gitignore index 81d9755..c79d95e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ Helpers/OAuthCredentials.cs .vs/ *.DotSettings.user +# Build artifacts — the MSIX + its staging tree are produced by scripts/package-msix.sh, +# never committed (the package embeds the published app and the bundled ffmpeg). +artifacts/ + # Confidential business files (gitignored, not committed) MONETIZATION.md MARCOM.md diff --git a/Distribution.md b/Distribution.md index c5cf691..5f56951 100644 --- a/Distribution.md +++ b/Distribution.md @@ -305,18 +305,20 @@ steps: ### 4.1 Files to Ship ``` -LlamaCasty.exe (single-file, self-contained, ~80-120MB) +LlamaCasty.msix (full-trust x64 package, self-contained, ~159MB) ``` -Single-file means everything is inside the `.exe`. No DLLs, no runtime dependency. +Built by **`scripts/package-msix.sh`** (TASK 48 item 2, shipped 2026-10-22): it publishes +`Release -r win-x64 --self-contained` into `artifacts/msix-stage`, stages +`Packaging/Package.appxmanifest` (as `AppxManifest.xml`) + `Packaging/Assets/`, and packs with +the Windows SDK's `makeappx` → `artifacts/LlamaCasty.msix` (gitignored). Unsigned by default — +**the Store signs submissions**; set `MSIX_PFX`/`MSIX_PFX_PASSWORD` to sign a package for a +local test install. The manifest is a full-trust `packagedClassicApp` at `mediumIL` with +`runFullTrust` + webcam + microphone (see `TASKS/task-48-distribution-msix.md` item 2). -**Why not an installer?** -- Single `.exe` is simpler to distribute and update. -- No admin rights required (installs to `%LOCALAPPDATA%`). -- No installer framework to maintain (WiX, Inno Setup, etc.). -- User drops it anywhere and runs it. - -**Caveat:** Self-contained single-file means ~80-120MB due to bundled .NET runtime. For smaller size, use framework-dependent publishing (requires .NET 8 runtime on user machines) — drops to ~5-10MB. +A single-file `.exe` is **no longer the artifact**: the Store needs a package, and the package +carries the runtime (~80-120MB of it) plus the bundled ffmpeg (~135MB of DLLs). No installer +framework and no admin rights — the Store handles install, update and uninstall. ### 4.2 Distribution via Polar — ⛔ OBSOLETE (2026-09-27) diff --git a/HANDOFF.md b/HANDOFF.md index 900dafa..40d7c8f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -77,6 +77,17 @@ unchanged; the seams are two `internal` properties). Guard: `ytLive.Tests/AppLogRotationTests.cs`. Slices 2 (temp alert-clip cleanup) and 3 (low-disk guard before recording) are still open. +- **TASK 48 slice 2 — the app PACKS as a Store MSIX (shipped 2026-10-22).** `Packaging/Package.appxmanifest` + (full trust: `Windows.FullTrustApplication` + `uap10:TrustLevel=mediumIL` + + `uap10:RuntimeBehavior=packagedClassicApp` + `rescap runFullTrust`, plus webcam/microphone + `DeviceCapability`s, x64, en-US only) + `Packaging/Assets/` (four logo tiles generated from the + app's own artwork by `scripts/make-store-logos.py`) + `scripts/package-msix.sh` (publish + `Release -r win-x64 --self-contained` → stage → `makeappx pack`, optional `MSIX_PFX` signing). + **First real package built: `artifacts/LlamaCasty.msix`, 159 MB, 509 files, `Package creation + succeeded`** (`artifacts/` is gitignored). Guard: + `StorePackagingTests.The_package_manifest_declares_full_trust_camera_and_microphone`. + ⚠️ Before submission the manifest's `Identity/@Publisher` and `PublisherDisplayName` must be + set to the exact values Partner Center shows — a mismatch is a rejection. - **TASK 48 slice 3 — the self-updater is gone (shipped 2026-10-22).** The Store route (creator ruling 2026-09-27) means the Store updates the app, so the Velopack package + bootstrap were **deleted** (`ytLive.csproj`, `App.xaml.cs`) — a compliance action, not cleanup: the updater @@ -280,10 +291,10 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel Galaxy `cef.log` at 2.7 GB is NOT ours.) ## Tests -- Last full run: **437/437 passed, 0 warnings** (2026-10-22 — after TASK 48 slice 3's - `StorePackagingTests`; the run before was 436/436 after TASK 55 slice 1's - `AppLogRotationTests`, and the one before that 435/435 after TASK 63). The one failure seen - earlier in the +- Last full run: **438/438 passed, 0 warnings** (2026-10-22 — after TASK 48 slice 2's + manifest guard; the run before was 437/437 after slice 3's `StorePackagingTests`, then + 436/436 after TASK 55 slice 1's `AppLogRotationTests` and 435/435 after TASK 63). The one + failure seen earlier in the session was `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`, which **passes in isolation** (re-run: 3/3) — the known real-mouse flake, not a regression. - Known flaky-by-environment (all pass in isolation): `AudioPipelineTests.Mix_HonorsProviderGains…`, diff --git a/MyMistakes.md b/MyMistakes.md index f196630..a45ce9f 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -2320,3 +2320,44 @@ and change the callers. Don't. temp directory. Never let the test write to the real `%APPDATA%` log. 4. **Never throw from the rotation**: a failed rotation must degrade to the old behaviour (the log keeps growing), never take the app down. Same rule as the logger itself. + +## 2026-10-22 — PACK A WPF DESKTOP APP AS A MICROSOFT STORE MSIX (RECIPE) + +No installer project, no WiX, no `.wapproj`: `dotnet publish` + the Windows SDK's `makeappx` +is enough, and it is what `scripts/package-msix.sh` does. + +**The pipeline:** +1. `dotnet publish -c Release -r win-x64 --self-contained -o artifacts/msix-stage` — the RID + matters: an RID-less publish copies **every** RID's native folder (ONNX Runtime, ~17 MB + each) and the package bloats. Self-contained so the package carries its own .NET runtime. +2. The manifest goes at the **package root as `AppxManifest.xml`** (not `Package.appxmanifest`) + with its image folder beside it (`Assets\...` paths are relative to the root). +3. `makeappx pack /d /p .msix /o` → `Package creation succeeded`. Signing is the + Store's job; `signtool sign /fd SHA256 /f dev.pfx` is only for a local test install. +4. Find the tools with a glob, not a hard-coded SDK version: + `ls -d ".../Windows Kits/10/bin/"*/x64/makeappx.exe | sort -V | tail -1`. + +**Full trust, for a real Win32 app (this is the part that decides whether the app still works):** +```xml + +``` +plus `` and `DeviceCapability` entries for +`webcam`/`microphone`. `packagedClassicApp` is what lets the packaged exe launch a **child +process** (a bundled ffmpeg); an `appContainer` package silently virtualises file writes +instead — recordings "vanish". + +**The schema traps — makeappx validates ONE error at a time, so budget 2–3 pack runs:** +- `Square310x310Logo` and `Wide310x150Logo` are **not** attributes of `uap:VisualElements`; + they belong on ``, a child of `VisualElements`. (`Square44x44Logo` / + `Square150x150Logo` / `BackgroundColor` *are* on `VisualElements`.) +- Every referenced image must exist at the stated size (`StoreLogo` 50×50, 44×44, 150×150, + 310×310, 310×150) — the packer fails on a missing file, and the Store rejects a wrong size. +- Localization: `` only, unless the listing description is + translated into every declared language. + +**Testing the manifest without a Store account:** a source-level xunit guard over the XML — +identity/architecture, the full-trust application attributes, the capabilities, en-US only, and +"every referenced logo exists". ⚠️ Check `appContainer` absence on the **attribute values**, +not `manifest.ToString()` — the file's own comment is *supposed* to say "not appContainer", and +a text-wide ban fails on it (same trap as the `force-ssl`/`Download` guards). diff --git a/Packaging/Assets/Square150x150Logo.png b/Packaging/Assets/Square150x150Logo.png new file mode 100644 index 0000000..036969a Binary files /dev/null and b/Packaging/Assets/Square150x150Logo.png differ diff --git a/Packaging/Assets/Square310x310Logo.png b/Packaging/Assets/Square310x310Logo.png new file mode 100644 index 0000000..f5be193 Binary files /dev/null and b/Packaging/Assets/Square310x310Logo.png differ diff --git a/Packaging/Assets/Square44x44Logo.png b/Packaging/Assets/Square44x44Logo.png new file mode 100644 index 0000000..bfe59fa Binary files /dev/null and b/Packaging/Assets/Square44x44Logo.png differ diff --git a/Packaging/Assets/StoreLogo.png b/Packaging/Assets/StoreLogo.png new file mode 100644 index 0000000..95f9113 Binary files /dev/null and b/Packaging/Assets/StoreLogo.png differ diff --git a/Packaging/Assets/Wide310x150Logo.png b/Packaging/Assets/Wide310x150Logo.png new file mode 100644 index 0000000..291c03b Binary files /dev/null and b/Packaging/Assets/Wide310x150Logo.png differ diff --git a/Packaging/Package.appxmanifest b/Packaging/Package.appxmanifest new file mode 100644 index 0000000..62dbdbc --- /dev/null +++ b/Packaging/Package.appxmanifest @@ -0,0 +1,78 @@ + + + + + + + + LlamaCasty + LlamaCasty + Live streaming to YouTube: scenes, sources, a webcam with background effects, alerts, chat and recording. + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TASKS.md b/TASKS.md index d234957..cfa54f3 100644 --- a/TASKS.md +++ b/TASKS.md @@ -63,7 +63,7 @@ | 45 | TEST-tab chat fix #2: insert body must declare `snippet.type` (400 MISSING_REQUIRED_FIELD) | ✅ Done (2026-09-25) | [`TASKS/task-45-chat-insert-type.md`](TASKS/task-45-chat-insert-type.md) | | 46 | Drawers: click outside the rail closes whichever is open — TEST added to the existing Stream Settings + YPP dismiss behavior | ✅ Done (2026-09-25) | [`TASKS/task-46-drawer-click-outside-close.md`](TASKS/task-46-drawer-click-outside-close.md) | | 47 | Alert box video: built-in/custom alert clip (+ six-animation fallback) with read-time fade in/out + ticker (now in the preview too, 3 display methods) + alert volume in the live mix | ✅ Done (2026-09-26) | [`TASKS/task-47-alert-videos.md`](TASKS/task-47-alert-videos.md) | -| 48 | Distribution & packaging: **MSIX + Store IAP** | 🔶 In progress — **✅ route decided 2026-09-27 (Store MSIX + Store IAP)**; **item 1 (bundle ffmpeg) SHIPPED 2026-09-28**; **item 3 (remove the Velopack updater) SHIPPED 2026-10-22** — the Store updates the app | [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md) | +| 48 | Distribution & packaging: **MSIX + Store IAP** | 🔶 In progress — **✅ route decided 2026-09-27 (Store MSIX + Store IAP)**; **item 1 (bundle ffmpeg) SHIPPED 2026-09-28**; **item 2 (`Package.appxmanifest` + `scripts/package-msix.sh`) SHIPPED 2026-10-22 — a real 159 MB `LlamaCasty.msix` packs**; **item 3 (remove the Velopack updater) SHIPPED 2026-10-22** — the Store updates the app. Open: items 6-8 (recording invariant, disqualifiers, pre-submission gates); items 4/5 are the creator's (Certification Kit, Partner Center listing) | [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md) | | 49 | Chat profanity filter (local, opt-in, non-persistent, user word list) | ☐ Queued (2026-09-27) | [`TASKS/task-49-chat-profanity-filter.md`](TASKS/task-49-chat-profanity-filter.md) | | 50 | One-button "Publish this video" (resumable upload of the local recording) | ☐ Queued (2026-09-28) | [`TASKS/task-50-publish-recording.md`](TASKS/task-50-publish-recording.md) | | 51 | **Webcam background effects: Remove + Blur** | ✅ **Built 2026-10-09** (v1 requirement) — bundled MediaPipe Selfie Segmenter → ONNX (verified vs Google's runtime), Remove + Blur with strength, per-scene config persisted, one processing point in CameraManager, radio-pill UI in the webcam's Layers properties. Creator check 2026-10-09: **Remove jitter FIXED** (temporal mask smoothing 0.75/0.25, OBS/Jitsi-derived) and **Blur "heavy" FIXED** (quadratic radius 1→6 small px, default 0.5 = 2, StreamFX-consensus); recheck on the real camera pending | [`TASKS/task-51-webcam-bg-effects.md`](TASKS/task-51-webcam-bg-effects.md) | diff --git a/TASKS/task-48-distribution-msix.md b/TASKS/task-48-distribution-msix.md index ec8f349..8de368e 100644 --- a/TASKS/task-48-distribution-msix.md +++ b/TASKS/task-48-distribution-msix.md @@ -140,24 +140,44 @@ executes** — its teardown (item 3) is a compliance action, not just cleanup. --- -## 2. ☐ `Package.appxmanifest` — full trust, camera, microphone +## 2. ✅ `Package.appxmanifest` — full trust, camera, microphone (DONE 2026-10-22) -There is currently **no `.manifest` file in the repo at all**, so this is purely additive. +There was **no `.manifest` file in the repo at all**, so this was purely additive. + +**Shipped:** `Packaging/Package.appxmanifest` + `Packaging/Assets/` (the four logo tiles) + +`scripts/package-msix.sh` (publish → stage → `makeappx pack`, optional `signtool` via +`MSIX_PFX`). The first real package was built on 2026-10-22: **`Package creation succeeded`, +159 MB, 509 files** in `artifacts/LlamaCasty.msix` (gitignored). The logos are generated from +the app's own artwork by `scripts/make-store-logos.py`, so listing art and app art cannot drift. - `rescap:Capability Name="runFullTrust"` — medium integrity, not AppContainer -- `webcam` and `microphone` capabilities -- `uap10:TrustLevel="mediumIL"` and `uap10:RuntimeBehavior="packagedClassicApp"` — the - latter is what allows launching package executables as child processes -- Declare **English only** (10.7 — otherwise the description must be localized into every - declared language) -- Block map SHA2-256, `StoreManifest`, under the 25 GB cap -- **x64 only** (64-bit requirement, creator ruling 2026-10-09 — `PlatformTarget=x64` already in - both csproj files): package architecture `x64`, publish with `-r win-x64` so only the win-x64 - native folders ship (an RID-less build copies every RID's ONNX Runtime native, ~17 MB each). - Store listing system requirements: "64-bit Windows 10 or 11". +- `DeviceCapability` **webcam** and **microphone** +- `uap10:TrustLevel="mediumIL"` and `uap10:RuntimeBehavior="packagedClassicApp"` on + `` — the latter is what allows launching package executables (the bundled + ffmpeg) as child processes +- **English only** (``) — 10.7: otherwise the description must be + localized into every declared language +- Block map SHA2-256 is generated by `makeappx`; no `StoreManifest` is needed for a plain + MSIX submission; 159 MB is far under the 25 GB cap +- **x64 only** (creator ruling 2026-10-09 — `PlatformTarget=x64` already in both csproj files): + `ProcessorArchitecture="x64"`, published `-r win-x64 --self-contained` so only the win-x64 + native folders ship (an RID-less build copies every RID's ONNX Runtime native, ~17 MB each) + and the package carries its own .NET runtime. Store listing system requirements: + "64-bit Windows 10 or 11". **Do NOT use `appContainer`** — see the invariant in item 6. +**Schema gotchas found the hard way** (the packer rejects the manifest, one error at a time): +`Square310x310Logo` and `Wide310x150Logo` are **NOT** attributes of `uap:VisualElements` in the +Windows 10 schema — they belong on ``, a child of `VisualElements`. And +`Assert.DoesNotContain("appContainer", manifest.ToString())` fails on the manifest's own +comment: guard the **attribute values**, not the file text. + +**Guard:** `ytLive.Tests/StorePackagingTests.The_package_manifest_declares_full_trust_camera_and_microphone` +— identity (x64), full-trust application element, the three capabilities, no `appContainer` +value, en-US only, and every referenced logo exists in `Packaging/` (makeappx refuses to pack +otherwise). + ## 3. ✅ Remove Velopack — 3 edit sites (DONE 2026-10-22) The Store handles updates, so this is simply **deleted**. Trivially removable; the code was diff --git a/ai.md b/ai.md index acb61e0..d46a3dd 100644 --- a/ai.md +++ b/ai.md @@ -2587,6 +2587,15 @@ before re-deriving it. Executable checklist: `TASKS/task-48-distribution-msix.md (TASK 48 slice 3): the `PackageReference` in `ytLive.csproj` and the bootstrap in `App.xaml.cs` are gone, and `ytLive.Tests/StorePackagingTests.cs` guards that no updater package or updater API comes back. The self-hosted DO droplet dies with it. +- **The package is built by `scripts/package-msix.sh` (2026-10-22, TASK 48 slice 2)** — publish + `Release -r win-x64 --self-contained` into `artifacts/msix-stage`, copy + `Packaging/Package.appxmanifest` in as `AppxManifest.xml` + `Packaging/Assets/`, then + `makeappx pack` → `artifacts/LlamaCasty.msix` (159 MB, gitignored; `MSIX_PFX` signs it for a + local install). The manifest is a **full-trust `packagedClassicApp` at `mediumIL`** with + `runFullTrust` + webcam + microphone — that is what lets the app launch the bundled ffmpeg as + a child process; an `appContainer` package would virtualise the recordings folder instead. + `Square310x310Logo`/`Wide310x150Logo` live on `uap:DefaultTile`, **not** on `VisualElements`. + Logos come from the app's own artwork via `scripts/make-store-logos.py`. - **⛔ The Polar licensing subsystem is deleted** — `PolarLicenseService`, `PolarLicense`, `MainViewModel.License.cs` (`PremiumUrl`, customer portal, the `OfflineGracePeriod = 14 days` subscription-era artifact, renewal/lapse copy), and the diff --git a/scripts/make-store-logos.py b/scripts/make-store-logos.py new file mode 100644 index 0000000..ecec6ed --- /dev/null +++ b/scripts/make-store-logos.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Generate the Store logo tiles from the app's own brand artwork. + +The MSIX manifest references four images that must exist inside the package +(50x50 StoreLogo, 44x44, 150x150, 310x150 wide). They are derived from +`Assets/llamacasty-logo.jpg` — the same artwork the app shows in the overlay — +so the listing art and the app art cannot drift apart. + +The bright mark is located by luminance (the artwork is a dark navy field with a +light mark in the middle), cropped, and re-laid on the artwork's own background +colour so the tiles look like the app. + +Run: python3 scripts/make-store-logos.py +""" +import os +from PIL import Image + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SOURCE = os.path.join(ROOT, "Assets", "llamacasty-logo.jpg") +OUT_DIR = os.path.join(ROOT, "Packaging", "Assets") + + +def background(image: Image.Image) -> tuple: + """The artwork's own background colour (a corner pixel).""" + return image.getpixel((4, 4)) + + +def mark_crop(image: Image.Image) -> tuple: + """The bbox of the light mark, found by luminance threshold.""" + grey = image.convert("L") + bright = grey.point(lambda p: 255 if p > 190 else 0) + bbox = bright.getbbox() + if bbox is None: + raise SystemExit("no bright mark found in the source artwork") + return bbox + + +def tile(image: Image.Image, crop: tuple, size: int) -> Image.Image: + """A square tile: the mark scaled to fit, on the artwork's background.""" + canvas = Image.new("RGB", (size, size), background(image)) + mark = image.crop(crop) + scale = size / max(mark.size) + mark = mark.resize((max(1, int(mark.width * scale)), max(1, int(mark.height * scale)))) + canvas.paste(mark, ((size - mark.width) // 2, (size - mark.height) // 2)) + return canvas + + +def wide(image: Image.Image, crop: tuple, width: int, height: int) -> Image.Image: + """A wide tile: the mark at the artwork's height, centred on the background.""" + canvas = Image.new("RGB", (width, height), background(image)) + mark = image.crop(crop) + scale = height / mark.height + mark = mark.resize((max(1, int(mark.width * scale)), height)) + canvas.paste(mark, ((width - mark.width) // 2, 0)) + return canvas + + +def main() -> None: + os.makedirs(OUT_DIR, exist_ok=True) + image = Image.open(SOURCE).convert("RGB") + crop = mark_crop(image) + print(f"source {SOURCE} {image.size}, mark at {crop}") + + outputs = { + "StoreLogo.png": tile(image, crop, 50), + "Square44x44Logo.png": tile(image, crop, 44), + "Square150x150Logo.png": tile(image, crop, 150), + "Square310x310Logo.png": tile(image, crop, 310), + "Wide310x150Logo.png": wide(image, crop, 310, 150), + } + for name, canvas in outputs.items(): + path = os.path.join(OUT_DIR, name) + canvas.save(path, "PNG") + print(f"wrote {os.path.relpath(path, ROOT)} {canvas.size}") + + +if __name__ == "__main__": + main() diff --git a/scripts/package-msix.sh b/scripts/package-msix.sh new file mode 100644 index 0000000..1019434 --- /dev/null +++ b/scripts/package-msix.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Build the Microsoft Store MSIX package (TASK 48 item 2). +# +# scripts/package-msix.sh +# +# Publishes the app self-contained for win-x64 (so the package carries its own .NET +# runtime and the ONLY native folders are win-x64 — a RID-less publish copies every +# ONNX Runtime native folder), stages the manifest + logos from Packaging/, and packs +# with the Windows SDK's makeappx. +# +# The result is UNSIGNED (the Store signs the submitted package). To produce a package +# you can install locally for testing, point MSIX_PFX at a self-signed .pfx: +# +# MSIX_PFX=/path/to/dev.pfx MSIX_PFX_PASSWORD=... scripts/package-msix.sh +# +# ⚠️ Before a real submission: set Identity/@Publisher and Properties/P +# PublisherDisplayName in Packaging/Package.appxmanifest to the exact values Partner +# Center shows, and bump Identity/@Version. A mismatch is a certification rejection. +set -euo pipefail + +REPO_WSL="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_WIN="$(wslpath -w "$REPO_WSL")" +DOTNET="/mnt/c/Program Files/dotnet/dotnet.exe" +STAGE_WSL="$REPO_WSL/artifacts/msix-stage" +OUT_WSL="$REPO_WSL/artifacts/LlamaCasty.msix" + +MAKEAPPX="$(ls -d "/mnt/c/Program Files (x86)/Windows Kits/10/bin/"*/x64/makeappx.exe 2>/dev/null \ + | sort -V | tail -1 || true)" +SIGNTOOL="$(ls -d "/mnt/c/Program Files (x86)/Windows Kits/10/bin/"*/x64/signtool.exe 2>/dev/null \ + | sort -V | tail -1 || true)" + +[ -x "$MAKEAPPX" ] || { echo "makeappx.exe not found — install the Windows 10/11 SDK." >&2; exit 1; } + +echo "== publish (Release, win-x64, self-contained) ==" +rm -rf "$STAGE_WSL" +mkdir -p "$STAGE_WSL" +"$DOTNET" publish "$REPO_WIN\\ytLive.csproj" \ + -c Release -r win-x64 --self-contained true \ + --nologo -v minimal \ + -o "$(wslpath -w "$STAGE_WSL")" + +[ -f "$STAGE_WSL/ytLive.exe" ] || { echo "publish produced no ytLive.exe" >&2; exit 1; } +[ -d "$STAGE_WSL/ffmpeg" ] || { + echo "ffmpeg/ is missing from the package — it must ship bundled (Store policy 10.2.2)." >&2 + exit 1 +} + +echo "== stage the manifest + logos ==" +cp "$REPO_WSL/Packaging/Package.appxmanifest" "$STAGE_WSL/AppxManifest.xml" +cp -r "$REPO_WSL/Packaging/Assets" "$STAGE_WSL/Assets" + +echo "== pack ==" +"$MAKEAPPX" pack /d "$(wslpath -w "$STAGE_WSL")" /p "$(wslpath -w "$OUT_WSL")" /o + +if [ -n "${MSIX_PFX:-}" ]; then + [ -x "$SIGNTOOL" ] || { echo "signtool.exe not found — install the Windows 10/11 SDK." >&2; exit 1; } + echo "== sign ==" + if [ -n "${MSIX_PFX_PASSWORD:-}" ]; then + "$SIGNTOOL" sign /fd SHA256 /a /f "$(wslpath -w "$MSIX_PFX")" /p "$MSIX_PFX_PASSWORD" \ + "$(wslpath -w "$OUT_WSL")" + else + "$SIGNTOOL" sign /fd SHA256 /a /f "$(wslpath -w "$MSIX_PFX")" "$(wslpath -w "$OUT_WSL")" + fi +else + echo "note: unsigned package (the Store signs submissions); set MSIX_PFX to sign locally." +fi + +echo +echo "MSIX: $OUT_WSL ($(du -h "$OUT_WSL" | cut -f1))" diff --git a/ytLive.Tests/StorePackagingTests.cs b/ytLive.Tests/StorePackagingTests.cs index 6d1a4b9..5cc7288 100644 --- a/ytLive.Tests/StorePackagingTests.cs +++ b/ytLive.Tests/StorePackagingTests.cs @@ -63,4 +63,78 @@ public class StorePackagingTests .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"; + + /// 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. + [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}"); + } + } }