ADR: Reconcile the MSIX companion host after the Installer.Host repurpose (spec T16)¶
- Status: Accepted (decision ADR for T16a; implemented by T16b)
- Date: 2026-07-09
- Decision driver: task T16 of the exe-installer track ("reconcile the
MSIX companion host"). Before that track,
Installer.Hostwas the MSIX companion installer:InstallerHostBundlerbundledinstaller.exeplus aBrandTokens.g.jsonsidecar into MSIX staging, driven by the throwaway copy-loopInstallerEngine. Repurposing the Host as the exe-wrapper runtime silently changed what MSIX ships, so T16 required an explicit choice — (a) MSIX bundles the same engine-driven host, or (b) the companion becomes a separate minimal exe — recorded as an ADR, withInstallerHostBundlerupdated to match and no orphaned copy-loop code left behind. The two sibling tasks that broke it were T2 (Installer.Host drives the realWrapper.Coreengine and deletesServices/InstallerEngine.cs) and T7 (removes theBrandTokens.g.jsonsidecar in favour of blob-embedded brand tokens). (The T-numbered spec that set these tasks has since been retired; its content for T16/T2/T7 is restated here so this ADR stands alone.) - Scope: decides what MSIX ships as its "companion installer" and the fate
of the
BrandTokens.g.jsonsidecar in MSIX. This is a decision ADR — nosrc/changes are made here. The concrete edits are T16b (wave 3); a minimal implementation outline for T16b is in the last section.
Decision (TL;DR)¶
Option (b): the MSIX companion is NOT the engine-driven host — and in its minimal, honest limit it is removed entirely, not replaced by a second exe.
MSIX is a native OS deployment format: Windows lays down the payload and
launches the declared app entry point. MsixPackager already stages the app's
own files (CopyTree) and declares the real app exe as the entry point
(AppxManifest.xml → Executable="{execName}.exe",
EntryPoint="Windows.FullTrustApplication"). A bundled wizard has no install
job left to do — and, tellingly, nothing in the generated AppxManifest.xml
references the bundled installer.exe at all. It is a vestigial extra binary
that survived from the pre-T2 world.
Concretely, T16b stops bundling installer.exe + BrandTokens.g.json into
MSIX: delete the manifest.Installer is not null arm in MsixPackager.PackAsync
and delete InstallerHostBundler. Branding in MSIX flows through the surfaces the
OS actually honours and that MsixPackager already emits — AppxManifest.xml
<VisualElements> and the logo Assets/ produced by LogoAssetGenerator — not
through a bundled wizard reading a loose file.
Why not option (a): both sub-variants of (a) are actively worse than doing nothing:
- "Feed it a stamped blob" means
MsixPackagerwould have to invoke the exe-wrapper stamping pipeline (WrapperResourceWriter) to embed aSIGIL_BLOB_V1(steps) +SIGIL_PAYLOAD_V1(payload) resource into the bundled companion — shipping the payload twice (once as MSIX-deployed files, once inside the companion's blob) and duplicating the entireExeWrapperPackagermachinery inside the MSIX packager, all to run a wizard that redundantly re-installs what the OS already installed. Pure bloat (~35 MB Avalonia+Skia host per MSIX) and confusing UX. - "Keep a directory-source mode" means re-introducing a loose-file source the host reads at runtime — i.e. re-inventing exactly the sidecar mechanism T7 deletes. It contradicts T7's "the blob is the single source of truth, no loose files beside the exe" contract head-on.
The branded wizard runtime is, after T2, exclusively the exe-wrapper track's
concern (a single stamped -Setup.exe). MSIX and the .exe installer are two
parallel distribution formats for the same app, not layers of one pipeline.
Context: what the code actually does today¶
MsixPackager.PackAsync (src/SigilBuild.Packaging/Msix/MsixPackager.cs)¶
CopyTree(options.SourceDirectory, staging, …)— stages the app's own payload files (lines 45, 97–105).AppxManifestBuilder.Build(...)— writesAppxManifest.xml. The launched application isExecutable="{execName}.exe"whereexecNameis derived fromApp.Id(DeriveExeName,AppxManifestBuilder.cs:81), withEntryPoint="Windows.FullTrustApplication"(AppxManifestBuilder.cs:46–56). This is the real app exe, notinstaller.exe.LogoAssetGenerator.Generate(...)orCreatePlaceholderAssets(...)— writes theAssets/*Logo.pngreferenced by<VisualElements>(lines 50–56).- Only if
manifest.Installer is not null(lines 58–66): locate a host exe (envSIGIL_INSTALLER_HOST_EXE, elseinstaller/installer.exeunderAppContext.BaseDirectory) and callInstallerHostBundler.Bundle. MakeAppxRunner.PackAsync(...)→.msix.
InstallerHostBundler.Bundle (src/SigilBuild.Packaging/Installer/InstallerHostBundler.cs)¶
Two side effects, both into MSIX staging:
File.Copy(installerExeSource, Path.Combine(stagingDir, "installer.exe"), overwrite: true); // (1)
var tokens = BrandTokenEmitter.Emit(manifest);
File.WriteAllText(Path.Combine(stagingDir, "BrandTokens.g.json"), tokens); // (2)
Nothing in AppxManifest.xml references either file. InstallerHostBundler is
called from exactly one place — MsixPackager (line 65) — and BrandTokenEmitter
has exactly one production caller: InstallerHostBundler (line 20).
Why T2 already made this companion inert¶
Pre-T2, the bundled host ran the throwaway copy-loop
Installer.Host/Services/InstallerEngine.cs to copy files, and read its branding
from the BrandTokens.g.json dropped beside it
(App.axaml.cs:29 → BrandTokens.LoadOrDefault("BrandTokens.g.json"), reading from
CWD). T2 deleted that copy loop and rewired the host to drive the real engine
through InstallSession.Create → WrapperBlob.LoadFromSelf()
(InstallSession.cs; WrapperBlob.cs:50), which reads steps + parameters from a
Win32 resource SIGIL_BLOB_V1 embedded in the exe.
InstallerHostBundler copies the raw AOT-published installer.exe — which has
no SIGIL_BLOB_V1 resource stamped into it (only ExeWrapperPackager /
WrapperResourceWriter stamps blobs). So inside an MSIX, the bundled companion's
WrapperBlob.LoadFromSelf() finds no resource, returns WrapperBlob.Empty
(WrapperBlob.cs:31–37, 52–53) → zero steps, zero parameters. The MSIX
companion is already a do-nothing wizard even before T7 lands. T16 is the moment we
stop pretending it installs anything.
The BrandTokens.g.json sidecar's fate¶
Deleted from the MSIX path. Not migrated to a blob-in-MSIX, and not needed.
- The sidecar only ever existed so the bundled wizard could self-brand at
runtime. With no bundled wizard in MSIX, there is nothing to brand at runtime —
the OS renders the MSIX's identity from
AppxManifest.xml<VisualElements>(DisplayName,Description,BackgroundColor) and theAssets/logos, whichMsixPackageralready produces. Those are the branding surfaces Windows honours for a Start-menu tile / Store listing; a JSON of hex colours next to an unreferenced exe is not. - T7 removes the sidecar for the exe-wrapper host by threading the derived
light/dark token maps + base64 logo/hero bytes into
WrapperBlob(SerializableWrapperBlob+ itsJsonSerializerContext), and rewritesApp.axaml.csto read brand data from the blob instead ofLoadOrDefault("BrandTokens.g.json"). That is the single-stamped-.exedelivery mechanism. MSIX does not participate in it — there is no host in the MSIX to feed. - Net for MSIX: the
BrandTokens.g.jsonwrite inInstallerHostBundler.Bundle(line 21) is deleted outright. MSIX carries no brand-token file, in any form (sidecar or blob).
What feeds the host in MSIX now that the copy-loop InstallerEngine is gone¶
Nothing — because there is no host in the MSIX. This is the crux of choosing (b) over (a):
- In the exe-wrapper track, the stamped
SIGIL_BLOB_V1resource feeds the host (WrapperBlob.LoadFromSelf→InstallSession→InstallEngine). - In the MSIX track, the OS is the installer. The payload that the deleted
copy-loop used to lay down is exactly what
MsixPackager.CopyTreealready stages and MakeAppx/Windows deploys. There is no residual install work for a companion to perform, so there is nothing to feed it. Bundling a host purely so it can load an empty (or, under option (a), redundantly payload-duplicating) blob is the bloat this ADR rejects.
Consequences¶
- Smaller, honest MSIX packages. No ~35 MB Avalonia+Skia companion, no unreferenced files, no payload duplication.
- One branded-wizard runtime, one delivery path. The stamped-blob mechanism (T7) lives solely in the exe-wrapper track; the MSIX packager stays a thin manifest+assets+MakeAppx pipeline.
- Determinism preserved (spec §0). Removing the two staged files removes the only inputs to the MSIX that were not deterministic file-tree copies; output stays byte-identical across builds.
- Dead code retires cleanly.
InstallerHostBundlerand its only-consumer chain (BrandTokenEmitterinSigilBuild.Packaging) become removable. Note: T7 independently ports the real colour-derivation logic into the exe-wrapper / blob path, soSigilBuild.Packaging.Installer.BrandTokenEmitteris not a loss — it is a pre-T7 stub that emits hardcoded gradient defaults the schema never exposed. Coordinate the deletion with T7 (see risks). - Future option preserved. If a genuine requirement ever emerges for a branded
first-run experience inside MSIX, the correct shape is a purpose-built
minimal exe wired as an MSIX
<Extensions>/StartupTaskand stamped with a blob — never the directory-source sidecar of option (a). This ADR closes the vestigial companion; it does not preclude a deliberately-designed one later.
Minimal implementation outline for T16b (wave 3)¶
Ordered, and scoped to keep MSIX packaging green after T2/T7 (spec T16 acceptance: "MSIX packaging tests stay green; no orphaned copy-loop code remains").
MsixPackager.PackAsync— delete the wholeif (manifest.Installer is not null) { … }block (MsixPackager.cs:58–66), including theSIGIL_INSTALLER_HOST_EXEenv lookup and theinstaller/installer.exefallback probe. MSIX no longer cares whether the manifest declares aninstallersection.- Delete
src/SigilBuild.Packaging/Installer/InstallerHostBundler.cs— its only caller is the block removed in step 1. - Retire
BrandTokenEmitterfromSigilBuild.Packaging(coordinate with T7). Its only production caller wasInstallerHostBundler. Remaining callers are tests:tests/SigilBuild.Packaging.Tests/Installer/BrandTokenEmitterTests.csandtests/SigilBuild.Installer.Host.Tests/Negative/NegativeTests.cs. The WCAG contrast check those exercise moves with T7 into the blob token-derivation path; delete or migrate these tests to the T7 assertions. Do not silently leave a dead emitter behind. - Host side (verify T7 landed it): confirm
App.axaml.csno longer callsBrandTokens.LoadOrDefault("BrandTokens.g.json")and thatBrandTokens.cs's sidecar-read API and gradient fields are gone. MSIX correctness does not depend on this, but the sidecar file must have zero remaining producers or consumers repo-wide. - Lock the decision with a test. In
MsixPackagerTests(Windows+SDK gated, mirroringPack_OnWindows_ProducesMsixWhenSdkPresent), pack a manifest that does declare aninstallersection and assert the produced package's staging/extraction contains noinstaller.exeand noBrandTokens.g.json. This pins "MSIX never bundles the wizard" so it cannot regress. (Today's two tests assert nothing about the companion, so they stay green regardless — but they also would not catch a reintroduction.) - Grep-gate the cleanup (T16b VERIFY): confirm no references to the
deleted
Services/InstallerEngineremain anywhere insrc//tests/(narrative mentions in historical plan documents were acceptable; those documents have since been retired), and no references toInstallerHostBundler/BrandTokens.g.jsonremain outside docs.
Risks / coordination:
- T7 ordering. T16b removes
BrandTokenEmitter's only production caller; T7 removes gradient fields and moves colour derivation into the blob. Land T16b after (or same wave as) T7 so the emitter/sidecar are deleted once, coherently, without a window where one half references a removed member. If T16b runs first, keepBrandTokenEmittercompiling (delete only the bundler) and let T7 finish the emitter retirement. AppxManifestBuilderis unaffected. It already derives the executable name fromApp.Idand never referencedinstaller.exe; no manifest change is needed.- No behavioural change for MSIX branding —
<VisualElements>+ logoAssetswere always the OS-honoured surfaces and are already emitted. Optionally (nice-to- have, not required): defaultmanifest.Package.Msix.Logofrommanifest.Installer?.Brand?.Logowhen the MSIX logo is unset, so a brand logo declared once still reaches the tile. Flag as optional in T16b, not blocking.