Overview

FalkForge is a C# framework for building Windows installers — MSI packages, MSIX app packages, and self-extracting EXE bundles. It compiles directly to production-ready output with no external tools required.

Three Ways to Build Installers

ApproachBest ForDescription
C# Fluent API Developers who want full control Define installers as ordinary C# programs with IntelliSense, type safety, and refactoring. Compiled with dotnet build.
JSON Configuration Simple, no-compiler installers Write a JSON file describing your installer. Build with forge build config.json. No C# required. Experimental — see the work-in-progress note below.
FalkForge Studio Visual designers, non-developers A WPF desktop IDE for designing installers visually. Supports import from MSI and WiX, export to C# and CI/CD pipelines.
FalkForge Studio is a work in progress. The visual editor lags behind the fluent API: expect visual glitches and functional gaps, and treat it as a preview rather than a daily driver during the beta. The C# fluent API is the primary, fully supported authoring path.
JSON configuration is an experimental, incomplete subset — work in progress. It covers product metadata, features, files, shortcuts, registry, services, environment variables, major upgrade/downgrade, launch conditions, license, and signing. The extensions block now authors the Firewall, IIS, SQL, and .NET extensions — each is translated into the same real extension the C# API attaches via new MsiCompiler().Use(extension) and emitted into the compiled MSI (see JSON Configuration Format). A dotnet block now emits real MSI-native runtime detection — Signature/DrLocator/AppSearch rows plus a LaunchCondition gate on the block's own message — into a standalone MSI; the old JSN019 hard-fail for a dotnet block is gone. For anything beyond this subset, use the C# fluent API, which forge init scaffolds by default.

Key Features

Technology Stack

AspectDetail
Runtime.NET 10, C# latest
NullableEnabled project-wide
Package ManagementCentral Package Management (Directory.Packages.props)
Engine/ElevationNativeAOT (~3-5 MB), no reflection, no dynamic
UI FrameworkWPF + ReactiveUI
TestingxUnit 2.9.3 (~7,000+ tests)
Build PolicyTreatWarningsAsErrors — 0 warnings required

Quick Example — Minimal MSI Installer

The following program produces a working MSI that installs a single file into Program Files\Demo\HelloWorld with a minimal UI dialog set:

using FalkForge;
using FalkForge.Builders;
using FalkForge.Compiler.Msi;
using FalkForge.Models;

// The simplest possible installer: one file, no features, Minimal dialog set.
return Installer.Build(args, package =>
{
    package.Name = "Hello World";
    package.Manufacturer = "Demo";
    package.Version = new Version(1, 0, 0);

    package.UseDialogSet(MsiDialogSet.Minimal);

    package.Files(files => files
        .Add("payload/hello.txt")
        .To(KnownFolder.ProgramFiles / "Demo" / "HelloWorld"));

}, new MsiCompiler());

Concepts

Windows Installer (MSI) has its own vocabulary and its own rules, and they don't map cleanly onto "just copy some files and write some registry keys." If you're new to MSI — or coming from a tool like WiX where you write this machinery by hand — this section explains the mental model before the API reference dives into builders and types. FalkForge automates most of what follows, but understanding why it exists will save you from the installer bugs that only show up on repair, uninstall, or upgrade.

Components & the Component Rule

Every file, registry value, or shortcut an MSI installs belongs to a Component — a row in the MSI database identified by its own GUID. Windows Installer does not track installed state per file; it tracks it per component. The component's GUID is what repair, upgrade, and uninstall use to answer "is this still needed, and by whom?" A component also has exactly one KeyPath — the file (or registry value) Windows Installer checks to decide whether the component is present, damaged, or missing.

This is why WiX authors learn the component rule the hard way: put more than one file in a component and Windows Installer can no longer version, repair, or remove them independently — a repair can silently skip a "hidden" file, or an uninstall can delete a file another feature still needs. Reuse a component GUID across two products and you get reference-counting corruption: uninstalling one product can delete files the other still relies on. The rule that keeps this safe is simple to state and tedious to hand-author: one file per component.

FalkForge enforces the rule by never giving you the choice: ComponentResolver generates exactly one component per file automatically, with a deterministic GUID derived from the file's target directory and name (so rebuilding the same installer twice, unchanged, produces the same component identities). There is no <Component>, <ComponentGroup>, or ComponentRef to write — add a file, and its component exists. Assigning a file (or a shortcut, registry entry, or service) to a feature is a single chained call:

package.Files(files => files
    .Add("payload/app.exe")
    .To(KnownFolder.ProgramFiles / "Contoso" / "App")
    .WithFeature("Client"));

If you're coming from WiX, the Coming from WiX tutorial maps the rest of the XML element model — Directory, Feature, InstallExecuteSequence — onto the fluent API.

ProductCode, UpgradeCode & PackageCode

MSI packages carry three distinct GUIDs, and confusing them is one of the most common sources of broken upgrades. FalkForge's PackageBuilder.Build() assigns sensible defaults for all three, but knowing what each one means — and when FalkForge changes it — matters once you need stable identity across releases.

CodeIdentifiesFalkForge default behavior
UpgradeCode The product family, across every version you ever ship. Must stay constant — MajorUpgrade detection matches installed products by this GUID. Deterministically derived from Name + Manufacturer, so it stays stable across rebuilds automatically as long as those two values don't change. Pin it explicitly with package.UpgradeCode = ... if you ever plan to rename the product or manufacturer.
ProductCode One specific product+version combination. Classic MSI guidance says to bump it for every version you release. A fresh Guid.NewGuid() on every Build() call — even two builds of the identical version get different ProductCodes, unless you pin one explicitly. In reproducible-build mode (ReproducibleOptions), it's instead a deterministic digest of Name::Manufacturer::Version, so identical inputs reproduce the same code.
PackageCode This specific .msi file, byte-for-byte. The MSI spec requires every non-identical package to carry a unique PackageCode so Windows Installer can tell cached packages apart during repair (SECREPAIR / KB2918614). PackageBuilder.Build() assigns a fresh Guid.NewGuid() directly in normal mode (stable for the lifetime of that PackageModel, even if compiled more than once). In reproducible mode it's left null instead, and the compiler derives it from a SHA-256 content digest of the resolved files and product identity, so byte-identical builds share a PackageCode and any change to the payload produces a different one.
Leave all three alone unless you have a specific reason to pin them — a planned rename, a reproducible build pipeline, or mirroring a known binary-identical release. The defaults are correct for the overwhelming majority of installers, and pinning ProductCode without understanding upgrade semantics is a common way to break future MajorUpgrade detection.

Migrating an Existing Installer to FalkForge

A common starting point isn't a blank product — it's a product you already ship, built with WiX, another MSI toolkit, or a non-MSI installer (Inno Setup, NSIS, a hand-rolled EXE), and the goal is for a new FalkForge-built installer to replace the old one on a user's machine, not sit next to it as an unrelated second product. The single fact that decides whether that happens automatically is the one from the table above: Windows Installer's MajorUpgrade detection matches products by UpgradeCode alone, and PackageBuilder.Build() derives a fresh one (deterministically, from Name + Manufacturer) unless you set it explicitly. That derived value is not the old installer's UpgradeCode — a different tool used a different algorithm (or a human just picked a GUID) — so left alone, the new package installs as a stranger to Windows Installer, not a successor.

The Rule: Reuse the Old UpgradeCode

Set PackageBuilder.UpgradeCode to the exact GUID the old installer registered, and configure MajorUpgrade as usual. A fresh ProductCode on every build is still correct and expected — only UpgradeCode has to carry over:

var package = new PackageBuilder
{
    Name = "Contoso App",
    Manufacturer = "Contoso Inc.",
    Version = new Version(3, 0, 0),
    // The OLD installer's UpgradeCode, recovered below. Do not regenerate this value.
    UpgradeCode = Guid.Parse("12345678-1234-1234-1234-1234567890AB"),
};

package.MajorUpgrade(mu => mu
    .Schedule(RemoveExistingProductsSchedule.AfterInstallValidate)
    .MigrateFeatures(true));

See 8.14 MajorUpgradeBuilder for the full Schedule/MigrateFeatures/AllowSameVersionUpgrades API.

Recovering the Old UpgradeCode

If the old installer is itself an .msi (WiX, InstallShield, or any other MSI tool), the most direct route is forge decompile old.msi -o old.cs (Windows-only — it calls msi.dll the same way the compiler does). The generated C# includes a builder.UpgradeCode = new Guid("…") line read straight from the old MSI's Property table. forge inspect is not the tool for this — today it surfaces product name, manufacturer, version, and ProductCode, but not UpgradeCode.

The MSI spec doesn't strictly require an UpgradeCode row in the Property table, so an occasional old MSI won't have one for forge decompile to read (it comes back Guid.Empty). Two fallbacks that work on any MSI, FalkForge-built or not: Orca (Microsoft's free MSI table editor) — open the Upgrade table directly, every row's UpgradeCode column has it — or the registry: every product Windows Installer ever registered leaves a key under HKLM\SOFTWARE\Classes\Installer\UpgradeCodes\ (per-user installs mirror under HKCU). The key name is a packed GUID — compressed, no dashes, byte order reversed — not the human-readable form; let Orca or a dedicated unpacker convert it rather than hand-editing the dashes back in.

Demo 54 — forge migrate automates the whole path: forge migrate old.msi -o ./migrated decompiles the old installer and writes a buildable FalkForge project whose generated Program.cs already carries the recovered UpgradeCode forward, along with the reconstructed features, files, and other metadata it could map (a MIGRATION-REPORT.md lists anything it couldn't). For most migrations, starting from that generated project and adjusting it is faster and safer than retyping a recovered GUID into a hand-written PackageBuilder. forge migrate also reads WiX Burn bundle .exes the same way, for teams migrating a bundled installer rather than a bare MSI.

Caveats

Elevation: Per-Machine vs. Per-User

PackageBuilder.Scope is InstallScope.PerMachine by default. A per-machine install writes to protected locations — Program Files, HKLM, the Services database — that standard (non-admin) users can't touch, so Windows requires administrative privileges to run it. InstallScope.PerUser installs entirely inside the current user's profile and never needs elevation, at the cost of not being visible to other accounts on the machine.

For bundles, FalkForge's engine keeps the unprivileged UI process separated from anything that touches the system: the UI talks to an unprivileged Engine process over a named pipe, and only when the Applying phase actually needs a privileged operation does the Engine launch the Elevated companion — a second, minimal NativeAOT process that is the only one of the three ever running with elevated rights. The Engine and the Elevated companion mutually authenticate (HMAC-SHA256 handshake plus parent-process verification) before the Elevated side will execute anything, and it accepts only a fixed, whitelisted set of commands (MSI install/uninstall, service install, registry write, file write) — it never executes arbitrary code the UI sends it. See Engine Architecture for the full three-process design.

This is separate from the elevation the MSI itself may still trigger. A per-machine MSI run through MsiInstallProduct/MsiConfigureProduct carries its own ALLUSERS property and still needs an elevated token to execute — the engine's elevation model exists so the bundle wrapper only asks Windows for elevation once, at the point it's actually needed, instead of relaunching the whole UI as admin.

Install Sequencing

Windows Installer executes a fixed, ordered list of standard actionsCostInitialize, FileCost, CostFinalize, InstallValidate, InstallInitialize, InstallFiles, InstallFinalize, and dozens more — split across two sequences: the InstallUISequence (runs with the invoking user's own rights, collects input, shows dialogs) and the InstallExecuteSequence (does the real work, elevated for a per-machine install). A custom action doesn't get to run "whenever" — it schedules itself relative to a standard action with Before or After:

package.CustomAction("SetFalkVersion", ca =>
{
    ca.SetProperty("FALK_VERSION", "5.0.0");
    ca.After = "InstallValidate";
});

Order matters because later standard actions depend on the state earlier ones establish — InstallFiles needs the cost information CostFinalize computed; nothing should modify the system before InstallValidate has confirmed the install is allowed to proceed at all.

The other axis is immediate vs. deferred execution, and it's the one that trips up newcomers writing their own custom actions. Immediate actions run in place, with full access to the live property table — but if a later action fails and the install rolls back, anything an immediate action changed on disk or in the registry is not automatically undone. Deferred actions (MSI's InScript execution context) are the only actions allowed to make real system changes: they're written into the execute script ahead of time, run later (elevated, for a per-machine install), and participate in rollback — but they can no longer see the property table live, so any data they need must be handed to them in advance via CustomActionData, typically set by an immediate action that runs just before them. FalkForge's own extensions (Firewall, IIS, SQL, and the rest — see Extension System) follow exactly this split internally: an immediate action stages configuration into CustomActionData, and a paired deferred action performs the actual system change.

For a narrative, demo-by-demo walkthrough that builds on these ideas, see the tutorials — start with Getting Started, then MSI Basics.

Installation & Build

Prerequisites

RequirementDetail
.NET SDK10.0.103 or later (pinned via global.json)
Operating SystemWindows (required for MSI compilation via msi.dll P/Invoke)
Architecturex64 or Arm64

Building

# Build all projects (0 warnings required — TreatWarningsAsErrors is on)
dotnet build

# Run the full test suite (~7,000+ tests, xUnit 2.9.3; fast default skips
# heavyweight end-to-end tests -- set FALKFORGE_E2E=1 to include them)
dotnet test

# Publish NativeAOT binaries for Engine and Elevation
dotnet publish -c Release
The solution enforces TreatWarningsAsErrors across all projects. Any compiler warning will fail the build.

NativeAOT Publishing

The FalkForge.Engine and FalkForge.Engine.Elevation projects are configured for NativeAOT compilation with the following settings:

This produces standalone executables of approximately 3-5 MB with no .NET runtime dependency. NativeAOT constraints apply: no reflection, no dynamic, no BinaryFormatter. All serialization uses a custom binary protocol.

CLI Tool — forge

The FalkForge.Cli project provides the forge command-line tool built on Spectre.Console:

CommandDescription
forge init --name "My App"Scaffold a starter installer project (csproj + Program.cs + payload) referencing the FalkForge meta-package
forge build installer.csxBuild installer from C# script (Roslyn scripting)
forge build installer.jsonBuild installer from JSON config file
forge validate installer.csxValidate without compiling
forge inspect Package.msiDisplay MSI metadata with tree views (Windows-only)
forge decompile Package.msiDecompile MSI to PackageModel or C# source (Windows-only)
forge migrate Package.msiConvert existing .msi/.msm/.exe (WiX Burn) into a buildable FalkForge C# project
forge plan bundle.exeCompute and display the install plan for a bundle without running it
forge plan-diff a.exe b.exeDiff two installers of the same type and report changes
forge verify bundle.exeIndependently rebuild from source and byte-compare to prove reproducibility; verdicts: VERIFIED / MISMATCH / REBUILD-FAILED / SETUP-ERROR
forge loc export --culture en-US -o custom-en-US.jsonExport a built-in localization JSON file as a starting point for an override

Exit Codes

CodeMeaning
0Success
1Validation error
2Compilation error
3Runtime error

Solution Structure

FalkForge is organized into 29 source projects and 26 test projects, each with a single responsibility. Source projects live under src/; test projects under tests/.

Source Projects (29)

ProjectDescription
FalkForge.CoreDomain model, fluent builder API, validation, core types (Result, Error, MsiProperty, Condition)
FalkForge.Compiler.MsiMSI/MSM/MSP/MST generation via msi.dll P/Invoke, cabinet building, ICE validation
FalkForge.Compiler.BundleSelf-extracting EXE bundle compiler with manifest generation and payload embedding
FalkForge.Compiler.MsixExperimental MSIX and .msixbundle compiler. Wired into Studio; full CLI dispatch not yet implemented. Entry points: InstallerMsix.BuildMsix() / BuildMsixBundle().
FalkForge.EngineNativeAOT installer runtime — pipeline, phase steps, detection, planning, execution, rollback, logging, metrics
FalkForge.Engine.ElevationNativeAOT elevated companion process for privileged operations
FalkForge.Engine.ProtocolIPC message types, binary serialization, named pipe transport with HMAC-SHA256 handshake, ProgramArgs log-flag parser
FalkForge.PlatformOS abstractions (IFileSystem, IRegistry)
FalkForge.Platform.WindowsWindows P/Invoke implementations: IFileSystem, IRegistry, IMsiApi (MsiInstallProduct/MsiConfigureProduct)
FalkForge.ExtensibilityExtension system interfaces (IFalkForgeExtension, IComponentContributor, IMsiTableContributor, ITableQuery, ITableReadSchema)
FalkForge.Extensions.UtilXmlConfig, UserManagement, FileShare, QuietExec, RemoveFolderEx, InternetShortcut
FalkForge.Extensions.DependencyProvider/consumer ref-counting (WiX-compatible) via HKLM registry
FalkForge.Extensions.FirewallWindows Firewall rule definitions and validation
FalkForge.Extensions.DotNet.NET runtime detection via registry and filesystem probing
FalkForge.Extensions.DriverWindows driver install/uninstall actions (.inf / DIFx)
FalkForge.Extensions.HttpHTTP listener / URL ACL reservations (http.sys)
FalkForge.Extensions.IisIIS AppPool, WebSite, WebBinding, Certificate configuration
FalkForge.Extensions.SqlSQL Server database creation, script execution, string execution
FalkForge.Ui.AbstractionsIInstallerEngine, base ViewModels, PageResult, InstallerState, SensitiveBytes
FalkForge.UiWPF + ReactiveUI installer UI, custom UI framework (InstallerPage, InstallerApp)
FalkForge.SdkMSBuild SDK targets for source generation (netstandard2.0)
FalkForge.StudioWPF authoring environment for editing installer definitions visually
FalkForge.TestingTest utilities, mocks, shared test infrastructure
FalkForge.LocalizationJSON-based localization with culture fallback chains
FalkForge.DecompilerMSI / Bundle / WiX Burn decompiler producing PackageModel / BundleModel / C# source
FalkForge.CliSpectre.Console CLI: build, validate, inspect, decompile, extract, winget, rules, bundle commands
FalkForge.Plugins.SqlSQL Server discovery, listing, connection testing
FalkForge.Plugins.OdbcODBC DSN checking and admin launcher (Windows-only)
FalkForge.Plugins.FileSystemFolder browser dialog (WPF, Windows-only)

Test Projects (26)

ProjectTests For
FalkForge.Core.TestsCore domain model, builders, validation
FalkForge.Compiler.Msi.TestsMSI compiler, table emission, cabinet building
FalkForge.Compiler.Msix.TestsMSIX compiler (experimental)
FalkForge.Compiler.Bundle.TestsBundle compiler, manifest, payload embedding
FalkForge.Engine.TestsEngine session, pipeline, phase steps, runner, rollback
FalkForge.Engine.Elevation.TestsElevated command dispatch
FalkForge.Engine.Protocol.TestsProtocol serialization, transport, golden-byte parity
FalkForge.Ui.Abstractions.TestsPageResult, InstallerState
FalkForge.Ui.TestsWPF UI, custom UI framework
FalkForge.Studio.TestsStudio editors and view-models
FalkForge.Integration.TestsEnd-to-end integration scenarios
FalkForge.Extensibility.TestsExtension interfaces, table contributors, rule merging
FalkForge.Extensions.Util.TestsUtility extension actions
FalkForge.Extensions.Dependency.TestsDependency provider/consumer validation
FalkForge.Extensions.Firewall.TestsFirewall rule validation
FalkForge.Extensions.DotNet.Tests.NET detection logic
FalkForge.Extensions.Driver.TestsDriver extension actions
FalkForge.Extensions.Http.TestsHTTP listener / URL ACL extension
FalkForge.Extensions.Iis.TestsIIS configuration validation
FalkForge.Extensions.Sql.TestsSQL extension validation
FalkForge.Localization.TestsLocalization loading, fallback, resolution
FalkForge.Decompiler.TestsMSI/bundle/Burn decompilation and C# emission
FalkForge.Cli.TestsCLI commands, JSON config loading
FalkForge.Platform.Windows.TestsIMsiApi contract, P/Invoke bindings
FalkForge.Plugins.Odbc.TestsODBC DSN management
FalkForge.Plugins.Sql.TestsSQL Server discovery, connection testing

Dependency Graph

Core (no dependencies)
  +--> Platform --> Platform.Windows
  +--> Engine.Protocol (AOT-safe) --> Ui.Abstractions --> Ui (WPF + ReactiveUI)
  |                                +--> Compiler.Bundle
  +--> Compiler.Msi              (Core + Platform)
  +--> Compiler.Msix             (Core + Platform, Windows-only, experimental)
  +--> Extensibility             (standalone)
  +--> Extensions.Util           (Core + Extensibility)
  +--> Extensions.Dependency     (Core + Extensibility)
  +--> Extensions.Firewall       (Core + Extensibility)
  +--> Extensions.DotNet         (Core + Extensibility + Platform)
  +--> Extensions.Driver         (Core + Extensibility)
  +--> Extensions.Http           (Core + Extensibility)
  +--> Extensions.Iis            (Core + Extensibility)
  +--> Extensions.Sql            (Core + Extensibility)
  +--> Testing                   (Core + Platform)
  +--> Localization              (Core)
  +--> Decompiler                (Core + Compiler.Msi + Compiler.Bundle)
  +--> Plugins.Sql               (Core + Microsoft.Data.SqlClient)
  +--> Plugins.Odbc              (Core, Windows-only)
  +--> Plugins.FileSystem        (Core, Windows-only)
  +--> Studio                    (Core + Compiler.Msi/Msix/Bundle + Decompiler + Ui)

Executables:
  Engine     = Engine.Pipeline + Engine.Protocol + Platform.Windows
  Elevation  = Engine.Protocol + Platform.Windows
  Cli        = Core + Compiler.Msi + Compiler.Bundle + Decompiler
             + Localization + Extensibility + all Extensions

3-Process Engine Architecture

[UI Process]              [Engine Process]            [Elevated Engine]
 WPF + ReactiveUI          NativeAOT (~3-5 MB)         NativeAOT (elevated)
 FalkForge.Ui               FalkForge.Engine             FalkForge.Engine.Elevation
       |<-- Named Pipe A -->|<-- Named Pipe B ---------->|

The UI process hosts the WPF application, the Engine process runs detection, planning, and orchestration, and the Elevated Engine performs privileged operations (MSI install, registry writes, service installs) as a separate process with administrator rights. All inter-process communication uses a binary protocol over named pipes with HMAC-SHA256 authentication.

Core Types

The FalkForge namespace contains the foundational types used throughout the framework. These types enforce a functional error-handling pattern using Result<T> instead of exceptions.

Result<T>

A readonly record struct representing the outcome of a fallible operation. Either contains a success value of type T or an Error. Defined in src/FalkForge.Core/Result.cs.

public readonly record struct Result<T>

Public Members

MemberTypeDescription
IsSuccessboolTrue when the result holds a value (no error).
IsFailureboolTrue when the result holds an error.
ValueTThe success value. Throws InvalidOperationException if IsFailure.
ErrorErrorThe error. Throws InvalidOperationException if IsSuccess.

Static Methods

MethodDescription
Success(T value)Creates a successful result. Throws ArgumentNullException if value is null.
Failure(Error error)Creates a failed result from an Error.
Failure(ErrorKind kind, string message)Creates a failed result from kind and message.

Instance Methods

MethodSignatureDescription
Match TResult Match<TResult>(Func<T, TResult> onSuccess, Func<Error, TResult> onFailure) Pattern-match on success or failure, returning a value of type TResult.
Map Result<TResult> Map<TResult>(Func<T, TResult> map) Transform the success value. Propagates failure unchanged.
Bind Result<TResult> Bind<TResult>(Func<T, Result<TResult>> bind) Chain a fallible operation. Propagates failure unchanged.

Operators

OperatorDescription
implicit operator Result<T>(T value)Implicit conversion from T to a successful result.

Usage Example

Result<string> result = compiler.Compile(model, outputPath);

// Pattern matching
string message = result.Match(
    path => $"Created: {path}",
    error => $"Failed: {error.Kind} - {error.Message}");

// Chaining
Result<int> lineCount = result
    .Map(path => File.ReadAllLines(path).Length);

// Binding
Result<PackageModel> validated = result
    .Bind(path => ValidateOutput(path));

Error

An immutable record struct pairing an ErrorKind with a human-readable message. Defined in src/FalkForge.Core/Error.cs.

public readonly record struct Error(ErrorKind Kind, string Message)

Public Members

MemberTypeDescription
KindErrorKindThe category of error.
MessagestringHuman-readable error description.
ToString()stringReturns "{Kind}: {Message}".

ErrorKind

Enum classifying the domain of an error. Defined in src/FalkForge.Core/ErrorKind.cs. See the Enum Reference for all values.

Unit

A readonly record struct representing the absence of a value, used as the type parameter for Result<Unit> when an operation succeeds without producing a value. Defined in src/FalkForge.Core/Unit.cs.

public readonly record struct Unit
{
    public static readonly Unit Value = default;
}

Usage Example

public Result<Unit> DeleteFile(string path)
{
    if (!File.Exists(path))
        return Result<Unit>.Failure(ErrorKind.FileNotFound, $"File not found: {path}");

    File.Delete(path);
    return Unit.Value; // implicit conversion to Result<Unit>
}

Installer (Entry Point)

The static Installer class is the top-level entry point for all output types. Each method accepts command-line arguments (supporting -o/--output for output path), a configure action, and a compiler/compile function. Defined in src/FalkForge.Core/Installer.cs.

public static class Installer

Static Methods

MethodSignatureDescription
Build int Build(string[] args, Action<PackageBuilder> configure, ICompiler? compiler = null) Build an MSI package. Configures via PackageBuilder, validates, and optionally compiles. Returns exit code (0 = success, 1 = failure).
BuildBundle int BuildBundle(string[] args, Func<string, Result<string>> compile) Build an EXE bundle. The caller provides a compile function receiving the output path.
BuildMergeModule int BuildMergeModule(string[] args, Action<MergeModuleBuilder> configure, Func<MergeModuleModel, string, Result<string>> compile) Build a merge module (.msm). Configures, validates, and compiles.
BuildPatch int BuildPatch(string[] args, Action<PatchBuilder> configure, Func<PatchModel, string, Result<string>> compile) Build a patch (.msp). Configures, validates, and compiles.
BuildTransform int BuildTransform(string[] args, Action<TransformBuilder> configure, Func<TransformModel, string, Result<string>> compile) Build a transform (.mst). Configures, validates, and compiles.
All methods support -o / --output in the args array to specify the output directory. When not provided, the current working directory is used.

Workflow

Each Build* method follows the same pattern:

  1. Instantiate the appropriate builder
  2. Call the user's configure action
  3. Build the model from the builder
  4. Validate the model (print errors/warnings to stderr)
  5. Compile if a compiler is provided
  6. Return 0 on success, 1 on failure

ICompiler

The compiler interface for MSI package generation. Defined in src/FalkForge.Core/ICompiler.cs.

public interface ICompiler
{
    Result<string> Compile(PackageModel model, string outputPath);
}

The MsiCompiler class in FalkForge.Compiler.Msi is the production implementation.

KnownFolder

Sealed class representing standard MSI directory tokens with type-safe path composition. Defined in src/FalkForge.Core/KnownFolder.cs.

public sealed class KnownFolder

Static Instances

InstanceMSI TokenDisplay Name
ProgramFilesProgramFilesFolderProgram Files
ProgramFiles64ProgramFiles64FolderProgram Files (64-bit)
CommonAppDataCommonAppDataFolderProgramData
LocalAppDataLocalAppDataFolderLocal AppData
AppDataAppDataFolderAppData
SystemFolderSystemFolderSystem32
System64FolderSystem64FolderSystem64
WindowsFolderWindowsFolderWindows
TempFolderTempFolderTemp
DesktopFolderDesktopFolderDesktop
StartMenuFolderStartMenuFolderStart Menu
ProgramMenuFolderProgramMenuFolderPrograms
StartupFolderStartupFolderStartup
CommonFilesFolderCommonFilesFolderCommon Files
CommonFiles64FolderCommonFiles64FolderCommon Files (64-bit)
FontsFolderFontsFolderFonts

Operators

OperatorDescription
KnownFolder / string Creates an InstallPath by combining the folder with a sub-path.
KnownFolder[string] Indexer — equivalent to the / operator.

Usage Example

// Compose install paths using the / operator
InstallPath appDir = KnownFolder.ProgramFiles / "MyCompany" / "MyApp";
InstallPath binDir = appDir / "bin";

// Use with file builder
package.Files(f => f
    .Add("payload/app.exe")
    .To(KnownFolder.ProgramFiles / "MyCompany" / "MyApp"));

InstallPath

Represents a composed directory path rooted at a KnownFolder. Defined in src/FalkForge.Core/InstallPath.cs.

public sealed class InstallPath

Public Members

MemberTypeDescription
RootKnownFolderThe root known folder.
RelativePathstringThe relative path from the root (forward slashes, no trailing slash).
SegmentsIReadOnlyList<string>All directory segments from root to this path.
InstallPath / stringInstallPathAppends a sub-path, returning a new InstallPath.

MSI Properties & Conditions

FalkForge provides type-safe wrappers for MSI properties and conditions. The MsiProperty class represents named MSI properties with operator overloads for path composition and condition generation. The Condition class represents MSI conditional expressions with logical operators.

MsiProperty

Sealed class for type-safe MSI property references. Provides static instances for ~42 built-in properties and a Custom() factory for user-defined properties. Defined in src/FalkForge.Core/MsiProperty.cs.

public sealed class MsiProperty : IEquatable<MsiProperty>

Properties

MemberTypeDescription
NamestringThe MSI property name (e.g., "ProductName", "INSTALLFOLDER").

Factory Method

MethodDescription
Custom(string name)Creates a user-defined property reference. Throws if name is null or whitespace.

Static Instances — Product

InstanceMSI Property NameDescription
ProductNameProductNameProduct display name
ProductCodeProductCodeProduct GUID
ProductVersionProductVersionProduct version string
ProductLanguageProductLanguageProduct language LCID
ManufacturerManufacturerProduct manufacturer name
UpgradeCodeUpgradeCodeUpgrade code GUID for major upgrades

Static Instances — Directories

InstanceMSI Property NameDescription
InstallFolderINSTALLFOLDERPrimary installation directory
InstallDirINSTALLDIRAlternative install directory property
TargetDirTARGETDIRRoot target directory

Static Instances — OS Version

InstanceMSI Property NameDescription
VersionNTVersionNTWindows NT version number
VersionNT64VersionNT6464-bit Windows version number
ServicePackLevelServicePackLevelInstalled service pack level
WindowsBuildNumberWindowsBuildNumberWindows build number

Static Instances — Architecture

InstanceMSI Property NameDescription
IntelIntelx86 processor detected
Intel64Intel64Itanium processor detected
MsiAMD64MsiAMD64x64 (AMD64) processor detected
Msix64Msix6464-bit processor detected (x64 or Arm64)

Static Instances — System Folders

InstanceMSI Property NameDescription
ProgramFilesFolderProgramFilesFolderProgram Files directory
ProgramFiles64FolderProgramFiles64Folder64-bit Program Files
CommonFilesFolderCommonFilesFolderCommon Files directory
SystemFolderSystemFolderSystem32 directory
System64FolderSystem64Folder64-bit System directory
WindowsFolderWindowsFolderWindows directory
TempFolderTempFolderTemporary directory
AppDataFolderAppDataFolderRoaming AppData
LocalAppDataFolderLocalAppDataFolderLocal AppData
CommonAppDataFolderCommonAppDataFolderProgramData directory
DesktopFolderDesktopFolderUser desktop
StartMenuFolderStartMenuFolderStart Menu root
ProgramMenuFolderProgramMenuFolderPrograms folder in Start Menu
StartupFolderStartupFolderStartup folder
FontsFolderFontsFolderFonts directory
PersonalFolderPersonalFolderUser documents folder

Static Instances — Session

InstanceMSI Property NameDescription
PrivilegedPrivilegedSet when installer runs with elevated privileges
AdminUserAdminUserSet when user is an administrator
TerminalServerTerminalServerSet when running on Terminal Server
RemoteAdminTSRemoteAdminTSSet for remote administration via Terminal Server

Static Instances — User

InstanceMSI Property NameDescription
ComputerNameComputerNameMachine name
LogonUserLogonUserCurrent logged-on user
UserSIDUserSIDUser security identifier

Static Instances — State

InstanceMSI Property NameDescription
InstalledInstalledSet when product is already installed
UILevelUILevelUI level (2=none, 3=basic, 4=reduced, 5=full)
REMOVEREMOVEFeatures being removed (e.g., "ALL")
REINSTALLREINSTALLFeatures being reinstalled
CustomActionDataCustomActionDataData passed to deferred custom actions

Path Composition — / Operator

The / operator composes an MSI property reference with a sub-path:

// MsiProperty / "subpath" produces "[PROPERTY]subpath"
string path = MsiProperty.InstallFolder / "bin";
// Result: "[INSTALLFOLDER]bin"

string configDir = MsiProperty.InstallFolder / "config" ;
// Result: "[INSTALLFOLDER]config"

Comparison Operators — Returning Condition

Comparison operators on MsiProperty produce Condition objects. String comparisons quote the value; integer comparisons do not.

// String equality: ProductName = "MyApp"
Condition c1 = MsiProperty.ProductName == "MyApp";

// String inequality: ProductName <> "MyApp"
Condition c2 = MsiProperty.ProductName != "MyApp";

// Integer comparisons
Condition c3 = MsiProperty.VersionNT >= 603;      // VersionNT >= 603
Condition c4 = MsiProperty.UILevel > 2;            // UILevel > 2
Condition c5 = MsiProperty.WindowsBuildNumber >= 22000; // Windows 11+
OperatorMSI ExpressionValue Types
==Name = "value" or Name = valuestring, int
!=Name <> "value" or Name <> valuestring, int
>Name > valueint
<Name < valueint
>=Name >= valueint
<=Name <= valueint

Condition

Sealed class for type-safe MSI conditional expressions with logical operators and pre-composed common conditions. Defined in src/FalkForge.Core/Condition.cs.

public sealed class Condition

Factory Methods

MethodDescription
Property(string name) Creates a condition that tests whether a property is set (truthy). Equivalent to just the property name in MSI conditions.
Raw(string expression) Creates a condition from a raw MSI condition expression string. Use for complex expressions not covered by operators.

Pre-Composed Conditions

InstanceExpressionDescription
Is64BitOSVersionNT64 OR Msix64True on 64-bit Windows (x64 or Arm64)
IsPrivilegedPrivilegedTrue when running with elevated privileges
IsAdminAdminUserTrue when user has administrator rights
IsTerminalServerTerminalServerTrue on Terminal Server
IsWindows10OrLaterVersionNT >= 603True on Windows 10 or later
IsWindows11OrLaterWindowsBuildNumber >= 22000True on Windows 11 or later
IsInstalledInstalledTrue when the product is already installed
IsInstallingNOT InstalledTrue during first-time installation
IsUninstallingREMOVE="ALL"True during complete uninstallation
IsRepairingREINSTALLTrue during repair

Logical Operators

OperatorResultDescription
&(left) AND (right)Logical AND with automatic parenthesization
|(left) OR (right)Logical OR with automatic parenthesization
!NOT (condition)Logical NOT with automatic parenthesization

Implicit Conversion

Condition implicitly converts to string for backward compatibility with methods that accept string conditions.

Usage Examples

// Combine pre-composed conditions with logical operators
Condition installOnlyOn64Bit = Condition.IsInstalling & Condition.Is64BitOS;
// Result: "(NOT Installed) AND (VersionNT64 OR Msix64)"

// Negate a condition
Condition notAdmin = !Condition.IsAdmin;
// Result: "NOT (AdminUser)"

// Build complex conditions from property comparisons
Condition win11Admin = (MsiProperty.WindowsBuildNumber >= 22000) & Condition.IsAdmin;
// Result: "(WindowsBuildNumber >= 22000) AND (AdminUser)"

// Use Raw for edge cases
Condition custom = Condition.Raw("MsiNTSuitePersonal");

// Use Property to test if a property is set
Condition hasFeatureX = Condition.Property("FEATUREX_SELECTED");

Enum Reference

This section documents all public enums in the FalkForge framework, grouped by project.

FalkForge.Core

ErrorKind

namespace FalkForge — Classifies the domain of an error within Result<T>.

None = 0 Validation = 1 FileNotFound = 2 DirectoryNotFound = 3 InvalidConfiguration = 4 IoError = 5 CompilationError = 6 SecurityError = 7 PlatformError = 8 InvalidOperation = 9 NotSupported = 10 ProtocolError = 11 TransportError = 12 HandshakeError = 13 EngineError = 14 ElevationError = 15 BundleError = 16 PayloadError = 17 CacheError = 18 RollbackError = 19 DetectionError = 20 PlanningError = 21 ExecutionError = 22 DownloadError = 23 LayoutError = 24

InstallScope

namespace FalkForge — Determines whether the package installs per-machine or per-user.

PerMachine PerUser

CompressionLevel

namespace FalkForge — Cabinet file compression level.

None Low Medium High

ProcessorArchitecture

namespace FalkForge — Target processor architecture for the package.

X86 X64 Arm64

RegistryRoot

namespace FalkForge — Registry hive root for registry entries.

LocalMachine CurrentUser ClassesRoot Users

RegistryValueType

namespace FalkForge — Registry value data type.

String ExpandString MultiString DWord QWord Binary

ServiceAccount

namespace FalkForge — Windows service logon account type.

LocalSystem LocalService NetworkService User

ServiceStartMode

namespace FalkForge — Windows service startup type.

Automatic Manual Disabled DelayedAutomatic

ShortcutLocation

namespace FalkForge — Target location for installed shortcuts.

Desktop StartMenu Startup

InstallAction

namespace FalkForge — Top-level installer action requested by the user.

Install Uninstall Repair Modify

ExitCodeBehavior

namespace FalkForge — How the engine interprets a package exit code.

Success Failure RebootRequired ScheduleReboot

FailureAction

namespace FalkForge — Service failure recovery action.

None Restart Reboot RunCommand

RelatedBundleRelation

namespace FalkForge — Relationship type between related bundles.

Upgrade Addon Patch Detect

FalkForge.Core / Models

AssemblyType

namespace FalkForge.Models — Type of assembly for side-by-side registration.

DotNetAssembly = 0 Win32Assembly = 1

CustomActionType

namespace FalkForge.Models — Static class with MSI custom action type constants (bitmask values).

ConstantValueDescription
DllFromBinary1Call a DLL entry point from a Binary table stream
ExeFromBinary2Run an EXE from a Binary table stream
JScriptFromBinary5Execute JScript from a Binary table stream
VBScriptFromBinary6Execute VBScript from a Binary table stream
ExeInDir34Run an EXE located in a directory
SetProperty51Set a property value
SetDirectory35Set a directory path
Continue0x040 (64)Execution flag: continue on failure
InScript0x100 (256)Execution flag: deferred execution
Rollback0x200 (512)Execution flag: rollback custom action
Commit0x400 (1024)Execution flag: commit custom action
NoImpersonate0x800 (2048)Execution flag: run with elevated (system) privileges
CustomActionType is a static class with const int fields, not an enum. Source and execution flags are combined with bitwise OR to form the full custom action type value.

CustomTableColumnType

namespace FalkForge.Models — Data type for custom MSI table columns.

String Int16 Int32 Binary Stream

EnvironmentVariableAction

namespace FalkForge.Models — How an environment variable is modified.

Set Append Prepend

IniFileAction

namespace FalkForge.Models — INI file modification action.

CreateLine = 0 CreateEntry = 1 RemoveLine = 2 RemoveTag = 3

RemoveRegistryAction

namespace FalkForge.Models — Registry removal granularity.

RemoveKey RemoveValue

MsiDialogSet

namespace FalkForge.Models — Built-in MSI dialog set templates.

None = 0 Minimal InstallDir FeatureTree Mondo Advanced

SequenceTable

namespace FalkForge.Models — MSI sequence table for custom action scheduling.

InstallExecuteSequence InstallUISequence

RemoveExistingProductsSchedule

namespace FalkForge.Models — When to remove existing products during a major upgrade.

AfterInstallValidate AfterInstallInitialize AfterInstallExecute AfterInstallExecuteAgain AfterInstallFinalize

PatchClassification

namespace FalkForge.Models — Classification for MSP patch packages.

Hotfix Update SecurityUpdate

ServiceControlEvent

namespace FalkForge.Models[Flags] enum for service control actions during install/uninstall.

ValueNumericDescription
None0No service control action
StartOnInstall1Start the service during install
StopOnInstall2Stop the service during install
DeleteOnInstall8Delete the service during install
StartOnUninstall16Start the service during uninstall
StopOnUninstall32Stop the service during uninstall
DeleteOnUninstall128Delete the service during uninstall

FalkForge.Core / Validation

ValidationSeverity

namespace FalkForge.Validation — Severity level for validation results.

Warning Error

FalkForge.Compiler.Msi

IceMessageSeverity

namespace FalkForge.Compiler.Msi.Validation — Severity of ICE validation messages.

Failure = 0 Error = 1 Warning = 2 Information = 3

FalkForge.Compiler.Bundle

BundlePackageType

namespace FalkForge.Compiler.Bundle — Type of package within a bundle chain.

MsiPackage ExePackage NetRuntime MsuPackage MspPackage BundlePackage

BundleUiType

namespace FalkForge.Compiler.Bundle — UI mode for bundle installers.

BuiltIn Custom Silent

BundleVariableType

namespace FalkForge.Compiler.Bundle — Data type for bundle variables.

String Numeric Version

FalkForge.Engine.Protocol

MessageType

namespace FalkForge.Engine.Protocol: ushort. IPC message type identifiers organized by direction.

ValueHexDirectionDescription
DetectBegin0x0101Engine → UIDetection phase started
DetectComplete0x0102Engine → UIDetection phase completed
PlanBegin0x0103Engine → UIPlanning phase started
PlanComplete0x0104Engine → UIPlanning phase completed
ApplyBegin0x0105Engine → UIApply phase started
ApplyComplete0x0106Engine → UIApply phase completed
Progress0x0107Engine → UIProgress update
Error0x0108Engine → UIError notification
PhaseChanged0x0109Engine → UIEngine phase transition
Log0x010AEngine → UILog message
ShutdownResponse0x010BEngine → UIAcknowledgment of shutdown request
UpdateAvailable0x010CEngine → UIUpdate available notification
UpdateReady0x010DEngine → UIUpdate downloaded and ready
Cancel0x0201UI → EngineUser cancelled the operation
ShutdownRequest0x0202UI → EngineRequest engine shutdown
SetInstallDirectory0x0203UI → EngineSet the installation directory
SetFeatureSelection0x0204UI → EngineSet feature selection state
RequestDetect0x0205UI → EngineRequest detection phase
RequestPlan0x0206UI → EngineRequest planning phase
RequestApply0x0207UI → EngineRequest apply phase
ElevateExecute0x0301Engine → ElevatedExecute elevated command
ElevateResult0x0401Elevated → EngineElevated command result

EnginePhase

namespace FalkForge.Engine.Protocol — Engine state machine phases.

Initializing Detecting Planning Elevating Applying Completing Failed RollingBack Shutdown

InstallState

namespace FalkForge.Engine.Protocol — Detected installation state of a package.

NotInstalled Installed OlderVersion NewerVersion

LogLevel

namespace FalkForge.Engine.Protocol — Engine log message severity.

Verbose = 0 Debug = 1 Info = 2 Warning = 3 Error = 4

FalkForge.Engine.Protocol / Manifest

PackageType (Manifest)

namespace FalkForge.Engine.Protocol.Manifest — Package type within an installer manifest.

MsiPackage ExePackage NetRuntime MsuPackage MspPackage BundlePackage

UpdatePolicy

namespace FalkForge.Engine.Protocol.Manifest — Update check behavior policy for bundles.

NotifyOnly DownloadAndPrompt AutoUpdate

FalkForge.Engine

PlanActionType

namespace FalkForge.Engine.Planning — Planned action for a package.

Install Uninstall Repair

JournalEntryType

namespace FalkForge.Engine.Journal — Type of operation recorded in the rollback journal.

PackageInstalled PackageUninstalled FileCreated FileModified RegistryKeyCreated RegistryValueSet ServiceInstalled SegmentBoundary MsiInstalled ExeInstalled PayloadCached RegistryModified

TokenType (Condition Lexer)

namespace FalkForge.Engine.Variables — Token types produced by the condition expression lexer.

Variable StringLiteral IntLiteral VersionLiteral Equals NotEquals LessThan GreaterThan LessOrEqual GreaterOrEqual CaseInsensitiveEquals And Or Not LeftParen RightParen End Invalid

FalkForge.Ui.Abstractions

PageResultKind

namespace FalkForge.Ui.Abstractions — Navigation result kind for custom UI pages.

Next Previous Stay GoTo Finish Cancel Install Uninstall Repair

FalkForge.Extensions.Firewall

FirewallRuleAction

namespace FalkForge.Extensions.Firewall — Whether a firewall rule allows or blocks traffic.

Allow Block

FirewallProtocol

namespace FalkForge.Extensions.Firewall — Network protocol for a firewall rule.

Tcp Udp Any

FirewallProfile

namespace FalkForge.Extensions.Firewall[Flags] enum for network profiles a firewall rule applies to.

ValueNumericDescription
Domain1Domain network profile
Private2Private network profile
Public4Public network profile
All7All profiles (Domain | Private | Public)

FirewallDirection

namespace FalkForge.Extensions.Firewall — Traffic direction for a firewall rule.

Inbound Outbound

FalkForge.Extensions.DotNet

DotNetRuntimeType

namespace FalkForge.Extensions.DotNet — Type of .NET runtime to detect.

Runtime AspNetCore WindowsDesktop Sdk

DotNetPlatform

namespace FalkForge.Extensions.DotNet — Target platform for .NET runtime detection.

X64 X86 Arm64

FalkForge.Extensions.Iis

AppPoolIdentityType

namespace FalkForge.Extensions.Iis.Models — Identity under which an IIS application pool runs.

ApplicationPoolIdentity LocalSystem LocalService NetworkService SpecificUser

ManagedPipelineMode

namespace FalkForge.Extensions.Iis.Models — Managed pipeline mode for an IIS application pool.

Integrated Classic

CertificateFindType

namespace FalkForge.Extensions.Iis.Models — How to locate a certificate in a store.

FindByThumbprint FindBySubjectName

CertificateStoreName

namespace FalkForge.Extensions.Iis.Models — Certificate store name.

My Root CA

CertificateStoreLocation

namespace FalkForge.Extensions.Iis.Models — Certificate store location.

LocalMachine CurrentUser

FalkForge.Extensions.Util

XmlConfigAction

namespace FalkForge.Extensions.Util.XmlConfig — XML configuration file modification action.

CreateElement DeleteElement SetAttribute DeleteAttribute SetValue BulkSetValue

RemoveFolderExInstallMode

namespace FalkForge.Extensions.Util.RemoveFolderEx — When to remove folders.

Install Uninstall Both

FileSharePermissionLevel

namespace FalkForge.Extensions.Util.FileShare — Permission level for file share access.

Read Change Full

8. MSI Builder API

FalkForge uses a fluent builder API to define MSI installer packages. The entry point is Installer.Build(), which configures a PackageBuilder, validates the resulting model, and optionally compiles it to an MSI file. Additional entry points exist for merge modules, patches, and transforms.

All builder types reside in the FalkForge.Builders namespace within src/FalkForge.Core/Builders/. Builders have internal constructors and are created through their parent builder's fluent methods, not instantiated directly.

8.1 Entry Points

The static Installer class (src/FalkForge.Core/Installer.cs) provides four entry points for building different output types:

MethodSignatureDescription
Build int Build(string[] args, Action<PackageBuilder> configure, ICompiler? compiler = null) Builds an MSI package. Configures the builder, validates, and compiles. Returns exit code 0 on success, 1 on failure.
BuildBundle int BuildBundle(string[] args, Func<string, Result<string>> compile) Builds a self-extracting EXE bundle. The caller provides the compile function that receives the output path.
BuildMergeModule int BuildMergeModule(string[] args, Action<MergeModuleBuilder> configure, Func<MergeModuleModel, string, Result<string>> compile) Builds a merge module (.msm). Validates via MergeModuleValidator before compilation.
BuildPatch int BuildPatch(string[] args, Action<PatchBuilder> configure, Func<PatchModel, string, Result<string>> compile) Builds a patch (.msp). Validates via PatchValidator before compilation.
BuildTransform int BuildTransform(string[] args, Action<TransformBuilder> configure, Func<TransformModel, string, Result<string>> compile) Builds a transform (.mst). Validates via TransformValidator before compilation.

All entry points support -o / --output command-line arguments to specify the output path.

// Minimal MSI package
return Installer.Build(args, p =>
{
    p.Name = "My Application";
    p.Manufacturer = "Contoso Ltd";
    p.Version = new Version(1, 0, 0);

    p.Files(f => f
        .Add("bin/MyApp.exe")
        .Add("bin/MyApp.dll")
        .To(KnownFolder.ProgramFiles / "Contoso" / "MyApp"));
}, new MsiCompiler());

8.2 PackageBuilder

The central orchestrator for MSI package definition. All sub-builders are accessed through its fluent methods. Located at src/FalkForge.Core/Builders/PackageBuilder.cs.

Properties

PropertyTypeDefaultDescription
Namestring""Product name displayed in Programs and Features.
Manufacturerstring""Publisher / manufacturer name.
VersionVersion1.0.0Product version (major.minor.build).
UpgradeCodeGuid?DeterministicUpgrade code. Auto-generated from Name + Manufacturer if not set.
ProductCodeGuid?Guid.NewGuid()Unique product code per build. Auto-generated if not set.
ScopeInstallScopePerMachineInstall scope: PerMachine or PerUser.
ArchitectureProcessorArchitectureX64Target processor architecture.
DefaultInstallDirectoryInstallPath?ProgramFiles/Manufacturer/NameDefault installation directory path.
CompressionCompressionLevelHighCabinet compression level.
Descriptionstring?nullProduct description for summary information stream.
Commentsstring?nullComments for summary information stream.
Contactstring?nullContact information.
HelpUrlstring?nullHelp/support URL shown in Programs and Features.
AboutUrlstring?nullProduct information URL.
UpdateUrlstring?nullUpdate information URL.
LicenseFilestring?nullPath to RTF license file for the license dialog.
EnableRestartManagerboolfalseWhether to enable Windows Restart Manager integration (sets MSIRMSHUTDOWN=0 and authors the MsiRMFilesInUse dialog). Set via EnableRestartManagerSupport(), not directly.

Fluent Methods

MethodReturn TypeDescription
Files(Action<FileSetBuilder>)PackageBuilderAdds files to the package at a target directory.
Shortcut(string name, string targetFile)ShortcutBuilderCreates a shortcut builder for the specified file. Chain with OnDesktop(), OnStartMenu(), etc.
Service(string name, Action<ServiceBuilder>)PackageBuilderDefines a Windows service to install.
ServiceControl(Action<ServiceControlBuilder>)PackageBuilderDefines service start/stop/delete control operations.
Feature(string id, Action<FeatureBuilder>)PackageBuilderDefines a feature node in the feature tree. If no features are defined, an implicit "Complete" feature is created.
Registry(Action<RegistryBuilder>)PackageBuilderWrites registry values during installation.
RemoveRegistry(Action<RemoveRegistryBuilder>)PackageBuilderRemoves registry keys or values during installation.
EnvironmentVariable(string name, string value, Action<EnvironmentVariableBuilder>?)PackageBuilderSets or modifies an environment variable.
EnvironmentVariable(string name, MsiProperty property, Action<EnvironmentVariableBuilder>?)PackageBuilderSets an environment variable using an MSI property reference.
Property(string name, string value, Action<PropertyBuilder>?)PackageBuilderDefines an MSI property.
Require(string condition, string message)PackageBuilderAdds a launch condition. Installation aborts with message if condition evaluates to false.
Require(Condition condition, string message)PackageBuilderAdds a launch condition using a type-safe Condition object.
Upgrade(Action<UpgradeBuilder>)PackageBuilderConfigures legacy upgrade detection (version ranges).
MajorUpgrade(Action<MajorUpgradeBuilder>)PackageBuilderConfigures standard major upgrade behavior.
Font(string fileName, Action<FontBuilder>?)PackageBuilderRegisters a font file for installation.
IniFile(string fileName, Action<IniFileBuilder>)PackageBuilderCreates or modifies INI file entries.
Permission(string lockObject, Action<PermissionBuilder>)PackageBuilderSets permissions on an installed object (file, folder, registry key).
FileAssociation(string extension, Action<FileAssociationBuilder>)PackageBuilderRegisters a file type association (e.g., .myext).
CustomAction(string id, Action<CustomActionBuilder>)PackageBuilderDefines a custom action with explicit configuration.
CustomAction(string binaryPath, string entryPoint, Action<CustomActionBuilder>?)PackageBuilderSimplified overload: auto-registers the binary and creates a DllFromBinary action.
RemoveFile(Action<RemoveFileBuilder>)PackageBuilderSchedules file removal on install or uninstall.
CreateFolder(Action<CreateFolderBuilder>)PackageBuilderCreates an empty folder during installation.
MoveFile(Action<MoveFileBuilder>)PackageBuilderMoves or copies a file during installation.
DuplicateFile(Action<DuplicateFileBuilder>)PackageBuilderDuplicates an installed file to another location.
GacAssembly(Action<AssemblyBuilder>)PackageBuilderRegisters an assembly in the Global Assembly Cache.
CustomTable(Action<CustomTableBuilder>)PackageBuilderDefines a custom MSI database table with schema and data.
ExecuteSequence(Action<SequenceBuilder>)PackageBuilderInserts actions into the InstallExecuteSequence table.
UISequence(Action<SequenceBuilder>)PackageBuilderInserts actions into the InstallUISequence table.
MediaTemplate(Action<MediaTemplateBuilder>)PackageBuilderConfigures cabinet and media settings.
Binary(string name, string sourcePath)PackageBuilderAdds a binary resource (DLL, EXE, bitmap) to the Binary table.
EnableRestartManagerSupport()PackageBuilderEnables Windows Restart Manager integration: sets MSIRMSHUTDOWN=0 and authors the MsiRMFilesInUse dialog so full-UI installs can close and restart locked applications — only apps that registered for restart come back automatically.
Signing(Action<SigningOptionsBuilder>)PackageBuilderConfigures Authenticode code signing for the output MSI.
UseDialogSet(MsiDialogSet dialogSet)PackageBuilderSelects the built-in MSI dialog template set.
SetLocalizationData(IReadOnlyList<LocalizationData>)PackageBuilderApplies localization string tables for multi-language support.
Integrity(Action<IntegrityBuilder>)PackageBuilderEnables ECDSA-P256 payload signing. See §23 for key generation, baking, and rotation.
Sbom(Action<SbomOptions>? configure = null)PackageBuilderEmits a CycloneDX 1.6 SBOM sidecar (<package>.msi.cdx.json) alongside the compiled MSI. Equivalent to the CLI --sbom flag. The sidecar is CycloneDX by definition and takes no SbomFormat; that setting applies only to the Integrity() attestation (§23).
Build()PackageModelFinalizes configuration and produces the immutable PackageModel.

MsiDialogSet Enum

ValueDescription
NoneNo UI dialogs (silent install).
MinimalProgress dialog only.
InstallDirWelcome, license, install directory selection, and progress.
FeatureTreeWelcome, license, feature tree selection, and progress.
MondoFull dialog set: typical/custom/complete install modes with feature tree.
AdvancedExtended dialog set with install directory, feature tree, and advanced options.

None and Minimal have no license page. Setting LicenseFile with one of those two active does not fail the build — the license is simply not shown — but it logs a DLG004 warning (via the optional IFalkLogger passed to MsiAuthoring.Compile) so the mismatch is not silent. Use InstallDir, FeatureTree, Mondo, or Advanced to actually render the license.

// Full-featured MSI package
return Installer.Build(args, p =>
{
    p.Name = "Enterprise Suite";
    p.Manufacturer = "Contoso Ltd";
    p.Version = new Version(2, 1, 0);
    p.Architecture = ProcessorArchitecture.X64;
    p.Scope = InstallScope.PerMachine;
    p.Description = "Enterprise productivity suite";
    p.LicenseFile = "assets/license.rtf";
    p.DefaultInstallDirectory = KnownFolder.ProgramFiles / "Contoso" / "Suite";

    p.UseDialogSet(MsiDialogSet.FeatureTree);

    p.MajorUpgrade(mu => mu
        .Schedule(RemoveExistingProductsSchedule.AfterInstallValidate));

    p.Downgrade(d => d.Block("A newer version is already installed."));

    p.Require(Condition.IsWindows10OrLater, "Windows 10 or later is required.");

    p.Feature("Core", f =>
    {
        f.Title = "Core Components";
        f.IsRequired = true;
        f.Files(fs => fs.Add("bin/app.exe").To(KnownFolder.ProgramFiles / "Contoso" / "Suite"));
    });

    p.Feature("Plugins", f =>
    {
        f.Title = "Optional Plugins";
        f.Description = "Additional plugin modules";
        f.Files(fs => fs.FromDirectory("plugins/").To(KnownFolder.ProgramFiles / "Contoso" / "Suite" / "Plugins"));
    });

    p.Shortcut("Enterprise Suite", "bin/app.exe")
        .WithIcon("assets/app.ico")
        .WithDescription("Launch Enterprise Suite")
        .OnDesktop()
        .OnStartMenu("Contoso");

    p.Registry(r => r.Key(RegistryRoot.LocalMachine, @"SOFTWARE\Contoso\Suite", k =>
    {
        k.Value("InstallDir", MsiProperty.InstallFolder);
        k.Value("Version", "2.1.0");
        k.DWord("Installed", 1);
    }));

    p.EnvironmentVariable("CONTOSO_HOME", MsiProperty.InstallFolder);

    p.Signing(s => s
        .Thumbprint("A1B2C3D4E5F6...")
        .Timestamp("http://timestamp.digicert.com")
        .Algorithm("sha256"));
}, new MsiCompiler());

8.3 FeatureBuilder

Defines a node in the MSI feature tree. Created via PackageBuilder.Feature() or FeatureBuilder.Feature() for nested sub-features. Located at src/FalkForge.Core/Builders/FeatureBuilder.cs.

Properties

PropertyTypeDefaultDescription
Titlestring""Display title. Falls back to the feature ID if empty.
Descriptionstring?nullDescription shown in the feature selection dialog.
IsRequiredboolfalseIf true, the feature cannot be deselected by the user.
IsDefaultbooltrueIf true, the feature is selected by default.

Methods

MethodReturn TypeDescription
Feature(string id, Action<FeatureBuilder>)FeatureBuilderAdds a nested sub-feature.
Condition(string condition, int level)FeatureBuilderAdds a conditional install level. When condition is true, the feature's install level is set to level.
Condition(string condition)FeatureBuilderAdds a condition with level 0 (disabled when true).
Condition(Condition condition, int level)FeatureBuilderType-safe overload using a Condition object.
Condition(Condition condition)FeatureBuilderType-safe overload with level 0.
Files(Action<FileSetBuilder>)FeatureBuilderAdds files scoped to this feature.
p.Feature("Server", f =>
{
    f.Title = "Server Components";
    f.IsRequired = true;
    f.Files(fs => fs
        .Add("bin/server.exe")
        .Add("bin/server.dll")
        .To(KnownFolder.ProgramFiles / "Contoso" / "Server"));

    f.Feature("Docs", sub =>
    {
        sub.Title = "Documentation";
        sub.Description = "API reference and user guide";
        sub.IsDefault = false;
        sub.Files(fs => fs.FromDirectory("docs/").To(KnownFolder.ProgramFiles / "Contoso" / "Server" / "Docs"));
    });

    f.Condition(Condition.Is64BitOS, 1);  // Only enabled on 64-bit systems
});

8.4 FileSetBuilder

Defines a set of files to install to a target directory. Created via PackageBuilder.Files() or FeatureBuilder.Files(). Located at src/FalkForge.Core/Builders/FileSetBuilder.cs.

MethodReturn TypeDescription
Add(string filePath)FileSetBuilderAdds a single file by path.
FromDirectory(string sourcePath)FileSetBuilderAdds all files from a source directory. Resolved at compile time.
To(InstallPath targetDirectory)FileSetBuilderSets the target installation directory. Required -- build throws if not specified.
NeverOverwrite()FileSetBuilderPrevents overwriting the file on upgrade if it already exists. Maps to MSI component attribute msidbComponentAttributesNeverOverwrite.
Permanent()FileSetBuilderFile is never removed on uninstall. Maps to MSI component attribute msidbComponentAttributesPermanent. Use for config files and user data.
ComponentCondition(string condition)FileSetBuilderApplies a condition to all components generated from this file set.
The To() method accepts an InstallPath, which is constructed using KnownFolder instances and the / operator for path composition: KnownFolder.ProgramFiles / "Company" / "Product".

KnownFolder Reference

Static PropertyMSI TokenDescription
KnownFolder.ProgramFilesProgramFilesFolderProgram Files directory
KnownFolder.ProgramFiles64ProgramFiles64FolderProgram Files (64-bit) directory
KnownFolder.CommonAppDataCommonAppDataFolderProgramData directory
KnownFolder.LocalAppDataLocalAppDataFolderLocal AppData directory
KnownFolder.AppDataAppDataFolderRoaming AppData directory
KnownFolder.SystemFolderSystemFolderSystem32 directory
KnownFolder.System64FolderSystem64Folder64-bit System directory
KnownFolder.WindowsFolderWindowsFolderWindows directory
KnownFolder.TempFolderTempFolderTemp directory
KnownFolder.DesktopFolderDesktopFolderDesktop directory
KnownFolder.StartMenuFolderStartMenuFolderStart Menu root
KnownFolder.ProgramMenuFolderProgramMenuFolderPrograms menu directory
KnownFolder.StartupFolderStartupFolderStartup directory
KnownFolder.CommonFilesFolderCommonFilesFolderCommon Files directory
p.Files(f => f
    .Add("bin/MyApp.exe")
    .Add("bin/MyApp.dll")
    .Add("bin/config.json")
    .To(KnownFolder.ProgramFiles / "Contoso" / "MyApp")
    .ComponentCondition("NOT REMOVE"));

Preserving Files Across Upgrades

Use NeverOverwrite() and Permanent() to protect configuration files and user data during upgrades and uninstalls:

// Config file: keep user edits on upgrade, leave on uninstall
p.Files(f => f
    .Add("defaults/appsettings.json")
    .To(KnownFolder.CommonAppData / "Contoso" / "MyApp")
    .NeverOverwrite()
    .Permanent());

8.5 ShortcutBuilder

Creates shortcuts to installed files on the desktop, Start Menu, or Startup folder. Created via PackageBuilder.Shortcut(). Located at src/FalkForge.Core/Builders/ShortcutBuilder.cs.

Each location method (OnDesktop(), OnStartMenu(), OnStartup()) immediately registers a shortcut with the parent PackageBuilder. You can chain multiple locations to create a shortcut in each place.
MethodReturn TypeDescription
OnDesktop()ShortcutBuilderPlaces a shortcut on the user's desktop.
OnStartMenu(string? subfolder)ShortcutBuilderPlaces a shortcut in the Start Menu, optionally under a subfolder.
OnStartup()ShortcutBuilderPlaces a shortcut in the Startup folder (auto-launch on login).
WithArguments(string arguments)ShortcutBuilderSets command-line arguments for the shortcut target.
WithDescription(string description)ShortcutBuilderSets the shortcut tooltip description.
WithIcon(string iconFile, int iconIndex)ShortcutBuilderSets the shortcut icon. iconIndex defaults to 0.
WithWorkingDirectory(string workingDirectory)ShortcutBuilderSets the working directory when launching the target.
p.Shortcut("My Application", "bin/MyApp.exe")
    .WithIcon("assets/app.ico")
    .WithDescription("Launch My Application")
    .WithArguments("--startup")
    .OnDesktop()
    .OnStartMenu("My Company");

8.6 ServiceBuilder

Defines a Windows service to install. Created via PackageBuilder.Service(). Located at src/FalkForge.Core/Builders/ServiceBuilder.cs.

Properties

PropertyTypeDefaultDescription
DisplayNamestring""Service display name. Falls back to service name if empty.
Executablestring""Service executable file reference.
Descriptionstring?nullService description shown in Services console.
StartModeServiceStartModeAutomaticStartup type: Automatic, Manual, Disabled, DelayedAutomatic.
AccountServiceAccountLocalSystemService account: LocalSystem, LocalService, NetworkService, User.
UserNamestring?nullUsername when Account is User.
Passwordstring?nullPassword when Account is User.
Argumentsstring?nullCommand-line arguments passed to the service executable at startup. Supports MSI property references: "DSN=[ODBCNAME]".
DependenciesList<string>EmptyLegacy string-based dependency list.

Methods

MethodReturn TypeDescription
DependsOn(string serviceName)ServiceBuilderAdds a dependency on another service.
DependsOnGroup(string groupName)ServiceBuilderAdds a dependency on a service group.
AccountProperty(string propertyRef)ServiceBuilderSets the service account via an MSI property reference (e.g., [SERVICEACCOUNT]) resolved at install time. Overrides the Account enum.
Condition(string condition)ServiceBuilderMSI condition controlling whether the service is installed. Example: "ASSERVICE ~= \"true\"".
Condition(Condition condition)ServiceBuilderType-safe overload using the Condition builder.
Permission(Action<PermissionBuilder>)ServiceBuilderConfigures service access control. Auto-sets ForTable("ServiceInstall"). Use SDDL or User/Domain for fine-grained permissions.
FailureActions(Action<ServiceFailureActionsBuilder>)ServiceBuilderConfigures failure recovery actions.
p.Service("MyWorkerService", svc =>
{
    svc.DisplayName = "My Worker Service";
    svc.Description = "Background processing service";
    svc.Executable = "bin/WorkerService.exe";
    svc.StartMode = ServiceStartMode.DelayedAutomatic;
    svc.Account = ServiceAccount.NetworkService;
    svc.Arguments = "DSN=[ODBCNAME] --mode production";

    svc.DependsOn("Tcpip");
    svc.DependsOnGroup("NetworkProvider");

    svc.FailureActions(fa =>
    {
        fa.OnFirstFailure = FailureAction.Restart;
        fa.OnSecondFailure = FailureAction.Restart;
        fa.OnSubsequentFailures = FailureAction.RunCommand;
        fa.ResetPeriod = TimeSpan.FromDays(1);
        fa.RestartDelay = TimeSpan.FromMinutes(2);
        fa.Command = @"C:\Tools\notify.exe --service-failed";
    });
});

Advanced Service Configuration

The following example shows dynamic account selection via MSI properties, conditional installation, and service permissions:

p.Service("DataSync", svc =>
{
    svc.DisplayName = "Data Sync Agent";
    svc.Executable = "bin/DataSync.exe";
    svc.StartMode = ServiceStartMode.Automatic;

    // Account resolved at install time from an MSI property
    svc.AccountProperty("[SERVICEACCOUNT]");

    // Only install if the user chose "service mode"
    svc.Condition("ASSERVICE ~= \"true\"");

    // Grant specific permissions on the service object
    svc.Permission(perm =>
    {
        perm.User = "NT AUTHORITY\\Authenticated Users";
        perm.GenericRead = true;
    });
});

8.7 ServiceControlBuilder

Controls service lifecycle during install and uninstall. Created via PackageBuilder.ServiceControl(). Located at src/FalkForge.Core/Builders/ServiceControlBuilder.cs.

MethodReturn TypeDescription
Id(string id)ServiceControlBuilderSets the unique identifier for this service control entry.
ServiceName(string serviceName)ServiceControlBuilderSets the name of the service to control.
StartOnInstall()ServiceControlBuilderStarts the service after installation.
StopOnInstall()ServiceControlBuilderStops the service before installation (for upgrades).
DeleteOnInstall()ServiceControlBuilderDeletes the service during installation.
StartOnUninstall()ServiceControlBuilderStarts the service during uninstall.
StopOnUninstall()ServiceControlBuilderStops the service during uninstall.
DeleteOnUninstall()ServiceControlBuilderDeletes the service on uninstall.
Wait(bool wait)ServiceControlBuilderWhether to wait for the service operation to complete. Defaults to true.
Arguments(string arguments)ServiceControlBuilderSets command-line arguments passed to the service on start.
ComponentRef(string componentRef)ServiceControlBuilderAssociates this control entry with a specific component.
p.ServiceControl(sc => sc
    .Id("StopMyService")
    .ServiceName("MyWorkerService")
    .StopOnInstall()
    .StopOnUninstall()
    .DeleteOnUninstall()
    .Wait(true));

p.ServiceControl(sc => sc
    .Id("StartMyService")
    .ServiceName("MyWorkerService")
    .StartOnInstall()
    .Arguments("--mode production"));

8.8 ServiceFailureActionsBuilder

Configures recovery actions when a service crashes. Used within ServiceBuilder.FailureActions(). Located at src/FalkForge.Core/Builders/ServiceFailureActionsBuilder.cs.

PropertyTypeDefaultDescription
OnFirstFailureFailureActionNoneAction on first failure: None, Restart, Reboot, RunCommand.
OnSecondFailureFailureActionNoneAction on second failure.
OnSubsequentFailuresFailureActionNoneAction on all subsequent failures.
ResetPeriodTimeSpan1 dayTime after which the failure counter resets.
RestartDelayTimeSpan1 minuteDelay before restarting the service.
Commandstring?nullCommand to execute when FailureAction.RunCommand is specified.
RebootMessagestring?nullMessage displayed before reboot when FailureAction.Reboot is specified.

8.9 RegistryBuilder & RegistryKeyBuilder

Writes registry values during installation. RegistryBuilder organizes entries by key; RegistryKeyBuilder adds values within a key. Located at src/FalkForge.Core/Builders/RegistryBuilder.cs and src/FalkForge.Core/Builders/RegistryKeyBuilder.cs.

RegistryBuilder

MethodReturn TypeDescription
Key(RegistryRoot root, string key, Action<RegistryKeyBuilder>)RegistryBuilderOpens a registry key path for value definition. Can be called multiple times for different keys.

RegistryKeyBuilder

MethodReturn TypeDescription
Value(string name, string value, RegistryValueType type)RegistryKeyBuilderAdds a named value. type defaults to String. Supports: String, ExpandString, MultiString, DWord, QWord, Binary.
Value(string name, MsiProperty property)RegistryKeyBuilderAdds a named value resolved from an MSI property at install time.
DWord(string name, int value)RegistryKeyBuilderAdds a DWORD (32-bit integer) value.
DefaultValue(string value)RegistryKeyBuilderSets the default (unnamed) value of the key.

RegistryRoot Enum

ValueHive
LocalMachineHKLM
CurrentUserHKCU
ClassesRootHKCR
UsersHKU
p.Registry(r =>
{
    r.Key(RegistryRoot.LocalMachine, @"SOFTWARE\Contoso\MyApp", k =>
    {
        k.Value("InstallPath", MsiProperty.InstallFolder);
        k.Value("Version", "2.0.0");
        k.DWord("LaunchCount", 0);
        k.Value("Config", @"%CONTOSO_HOME%\config.xml", RegistryValueType.ExpandString);
        k.DefaultValue("My Application");
    });

    r.Key(RegistryRoot.CurrentUser, @"SOFTWARE\Contoso\MyApp\Preferences", k =>
    {
        k.Value("Theme", "dark");
        k.DWord("AutoUpdate", 1);
    });
});

8.10 RemoveRegistryBuilder

Removes registry keys or individual values during installation. Located at src/FalkForge.Core/Builders/RemoveRegistryBuilder.cs.

MethodReturn TypeDescription
Id(string id)RemoveRegistryBuilderSets the unique identifier.
Root(RegistryRoot root)RemoveRegistryBuilderSets the registry hive.
Key(string key)RemoveRegistryBuilderSets the registry key path.
Name(string name)RemoveRegistryBuilderSets the value name to remove (for RemoveValue action).
RemoveKey()RemoveRegistryBuilderRemoves the entire key and all its values. This is the default action.
RemoveValue()RemoveRegistryBuilderRemoves only the specified named value.
ComponentRef(string componentRef)RemoveRegistryBuilderAssociates the removal with a specific component.
p.RemoveRegistry(rr => rr
    .Id("RemoveLegacyKey")
    .Root(RegistryRoot.LocalMachine)
    .Key(@"SOFTWARE\Contoso\OldApp")
    .RemoveKey());

p.RemoveRegistry(rr => rr
    .Id("RemoveObsoleteValue")
    .Root(RegistryRoot.CurrentUser)
    .Key(@"SOFTWARE\Contoso\MyApp")
    .Name("DeprecatedSetting")
    .RemoveValue());

8.11 CustomActionBuilder

Defines custom actions that execute during installation. Supports DLL, EXE, and property-setting actions with execution flags for deferred, rollback, and commit scheduling. Located at src/FalkForge.Core/Builders/CustomActionBuilder.cs.

Properties

PropertyTypeDefaultDescription
Targetstring?nullDLL entry point, EXE arguments, or property value.
Conditionstring?nullCondition expression controlling execution.
Sequenceint?nullExplicit sequence number in the execution sequence.
Afterstring?nullSchedule this action after the named action.
Beforestring?nullSchedule this action before the named action.

Source Type Methods

MethodReturn TypeDescription
DllFromBinary(string binaryName, string entryPoint)CustomActionBuilderCalls a DLL function from a binary embedded in the Binary table. Type value: 1.
ExeFromBinary(string binaryName)CustomActionBuilderExecutes an EXE from a binary embedded in the Binary table. Type value: 2.
SetProperty(string propertyName, string value)CustomActionBuilderSets an MSI property to a value. Type value: 51.
SetDirectory(string directoryId, string value)CustomActionBuilderSets a Directory table entry to a formatted value (type 35 / msidbCustomActionTypeDirectory). Commonly used to redirect an install directory to a path computed by an earlier custom action — e.g. an existing install location read back from the registry.
PowerShellScript(string script)CustomActionBuilderRuns an inline PowerShell script via ExeInDir (type 34) targeting powershell.exe in the SystemFolder directory. See PowerShell Custom Actions.
PowerShellFile(string filePath)CustomActionBuilderReads a PowerShell script from disk at build time and embeds its content inline via PowerShellScript.

Execution Flag Methods

MethodReturn TypeDescription
Deferred()CustomActionBuilderRuns during the installation script phase (in-script). Required for actions that modify the system.
Rollback()CustomActionBuilderRuns only if installation fails after this point. Automatically sets the InScript flag.
Commit()CustomActionBuilderRuns only after a successful installation. Automatically sets the InScript flag.
NoImpersonate()CustomActionBuilderRuns with elevated SYSTEM privileges instead of impersonating the user. Only meaningful for deferred/rollback/commit actions.
ContinueOnError()CustomActionBuilderContinues installation if the action fails, instead of aborting.

Simplified Overload

The PackageBuilder.CustomAction(string binaryPath, string entryPoint, Action<CustomActionBuilder>?) overload auto-registers the binary from the file system and creates a DllFromBinary action, eliminating the need to call Binary() separately:

// Explicit approach: register binary + define action
p.Binary("MyCustomDll", "lib/CustomActions.dll");
p.CustomAction("RunSetup", ca =>
{
    ca.DllFromBinary("MyCustomDll", "InitializeDatabase");
    ca.After = "InstallFiles";
    ca.Deferred();
    ca.NoImpersonate();
});

// Simplified approach: auto-registers binary
p.CustomAction("lib/CustomActions.dll", "InitializeDatabase", ca =>
{
    ca.After = "InstallFiles";
    ca.Deferred();
    ca.NoImpersonate();
});

// Set-property custom action
p.CustomAction("SetInstallMode", ca =>
{
    ca.SetProperty("INSTALL_MODE", "advanced");
    ca.Condition = "ADVANCED_MODE";
});

8.12 CustomTableBuilder

Defines custom MSI database tables with schema and data rows. Used for storing application-specific configuration that custom actions can query at install time. Located at src/FalkForge.Core/Builders/CustomTableBuilder.cs.

CustomTableBuilder

MethodReturn TypeDescription
Name(string name)CustomTableBuilderSets the table name.
Column(string name, CustomTableColumnType type, Action<ColumnOptions>?)CustomTableBuilderAdds a column. Column names must match [A-Za-z_][A-Za-z0-9_]*.
Row(Action<RowBuilder>)CustomTableBuilderAdds a data row.

ColumnOptions

MethodReturn TypeDescription
PrimaryKey()ColumnOptionsMarks the column as part of the primary key.
Nullable()ColumnOptionsAllows null values in the column.
Width(int width)ColumnOptionsSets the maximum column width. Defaults to 255.
LocalizedDescription(string description)ColumnOptionsSets a localizable column description.

RowBuilder

MethodReturn TypeDescription
Set(string column, object? value)RowBuilderSets a column value for the current row.

CustomTableColumnType Enum

ValueDescription
StringVariable-length string.
Int1616-bit integer.
Int3232-bit integer.
BinaryBinary data (file reference).
StreamBinary stream stored in the MSI.
p.CustomTable(ct =>
{
    ct.Name("DatabaseConfig");
    ct.Column("Id", CustomTableColumnType.String, o => o.PrimaryKey().Width(72));
    ct.Column("Server", CustomTableColumnType.String, o => o.Width(255));
    ct.Column("Port", CustomTableColumnType.Int32, o => o.Nullable());
    ct.Column("Description", CustomTableColumnType.String, o => o
        .Nullable()
        .LocalizedDescription("Database connection description"));

    ct.Row(r => r
        .Set("Id", "Primary")
        .Set("Server", "db-prod.contoso.com")
        .Set("Port", 1433)
        .Set("Description", "Production database"));

    ct.Row(r => r
        .Set("Id", "Backup")
        .Set("Server", "db-backup.contoso.com")
        .Set("Port", 1433)
        .Set("Description", "Failover database"));
});

8.13 SequenceBuilder

Inserts custom actions or standard actions into MSI sequence tables. Used via PackageBuilder.ExecuteSequence() (InstallExecuteSequence) or PackageBuilder.UISequence() (InstallUISequence). Located at src/FalkForge.Core/Builders/SequenceBuilder.cs.

MethodReturn TypeDescription
Action(string actionName)SequenceBuilderBegins defining an action entry. Flushes any previously started action. Action names must be 72 characters or fewer.
After(string referenceAction)SequenceBuilderPositions the action after the named reference action.
Before(string referenceAction)SequenceBuilderPositions the action before the named reference action.
At(int sequenceNumber)SequenceBuilderPositions the action at an explicit sequence number.
Condition(string condition)SequenceBuilderSets a condition expression that must be true for the action to execute.
If no position is specified, actions default to sequence number 4001. Each call to Action() flushes the previous action definition, so position and condition must be set between Action() calls.
p.ExecuteSequence(seq => seq
    .Action("InitializeDatabase")
        .After("InstallFiles")
        .Condition("NOT Installed")
    .Action("CleanupTempFiles")
        .Before("InstallFinalize"));

p.UISequence(seq => seq
    .Action("ValidateLicense")
        .At(1200)
        .Condition("NOT Installed"));

8.14 MajorUpgradeBuilder

Configures standard major upgrade behavior for replacing previous product versions. Located at src/FalkForge.Core/Builders/MajorUpgradeBuilder.cs.

MethodReturn TypeDescription
AllowSameVersionUpgrades()MajorUpgradeBuilderAllows reinstalling the same version.
Schedule(RemoveExistingProductsSchedule schedule)MajorUpgradeBuilderWhen to remove the previous version. Defaults to AfterInstallValidate.
MigrateFeatures(bool migrate)MajorUpgradeBuilderWhether to migrate feature selections from the old install. Defaults to true.

RemoveExistingProductsSchedule Enum

ValueDescription
AfterInstallValidateRemove early, before file installation. Cleanest upgrade but slower.
AfterInstallInitializeRemove after initialization.
AfterInstallExecuteRemove after file installation. Old and new versions briefly coexist.
AfterInstallExecuteAgainRemove after the second execution pass.
AfterInstallFinalizeRemove at the very end. Maximum coexistence window.

Downgrade blocking is configured separately via PackageBuilder.Downgrade(), not through MajorUpgradeBuilder. Located at src/FalkForge.Core/Builders/DowngradeBuilder.cs.

DowngradeBuilder

MethodReturn TypeDescription
Allow()DowngradeBuilderAllows installing an older version over a newer one.
Block(string message)DowngradeBuilderBlocks downgrades and sets the error message shown when a downgrade is blocked.
p.MajorUpgrade(mu => mu
    .Schedule(RemoveExistingProductsSchedule.AfterInstallValidate)
    .AllowSameVersionUpgrades()
    .MigrateFeatures(true));

p.Downgrade(d => d.Block("A newer version of [ProductName] is already installed."));

8.15 UpgradeBuilder

Configures legacy upgrade detection with version ranges. Used for fine-grained control over which previous versions to detect. For standard major upgrades, prefer MajorUpgradeBuilder. Located at src/FalkForge.Core/Builders/UpgradeBuilder.cs.

PropertyTypeDefaultDescription
AllowDowngradesboolfalseWhether to allow downgrades.
AllowSameVersionboolfalseWhether to allow reinstalling the same version.
MinimumVersionstring?nullMinimum version to detect for upgrade.
MaximumVersionstring?nullMaximum version to detect for upgrade.
DowngradeErrorMessagestring?"A newer version is already installed."Message shown when downgrade is blocked.

8.16 EnvironmentVariableBuilder

Configures environment variable operations during installation. Located at src/FalkForge.Core/Builders/EnvironmentVariableBuilder.cs.

PropertyTypeDefaultDescription
IsSystembooltrueIf true, sets a system-wide variable; otherwise, per-user.
ActionEnvironmentVariableActionSetOperation: Set (replace), Append, or Prepend.
Separatorstring?nullSeparator for Append/Prepend operations (e.g., ";" for PATH).
// Set a new environment variable
p.EnvironmentVariable("APP_HOME", MsiProperty.InstallFolder);

// Append to PATH
p.EnvironmentVariable("PATH", MsiProperty.InstallFolder / "bin", ev =>
{
    ev.Action = EnvironmentVariableAction.Append;
    ev.Separator = ";";
});

8.17 PropertyBuilder

Defines MSI properties with optional security and visibility flags. Located at src/FalkForge.Core/Builders/PropertyBuilder.cs.

PropertyTypeDefaultDescription
IsSecureboolfalseIf true, the property name is listed in the compiled MSI's SecureCustomProperties, so its run-time value is passed from the client (UI) process to the elevated execute sequence and reaches deferred custom actions. Windows Installer only ever recognizes this for a public property name, and a public property name must contain no lowercase letter (digits, underscores, periods, and uncased scripts are all still allowed); a lowercase-containing name marked IsSecure fails the build (PRP001) instead of silently doing nothing.
IsAdminboolfalseIf true, the property name is listed in the compiled MSI's AdminProperties: Windows Installer saves the property's value at administrative installation time (msiexec /a), and subsequent per-machine installs from that admin image use the saved value. This is not an access-control restriction — it has no relation to who may set or read the property.
IsHiddenboolfalseIf true, the property name is listed in the compiled MSI's MsiHiddenProperties, so Windows Installer scrubs its value from a verbose (/L*v) install log. Aggregated together with every extension-contributed secret into one row — see the IExecutionContributor / ExecutionStep secure secret channel below.
p.Property("INSTALL_MODE", "standard");

p.Property("DB_CONNECTION", "Server=localhost", prop =>
{
    prop.IsSecure = true;   // must be ALL-UPPERCASE, or PRP001 fails the build
    prop.IsHidden = true;   // Redact from install logs
});

8.18 PermissionBuilder

Sets NTFS or registry permissions on installed objects. Located at src/FalkForge.Core/Builders/PermissionBuilder.cs.

Property / MethodTypeDefaultDescription
Sddlstring?nullSDDL security descriptor string for fine-grained permission control.
Domainstring?nullDomain for the user/group.
Userstring?nullUser or group name to grant permissions to.
Permissionint0Permission bitmask (GENERIC_READ, GENERIC_WRITE, etc.).
ForTable(string table)PermissionBuilder"CreateFolder"The MSI table this permission applies to (e.g., "File", "Registry", "CreateFolder").
p.Permission("INSTALLFOLDER", perm =>
{
    perm.User = "Users";
    perm.Permission = 0x1F01FF;  // Full control
    perm.ForTable("CreateFolder");
});

8.19 MediaTemplateBuilder

Configures cabinet file generation and media layout. Located at src/FalkForge.Core/Builders/MediaTemplateBuilder.cs.

MethodReturn TypeDescription
CabinetTemplate(string template)MediaTemplateBuilderCabinet file name template. Defaults to "cab{0}.cab". The {0} placeholder is replaced with the cabinet index.
MaxCabinetSizeMB(int sizeMB)MediaTemplateBuilderMaximum cabinet file size in MB. 0 means unlimited.
MaxUncompressedMediaSize(int size)MediaTemplateBuilderMaximum uncompressed media size.
CompressionLevel(CompressionLevel level)MediaTemplateBuilderCabinet compression level. Defaults to High.
EmbedCabinet(bool embed)MediaTemplateBuilderWhether to embed cabinets inside the MSI. Defaults to true.
p.MediaTemplate(mt => mt
    .CabinetTemplate("data{0}.cab")
    .MaxCabinetSizeMB(100)
    .CompressionLevel(CompressionLevel.Medium)
    .EmbedCabinet(false));  // External cabinet files

8.20 SigningOptionsBuilder

Configures Authenticode code signing for the output MSI. Located at src/FalkForge.Core/Builders/SigningOptionsBuilder.cs.

MethodReturn TypeDescription
Certificate(string pfxPath)SigningOptionsBuilderPath to a PFX certificate file.
Thumbprint(string thumbprint)SigningOptionsBuilderCertificate thumbprint for store-based signing.
Store(string storeName)SigningOptionsBuilderCertificate store name. Defaults to "My".
Timestamp(string url)SigningOptionsBuilderRFC 3161 timestamp server URL.
Algorithm(string algorithm)SigningOptionsBuilderDigest algorithm. Defaults to "sha256".
WithDescription(string description, string? url)SigningOptionsBuilderDescription and optional URL embedded in the signature.

Properties (Direct Access)

PropertyTypeDescription
CertificatePathstring?PFX file path.
CertificateThumbprintstring?Certificate thumbprint.
StoreNamestringCertificate store name.
TimestampUrlstring?Timestamp server URL.
DigestAlgorithmstringDigest algorithm name.
AdditionalArgumentsstring?Additional arguments passed to the signing tool.
Descriptionstring?Signature description.
DescriptionUrlstring?Signature description URL.
p.Signing(s => s
    .Thumbprint("A1B2C3D4E5F60011223344556677889900AABBCC")
    .Store("My")
    .Timestamp("http://timestamp.digicert.com")
    .Algorithm("sha256")
    .WithDescription("Contoso Enterprise Suite", "https://contoso.com"));

8.21 FileAssociationBuilder

Registers file type associations. Created via PackageBuilder.FileAssociation(). Located at src/FalkForge.Core/Builders/FileAssociationBuilder.cs.

Properties

PropertyTypeDefaultDescription
Descriptionstring?nullFile type description shown in Explorer.
IconFilestring?nullIcon file for the file type.
IconIndexint0Icon index within the icon file.
ContentTypestring?nullMIME content type (e.g., "text/xml").

Methods

MethodReturn TypeDescription
ProgId(string progId)FileAssociationBuilderSets the programmatic identifier (e.g., "Contoso.Document").
Verb(string verb, string? argument, Action<VerbBuilder>?)FileAssociationBuilderAdds a shell verb (e.g., "open", "edit", "print").

VerbBuilder

PropertyTypeDescription
Commandstring?Display text for the verb in context menus.
Argumentstring?Command-line argument pattern (e.g., "%1").
SequenceintVerb display order.
p.FileAssociation(".cproj", fa =>
{
    fa.ProgId("Contoso.Project");
    fa.Description = "Contoso Project File";
    fa.IconFile = "assets/project.ico";
    fa.ContentType = "application/x-contoso-project";

    fa.Verb("open", @"""%1""", v => { v.Command = "&Open"; v.Sequence = 1; });
    fa.Verb("edit", @"""%1""", v => { v.Command = "&Edit"; v.Sequence = 2; });
});

8.22 FontBuilder

Registers a font for installation. Created via PackageBuilder.Font(). Located at src/FalkForge.Core/Builders/FontBuilder.cs.

PropertyTypeDefaultDescription
Titlestring?nullFont title as registered in the system. If null, extracted from the font file at compile time.
p.Font("fonts/ContosoSans-Regular.ttf");
p.Font("fonts/ContosoSans-Bold.ttf", f => f.Title = "Contoso Sans Bold");

8.23 IniFileBuilder

Creates or modifies INI file entries. Located at src/FalkForge.Core/Builders/IniFileBuilder.cs.

MethodReturn TypeDescription
Section(string section)IniFileBuilderSets the INI section name.
Key(string key)IniFileBuilderSets the key name within the section.
Value(string value)IniFileBuilderSets the value to write.
Action(IniFileAction action)IniFileBuilderSets the action type. Defaults to CreateEntry.

IniFileAction Enum

ValueDescription
CreateLineAdds the entire line to the section (no key=value parsing).
CreateEntryCreates or updates a key=value entry. Default behavior.
RemoveLineRemoves the entire line matching the key.
RemoveTagRemoves only the value from a comma-delimited list.
p.IniFile("config.ini", ini =>
{
    ini.Section("Database");
    ini.Key("Server");
    ini.Value("localhost");
    ini.Action(IniFileAction.CreateEntry);
});

8.24 AssemblyBuilder

Registers assemblies in the Global Assembly Cache (GAC). Created via PackageBuilder.GacAssembly(). Located at src/FalkForge.Core/Builders/AssemblyBuilder.cs.

MethodReturn TypeDescription
FileRef(string fileRef)AssemblyBuilderFile reference for the assembly.
Type(AssemblyType type)AssemblyBuilderAssembly type: DotNetAssembly (default) or Win32Assembly.
Private(string applicationFileRef)AssemblyBuilderMakes this a private (non-GAC) assembly, referencing the application file.
Name(string name)AssemblyBuilderAssembly name for the manifest.
Version(string version)AssemblyBuilderAssembly version string.
Culture(string culture)AssemblyBuilderAssembly culture (e.g., "neutral", "en-US").
PublicKeyToken(string publicKeyToken)AssemblyBuilderPublic key token for strong-named assemblies.
Architecture(string architecture)AssemblyBuilderProcessor architecture (e.g., "msil", "amd64").
p.GacAssembly(a => a
    .FileRef("Contoso.Shared.dll")
    .Name("Contoso.Shared")
    .Version("2.0.0.0")
    .Culture("neutral")
    .PublicKeyToken("b77a5c561934e089")
    .Architecture("msil"));

8.25 File Operation Builders

MoveFileBuilder

Moves or copies files during installation. Located at src/FalkForge.Core/Builders/MoveFileBuilder.cs.

MethodReturn TypeDescription
Id(string id)MoveFileBuilderUnique identifier.
SourceDirectory(string directory)MoveFileBuilderSource directory path or property reference.
SourceFileName(string fileName)MoveFileBuilderSource file name (supports wildcards).
DestDirectory(string directory)MoveFileBuilderDestination directory path or property reference.
DestFileName(string fileName)MoveFileBuilderDestination file name (optional rename).
AsCopy()MoveFileBuilderCopies the file instead of moving it (options = 0).
AsMove()MoveFileBuilderMoves the file. This is the default behavior (options = 1).
ComponentRef(string componentRef)MoveFileBuilderAssociates with a specific component.

DuplicateFileBuilder

Duplicates an installed file to another location. Located at src/FalkForge.Core/Builders/DuplicateFileBuilder.cs.

MethodReturn TypeDescription
Id(string id)DuplicateFileBuilderUnique identifier.
FileRef(string fileRef)DuplicateFileBuilderReference to the source file in the File table.
DestDirectory(string directory)DuplicateFileBuilderDestination directory.
DestFileName(string fileName)DuplicateFileBuilderDestination file name (optional rename).
ComponentRef(string componentRef)DuplicateFileBuilderAssociates with a specific component.

RemoveFileBuilder

Schedules file removal on install or uninstall. Located at src/FalkForge.Core/Builders/RemoveFileBuilder.cs.

MethodReturn TypeDescription
Id(string id)RemoveFileBuilderUnique identifier.
Directory(string directory)RemoveFileBuilderDirectory containing the file to remove.
FileName(string fileName)RemoveFileBuilderFile name to remove (supports wildcards).
OnInstall()RemoveFileBuilderRemove the file during installation.
OnUninstall()RemoveFileBuilderRemove the file during uninstallation.
ComponentRef(string componentRef)RemoveFileBuilderAssociates with a specific component.

CreateFolderBuilder

Creates empty folders during installation. Located at src/FalkForge.Core/Builders/CreateFolderBuilder.cs.

MethodReturn TypeDescription
Id(string id)CreateFolderBuilderUnique identifier.
Directory(string directory)CreateFolderBuilderDirectory to create.
ComponentRef(string componentRef)CreateFolderBuilderAssociates with a specific component.
// Move a config template into place
p.MoveFile(mf => mf
    .Id("MoveConfig")
    .SourceDirectory("INSTALLFOLDER")
    .SourceFileName("config.template")
    .DestDirectory("INSTALLFOLDER")
    .DestFileName("config.xml")
    .AsCopy());

// Duplicate a DLL to a second location
p.DuplicateFile(df => df
    .Id("DupSharedLib")
    .FileRef("shared.dll")
    .DestDirectory("CommonFilesFolder")
    .DestFileName("ContosoShared.dll"));

// Clean up log files on uninstall
p.RemoveFile(rf => rf
    .Id("RemoveLogs")
    .Directory("INSTALLFOLDER")
    .FileName("*.log")
    .OnUninstall());

// Create an empty data directory
p.CreateFolder(cf => cf
    .Id("DataFolder")
    .Directory("INSTALLFOLDER_DATA"));

8.26 Output Type Builders

In addition to standard MSI packages, FalkForge supports building merge modules (.msm), patches (.msp), and transforms (.mst) through dedicated builders. Each builder validates its model before returning it.

MergeModuleBuilder

Defines a merge module for shared components. Entry point: Installer.BuildMergeModule(). Located at src/FalkForge.Core/Builders/MergeModuleBuilder.cs. Validates via MergeModuleValidator (error codes MSM001-MSM004).

MethodReturn TypeDescription
Id(Guid id)MergeModuleBuilderSets the merge module GUID identifier.
Language(int language)MergeModuleBuilderLanguage code. Defaults to 1033 (English).
Version(Version version)MergeModuleBuilderModule version. Defaults to 1.0.0.
Manufacturer(string manufacturer)MergeModuleBuilderModule manufacturer name.
Description(string description)MergeModuleBuilderModule description.
Component(string componentId)MergeModuleBuilderAdds a component to the module.
Dependency(string dependencyId)MergeModuleBuilderDeclares a dependency on another merge module.
Build()Result<MergeModuleModel>Validates and builds the model. Returns Result.Failure if validation fails.
return Installer.BuildMergeModule(args, msm =>
{
    msm.Id(Guid.Parse("12345678-1234-1234-1234-123456789012"));
    msm.Manufacturer("Contoso Ltd");
    msm.Version(new Version(1, 0, 0));
    msm.Description("Shared data access components");
    msm.Component("DataAccessLayer");
    msm.Component("ConnectionPooling");
    msm.Dependency("ContosoBase.msm");
}, (model, output) => new MsmCompiler().Compile(model, output));

MsmCompiler emits one row per Dependency(...) call into the standard Windows Installer ModuleDependency table (ModuleID, ModuleLanguage, RequiredID, RequiredLanguage, RequiredVersion). Because Dependency(string dependencyId) only accepts a single opaque identifier, the compiler fills in RequiredID with that string verbatim, defaults RequiredLanguage to the declaring module's own Language (same-language dependency assumed), and leaves RequiredVersion unset (no minimum-version constraint). Depending on a module published in a different language, or pinning a minimum required version, needs a richer overload on MergeModuleBuilder -- not available today.

PatchBuilder

Defines a patch package that upgrades an existing MSI installation. Entry point: Installer.BuildPatch(). Located at src/FalkForge.Core/Builders/PatchBuilder.cs. Validates via PatchValidator (error codes MSP001-MSP004).

MethodReturn TypeDescription
Id(Guid id)PatchBuilderSets the patch GUID identifier.
Classification(PatchClassification classification)PatchBuilderPatch type: Hotfix, Update (default), or SecurityUpdate.
Description(string description)PatchBuilderPatch description.
Manufacturer(string manufacturer)PatchBuilderPatch manufacturer.
TargetProduct(Guid productCode)PatchBuilderProduct code of the MSI to patch.
TargetVersion(string version)PatchBuilderVersion of the original MSI.
UpdatedVersion(string version)PatchBuilderVersion of the patched MSI.
TargetMsi(string path)PatchBuilderPath to the original (baseline) MSI file.
UpdatedMsi(string path)PatchBuilderPath to the updated MSI file.
AllowRemoval(bool allow)PatchBuilderWhether the patch can be uninstalled. Defaults to false.
Build()Result<PatchModel>Validates and builds the model.
return Installer.BuildPatch(args, patch =>
{
    patch.Id(Guid.Parse("AABBCCDD-1122-3344-5566-778899001122"));
    patch.Classification(PatchClassification.SecurityUpdate);
    patch.Description("Security fix for CVE-2026-1234");
    patch.Manufacturer("Contoso Ltd");
    patch.TargetProduct(Guid.Parse("11111111-2222-3333-4444-555555555555"));
    patch.TargetVersion("1.0.0");
    patch.UpdatedVersion("1.0.1");
    patch.TargetMsi("releases/v1.0.0/MyApp.msi");
    patch.UpdatedMsi("releases/v1.0.1/MyApp.msi");
    patch.AllowRemoval();
}, (model, output) => new PatchCompiler().Compile(model, output));

TransformBuilder

Defines a transform that modifies properties in an existing MSI at install time. Entry point: Installer.BuildTransform(). Located at src/FalkForge.Core/Builders/TransformBuilder.cs. Validates via TransformValidator (error codes MST001-MST002).

MethodReturn TypeDescription
Id(string id)TransformBuilderSets the transform identifier.
BaseMsi(string path)TransformBuilderPath to the base MSI file.
TargetMsi(string path)TransformBuilderPath to the target (modified) MSI file.
Description(string description)TransformBuilderTransform description.
SetProperty(string name, string value)TransformBuilderSets a property override. Can be called multiple times.
Build()Result<TransformModel>Validates and builds the model.
return Installer.BuildTransform(args, mst =>
{
    mst.Id("EnterpriseDefaults");
    mst.BaseMsi("releases/v2.0.0/MyApp.msi");
    mst.TargetMsi("releases/v2.0.0/MyApp.msi");
    mst.Description("Enterprise deployment defaults");
    mst.SetProperty("INSTALLLEVEL", "200");
    mst.SetProperty("INSTALL_MODE", "enterprise");
    mst.SetProperty("AGREETOLICENSE", "Yes");
}, (model, output) => new TransformCompiler().Compile(model, output));

8.27 Complete Example

The following example demonstrates a comprehensive MSI package definition using most builder APIs:

return Installer.Build(args, p =>
{
    // Package metadata
    p.Name = "Contoso Server";
    p.Manufacturer = "Contoso Ltd";
    p.Version = new Version(3, 0, 0);
    p.Architecture = ProcessorArchitecture.X64;
    p.Scope = InstallScope.PerMachine;
    p.Description = "Contoso Server application suite";
    p.LicenseFile = "assets/license.rtf";
    p.HelpUrl = "https://support.contoso.com";

    // Upgrade behavior
    p.MajorUpgrade(mu => mu
        .Schedule(RemoveExistingProductsSchedule.AfterInstallValidate));

    p.Downgrade(d => d.Block("A newer version is already installed."));

    // Launch conditions
    p.Require(Condition.IsWindows10OrLater, "Windows 10 or later is required.");
    p.Require(Condition.Is64BitOS, "A 64-bit operating system is required.");
    p.Require(Condition.IsPrivileged, "Administrator privileges are required.");

    // UI
    p.UseDialogSet(MsiDialogSet.FeatureTree);

    // Features
    p.Feature("Server", f =>
    {
        f.Title = "Server Components";
        f.IsRequired = true;
        f.Files(fs => fs
            .Add("bin/server.exe")
            .Add("bin/server.dll")
            .To(KnownFolder.ProgramFiles / "Contoso" / "Server"));

        f.Feature("WebUI", sub =>
        {
            sub.Title = "Web Dashboard";
            sub.Description = "Browser-based management interface";
            sub.Files(fs => fs
                .FromDirectory("wwwroot/")
                .To(KnownFolder.ProgramFiles / "Contoso" / "Server" / "wwwroot"));
        });
    });

    p.Feature("Tools", f =>
    {
        f.Title = "Command-Line Tools";
        f.IsDefault = false;
        f.Files(fs => fs
            .Add("tools/cli.exe")
            .To(KnownFolder.ProgramFiles / "Contoso" / "Server" / "Tools"));
    });

    // Windows service
    p.Service("ContosoServer", svc =>
    {
        svc.DisplayName = "Contoso Server";
        svc.Description = "Contoso application server";
        svc.Executable = "bin/server.exe";
        svc.StartMode = ServiceStartMode.DelayedAutomatic;
        svc.Account = ServiceAccount.NetworkService;
        svc.DependsOn("Tcpip");
        svc.FailureActions(fa =>
        {
            fa.OnFirstFailure = FailureAction.Restart;
            fa.OnSecondFailure = FailureAction.Restart;
            fa.OnSubsequentFailures = FailureAction.None;
            fa.RestartDelay = TimeSpan.FromSeconds(30);
        });
    });

    p.ServiceControl(sc => sc
        .Id("StopContosoServer")
        .ServiceName("ContosoServer")
        .StopOnInstall()
        .StopOnUninstall()
        .DeleteOnUninstall());

    p.ServiceControl(sc => sc
        .Id("StartContosoServer")
        .ServiceName("ContosoServer")
        .StartOnInstall());

    // Shortcuts
    p.Shortcut("Contoso Server", "bin/server.exe")
        .WithIcon("assets/server.ico")
        .OnStartMenu("Contoso");

    // Registry
    p.Registry(r => r.Key(RegistryRoot.LocalMachine, @"SOFTWARE\Contoso\Server", k =>
    {
        k.Value("InstallPath", MsiProperty.InstallFolder);
        k.Value("Version", "3.0.0");
        k.DWord("Port", 8443);
    }));

    // Environment
    p.EnvironmentVariable("CONTOSO_HOME", MsiProperty.InstallFolder);
    p.EnvironmentVariable("PATH", MsiProperty.InstallFolder / "bin", ev =>
    {
        ev.Action = EnvironmentVariableAction.Append;
        ev.Separator = ";";
    });

    // Properties
    p.Property("SERVER_PORT", "8443", prop => prop.IsSecure = true);

    // Custom action
    p.CustomAction("lib/ServerSetup.dll", "ConfigureDatabase", ca =>
    {
        ca.After = "InstallFiles";
        ca.Condition = "NOT Installed";
        ca.Deferred();
        ca.NoImpersonate();
    });

    // Sequence
    p.ExecuteSequence(seq => seq
        .Action("ConfigureDatabase")
            .After("InstallFiles")
            .Condition("NOT Installed"));

    // Create data directory
    p.CreateFolder(cf => cf
        .Id("DataDir")
        .Directory("INSTALLFOLDER_DATA"));

    // Clean up on uninstall
    p.RemoveFile(rf => rf
        .Id("RemoveLogs")
        .Directory("INSTALLFOLDER")
        .FileName("*.log")
        .OnUninstall());

    // Signing
    p.Signing(s => s
        .Thumbprint("A1B2C3D4E5F6...")
        .Timestamp("http://timestamp.digicert.com")
        .WithDescription("Contoso Server", "https://contoso.com"));

    p.EnableRestartManagerSupport();
}, new MsiCompiler());

Because the sample above selects MsiDialogSet.FeatureTree, calling EnableRestartManagerSupport() also authors the MsiRMFilesInUse dialog into that dialog set (see §14). FalkForge appends this dialog to every stock dialog set (Minimal, InstallDir, FeatureTree, Mondo, Advanced) when the flag is set; it is inert for MsiDialogSet.None, since a silent install has no dialog set to append to.

8.28 WinGet Manifest Generation

PackageBuilder.WinGet() attaches a WinGet manifest configuration to the MSI build. When configured, the compiler emits a three-file WinGet singleton manifest (version YAML, installer YAML, locale YAML) alongside the .msi output. The SHA-256 of the installer payload is computed during emission so the manifest is publishable as-is.

var compiler = new MsiCompiler();
Installer.Build(p =>
{
    p.Name("Acme Web App").Manufacturer("Acme")
     .Version("1.2.3").UpgradeCode(Guid.Parse("..."));

    // ... product configuration ...

    p.WinGet(w => w
        .PackageIdentifier("Acme.WebApp")        // "Publisher.PackageName"
        .License("MIT")                          // SPDX license identifier
        .ShortDescription("Sample web app")
        .InstallerUrl("https://acme.example/app.msi")
        .Tags("web", "sample"));
}, compiler);
// Produces: AcmeWebApp.msi
//   + manifests/Acme.WebApp.yaml
//   + manifests/Acme.WebApp.installer.yaml
//   + manifests/Acme.WebApp.locale.en-US.yaml

The CLI command forge winget <file.msi> --id ... --license ... --desc ... (see Section 14) generates the same three-file set from an existing MSI that was not built with PackageBuilder.WinGet() — useful for retrofitting legacy installers.

8.29 PackageCode Semantics

PackageModel.PackageCode (Guid?) is the MSI SummaryInformation RevisionNumber (PID 9). The Windows Installer specification requires every non-identical package to carry a unique PackageCode so that the installer service can distinguish cached packages at repair time (SECREPAIR / KB2918614). ProductCode encodes product identity; PackageCode encodes the specific package bytes. Two MSI files with the same ProductCode but different content must have different PackageCode values, or Windows Installer may use a stale cached copy during repair instead of the updated package.

How the compiler assigns PackageCode

ScenarioPackageCode valueBehavior
Normal build (no Reproducible(), no explicit value) null → compiler generates a fresh Guid.NewGuid() Every packaging event produces a unique PackageCode. Guarantees uniqueness even when ProductCode is pinned for upgrade detection.
Reproducible build (PackageBuilder.Reproducible()) null → compiler derives a deterministic UUID v5 via PackageCodeDerivation.Derive() SHA-256 is computed over: ProductCode, Version, source-date epoch, and each resolved file’s SHA-256 (ordered by FileId). The digest is converted to a UUID v5 GUID using the FalkForge namespace. Identical inputs reproduce the same PackageCode; non-identical packages always produce a different code (SECREPAIR-safe).
Explicit value The supplied Guid is written as-is Use only when pinning the code for a known binary-identical re-release (e.g. mirror distribution of a pre-built MSI). Do not reuse across content-different builds.

Source: src/FalkForge.Core/Models/PackageModel.cs, src/FalkForge.Core/Builders/PackageBuilder.cs, src/FalkForge.Compiler.Msi/Recipe/PackageCodeDerivation.cs.

Why Reproducible Builds Matter

The table above explains what Reproducible() does mechanically; this is why you'd turn it on. "Reproducible" has one precise meaning here: the same source, built twice, produces byte-identical output. package.Reproducible(epochOverride = null) pins every timestamp the compiler would otherwise take from the wall clock to a fixed SOURCE_DATE_EPOCH (an explicit argument, or the SOURCE_DATE_EPOCH environment variable — it throws rather than silently falling back to "now" if neither is set), and leaves PackageCode null so the compiler derives it from a content digest instead of a random GUID. Nothing about the installer's behavior changes; only whether two builds of the same inputs are byte-for-byte the same file.

That one property enables several things a non-reproducible build can't offer:

None of this is free, which is why it's opt-in rather than the default. A normal build's fresh Guid.NewGuid() PackageCode on every compile exists for a reason of its own: Windows Installer's repair cache needs every packaging event to be unambiguously distinguishable, and a fresh GUID guarantees that without you having to think about it, even across many builds of an unchanged ProductCode. Reproducible mode's content-derived PackageCode preserves that same SECREPAIR safety property a different way — two non-identical packages still never collide, because the digest folds in per-file content hashes — but it requires you to actually pin SOURCE_DATE_EPOCH and accept that identical inputs now produce identical output rather than a fresh identity every time. Pick reproducible mode for a release build you intend to publish, sign, or attest to; a normal (non-reproducible) build remains the simpler default for local development, where a unique identity per compile is exactly what you want and there's nothing yet to verify against.

Combining Reproducible() with Integrity() (MSI)

ECDSA-P256 signing is intentionally nondeterministic (a fresh random nonce every call), so embedding an Integrity() signature in-band would defeat Reproducible()'s byte-identical guarantee the moment both are configured on the same PackageBuilder. When both are set, IntegritySigner skips the in-band _FalkForgeIntegrity table entirely and writes the signature sidecar-only instead (<msi>.sig.json, the same envelope shape as always) — the MSI artifact itself stays byte-identical across rebuilds. The compiler logs an explicit Info-level notice when this applies (not hidden behind --verbose, since it is a real, user-visible change of where the signature lives). A non-reproducible Integrity() build still gets both: the in-band table and the sidecar. forge verify <msi> (CLI, 15) checks the table first and falls back to the sidecar automatically, so verification works the same way either way.

Bundles do not yet have this guard. BundleBuilder.Integrity() always embeds its ECDSA signature in the bundle manifest, so a bundle combining Reproducible() with Integrity() is not byte-identical across rebuilds today — a known, tracked gap, not a design choice. If you need a byte-reproducible signed bundle today, verify the unsigned artifact with FALKFORGE_NO_SIGN=1 and sign out-of-band instead.

See Sigil: Optional SBOM Attestation & MSI Integrity Signing (23) for what does and doesn't need Sigil in either mode.

See Demo 57 — reproducible-sbom for the full pin-build-twice-and-compare workflow with an SBOM sidecar, Demo 56 — verify-and-plan for forge plan/plan-diff/verify --rebuild together, the CLI reference (15) for full forge verify and forge plan-diff flags, and Bundle Signing, Trust & Key Rotation (23) for how payload signing and integrity attestation build on top of a reproducible artifact.

8.30 Referencing another project's build output (ProjectOutputs)

Hardcoding a path such as files.Add("../app/bin/Debug/net10.0-windows/DemoApp.dll") is fragile: it breaks across Debug/Release configurations, target-framework changes, CI machines with custom output paths, and silently goes stale instead of failing. The FalkForge SDK solves this by generating a typed ProjectOutputs class at build time so that paths are always resolved from MSBuild, not guessed.

Setup (3 steps)

Step 1 — Import Sdk.targets in both projects.

The FalkForge SDK is not NuGet-packaged; import it by relative path. Add the following to the .csproj of both the installer project and the referenced project. Import Sdk.targets only — not Sdk.props, which would default FalkOutputType to Msi and trigger an unwanted build step.

<Import Project="../../../src/FalkForge.Sdk/Sdk/Sdk.targets" />

Step 2 — Add a ProjectReference with ReferenceOutputAssembly="false".

In the installer project, reference the other project with ReferenceOutputAssembly="false". This requests the output path without creating a compile-time assembly reference.

<ProjectReference Include="../app/DemoApp.csproj"
                   ReferenceOutputAssembly="false" />

Step 3 — Set <FalkOutputType> in the referenced project.

The referenced project declares what kind of artifact it produces. The SDK uses this to resolve the correct output path:

FalkOutputTypeResolves toNotes
Msi$(TargetDir)$(AssemblyName).msiDefault when Sdk.props is imported
Bundle$(TargetDir)$(AssemblyName).exeEXE bundle output
Module$(TargetDir)$(AssemblyName).msmMerge module
Patch$(TargetDir)$(AssemblyName).mspMSP patch
CustomAction$(TargetPath)Primary output assembly (.dll or .exe). Use this for plain apps and libraries whose compiled output you want to package.
Msix$(TargetDir)$(AssemblyName).msixMSIX package (experimental)
MsixBundle$(TargetDir)$(AssemblyName).msixbundleMSIX bundle (experimental)
NoneDisables the SDK auto-build step. Use on a runnable installer project so you can invoke it manually with -o.

The SDK generates obj/…/ProjectOutputs.g.cs at build time. For a referenced project named DemoApp with FalkOutputType=CustomAction the generated file looks like:

namespace FalkForge;
internal static class ProjectOutputs
{
    internal const string DemoApp = @"C:\repo\app\bin\Debug\net10.0-windows\DemoApp.dll";
}

Naming rule

The generated property name equals the referenced project's file name ($(MSBuildProjectName)) sanitized to a valid C# identifier: every character outside [A-Za-z0-9_] (including ., -, and spaces) becomes _, and a leading digit gets a _ prefix. Examples: DemoApp → DemoApp; My.Cool-App 2 → My_Cool_App_2.

Usage

// Single file
files.Add(ProjectOutputs.DemoApp);

// Whole output directory — packages everything the app builds
// (exe, dll, runtimeconfig.json, deps.json, pdb, etc.)
files.FromDirectory(Path.GetDirectoryName(ProjectOutputs.DemoApp)!)
     .To(KnownFolder.ProgramFiles / "Contoso" / "DemoApp");

// Bundle chain
chain.MsiPackage(ProjectOutputs.MyInstaller);

// Custom action referencing a managed DLL
package.CustomAction(ProjectOutputs.MyCaProject, nameof(MyEntryPoint));
What does FromDirectory actually package? When FalkOutputType=CustomAction, ProjectOutputs.DemoApp resolves to the primary .dll. Calling FromDirectory(Path.GetDirectoryName(ProjectOutputs.DemoApp)!) packages every file in that output folder. For a self-contained .NET app this includes the apphost .exe, .dll, .pdb, runtimeconfig.json, and deps.json — exactly what the app needs to run.
Set FalkOutputType=None on the installer project itself. Every demo uses this pattern so the installer project can be run manually with the -o flag without triggering the SDK auto-build step. See demo/58-project-references/ for a working end-to-end example.

9. Bundle Builder API

The Bundle Builder API compiles self-extracting EXE bundles that orchestrate the installation of multiple packages (MSI, EXE, MSU, MSP, nested bundles). Bundles are defined through a fluent API rooted at Installer.BuildBundle(), which delegates to BundleCompiler for output generation.

The compiled EXE follows the format: [PE stub][Magic: "FALKBUNDLE"][Manifest][Compressed payloads][TOC][Footer].

9.1 BundleBuilder

Top-level builder for defining an EXE bundle. Configures bundle metadata, UI mode, chain of packages, related bundles, containers, variables, features, dependency tracking, and update feed. Located in src/FalkForge.Compiler.Bundle/Builders/BundleBuilder.cs.

MethodReturn TypeDescription
Name(string name)BundleBuilderSets the bundle display name.
Manufacturer(string manufacturer)BundleBuilderSets the bundle manufacturer.
Version(string version)BundleBuilderSets the bundle version string. Default: "1.0.0".
BundleId(Guid id)BundleBuilderSets the bundle identifier. Default: auto-generated GUID.
UpgradeCode(Guid code)BundleBuilderSets the upgrade code for related bundle detection. Default: auto-generated GUID.
Scope(InstallScope scope)BundleBuilderSets the install scope. Default: InstallScope.PerMachine.
Chain(Action<ChainBuilder> configure)BundleBuilderConfigures the package installation chain. Adds packages and chain items (including rollback boundaries).
UseBuiltInUI(string? licenseFile, string? logoFile, string? themeColor, string? watermarkImage, string? bannerImage, string? bannerIcon)BundleBuilderUses the built-in WPF installer UI with optional customization for license, logo, theme color, watermark, banner, and banner icon.
UseSilentUI()BundleBuilderConfigures the bundle for silent (no-UI) installation.
UseCustomUI(string uiProjectPath)BundleBuilderRegisters a custom WPF UI project for the bundle. Throws if path is null or whitespace.
UpdateFeed(string feedUrl, UpdatePolicy policy)BundleBuilderConfigures an update feed URL and policy. Default policy: UpdatePolicy.NotifyOnly.
RelatedBundle(string bundleId, Action<RelatedBundleBuilder>? configure)BundleBuilderDeclares a related bundle by string ID.
RelatedBundle(Guid bundleId, Action<RelatedBundleBuilder>? configure)BundleBuilderDeclares a related bundle by GUID (formatted as uppercase brace-style).
Container(string id, Action<ContainerBuilder>? configure)BundleBuilderAdds a named payload container.
DefineContainer(string id, Action<ContainerBuilder>? configure)ContainerRefDefines a container with explicit ID and returns a typed reference handle for compile-time-safe cross-referencing.
DefineContainer(Action<ContainerBuilder>? configure)ContainerRefDefines a container with auto-generated ID (Container_1, Container_2, ...) and returns a typed reference.
DefineRollbackBoundary(string id)RollbackBoundaryRefCreates a rollback boundary reference with an explicit ID. The boundary is registered when passed to ChainBuilder.RollbackBoundary().
DefineRollbackBoundary()RollbackBoundaryRefCreates a rollback boundary reference with auto-generated ID (RollbackBoundary_1, ...).
Variable(string name, Action<BundleVariableBuilder> configure)BundleBuilderDeclares a bundle variable with configuration for type, default value, persistence, and visibility.
Feature(string id, Action<BundleFeatureBuilder> configure)BundleBuilderDeclares a bundle feature that groups packages for optional selection.
DependencyProvider(string key, string version, string? displayName)BundleBuilderRegisters this bundle as a dependency provider with the given key and version.
DependencyConsumer(string providerKey, string consumerKey)BundleBuilderDeclares a dependency on another provider identified by key.
Sbom(Action<SbomOptions>? configure = null)BundleBuilderEmits a CycloneDX 1.6 SBOM sidecar (<bundle>.exe.cdx.json) alongside the compiled bundle. Equivalent to the CLI --sbom flag. The sidecar is CycloneDX by definition and takes no SbomFormat; bundle Integrity() attestations are CycloneDX too (§23).
Integrity(Action<IntegrityBuilder>)BundleBuilderEnables ECDSA-P256 payload signing of the bundle's payload hash list. With no key configured a throwaway key is generated per build (tamper-evidence only); SigningKey(path) / AddSigningKey(path) / SigningKeys(params) load a stable PEM key (publishers bake its fingerprint into the engine for authorship). HybridKey(classicalKeyPath, pqKeyPath) (repeatable) adds a hybrid post-quantum signing identity: the classical ECDSA key plus an ML-DSA-65 companion, both signing the same manifest. See §23 for the full signing, trust, and key-rotation narrative.
DryRun()BundleBuilderBakes dry-run mode into the manifest: the engine Apply phase simulates package execution instead of running real installers. Equivalent at runtime to launching the compiled bundle with --dry-run (MyInstaller.exe --dry-run), which takes precedence over the baked flag.
Build()BundleModelBuilds and returns the final BundleModel for compilation.

9.2 ChainBuilder

Configures the ordered chain of packages to install. Each package type has a dedicated method that accepts a source path and a configuration callback. Rollback boundaries can be inserted between packages to control rollback scope. Located in src/FalkForge.Compiler.Bundle/Builders/ChainBuilder.cs.

MethodReturn TypeDescription
MsiPackage(string sourcePath, Action<BundlePackageBuilder> configure)ChainBuilderAdds an MSI package to the chain.
ExePackage(string sourcePath, Action<BundlePackageBuilder> configure)ChainBuilderAdds an EXE package to the chain.
NetRuntime(string sourcePath, Action<BundlePackageBuilder> configure)ChainBuilderAdds a .NET runtime redistributable package to the chain.
MsuPackage(string sourcePath, Action<MsuPackageBuilder> configure)ChainBuilderAdds a Windows Update (MSU) package to the chain.
MspPackage(string sourcePath, Action<MspPackageBuilder> configure)ChainBuilderAdds a patch (MSP) package to the chain.
BundlePackage(string sourcePath, Action<NestedBundlePackageBuilder> configure)ChainBuilderAdds a nested bundle package to the chain.
RollbackBoundary(string id, Action<RollbackBoundaryBuilder>? configure)ChainBuilderInserts a rollback boundary at the current chain position by string ID.
RollbackBoundary(RollbackBoundaryRef boundaryRef, Action<RollbackBoundaryBuilder>? configure)ChainBuilderInserts a rollback boundary using a typed reference handle.

9.3 BundlePackageBuilder

Configures an individual package (MSI or EXE) within the bundle chain. Created internally by ChainBuilder.MsiPackage(), ChainBuilder.ExePackage(), and ChainBuilder.NetRuntime(). Located in src/FalkForge.Compiler.Bundle/Builders/BundlePackageBuilder.cs.

MethodReturn TypeDescription
Id(string id)BundlePackageBuilderSets the package identifier. Default: filename without extension.
DisplayName(string name)BundlePackageBuilderSets the display name shown in the UI. Default: same as ID.
Version(string version)BundlePackageBuilderSets the package version.
Vital(bool vital)BundlePackageBuilderWhether failure of this package should abort the entire bundle. Default: true.
InstallCondition(string condition)BundlePackageBuilderSets a condition expression that must evaluate to true for this package to install.
InstallCondition(Condition condition)BundlePackageBuilderSets an install condition using a type-safe Condition object.
ExitCode(int code, ExitCodeBehavior behavior)BundlePackageBuilderMaps a specific exit code to a behavior (success, error, reboot required, etc.).
Property(string key, string value)BundlePackageBuilderSets an MSI property to pass to the package during installation.
RemotePayload(string url, string sha256, long size)BundlePackageBuilderDeclares a remote payload with download URL, SHA-256 hash, and size. Remote payloads are not embedded in the bundle.
Container(string containerId)BundlePackageBuilderAssigns this package to a named container by string ID.
Container(ContainerRef containerRef)BundlePackageBuilderAssigns this package to a container using a typed reference handle.
DetectionMode(DetectionMode mode)BundlePackageBuilderSets the detection strategy. Default uses product code (MSI) or ARP (EXE). SearchOnly relies solely on search conditions.
SearchCondition(Action<SearchConditionBuilder>)BundlePackageBuilderAdds a search condition for package detection. Multiple conditions are ANDed. See SearchConditionBuilder.
AuthenticodeThumbprint(string)BundlePackageBuilderSets the expected Authenticode certificate thumbprint for payload verification.
Prerequisite(bool)BundlePackageBuilderMarks the package as a prerequisite that must succeed before the main chain runs. Default: false.
Permanent(bool)BundlePackageBuilderPackage is never uninstalled when the bundle is removed. Use for shared runtimes or setup utilities. Default: false.
EnableFeatureSelection(bool)BundlePackageBuilderAllows bundle UI to present MSI feature selection for this package. Only valid for MSI packages. Default: false.

9.3a SearchConditionBuilder

Defines a search condition used for package detection. Created via BundlePackageBuilder.SearchCondition(). Located in src/FalkForge.Compiler.Bundle/Builders/SearchConditionBuilder.cs.

MethodReturn TypeDescription
FileExists(string path)SearchConditionBuilderDetects presence of a file at the specified path.
FileVersion(string path, string comparison, string version)SearchConditionBuilderCompares a file's version against an expected version (e.g., ">=", "14.0.0").
DirectoryExists(string path)SearchConditionBuilderDetects presence of a directory.
RegistryExists(RegistryRoot, string key, string? valueName)SearchConditionBuilderDetects presence of a registry key or value. Used for prerequisite detection.
RegistryValue(RegistryRoot, string key, string valueName, string comparison, string expected)SearchConditionBuilderCompares a registry value against an expected value. Supports version comparisons (e.g., ">=", "=").
RegistryRoot values: LocalMachine (HKLM), CurrentUser (HKCU), ClassesRoot (HKCR), Users (HKU).

9.3b PackageGroupBuilder

Defines a reusable group of packages that are flattened into the chain at build time. Created via ChainBuilder.PackageGroup() or passed as a pre-built PackageGroupModel. Located in src/FalkForge.Compiler.Bundle/Builders/PackageGroupBuilder.cs.

MethodReturn TypeDescription
Id(string id)PackageGroupBuilderSets the group identifier.
ExePackage(string sourcePath, Action<BundlePackageBuilder>)PackageGroupBuilderAdds an EXE package to the group.
MsiPackage(string sourcePath, Action<BundlePackageBuilder>)PackageGroupBuilderAdds an MSI package to the group.

ChainBuilder Integration

MethodReturn TypeDescription
PackageGroup(Action<PackageGroupBuilder>)ChainBuilderDefines and flattens a custom package group inline.
PackageGroup(PackageGroupModel)ChainBuilderFlattens a pre-built package group (e.g., from BuiltInPrerequisites).

9.3c Built-in Prerequisites

Pre-configured package groups for common runtime prerequisites. Each group defines detection via registry search conditions and silent install arguments. The actual installer files are not embedded -- users must provide the source files or use RemotePayload. Located in src/FalkForge.Compiler.Bundle/Prerequisites/BuiltInPrerequisites.cs.

MethodReturnsDescription
BuiltInPrerequisites.NetFx472()PackageGroupModel.NET Framework 4.7.2 offline installer. Detection: HKLM\...\NDP\v4\Full, Release >= 461808. Silent: /q /norestart.
BuiltInPrerequisites.VCRedist14x64()PackageGroupModelVisual C++ 2015-2022 Redistributable (x64). Detection: HKLM\...\VC\Runtimes\x64, Installed = 1. Silent: /install /quiet /norestart.
BuiltInPrerequisites.OdbcDriver17()PackageGroupModelMicrosoft ODBC Driver 17 for SQL Server. Detection: HKLM\...\ODBCINST.INI\ODBC Driver 17. Silent MSI install.
BuiltInPrerequisites.SqlExpress2017()PackageGroupModelSQL Server 2017 Express. Detection: HKLM\...\Instance Names\SQL\SQLEXPRESS. Silent: /QUIET.
// Use built-in prerequisites in a bundle chain
Installer.BuildBundle(args, bundle =>
{
    bundle.Name("My Enterprise App")
          .Version("2.0.0")
          .Chain(chain =>
          {
              // Add .NET Framework and VC++ prerequisites
              chain.PackageGroup(BuiltInPrerequisites.NetFx472());
              chain.PackageGroup(BuiltInPrerequisites.VCRedist14x64());

              // Main application MSI with feature selection
              chain.MsiPackage("MyApp.msi", p => p
                  .Id("MyApp")
                  .DisplayName("My Enterprise App")
                  .EnableFeatureSelection(true));
          });
});

9.3d Variable Resolution in Install Arguments

EXE package install arguments support the [VariableName] syntax, which is resolved at runtime by the bundle engine. Variables are sourced from the VariableStore, which includes built-in variables and any user-defined variables set via the bundle UI or command line.

chain.ExePackage("setup.exe", p => p
    .Id("Setup")
    .Property("InstallArguments", "/TARGETDIR=[InstallFolder] /LOG=[BundleLog]"));
Unresolved variables (not found in the VariableStore) are left as-is in the argument string. The pattern [name] is matched by a regex and replaced only when the variable exists.

9.3e Runtime Dependency Enforcement

DependencyProvider(key, version, displayName) and DependencyConsumer(providerKey, consumerKey) (§9.1) are now ENFORCED at runtime, not just recorded. The engine's plan phase refuses the operation before any package is touched:

Both checks read both HKEY_LOCAL_MACHINE and HKEY_CURRENT_USER under SOFTWARE\Classes\Installer\Dependencies\ — a per-user consumer of a per-machine provider (or the reverse) is a real shape and would otherwise be missed. A PerUser-scoped bundle writes its own provider/consumer registrations directly to HKCU; a PerMachine-scoped bundle writes to HKLM through the elevated companion process. The layout is byte-identical to the MSI-authored one (FalkForge.Extensions.Dependency, §7.11 in the extensions table), so a bundle-level dependency and an MSI-table-authored one are visible to each other.

The uninstall check fails closed: if the registry cannot be read (access denied, corrupted key), the uninstall is refused rather than silently treated as "nothing depends on this" — an unknown state must never be assumed safe. The install check refuses on a genuinely missing/ unsatisfied provider; a registration write failure after a successful apply (e.g. the elevated companion round-trip failed) is logged as a warning and never fails an install or uninstall that otherwise completed.

--ignore-dependencies bypasses both checks — pass it on the command line (MyInstaller.exe --ignore-dependencies --uninstall) when a stale or unwanted registration is blocking an operation you are sure is safe. It is NOT implied by silent/unattended mode: an automated uninstall that would otherwise break a dependent product still gets refused unless this flag is explicitly passed, since silent automation is exactly where a silently broken dependent hurts most.

Stale registrations. Uninstalling a bundle only removes ITS OWN consumer registration — the provider row it may have authored is never removed (nothing currently collects an orphaned provider once its last consumer is gone). If an uninstall is unexpectedly refused, check HKLM/HKCU under SOFTWARE\Classes\Installer\Dependencies\<key> \Dependents\ for a dependent subkey belonging to a product that is itself no longer installed (a leftover from an MSI-authored dependency predating this feature, or a bundle that was removed by means other than its own uninstaller) — delete the stale subkey by hand, or pass --ignore-dependencies for that one run.
A stale provider row can also make a MISSING dependency look satisfied. The note above covers the stale-refusal case (uninstall blocked by a leftover dependent); the opposite failure mode is the reverse and is NOT covered by --ignore-dependencies or any manual cleanup step above. A provider's own uninstall removes only its consumer registrations — the provider's own row (which carries the Version value the install-time check reads) is never removed, because the same key can legitimately be written by more than one product (an MSI-authored dependency and a bundle-authored one sharing the same provider key, or multiple products from the same vendor) and there is no reliable way to tell "the last one just uninstalled" from "another provider of the same key is still present." So the row is deliberately left in place rather than guessed away. The consequence: after the actual provider is gone, a stale row can make DetectUnsatisfiedProviders read a leftover Version value and report the requirement SATISFIED, letting a dependent install proceed against a shared component that is no longer actually present. Earlier release notes describing "install refusal on missing dependency" as an unconditional guarantee are stronger than what this mechanism actually provides once a provider has been uninstalled and re-uninstalled without a corresponding orphan-collection pass (not built yet — see ADR 0008).

9.4 MsuPackageBuilder

Configures a Windows Update (MSU) package within the chain. Located in src/FalkForge.Compiler.Bundle/Builders/MsuPackageBuilder.cs.

MethodReturn TypeDescription
Id(string id)MsuPackageBuilderSets the package identifier. Default: filename without extension.
DisplayName(string name)MsuPackageBuilderSets the display name.
Vital(bool vital)MsuPackageBuilderWhether failure should abort the bundle. Default: true.
KbArticle(string kbArticle)MsuPackageBuilderSets the KB article number for the Windows update.
InstallCondition(string condition)MsuPackageBuilderSets a condition expression for conditional installation.
InstallCondition(Condition condition)MsuPackageBuilderSets an install condition using a type-safe Condition object.

9.5 MspPackageBuilder

Configures a patch (MSP) package within the chain. Located in src/FalkForge.Compiler.Bundle/Builders/MspPackageBuilder.cs.

MethodReturn TypeDescription
Id(string id)MspPackageBuilderSets the package identifier. Default: filename without extension.
DisplayName(string name)MspPackageBuilderSets the display name.
Vital(bool vital)MspPackageBuilderWhether failure should abort the bundle. Default: true.
PatchCode(string patchCode)MspPackageBuilderSets the patch code (GUID) for this patch.
TargetProductCode(string targetProductCode)MspPackageBuilderSets the target product code that this patch applies to.
InstallCondition(string condition)MspPackageBuilderSets a condition expression for conditional installation.
InstallCondition(Condition condition)MspPackageBuilderSets an install condition using a type-safe Condition object.

9.6 NestedBundlePackageBuilder

Configures a nested bundle package within the chain. Located in src/FalkForge.Compiler.Bundle/Builders/NestedBundlePackageBuilder.cs.

MethodReturn TypeDescription
Id(string id)NestedBundlePackageBuilderSets the package identifier. Default: filename without extension.
DisplayName(string name)NestedBundlePackageBuilderSets the display name.
Vital(bool vital)NestedBundlePackageBuilderWhether failure should abort the parent bundle. Default: true.
InstallCondition(string condition)NestedBundlePackageBuilderSets a condition expression for conditional installation.
InstallCondition(Condition condition)NestedBundlePackageBuilderSets an install condition using a type-safe Condition object.

9.7 ContainerBuilder

Configures a payload container for grouping packages. Containers enable separate download of package subsets from a remote URL. Located in src/FalkForge.Compiler.Bundle/Builders/ContainerBuilder.cs.

MethodReturn TypeDescription
Id(string id)ContainerBuilderSets the container identifier.
DownloadUrl(string url)ContainerBuilderSets the remote download URL for this container.
Runtime acquisition. At install time, ExternalContainerAcquirer (src/FalkForge.Engine/Bootstrap/ExternalContainerAcquirer.cs) downloads each declared external container and verifies it through a three-layer integrity chain before any byte is extracted: (1) whole-file SHA-256 against the manifest's declared container hash, (2) declared-membership check that the container's table of contents carries exactly the package ids the manifest promised for it, and (3) signed-set binding of the container's payloads to the bundle's ECDSA-signed manifest — the authoritative gate for a signed bundle. Any layer failing aborts the whole acquisition; no partial or unverified install proceeds.

Declares a relationship to another bundle (upgrade, addon, detect, or patch). Located in src/FalkForge.Compiler.Bundle/Builders/RelatedBundleBuilder.cs.

MethodReturn TypeDescription
BundleId(string bundleId)RelatedBundleBuilderSets the related bundle identifier.
Relation(RelatedBundleRelation relation)RelatedBundleBuilderSets the relationship type. Default: RelatedBundleRelation.Upgrade.

The four RelatedBundleRelation values describe, at authoring time, how the bundle you're building relates to another bundle that might already be on the machine: Upgrade means this bundle supersedes the related one (a newer version of the same product); Addon means it extends the related bundle without replacing it (an optional module for a product that must already be present); Patch means it modifies the related bundle in place rather than standing alone; Detect means it should only be recognized as related for detection/reporting purposes, with no install-time action implied.

Current runtime behavior: today, the engine only branches on Upgrade. During Detect, FeatureDetector migrates prior feature selections from an Upgrade-related bundle when no current selections exist in the registry; during Planning, Planner auto-generates an uninstall action for the old bundle only when its relation is Upgrade. Addon, Patch, and Detect are fully round-tripped — serialized on the wire (DetectRelatedBundle, see Message Types), surfaced to the UI, present in the manifest — but as of this writing nothing in the engine currently treats them differently from each other at install time. Author with the intended semantics above; don't rely on distinct runtime behavior for Addon/Patch/Detect until the engine grows relation-specific handling for them.

9.9 RollbackBoundaryBuilder

Configures a rollback boundary inserted into the package chain. If a package after a rollback boundary fails, only packages between this boundary and the next are rolled back. Located in src/FalkForge.Compiler.Bundle/Builders/RollbackBoundaryBuilder.cs.

MethodReturn TypeDescription
Id(string id)RollbackBoundaryBuilderSets the boundary identifier.
Vital(bool vital)RollbackBoundaryBuilderWhether the boundary itself is vital. Default: true.

Each RollbackBoundary(...) call is a marker in the chain's package sequence, not a package itself — it opens a new rollback scope that covers every package after it, up to the next boundary (or the end of the chain). If a package inside a scope fails, only the packages within that same scope roll back; packages that already completed successfully in an earlier scope are left installed. Place a boundary wherever you want a failure to stop propagating backward — typically between prerequisites and the application itself, so a failed application install doesn't tear out prerequisites another product might also depend on:

.Chain(chain => chain
    // Scope 1: if Runtime.msi fails, only Runtime.msi rolls back (nothing precedes it)
    .RollbackBoundary("Prerequisites")
    .MsiPackage("Runtime.msi", p => p
        .Id("Runtime")
        .DisplayName("Runtime Prerequisites")
        .Vital(true))
    // Scope 2: if MyApp.msi fails, only MyApp.msi rolls back -- Runtime.msi (scope 1)
    // stays installed, since it already completed successfully
    .RollbackBoundary("Application")
    .MsiPackage("MyApp.msi", p => p
        .Id("MyApp")
        .DisplayName("My Application")
        .Vital(true)))

See Demo 41 — bundle-rollback for the full runnable bundle.

9.10 BundleVariableBuilder

Declares a bundle variable that can be used in conditions, passed to packages, or persisted across sessions. Created via BundleBuilder.Variable(). Located in src/FalkForge.Compiler.Bundle/Builders/BundleVariableBuilder.cs.

MethodReturn TypeDescription
String()BundleVariableBuilderSets the variable type to string (default).
Numeric()BundleVariableBuilderSets the variable type to numeric.
Version()BundleVariableBuilderSets the variable type to version.
Default(string value)BundleVariableBuilderSets the default value of the variable.
Persisted()BundleVariableBuilderMarks the variable as persisted across sessions (stored in the registry).
Hidden()BundleVariableBuilderHides the variable from the command line.
Secret()BundleVariableBuilderMarks the variable as a secret (implies hidden). Not written to logs.

9.11 BundleFeatureBuilder

Declares a feature that groups packages for optional selection by the user. Created via BundleBuilder.Feature(). Located in src/FalkForge.Compiler.Bundle/Builders/BundleFeatureBuilder.cs.

MethodReturn TypeDescription
Title(string title)BundleFeatureBuilderSets the feature display title.
Description(string description)BundleFeatureBuilderSets the feature description.
Default(bool isDefault)BundleFeatureBuilderWhether the feature is selected by default. Default: true.
Required()BundleFeatureBuilderMarks the feature as required (always installed, implies default).
Package(string packageId)BundleFeatureBuilderAssociates a package with this feature by package ID.

9.12 Typed Reference Handles

Sealed records that provide compile-time-safe cross-references between bundle elements. Prevents stringly-typed errors when referencing containers or rollback boundaries from other builders.

TypePropertyDescription
ContainerRefstring Id { get; }Typed reference to a container. Created by BundleBuilder.DefineContainer(). Accepted by BundlePackageBuilder.Container(ContainerRef).
RollbackBoundaryRefstring Id { get; }Typed reference to a rollback boundary. Created by BundleBuilder.DefineRollbackBoundary(). Accepted by ChainBuilder.RollbackBoundary(RollbackBoundaryRef).

9.13 Entry Point and Compilation

Bundles are compiled using the Installer.BuildBundle() entry point, which accepts command-line arguments and a compilation function. The BundleCompiler validates the model, generates the manifest, reads and hashes payloads, creates the PE stub, and embeds everything into the final EXE.

// Entry point signature
public static int BuildBundle(string[] args, Func<string, Result<string>> compile);

// BundleCompiler
public sealed class BundleCompiler
{
    public Result<string> Compile(BundleModel model, string outputPath);
}

9.14 Code Example

Installer.BuildBundle(args, outputPath =>
{
    var bundle = new BundleBuilder()
        .Name("MyProductSuite")
        .Manufacturer("Contoso")
        .Version("2.0.0")
        .UpgradeCode(Guid.Parse("01234567-89AB-CDEF-0123-456789ABCDEF"))
        .UseBuiltInUI(licenseFile: "License.rtf", themeColor: "#0078D4")
        .UpdateFeed("https://updates.contoso.com/feed.json");

    // Define typed references for compile-time safety
    var mainContainer = bundle.DefineContainer("MainPayloads", c =>
        c.DownloadUrl("https://cdn.contoso.com/main.cab"));
    var boundary = bundle.DefineRollbackBoundary("AfterPrereqs");

    bundle
        .Variable("InstallDir", v => v.String().Default(@"C:\Program Files\Contoso").Persisted())
        .Feature("Core", f => f.Title("Core Components").Required().Package("CoreMsi"))
        .Feature("Tools", f => f.Title("Developer Tools").Package("ToolsMsi"))
        .DependencyProvider("Contoso.Suite", "2.0.0", "Contoso Product Suite")
        .Chain(chain => chain
            .MsiPackage("prereqs.msi", p => p
                .Id("PrereqMsi")
                .DisplayName("Prerequisites")
                .Vital(true))
            .RollbackBoundary(boundary)
            .MsiPackage("core.msi", p => p
                .Id("CoreMsi")
                .DisplayName("Core Application")
                .Container(mainContainer)
                .Property("INSTALLFOLDER", "[InstallDir]"))
            .MsiPackage("tools.msi", p => p
                .Id("ToolsMsi")
                .DisplayName("Developer Tools")
                .Container(mainContainer)
                .Vital(false))
            .MsuPackage("kb12345.msu", p => p
                .KbArticle("KB12345")
                .InstallCondition("VersionNT < v10.0.19041")));

    var model = bundle.Build();
    return new BundleCompiler().Compile(model, outputPath);
});
Package types: The BundlePackageType enum defines six package types: MsiPackage, ExePackage, NetRuntime, MsuPackage, MspPackage, and BundlePackage. The BundleUiType enum defines three UI modes: BuiltIn, Custom, and Silent.

Delta Updates

BundleBuilder.DeltaFrom(oldBundlePath) switches the compiler into delta mode: instead of emitting a full self-extracting bundle, the compiler produces a smaller artifact that contains only the differences from the supplied previous bundle. Differences are computed with the Octodiff rsync-style algorithm over the compressed payloads. The TOC includes a flag byte plus BaseSha256Hash and ReconstructedSha256Hash per delta payload, so the engine can verify that the base bundle present on the target machine matches the one used at delta compile time.

Installer.BuildBundle(b =>
{
    b.Name("Acme Suite").Version("1.2.0")
     .BundleId(Guid.Parse("..."))
     .DeltaFrom("./previous/AcmeSuite-1.1.0.exe");   // enable delta mode

    b.Chain(c => c.MsiPackage("acme-core.msi"));
});
// Produces: AcmeSuite-1.2.0-delta.exe (much smaller than a full bundle)

Delta handling is split across download and install time. The engine's UpdateDownloader inspects the manifest update feed (UpdateFeedEntry.DeltaUrl + DeltaSha256), fetches and SHA-256-verifies the delta bundle first, then relaunches it — passing the currently-installed (base) bundle as --base-bundle. The relaunched bundle reconstructs each delta payload with DeltaApplicator against the matching payload in that base bundle: it checks the base payload's hash equals the delta's BaseSha256Hash, applies the Octodiff delta, and verifies the finished payload against ReconstructedSha256Hash before writing it — per payload, not per whole bundle. A failed delta download falls back to the full bundle URL. A delta that downloads but cannot be applied (wrong or missing base version, hash mismatch) fails loudly and instructs recovery via the full installer, rather than writing an unverifiable reconstruction. Older engines that don't understand delta flags simply skip the delta entry and download the full URL.

10. Extension System

FalkForge provides a pluggable extension system that allows contributing custom MSI tables, additional components, and validation rules. Extensions implement interfaces defined in FalkForge.Extensibility and are registered with the compiler through the IExtensionRegistry.

The shipped extensions below don't just author inert MSI table rows: Firewall, SQL, IIS (application pools and web sites), HTTP, Dependency, and Util schedule real deferred, elevated custom actions that create the firewall rule, database, site, URL reservation, or registry provider entry at install time. For IIS this now covers the full site graph: application pools, web sites with all their bindings, HTTPS/SSL certificate binding, sub-applications, and virtual directories are all created (and removed on uninstall, with rollback on a failed install) at install time via Microsoft.Web.Administration. An HTTPS binding's certificate is bound to a pre-provisioned certificate located in its authored store — FalkForge does not import the certificate, so it must already exist in the target store or the deferred bind fails loud (a pre-provisioning caveat surfaced via IIS013). A virtual directory targeting a parent application that is neither the site root (/) nor an authored sub-application is warned via IIS015 (its parent will not exist at install).

10.1 Extension Interfaces

Located in src/FalkForge.Extensibility/. These interfaces form the extension contract.

IFalkForgeExtension

Primary entry point for all extensions. Provides a name, optional version metadata, a registration callback, and a hook for contributing validation rules.

MemberTypeDescription
NamestringUnique extension name.
VersionstringOptional semantic version of the extension implementation (defaults to "0.0.0"). Surfaces in error messages and diagnostics.
MinHostVersionstringOptional minimum FalkForge host version. ExtensionRegistration.Register rejects the extension with PluginCompatibilityException if the host is older or the value cannot be parsed.
Register(IExtensionRegistry registry)voidCalled to register contributors with the compiler.
GetValidationRules()ImmutableArray<ValidationRule>Returns the rules this extension contributes. Merged into ModelValidator.Inspect alongside core rules. Default implementation returns an empty array, so existing extensions need no changes.

IExtensionRegistry

Registry interface provided to extensions during registration.

MethodReturn TypeDescription
RegisterTableContributor(IMsiTableContributor contributor)voidRegisters an MSI table contributor.
RegisterComponentContributor(IComponentContributor contributor)voidRegisters a component contributor.
RegisterExecutionContributor(IExecutionContributor contributor)voidRegisters an install-time execution contributor. Its ExecutionStep declarations become deferred, elevated custom actions so the contributor's work actually runs at install time. See 10.10 How Extensions Work.
RegisterDryRunContributor(IDryRunContributor contributor)voidRegisters a dry-run contributor used by forge build --dry-run and forge validate.
RegisterDialogStep(IDialogStepBuilder builder)voidRegisters an extension-contributed dialog step. The step becomes available for insertion via DialogCustomization.InsertStep(name, after:). DLG001 will not fire for names registered here.
IExtensionValidator was removed. The earlier IExtensionValidator cluster (Validate(ExtensionContext, ValidationResult)) and the corresponding RegisterValidator method were retired. All extension diagnostics are now contributed via IFalkForgeExtension.GetValidationRules() returning a ImmutableArray<ValidationRule>, which the model validator merges with the core rule set before ModelValidator.Inspect runs. The data-as-rules model means each rule is a first-class object the CLI can list and explain (forge rules list / forge rules explain <RuleId>).

IComponentContributor

MethodReturn TypeDescription
GetAdditionalFiles(ExtensionContext context)IReadOnlyList<FileEntryModel>Returns additional files to include in the MSI during compilation.

IMsiTableContributor

MemberTypeDescription
TableNamestringName of the MSI table to populate — either an existing built-in table (e.g. Registry, CustomAction) or a new custom table.
GetRows(ExtensionContext context)IReadOnlyList<MsiTableRow>Returns rows to insert into the table.
WriteColumnsIReadOnlyList<ContributedColumn>?Column schema for a custom (non-built-in) table, used to emit its CREATE TABLE statement. null by default. Required whenever TableName is not a built-in table — a contributor that yields rows for an unknown table without this schema fails the build loudly (EXT001) instead of dropping the rows. Ignored when TableName names a built-in table, since the compiler already knows its columns.
ReadSchemaITableReadSchema?Optional read-side schema so the decompile path can read this contributor's custom table back into MsiReadRecipe.ExtensionRows. null by default, in which case the table is silently skipped during decompile.

IExecutionContributor

The counterpart to IMsiTableContributor: where a table contributor puts data into the compiled MSI, an execution contributor makes that data live by scheduling the work that acts on it at install time. See 10.10 How Extensions Work for the full mechanics.

MemberTypeDescription
GetExecutionSteps(ExtensionContext context)IReadOnlyList<ExecutionStep>Returns the execution steps this contributor wants scheduled. Called once during compilation; an empty list contributes nothing.

ValidationRule (data-as-rules)

Each rule is a first-class object that names itself with a stable RuleId, declares its Severity, carries a human description and a recommended fix, and exposes the actual diagnostic via an Inspect delegate (which the engine calls during ModelValidator.Inspect with the current package model and an emitter). Extensions contribute their rule set by overriding IFalkForgeExtension.GetValidationRules(). Because rules are values rather than virtual methods, the CLI can enumerate them (forge rules list) and print their full metadata (forge rules explain <RuleId>) without instantiating any compiler internals.

ExtensionContext

Context object passed to all extension callbacks during compilation.

PropertyTypeDescription
PackagePackageModelThe package model being compiled.
OutputDirectorystringOutput directory for the compiled installer.
SourceDirectorystringSource directory of the project.

MsiTableRow

A flexible row container for custom MSI table data.

MethodReturn TypeDescription
Set(string column, object? value)MsiTableRowSets a column value. Returns self for chaining.
Get(string column)object?Gets a column value by name.
FieldsIReadOnlyDictionary<string, object?>All column-value pairs in the row.

10.2 Firewall Extension

Defines Windows Firewall rules to create during installation. Located in src/FalkForge.Extensions.Firewall/. Validation error codes: FWL001-004.

FirewallExtension

MethodReturn TypeDescription
NamestringReturns "Firewall".
AddRule(Action<FirewallRuleBuilder> configure)voidAdds a firewall rule definition.
ValidateRules()IReadOnlyList<FirewallValidationError>Validates all configured rules and returns any errors.
Register(IExtensionRegistry registry)voidRegisters the table contributor with the compiler.

FirewallRuleBuilder

MethodReturn TypeDescription
Id(string id)FirewallRuleBuilderSets the rule identifier.
Name(string name)FirewallRuleBuilderSets the display name of the firewall rule.
Description(string description)FirewallRuleBuilderSets the rule description.
Protocol(FirewallProtocol protocol)FirewallRuleBuilderSets the protocol. Default: FirewallProtocol.Tcp.
Port(string port)FirewallRuleBuilderSets the local port or port range (e.g., "80", "8080-8090").
RemotePort(string remotePort)FirewallRuleBuilderSets the remote port or port range.
LocalAddress(string localAddress)FirewallRuleBuilderSets the local address filter.
RemoteAddress(string remoteAddress)FirewallRuleBuilderSets the remote address filter.
Program(string program)FirewallRuleBuilderSets the program path the rule applies to.
Profile(FirewallProfile profile)FirewallRuleBuilderSets the firewall profile. Default: FirewallProfile.All.
Direction(FirewallDirection direction)FirewallRuleBuilderSets the direction. Default: FirewallDirection.Inbound.
Action(FirewallRuleAction action)FirewallRuleBuilderSets the action. Default: FirewallRuleAction.Allow.
ComponentRef(string componentRef)FirewallRuleBuilderAssociates the rule with a specific component.
Condition(string condition)FirewallRuleBuilderSets a condition expression for conditional rule creation.
var firewall = new FirewallExtension();
firewall.AddRule(rule => rule
    .Id("WebServerRule")
    .Name("Allow HTTP Traffic")
    .Protocol(FirewallProtocol.Tcp)
    .Port("80")
    .Direction(FirewallDirection.Inbound)
    .Action(FirewallRuleAction.Allow)
    .Profile(FirewallProfile.All));

firewall.AddRule(rule => rule
    .Id("AppRule")
    .Name("Allow Application")
    .Program(@"[INSTALLFOLDER]myapp.exe")
    .Direction(FirewallDirection.Inbound)
    .Action(FirewallRuleAction.Allow));

10.3 IIS Extension

Configures IIS application pools, web sites, web applications, bindings, and certificates. Located in src/FalkForge.Extensions.Iis/. Windows-only ([SupportedOSPlatform("windows")]). Validation error codes: IIS001-011.

IisExtension

MethodReturn TypeDescription
NamestringReturns "Iis".
AddWebSite(Action<WebSiteBuilder> configure)IisExtensionAdds a web site definition. Returns self for chaining.
AddAppPool(Action<AppPoolBuilder> configure)IisExtensionAdds an application pool definition.
DefineAppPool(Action<AppPoolBuilder> configure)AppPoolRefDefines an app pool and returns a typed reference for cross-referencing.
AddCertificate(Action<CertificateBuilder> configure)IisExtensionAdds a certificate definition.
DefineCertificate(Action<CertificateBuilder> configure)CertificateRefDefines a certificate and returns a typed reference for cross-referencing.
Validate()Result<Unit>Validates all configured IIS resources.
Register(IExtensionRegistry registry)voidIIS is model-only at compile time; actual management happens via custom actions at install time.

AppPoolBuilder

MethodReturn TypeDescription
Id(string id)AppPoolBuilderSets the app pool identifier. Defaults to the name if not set.
Name(string name)AppPoolBuilderSets the app pool name as it appears in IIS.
Runtime(string version)AppPoolBuilderSets the managed runtime version (e.g., "v4.0"). Default: "v4.0".
NoManagedCode()AppPoolBuilderSets managed runtime to empty string (no managed code, for .NET Core/reverse proxy).
PipelineMode(ManagedPipelineMode mode)AppPoolBuilderSets the pipeline mode. Default: ManagedPipelineMode.Integrated.
Enable32Bit()AppPoolBuilderEnables 32-bit applications on 64-bit Windows.
Identity(AppPoolIdentityType type)AppPoolBuilderSets the identity type. Default: ApplicationPoolIdentity.
Identity(AppPoolIdentityType type, string userName, string password)AppPoolBuilderSets the identity type with specific credentials.
MaxProcesses(int count)AppPoolBuilderSets the maximum number of worker processes. Default: 1.
RecycleMinutes(int minutes)AppPoolBuilderSets the recycling interval in minutes. Default: 1740 (29 hours).
IdleTimeout(int minutes)AppPoolBuilderSets the idle timeout in minutes. Default: 20.

WebSiteBuilder

MethodReturn TypeDescription
Id(string id)WebSiteBuilderSets the web site identifier. Defaults to the description if not set.
Description(string description)WebSiteBuilderSets the web site description / display name.
Directory(string directory)WebSiteBuilderSets the physical root directory.
Binding(Action<WebBindingBuilder> configure)WebSiteBuilderAdds a binding using the full builder API.
Binding(int port, string protocol, string? hostHeader)WebSiteBuilderAdds a binding with shorthand parameters. Default protocol: "http", IP: "*".
AppPool(string appPool)WebSiteBuilderAssociates the site with an app pool by string ID.
AppPool(AppPoolRef appPoolRef)WebSiteBuilderAssociates the site with an app pool using a typed reference.
AutoStart(bool autoStart)WebSiteBuilderWhether the site starts automatically. Default: true.
ConnectionTimeout(int seconds)WebSiteBuilderSets the connection timeout in seconds. Default: 120.
AddApplication(Action<WebApplicationBuilder> configure)WebSiteBuilderAdds a web application under this site.

WebBindingBuilder

MethodReturn TypeDescription
Protocol(string protocol)WebBindingBuilderSets the protocol. Default: "http".
Port(int port)WebBindingBuilderSets the port number.
HostHeader(string hostHeader)WebBindingBuilderSets the host header for name-based virtual hosting.
IpAddress(string ipAddress)WebBindingBuilderSets the IP address. Default: "*" (all).
Certificate(string certificateRef)WebBindingBuilderAssociates a certificate by string ID. Automatically sets protocol to "https".
Certificate(CertificateRef certificateRef)WebBindingBuilderAssociates a certificate using a typed reference.

CertificateBuilder

MethodReturn TypeDescription
Id(string id)CertificateBuilderSets the certificate identifier.
Store(CertificateStoreName storeName, CertificateStoreLocation storeLocation)CertificateBuilderSets the certificate store. Default: My / LocalMachine.
FindByThumbprint(string thumbprint)CertificateBuilderFinds the certificate by its thumbprint.
FindBySubjectName(string subjectName)CertificateBuilderFinds the certificate by subject name.
Exportable()CertificateBuilderMarks the certificate as exportable.

WebApplicationBuilder

MethodReturn TypeDescription
Id(string id)WebApplicationBuilderSets the web application identifier. Defaults to the alias if not set.
Alias(string alias)WebApplicationBuilderSets the virtual path alias (e.g., /api).
Directory(string directory)WebApplicationBuilderSets the physical directory.
AppPool(string appPool)WebApplicationBuilderAssociates the application with an app pool by string ID.
AppPool(AppPoolRef appPoolRef)WebApplicationBuilderAssociates the application with an app pool using a typed reference.

IIS Typed Reference Handles

TypePropertyDescription
AppPoolRefstring Id { get; }Typed reference to an app pool. Created by IisExtension.DefineAppPool(). Accepted by WebSiteBuilder.AppPool() and WebApplicationBuilder.AppPool().
CertificateRefstring Id { get; }Typed reference to a certificate. Created by IisExtension.DefineCertificate(). Accepted by WebBindingBuilder.Certificate().
var iis = new IisExtension();

// Define app pool with typed reference
var appPool = iis.DefineAppPool(pool => pool
    .Name("MyAppPool")
    .NoManagedCode()
    .PipelineMode(ManagedPipelineMode.Integrated)
    .IdleTimeout(0));

// Define certificate with typed reference
var cert = iis.DefineCertificate(c => c
    .Id("WildcardCert")
    .Store(CertificateStoreName.My, CertificateStoreLocation.LocalMachine)
    .FindBySubjectName("*.contoso.com"));

// Configure web site using typed references
iis.AddWebSite(site => site
    .Description("Contoso Web App")
    .Directory(@"[INSTALLFOLDER]wwwroot")
    .AppPool(appPool)              // compile-time safe
    .Binding(80)
    .Binding(b => b
        .Port(443)
        .HostHeader("www.contoso.com")
        .Certificate(cert))        // compile-time safe
    .AddApplication(app => app
        .Alias("/api")
        .Directory(@"[INSTALLFOLDER]api")
        .AppPool(appPool)));

10.4 SQL Extension

Configures SQL Server database creation, script execution, and inline SQL string execution. Located in src/FalkForge.Extensions.Sql/. Validation error codes: SQL001-013.

SqlExtension

MethodReturn TypeDescription
NamestringReturns "Sql".
DefineDatabase(Action<SqlDatabaseBuilder> configure)Result<SqlDatabaseRef>Defines a database and returns a typed reference on success. Validates the model before returning.
Register(IExtensionRegistry registry)voidRegisters database, script, and string table contributors.

SqlDatabaseBuilder

MethodReturn TypeDescription
Id(string id)SqlDatabaseBuilderSets the database definition identifier.
Server(string server)SqlDatabaseBuilderSets the SQL Server hostname or address.
Database(string database)SqlDatabaseBuilderSets the database name.
Instance(string instance)SqlDatabaseBuilderSets the named instance.
ConnectionString(string connectionString)SqlDatabaseBuilderSets a full connection string (alternative to Server/Database/Instance).
CreateOnInstall(bool create)SqlDatabaseBuilderWhether to create the database on install. Default: false.
DropOnUninstall(bool drop)SqlDatabaseBuilderWhether to drop the database on uninstall. Default: false.
ConfirmOverwrite(bool confirm)SqlDatabaseBuilderWhether to prompt before overwriting an existing database. Default: false.
ComponentRef(string componentRef)SqlDatabaseBuilderAssociates with a specific component.
Build()Result<SqlDatabaseModel>Validates and builds the database model.

SqlScriptBuilder

MethodReturn TypeDescription
Id(string id)SqlScriptBuilderSets the script identifier.
Database(string databaseRef)SqlScriptBuilderAssociates with a database by string ID.
Database(SqlDatabaseRef databaseRef)SqlScriptBuilderAssociates with a database using a typed reference.
SourceFile(string sourceFile)SqlScriptBuilderPath to the SQL script file to execute.
InlineSql(string sqlContent)SqlScriptBuilderInline SQL content (alternative to SourceFile).
ExecuteOnInstall(bool execute)SqlScriptBuilderWhether to execute during install. Default: false.
ExecuteOnReinstall(bool execute)SqlScriptBuilderWhether to execute during reinstall. Default: false.
ExecuteOnUninstall(bool execute)SqlScriptBuilderWhether to execute during uninstall. Default: false.
RollbackScript(string rollbackSourceFile)SqlScriptBuilderPath to a rollback script for undo on failure.
Sequence(int sequence)SqlScriptBuilderSets the execution order within the same database.
ContinueOnError(bool continueOnError)SqlScriptBuilderWhether to continue if the script fails. Default: false.
ComponentRef(string componentRef)SqlScriptBuilderAssociates with a specific component.
Build()Result<SqlScriptModel>Validates and builds the script model.

SqlStringBuilder

MethodReturn TypeDescription
Id(string id)SqlStringBuilderSets the SQL string identifier.
Database(string databaseRef)SqlStringBuilderAssociates with a database by string ID.
Database(SqlDatabaseRef databaseRef)SqlStringBuilderAssociates with a database using a typed reference.
Sql(string sql)SqlStringBuilderSets the inline SQL statement to execute.
ExecuteOnInstall(bool execute)SqlStringBuilderWhether to execute during install. Default: false.
ExecuteOnUninstall(bool execute)SqlStringBuilderWhether to execute during uninstall. Default: false.
Sequence(int sequence)SqlStringBuilderSets the execution order.
ContinueOnError(bool continueOnError)SqlStringBuilderWhether to continue if the statement fails. Default: false.
Build()Result<SqlStringModel>Validates and builds the SQL string model.

SqlDatabaseRef

TypePropertyDescription
SqlDatabaseRefstring Id { get; }Typed reference to a SQL database. Created by SqlExtension.DefineDatabase(). Accepted by SqlScriptBuilder.Database() and SqlStringBuilder.Database().
var sql = new SqlExtension();

// Define database with typed reference
var dbResult = sql.DefineDatabase(db => db
    .Id("AppDb")
    .Server("(local)")
    .Database("ContosoApp")
    .Instance("SQLEXPRESS")
    .CreateOnInstall()
    .DropOnUninstall());

if (dbResult.IsFailure)
    return Result<Unit>.Failure(dbResult.Error);

var dbRef = dbResult.Value;

// Execute schema script using typed reference
var schemaScript = new SqlScriptBuilder()
    .Id("CreateSchema")
    .Database(dbRef)
    .SourceFile("scripts/schema.sql")
    .ExecuteOnInstall()
    .RollbackScript("scripts/drop-schema.sql")
    .Sequence(1)
    .Build();

// Execute inline SQL using typed reference
var seedData = new SqlStringBuilder()
    .Id("SeedData")
    .Database(dbRef)
    .Sql("INSERT INTO Settings (Key, Value) VALUES ('Version', '1.0')")
    .ExecuteOnInstall()
    .Sequence(2)
    .Build();

10.5 .NET Extension

Detects installed .NET runtimes and emits real MSI-native detection into the compiled installer — a Signature (sentinel-file version) plus a DrLocator search over the platform-appropriate Program Files shared-framework directory, bound to the author's own property name via AppSearch. The built-in AppSearch standard action evaluates these natively at install time; no custom action is required (unlike the Dependency extension's version-range check, which needs one). Detection targets the shared-framework sentinel per RuntimeType: Runtimecoreclr.dll under Microsoft.NETCore.App, AspNetCoreMicrosoft.AspNetCore.dll under Microsoft.AspNetCore.App, WindowsDesktopPresentationCore.dll under Microsoft.WindowsDesktop.App. Sdk has no shared-framework directory (it is versioned via dotnet\sdk\{version}\, a different layout) and is rejected at author time with NET004. X64/Arm64 search under [ProgramFiles64Folder]; X86 searches [ProgramFilesFolder] (the WOW64 tree). Located in src/FalkForge.Extensions.DotNet/. Validation error codes: NET001–NET007.

Gating is author-chosen, two shapes: the C# fluent path leaves the search's message unset and gates explicitly with package.Require(variableName, message) (see Demo 32); the JSON authoring path (see JSON Configuration Format) has no separate Require call, so DotNetExtension emits its own LaunchCondition whenever a search carries a message (author-provided, or a generated default naming the requirement).

DotNetExtension

MemberTypeDescription
NamestringReturns "DotNet".
SearchForRuntime()DotNetCoreSearchBuilderFactory for a new runtime search.
AddSearch(DotNetCoreSearchModel model)Result<Unit>Adds a built search. Re-validates the whole set (including cross-search duplicate VariableName detection, NET003) so a model built outside DotNetCoreSearchBuilder cannot bypass validation. NET006 if model is null.
Register(IExtensionRegistry registry)voidRegisters Signature/DrLocator/AppSearch table contributors for every added search, plus a LaunchCondition contributor that emits conditions only for searches that carry a message. A package that never calls AddSearch contributes no tables.

DotNetCoreSearchBuilder

Defines a search for a .NET runtime on the target machine.

MethodReturn TypeDescription
RuntimeType(DotNetRuntimeType runtimeType)DotNetCoreSearchBuilderThe type of runtime to search for: Runtime, AspNetCore, or WindowsDesktop. Sdk is accepted here but rejected at Build() (NET004) — it has no MSI-native shared-framework sentinel.
Platform(DotNetPlatform platform)DotNetCoreSearchBuilderThe platform architecture: X64, X86, or Arm64 (NET007 if unrecognized).
MinVersion(Version minimumVersion)DotNetCoreSearchBuilderMinimum acceptable version, compared against the sentinel file's on-disk version. Required (NET002 if missing).
Variable(string variableName)DotNetCoreSearchBuilderThe MSI property AppSearch populates on a match. Required (NET001 if missing); must be a public MSI property identifier — all-uppercase, matching [A-Z_][A-Z0-9_.]* (NET005 otherwise).
Message(string message)DotNetCoreSearchBuilderOptional. When set, DotNetExtension emits its own LaunchCondition blocking install unless Variable's property was found (the JSON authoring path's shape). Leave unset to gate via package.Require(variableName, message) instead (the C# fluent path).
Build()Result<DotNetCoreSearchModel>Validates and builds the search model.
var dotnet = new DotNetExtension();
var search = dotnet.SearchForRuntime()
    .RuntimeType(DotNetRuntimeType.AspNetCore)
    .Platform(DotNetPlatform.X64)
    .MinVersion(new Version(8, 0))
    .Variable("DOTNET8_ASPNETCORE_FOUND")
    .Build();

dotnet.AddSearch(search.Value);

Attach the extension with new MsiCompiler().Use(dotnet); without a message on the search, gate explicitly with package.Require("DOTNET8_ASPNETCORE_FOUND", "...") (see Demo 32, demo/32-ext-dotnet/Program.cs). Emission — the table rows landing correctly in the compiled MSI — is proven by a compile-and-read-back test (DotNetDetectionEmissionTests); a real msiexec install exercising the runtime gate end-to-end is not part of the default test run — it is gated the same way as the project's other real-system e2e tests (FALKFORGE_E2E + FALKFORGE_REAL_SYSTEM_E2E plus elevation).

10.6 Util Extension

Provides utility actions: XML configuration editing, quiet command execution, user/group management, file share creation, recursive folder removal, internet shortcut creation, and ODBC driver/data source registration. Located in src/FalkForge.Extensions.Util/. Validation error codes: XCF001-009, QEX001-004, FSH001-003, ISC001-004, RFX001-002, GRP001, ODB001.

UtilExtension

MemberTypeDescription
NamestringReturns "Util".
XmlConfigXmlConfigTableContributorAccess to the XML configuration table contributor.
Register(IExtensionRegistry registry)voidRegisters the XML configuration table contributor.

XmlConfigBuilder

Configures an XML file modification to execute during installation.

MethodReturn TypeDescription
Id(string id)XmlConfigBuilderSets the XML config entry identifier.
File(string filePath)XmlConfigBuilderPath to the XML file to modify.
XPath(string xPath)XmlConfigBuilderXPath expression selecting the target node.
CreateElement(string elementName)XmlConfigBuilderCreates a new child element at the XPath location.
DeleteElement()XmlConfigBuilderDeletes the element at the XPath location.
SetAttribute(string attributeName, string value)XmlConfigBuilderSets or creates an attribute on the target element.
DeleteAttribute(string attributeName)XmlConfigBuilderDeletes an attribute from the target element.
SetValue(string value)XmlConfigBuilderSets the inner text of the target element.
BulkSetValue(string value)XmlConfigBuilderSets the inner text on all elements matching the XPath.
Sequence(int sequence)XmlConfigBuilderControls execution order.
ComponentRef(string componentRef)XmlConfigBuilderAssociates with a specific component.
Build()Result<XmlConfigModel>Validates and builds the model.

QuietExecBuilder

Configures a silent command-line execution during installation.

MethodReturn TypeDescription
Id(string id)QuietExecBuilderSets the quiet exec identifier. Required (QEX001).
Command(string commandLine)QuietExecBuilderSets the command line to execute. Required (QEX002). Max 32,767 characters (QEX003).
WorkingDir(string workingDirectory)QuietExecBuilderSets the working directory for the command.
Condition(string condition)QuietExecBuilderSets a condition expression for conditional execution.
RollbackCommand(string rollbackCommandLine)QuietExecBuilderSets a command to execute on rollback. Max 32,767 characters (QEX004).

FileShareBuilder

Creates a Windows file share during installation.

MethodReturn TypeDescription
Id(string id)FileShareBuilderSets the file share identifier. Required (FSH001).
Name(string name)FileShareBuilderSets the share name. Required (FSH002).
Description(string description)FileShareBuilderSets the share description.
Directory(string directory)FileShareBuilderSets the physical directory to share. Required (FSH003).
GrantRead(string user)FileShareBuilderGrants read permission to a user or group.
GrantChange(string user)FileShareBuilderGrants change permission to a user or group.
GrantFull(string user)FileShareBuilderGrants full control permission to a user or group.

UserBuilder

Creates or updates a Windows user account during installation.

MethodReturn TypeDescription
Name(string name)UserBuilderSets the user account name.
Password(string password)UserBuilderSets the user password as a literal string (discouraged — produces warning USR010; prefer PasswordProperty).
PasswordProperty(string propertyName)UserBuilderSources the password from an MSI property at install time instead of a literal, so the secret never sits in plaintext in the builder call. Mutually exclusive with Password.
Domain(string domain)UserBuilderSets the domain for the user account.
Description(string description)UserBuilderSets the account description.
CanNotChangePassword()UserBuilderPrevents the user from changing their password.
Disabled()UserBuilderCreates the account in a disabled state.
PasswordExpired()UserBuilderForces a password change at first logon.
PasswordNeverExpires()UserBuilderSets the password to never expire.
RemoveOnUninstall()UserBuilderRemoves the user account on uninstall.
UpdateIfExists()UserBuilderUpdates the account if it already exists instead of failing.
MemberOf(string groupName)UserBuilderAdds the account to a local group at creation time. Repeatable for multiple groups.
ComponentRef(string componentRef)UserBuilderAssociates with a specific component.

GroupBuilder

Creates or references a Windows group during installation.

MethodReturn TypeDescription
Name(string name)GroupBuilderSets the group name. Required (GRP001).
Domain(string domain)GroupBuilderSets the domain for the group.
Description(string description)GroupBuilderSets the group description.
UpdateIfExists()GroupBuilderUpdates the group if it already exists instead of failing.
RemoveOnUninstall()GroupBuilderRemoves the group on uninstall.
ComponentRef(string componentRef)GroupBuilderAssociates with a specific component.

InternetShortcutBuilder

Creates a .url internet shortcut file during installation.

MethodReturn TypeDescription
Id(string id)InternetShortcutBuilderSets the shortcut identifier. Required (ISC001).
Name(string name)InternetShortcutBuilderSets the shortcut display name. Required (ISC002).
Target(string url)InternetShortcutBuilderSets the target URL. Required (ISC003).
Directory(string directory)InternetShortcutBuilderSets the directory where the shortcut is created. Required (ISC004).
Icon(string iconFile, int iconIndex)InternetShortcutBuilderSets the icon file and index. Default index: 0.

RemoveFolderExBuilder

Recursively removes a folder during install or uninstall (handles non-empty directories).

MethodReturn TypeDescription
Id(string id)RemoveFolderExBuilderSets the identifier. Required (RFX001).
Directory(string directory)RemoveFolderExBuilderSets the directory path. Either Directory or Property is required (RFX002).
Property(string property)RemoveFolderExBuilderSets a property containing the directory path.
OnInstall()RemoveFolderExBuilderRemoves the folder during installation.
OnUninstall()RemoveFolderExBuilderRemoves the folder during uninstallation (default).
OnBoth()RemoveFolderExBuilderRemoves the folder during both install and uninstall.

OdbcDriverBuilder

Registers an ODBC driver (ODBCDriver table row) so the ODBC driver manager knows about it at install time. Unlike the other Util builders above, its file reference is not free text: FileName must name a file the package already declares via Files(...), because the compiler derives the row's File_ and Component_ external keys from that file instead of asking the author to supply generated MSI identifiers directly. There is no ComponentRef here for that reason — component IDs are compiler-generated and no author could ever supply a valid one up front.

MethodReturn TypeDescription
DriverName(string name)OdbcDriverBuilderSets the driver's display name (ODBCDriver.Description).
FileName(string fileName)OdbcDriverBuilderReferences the driver DLL — either the bare file name (mydriver.dll), or a trailing path segment (x64/mydriver.dll) when two declared files share that name. Must match exactly one file the package already declares; an unresolvable or ambiguous reference fails the build (ODB001) rather than being guessed.
SetupFileName(string fileName)OdbcDriverBuilderOptional reference to the driver's setup DLL, resolved identically to FileName and emitted as ODBCDriver.File_Setup. Authored but unresolvable also fails the build (ODB001) instead of silently dropping the column.

OdbcDataSourceBuilder

Registers an ODBC data source (ODBCDataSource table row, plus one ODBCSourceAttribute row per Property call). When DriverName matches an AddOdbcDriver entry's own driver name in the same package, the compiler attaches the data source to that driver's owning component so the two install and uninstall together; a DSN naming a driver the target machine already has falls back to the package's first component instead (the same fallback the built-in CreateFolder producer uses).

MethodReturn TypeDescription
Name(string name)OdbcDataSourceBuilderSets the data source's display name (ODBCDataSource.Description).
DriverName(string driver)OdbcDataSourceBuilderNames the driver this data source uses (ODBCDataSource.DriverDescription).
Registration(OdbcRegistration reg)OdbcDataSourceBuilderSets PerMachine (default) or PerUser registration.
Property(string key, string value)OdbcDataSourceBuilderAdds a connection attribute (e.g. Server, Database). Each call emits one ODBCSourceAttribute row.
// The driver DLL must already be declared as a package file -- FileName below references it
// by name, it is not a free-form path.
package.Files(files => files
    .Add("payload/mydriver.dll")
    .To(KnownFolder.ProgramFiles / "Contoso" / "Drivers"));

var util = new UtilExtension();

util.AddOdbcDriver("ContosoDriver", d => d
    .DriverName("Contoso ODBC Driver")
    .FileName("mydriver.dll"));

util.AddOdbcDataSource("ContosoDsn", ds => ds
    .Name("Contoso Data Source")
    .DriverName("Contoso ODBC Driver")
    .Registration(OdbcRegistration.PerMachine)
    .Property("Server", "localhost")
    .Property("Database", "ContosoDb"));

// Wire the extension into the compiler, same as any other extension:
Installer.Build(args, package => { /* ... */ }, new MsiCompiler().Use(util));
AddOdbcDriver/AddOdbcDataSource return void, not Result<Unit> — there is no author-time validation to fail up front. An unresolvable FileName/SetupFileName/DriverName reference is only caught at compile time, as a build failure carrying ODB001 guidance. Two gaps worth knowing before relying on this in production: forge build --dry-run does not yet preview ODBC actions (UtilExtension.GetDryRunActions has no ODBC entry), and forge migrate does not round-trip ODBC configuration back out of an existing MSI (neither ODBC contributor implements ReadSchema) — both are open follow-up work, not implemented today.
// XML configuration modification
var xmlConfig = new XmlConfigBuilder()
    .Id("SetConnectionString")
    .File(@"[INSTALLFOLDER]appsettings.json")
    .XPath("//configuration/connectionStrings/add[@name='Default']")
    .SetAttribute("connectionString", "Server=(local);Database=MyApp;Trusted_Connection=True")
    .Sequence(1)
    .Build();
QuietExecBuilder, InternetShortcutBuilder, FileShareBuilder, UserBuilder, GroupBuilder, and RemoveFolderExBuilder all have an internal Build() — unlike XmlConfigBuilder, they cannot be constructed and built directly. They're configured through UtilExtension.AddQuietExec/AddInternetShortcut/AddFileShare/AddUser/AddGroup/AddRemoveFolderEx (Action<TBuilder> configure), which builds the model internally and returns a Result<Unit> you check for validation failures.
var util = new UtilExtension();

// Group first, so the user below can join it by name.
var group = util.AddGroup(g => g
    .Name("ContosoOperators")
    .Description("Operators group for the Contoso service account")
    .RemoveOnUninstall());
if (group.IsFailure) { Console.Error.WriteLine(group.Error); return 1; }

// PasswordProperty (not Password) keeps the secret out of the MSI in plaintext -- it's
// sourced from an MSI property set at install time instead.
var user = util.AddUser(u => u
    .Name("svc-contoso")
    .PasswordProperty("SVC_PASSWORD")
    .PasswordNeverExpires()
    .MemberOf("ContosoOperators")
    .RemoveOnUninstall());
if (user.IsFailure) { Console.Error.WriteLine(user.Error); return 1; }

var fileShare = util.AddFileShare(f => f
    .Id("AppData")
    .Name("ContosoAppData")
    .Directory(@"C:\ProgramData\Contoso\MyApp")
    .GrantRead("Everyone")
    .GrantFull("ContosoOperators"));
if (fileShare.IsFailure) { Console.Error.WriteLine(fileShare.Error); return 1; }

var removeFolderEx = util.AddRemoveFolderEx(r => r
    .Id("Cache")
    .Directory(@"C:\ProgramData\Contoso\MyApp\Cache")
    .OnUninstall());
if (removeFolderEx.IsFailure) { Console.Error.WriteLine(removeFolderEx.Error); return 1; }

var quietExec = util.AddQuietExec(q => q
    .Id("RunMigration")
    .Command(@"[INSTALLFOLDER]migrate.exe --apply")
    .WorkingDir("[INSTALLFOLDER]")
    .RollbackCommand(@"[INSTALLFOLDER]migrate.exe --rollback"));
if (quietExec.IsFailure) { Console.Error.WriteLine(quietExec.Error); return 1; }

var shortcut = util.AddInternetShortcut(s => s
    .Id("HelpLink")
    .Name("Product Documentation")
    .Target("https://docs.contoso.com")
    .Directory("ProgramMenuDir"));
if (shortcut.IsFailure) { Console.Error.WriteLine(shortcut.Error); return 1; }

// Wire the extension into the compiler, same as any other extension:
Installer.Build(args, package => { /* ... */ }, new MsiCompiler().Use(util));

See Demo 33 — ext-util for a runnable version of this pattern (the demo exercises AddFileShare/AddRemoveFolderEx/AddQuietExec/ AddInternetShortcut plus XmlConfigBuilder; user/group management follows the identical Add*(Action<TBuilder>)Result<Unit> shape shown above).

11. Custom UI Framework

FalkForge provides a custom WPF UI framework for building installer user interfaces with full control over pages, styling, and navigation. The framework uses a page-registration pattern where each page is an InstallerPage<TView> subclass that acts as both ViewModel and page controller, with an associated WPF FrameworkElement as the view.

11.1 InstallerApp

Static entry point for custom UI installers. Orchestrates page creation, engine wiring, window creation, and WPF application lifecycle. Located in src/FalkForge.Ui/InstallerApp.cs.

MethodReturn TypeDescription
Run(string[] args, Action<InstallerUIBuilder> configure)intStatic entry point. Parses --pipe and --manifest args, builds pages, connects to the engine, runs the WPF application, and returns the engine shutdown exit code. Returns 1 if no pages are registered.
public static int Main(string[] args)
{
    return InstallerApp.Run(args, ui => ui
        .Window(w => w
            .Title("My Installer")
            .Size(600, 400)
            .Accent("#0078D4"))
        .Pages(pages => pages
            .Add<WelcomePage>()
            .Add<InstallPage>()
            .Add<CompletePage>()));
}

11.2 InstallerUIBuilder

Top-level fluent builder composing window configuration and page registration. Located in src/FalkForge.Ui/InstallerUIBuilder.cs.

MethodReturn TypeDescription
Window(Action<InstallerWindowBuilder> configure)InstallerUIBuilderConfigures window appearance (size, colors, title, icon, images).
Pages(Action<PageRegistrar> configure)InstallerUIBuilderRegisters the ordered sequence of pages to display.
Plugin<T>() where T : IInstallerPlugin, new()InstallerUIBuilderRegisters a single plugin type by generic parameter. See Plugin System (22) for the full wiring path into InstallerPage.PluginServices.
RegisterPlugins(PluginRegistry registry)InstallerUIBuilderBulk-registers every plugin in a pre-built PluginRegistry. Combines with any prior Plugin<T>() calls; earlier registrations win on duplicate service types.

11.3 InstallerWindowBuilder

Configures the installer window appearance and behavior. Located in src/FalkForge.Ui/InstallerWindowBuilder.cs.

MethodReturn TypeDescription
Size(double width, double height)InstallerWindowBuilderSets the window dimensions. Default: 600 x 400.
Borderless()InstallerWindowBuilderRemoves window chrome for a fully custom-drawn window.
CornerRadius(double radius)InstallerWindowBuilderSets the window corner radius.
Background(string hex)InstallerWindowBuilderSets the window background color from a hex string (e.g., "#1E1E1E").
Accent(string hex)InstallerWindowBuilderSets the accent color from a hex string (e.g., "#7B68EE").
Title(string title)InstallerWindowBuilderSets the window title text.
Icon(string iconPath)InstallerWindowBuilderSets the window icon file path.
CustomWindow<TWindow>()InstallerWindowBuilderUses a completely custom WPF Window subclass as the shell. TWindow must derive from Window and have a parameterless constructor.
WatermarkImage(string path)InstallerWindowBuilderSets the watermark image path for the classic wizard exterior pages (164px left panel).
BannerImage(string path)InstallerWindowBuilderSets the banner image path for the classic wizard interior pages (59px top banner).
BannerIcon(string path)InstallerWindowBuilderSets the banner icon path for the classic wizard interior pages.

11.4 PageRegistrar

Registers pages in display order. Each page is created via a parameterless constructor. Located in src/FalkForge.Ui/PageRegistrar.cs.

MethodReturn TypeDescription
Add<TPage>()PageRegistrarRegisters a page type. TPage must derive from InstallerPage and have a parameterless constructor. Pages are displayed in registration order.
// Pages are displayed in the order they are registered
pages.Add<WelcomePage>()      // Page 0 (shown first)
     .Add<LicensePage>()      // Page 1
     .Add<InstallDirPage>()   // Page 2
     .Add<ProgressPage>()     // Page 3
     .Add<CompletePage>();    // Page 4 (shown last)

11.5 InstallerPage (Abstract Base)

Abstract base class for all custom installer pages. Provides engine access, shared state, navigation hooks, and INotifyPropertyChanged support. Has an internal constructor, so pages must derive from InstallerPage<TView>. Located in src/FalkForge.Ui/InstallerPage.cs.

MemberType / ReturnDescription
Title (abstract)stringPage title displayed in the window. Must be implemented by subclasses.
EngineIInstallerEngineThe installer engine for performing detect/plan/apply operations.
SharedStateInstallerStateThread-safe shared state bag for cross-page data.
DetectedStateInstallStateCurrent detected install state (not installed, installed, etc.).
OnNext() (virtual)PageResultCalled when the user clicks Next. Default: PageResult.Next.
OnBack() (virtual)PageResultCalled when the user clicks Back. Default: PageResult.Previous.
OnNavigatedToAsync() (virtual)TaskLifecycle hook called when navigating to this page.
OnNavigatingFromAsync() (virtual)TaskLifecycle hook called when navigating away from this page.
CanGoNext (virtual)boolWhether the Next button is enabled. Default: true.
CanGoBack (virtual)boolWhether the Back button is enabled. Default: true.
SetField<T>(ref T field, T value, string? name)boolHelper for property change notification. Sets the field, raises PropertyChanged, returns whether the value changed.
OnPropertyChanged(string? name)voidRaises the PropertyChanged event. Auto-infers caller name via [CallerMemberName].

11.5.1 Engine Lifecycle Hooks & Fire Order

Beyond the navigation hooks above, an InstallerPage can override engine lifecycle hooks that fire while the engine runs the Detect → Plan → Apply sequence (triggered when a page returns PageResult.Install, PageResult.Uninstall, or PageResult.Repair). There are two granularities:

The hooks fire in this order (parenthesised groups repeat once per package / bundle; see the delivery-semantics note below for the ordering guarantee's scope):

OnDetectBeginAsync
  → (per package)        OnDetectPackageCompleteAsync
  → (per related bundle) OnDetectRelatedBundleAsync
OnDetectCompleteAsync
OnPlanBeginAsync
  → (per package)        OnPlanPackageBeginAsync → OnPlanPackageCompleteAsync
OnPlanCompleteAsync
OnApplyBeginAsync
  → (per package)        OnApplyPackageBeginAsync → OnApplyPackageCompleteAsync
OnApplyCompleteAsync
  → (Complete: page auto-advances)

Only the current page receives these callbacks. Per-package hooks are dispatched as the engine emits the corresponding events, so they are naturally interleaved between the phase-level begin and complete hooks. Default implementations are no-ops, so existing pages that override only the phase hooks are unaffected.

Delivery semantics (read before overriding). Per-package hooks are best-effort notifications: unlike the phase-level hooks, their returned Task is not awaited by the shell. The relative order shown above — and the interleaving with the phase hooks — holds for synchronous hook bodies; a hook that performs genuinely asynchronous work does not delay the surrounding phase. With the cross-process engine (EngineClient) these callbacks are raised on the engine's message-receive thread, so a hook that updates WPF-bound state must marshal to the UI thread itself (e.g. Application.Current.Dispatcher.Invoke(...)) — the shell does not marshal them for you. Keep per-package hooks lightweight (logging, progress annotation); use the awaited phase-level OnApplyCompleteAsync for work that must complete before the installer advances.

Hook (all virtual)SignatureArgumentFiresVeto?
OnDetectBeginAsyncTask<bool>Before detection starts.Yes — false cancels the operation.
OnDetectPackageCompleteAsyncTaskPackageDetectInfo (PackageId, State, Version?)Once per package, in manifest chain order, as detection completes.No (observational).
OnDetectRelatedBundleAsyncTaskRelatedBundleInfo (BundleId, Relation, InstalledVersion)Once per related bundle found on the machine, after the per-package detect notifications.No (observational).
OnDetectCompleteAsyncTaskDetectResultAfter detection completes.No.
OnPlanBeginAsyncTask<bool>InstallActionBefore planning starts.Yes — false cancels the operation.
OnPlanPackageBeginAsyncTaskPackagePlanInfo (PackageId, DisplayName, PlannedAction)Once per package as its planning begins.No (observational).
OnPlanPackageCompleteAsyncTaskPackagePlanInfoOnce per package after its planning completes.No (observational).
OnPlanCompleteAsyncTaskPlanResultAfter planning completes.No.
OnApplyBeginAsyncTask<bool>Before applying starts.Yes — false cancels the operation.
OnApplyPackageBeginAsyncTaskPackageApplyBeginInfo (PackageId, DisplayName)Once per package, in execution order, immediately before its installer runs.No (observational).
OnApplyPackageCompleteAsyncTaskPackageApplyCompleteInfo (PackageId, DisplayName, Succeeded)Once per package immediately after its installer returns (including the failing package that aborts the apply).No (observational).
OnApplyCompleteAsyncTaskApplyResultAfter apply completes.No.

Implementation note. Per-package events are surfaced by engines that implement IPackageLifecycleEvents (the production EngineClient does). The engine emits the granular events as it detects / plans / executes each package; the shell forwards each to the active page's hook. Planning is computed as a whole, so OnPlanPackageBeginAsync/Complete are derived from the resulting plan in chain order. See demo 14-lifecycle-hooks for a page that logs every hook.

11.6 InstallerPage<TView>

Generic subclass that auto-creates the WPF view and wires DataContext. This is the class custom pages should derive from. TView must be a FrameworkElement with a parameterless constructor (typically a UserControl). Located in src/FalkForge.Ui/InstallerPageOfT.cs.

// The page class acts as both the ViewModel and navigation controller.
// The TView type parameter specifies the WPF UserControl to display.
public class WelcomePage : InstallerPage<WelcomePageView>
{
    public override string Title => "Welcome";

    // Bind to this in XAML: {Binding ProductName}
    public string ProductName => "My Product v1.0";

    public override PageResult OnNext()
    {
        // Navigate to the next page
        return PageResult.Next;
    }
}

// The associated XAML view (WelcomePageView.xaml)
// DataContext is automatically set to the WelcomePage instance

11.7 IInstallerEngine

Interface for communicating with the installer engine from custom UI pages. Provides async operations for the detect/plan/apply lifecycle, observable progress streams, and feature/directory management. Located in src/FalkForge.Ui.Abstractions/IInstallerEngine.cs.

MemberType / ReturnDescription
DetectAsync(CancellationToken ct)Task<DetectResult>Initiates package detection. Determines what is currently installed.
PlanAsync(InstallAction action, CancellationToken ct)Task<PlanResult>Plans the specified action (install, uninstall, repair).
ApplyAsync(CancellationToken ct)Task<ApplyResult>Applies the planned action.
PhaseIObservable<EnginePhase>Observable stream of engine phase changes.
ProgressIObservable<InstallProgress>Observable stream of installation progress updates.
StatusMessageIObservable<string>Observable stream of status messages for display.
ManifestInstallerManifestThe installer manifest containing package and feature metadata.
DetectedStateInstallStateThe current detected installation state.
FeaturesIReadOnlyList<FeatureState>Feature states with selection status.
InstallDirectorystringGets or sets the installation directory.
Cancel()voidCancels the current operation.
ShutdownAsync()Task<int>Shuts down the engine and returns the process exit code.
SetProperty(string name, string value)voidSets an MSI property to pass to packages during installation. Only accepted during Initializing, Detecting, or Planning phases. Property names must match ^[A-Z_][A-Z0-9_.]*$.
SetSecureProperty(string name, SensitiveBytes value)voidSets a secure property transported via named pipe — never appears on the command line or in logs. The engine consumes the bytes synchronously; the caller disposes after return.

11.8 PageResult

Sealed class representing page navigation results. Returned from OnNext() and OnBack() to control navigation flow. Provides singleton instances for common results and factory methods for parameterized results. Located in src/FalkForge.Ui.Abstractions/PageResult.cs.

MemberKindDescription
PageResult.NextNextNavigate to the next page in order.
PageResult.PreviousPreviousNavigate to the previous page in order.
PageResult.FinishFinishClose the installer (success).
PageResult.CancelCancelCancel and close the installer.
PageResult.InstallInstallTrigger the install action and advance.
PageResult.UninstallUninstallTrigger the uninstall action and advance.
PageResult.RepairRepairTrigger the repair action and advance.
PageResult.Stay(string? message)StayStay on the current page (e.g., validation error). Optional message for display.
PageResult.GoTo<TPage>()GoToJump to a specific page type, bypassing sequential order.

PageResult Properties

PropertyTypeDescription
KindPageResultKindThe result kind: Next, Previous, Stay, GoTo, Finish, Cancel, Install, Uninstall, Repair.
Messagestring?Optional message (used with Stay).
TargetTypeType?Target page type (used with GoTo). Internal visibility.
public override PageResult OnNext()
{
    // Validate before proceeding
    if (string.IsNullOrWhiteSpace(InstallPath))
        return PageResult.Stay("Please specify an installation directory.");

    // Save state for other pages
    SharedState.InstallDirectory = InstallPath;

    // Different results based on install state
    return DetectedState switch
    {
        InstallState.NotPresent => PageResult.Install,
        InstallState.Present    => PageResult.GoTo<MaintenancePage>(),
        _                       => PageResult.Next
    };
}

11.9 InstallerState

Thread-safe state bag for sharing data between pages. Built on ConcurrentDictionary<string, object>. Provides typed Get<T>/Set<T> methods and a convenience InstallDirectory property. Located in src/FalkForge.Ui.Abstractions/InstallerState.cs.

MemberType / ReturnDescription
InstallDirectorystring?Convenience property for the install directory. Backed by the "InstallDirectory" key. Setting to null removes the key.
Get<T>(string key)T?Gets a typed value by key. Returns default if not found or wrong type.
Set<T>(string key, T value)voidSets a typed value by key. T must be non-null.
ContainsKey(string key)boolChecks whether a key exists in the state.
Remove(string key)boolRemoves a key and returns whether it existed.
// In page A: store data
SharedState.Set("LicenseAccepted", true);
SharedState.InstallDirectory = @"C:\Program Files\MyApp";

// In page B: retrieve data
bool accepted = SharedState.Get<bool>("LicenseAccepted");
string? dir = SharedState.InstallDirectory;

11.10 Complete Custom UI Example

A full example demonstrating the custom UI framework with three pages: Welcome, Install, and Complete.

// Program.cs -- Entry point
public static int Main(string[] args)
{
    return InstallerApp.Run(args, ui => ui
        .Window(w => w
            .Title("Contoso Setup")
            .Size(500, 380)
            .Accent("#0078D4")
            .Icon("icon.ico")
            .WatermarkImage("watermark.bmp")
            .BannerImage("banner.bmp"))
        .Pages(pages => pages
            .Add<WelcomePage>()
            .Add<InstallPage>()
            .Add<CompletePage>()));
}

// WelcomePage.cs -- First page
public class WelcomePage : InstallerPage<WelcomeView>
{
    public override string Title => "Welcome";
    public override bool CanGoBack => false;

    public string Greeting => "Welcome to Contoso Setup.";
}

// InstallPage.cs -- Triggers installation
public class InstallPage : InstallerPage<InstallView>
{
    public override string Title => "Installing";
    public override bool CanGoBack => false;
    public override bool CanGoNext => false;

    public override async Task OnNavigatedToAsync()
    {
        await Engine.PlanAsync(InstallAction.Install);
        await Engine.ApplyAsync();
    }

    public override PageResult OnNext() => PageResult.Next;
}

// CompletePage.cs -- Final page
public class CompletePage : InstallerPage<CompleteView>
{
    public override string Title => "Complete";
    public override bool CanGoBack => false;

    public override PageResult OnNext() => PageResult.Finish;
}
Architecture note: Each InstallerPage<TView> subclass acts as both the ViewModel (bindable properties, INotifyPropertyChanged) and the navigation controller (OnNext(), OnBack()). The associated TView XAML UserControl binds to the page via DataContext, which is automatically wired by the framework. The CustomShellViewModel orchestrates navigation, engine actions, and window close requests internally.

11.11 Property Passing

Custom pages can send properties to MSI packages via the engine. Regular properties use SetProperty; credentials and secrets use SetSecureProperty with PasswordBridge for secure WPF password collection. Properties are transported over HMAC-SHA256 authenticated named pipes and never appear on the command line.

PasswordBridge

src/FalkForge.Ui/PasswordBridge.cs

WPF attached property that registers PasswordBox controls with the parent InstallerPage. Pages retrieve passwords as SensitiveBytes via GetPassword(key), which returns UTF-8 encoded bytes that are zeroed on dispose.

<!-- XAML -->
<PasswordBox ui:PasswordBridge.Key="dbPassword" />

// Page code
using var password = GetPassword("dbPassword");
Engine.SetSecureProperty("DB_PASSWORD", password);

SensitiveBytes

src/FalkForge.Ui.Abstractions/SensitiveBytes.cs

Readonly struct wrapping a byte[]. Implements IDisposable — zeros memory via CryptographicOperations.ZeroMemory on dispose. Always use via using pattern. All copies share the same underlying array.

MemberTypeDescription
SpanReadOnlySpan<byte>Read-only view of the byte data
LengthintNumber of bytes
IsEmptyboolTrue if null or zero-length
Dispose()voidZeros the underlying byte array
Security: Never convert SensitiveBytes to a string — strings are immutable and cannot be zeroed from memory. Use SetSecureProperty to pass credentials directly as bytes through the named pipe protocol.

10.7 HTTP Extension

Configures HTTP.sys URL reservations and SNI SSL certificate bindings via netsh custom actions. Located in src/FalkForge.Extensions.Http/. Windows-only ([SupportedOSPlatform("windows")]). Requires elevated privileges at install time. Validation error codes: HTTP001-010.

Note: The HTTP extension generates deferred custom actions that execute netsh http commands during the MSI install/uninstall sequence. URL ACL reservations are added on install and removed on uninstall, with rollback support. SNI SSL certificate bindings follow the same pattern. A maximum of 40 combined URL reservations and SSL bindings is supported per package.

HttpExtension

MethodReturn TypeDescription
NamestringReturns "Http".
AddUrlReservation(string url, Action<UrlReservationBuilder> configure)HttpExtensionAdds a URL ACL reservation. Returns self for chaining.
AddSniSslBinding(string hostname, int port, Action<SniSslBindingBuilder> configure)HttpExtensionAdds an SNI SSL certificate binding. Returns self for chaining.
Validate()Result<Unit>Validates all configured reservations and bindings.
Register(IExtensionRegistry registry)voidRegisters custom action and sequence table contributors.

UrlReservationBuilder

Configures an HTTP.sys URL ACL reservation. The URL must start with http:// or https:// and end with /.

MethodReturn TypeDescription
AllowNetworkService()UrlReservationBuilderGrants access to the NETWORK SERVICE account (SDDL: D:(A;;GX;;;NS)).
AllowLocalService()UrlReservationBuilderGrants access to the LOCAL SERVICE account (SDDL: D:(A;;GX;;;LS)).
AllowLocalSystem()UrlReservationBuilderGrants access to the LOCAL SYSTEM account (SDDL: D:(A;;GX;;;SY)).
AllowEveryone()UrlReservationBuilderGrants access to Everyone (SDDL: D:(A;;GX;;;WD)).
AllowBuiltinUsers()UrlReservationBuilderGrants access to the BUILTIN\Users group (SDDL: D:(A;;GX;;;BU)).
AllowUser(string sddlOrUser)UrlReservationBuilderGrants access using a custom SDDL string or user identity.

SniSslBindingBuilder

Configures an SNI SSL certificate binding for HTTP.sys. If no AppId is provided, one is derived automatically from a SHA-256 hash of the hostname and port.

MethodReturn TypeDescription
Thumbprint(string thumbprint)SniSslBindingBuilderSets the certificate thumbprint (40 hex characters). Required (HTTP007, HTTP008).
AppId(Guid appId)SniSslBindingBuilderSets the application GUID. Auto-derived from hostname:port if not set.
CertStoreName(string storeName)SniSslBindingBuilderSets the certificate store name. Default: "MY".

Validation Error Codes

CodeDescription
HTTP001URL reservation URL must not be empty.
HTTP002URL reservation URL must start with http:// or https://.
HTTP003URL reservation URL must end with /.
HTTP004URL reservation User/SDDL string must not be empty.
HTTP005SNI SSL binding Hostname must not be empty.
HTTP006SNI SSL binding Port is outside valid range 1-65535.
HTTP007SNI SSL binding CertificateThumbprint must not be empty.
HTTP008SNI SSL binding CertificateThumbprint must be exactly 40 hexadecimal characters.
HTTP009SNI SSL binding AppId must not be an empty GUID.
HTTP010Value contains characters not permitted in netsh commands (shell injection prevention).
var http = new HttpExtension();

// Reserve a URL for a web service running as NETWORK SERVICE
http.AddUrlReservation("http://+:8080/myapi/", r => r
    .AllowNetworkService());

// Reserve an HTTPS URL for all users
http.AddUrlReservation("https://+:443/webapp/", r => r
    .AllowEveryone());

// Bind an SSL certificate for SNI-based HTTPS
http.AddSniSslBinding("api.contoso.com", 443, ssl => ssl
    .Thumbprint("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2")
    .CertStoreName("MY"));

// Validate before compilation
var result = http.Validate();
if (result.IsFailure)
    Console.WriteLine(result.Error.Message);

// Register with the package builder
Installer.Build(p => p
    .Name("Contoso Web Service")
    .UpgradeCode(Guid.Parse("..."))
    .Version("1.0.0")
    .Extension(http)
    .Feature("Main", f => f
        .Files(files => files
            .Source(@"bin\Release\")
            .Include("*.dll")
            .Include("*.exe"))));

10.8 Driver Extension

Installs and uninstalls Windows device drivers via pnputil custom actions. Located in src/FalkForge.Extensions.Driver/. Windows-only. Requires elevated privileges at install time. Validation error codes: DRV001-004.

Note: The Driver extension generates deferred MSI custom actions that invoke pnputil /add-driver on install and pnputil /delete-driver /uninstall on uninstall. Each driver definition produces both an install and uninstall custom action with the appropriate flags.

DriverExtension

MethodReturn TypeDescription
NamestringReturns "Driver".
AddDriver(Action<DriverBuilder> configure)Result<DriverModel>Adds a driver definition. Returns the validated model or a validation error.
ValidateDrivers()IReadOnlyList<DriverValidationError>Validates all configured drivers and returns any errors.
Register(IExtensionRegistry registry)voidRegisters the table contributor and dry-run contributor.

DriverBuilder

MethodReturn TypeDescription
Id(string id)DriverBuilderSets the driver identifier. Required (DRV001).
InfFilePath(string infFilePath)DriverBuilderSets the path to the driver INF file relative to INSTALLDIR. Required (DRV002). Must end with .inf (DRV003).
Force()DriverBuilderForces driver installation even if a newer version is already present. Adds /force flag to both install and uninstall commands.
PlugAndPlay()DriverBuilderInstalls the driver and triggers Plug and Play enumeration. Adds /install flag to the install command.
Description(string description)DriverBuilderSets an optional description for the driver.
Condition(string condition)DriverBuilderSets a condition expression for conditional driver installation.

DriverInstallFlags

ValueDescription
NoneNo special flags. Default behavior.
ForceInstallForce installation even if a newer driver is already installed. Maps to pnputil /force.
PlugAndPlayInstall the driver and trigger Plug and Play device enumeration. Maps to pnputil /install.

Validation Error Codes

CodeDescription
DRV001Driver Id must not be empty.
DRV002Driver InfFilePath must not be empty.
DRV003Driver InfFilePath must end with .inf.
DRV004Duplicate driver Id detected.
var driver = new DriverExtension();

// Add a USB device driver with Plug and Play enumeration
var result = driver.AddDriver(d => d
    .Id("ContosoUsb")
    .InfFilePath(@"drivers\contoso_usb.inf")
    .Description("Contoso USB Controller Driver")
    .PlugAndPlay());

// Add a printer driver with force install
driver.AddDriver(d => d
    .Id("ContosoPrinter")
    .InfFilePath(@"drivers\contoso_printer.inf")
    .Force()
    .Condition("NOT REMOVE"));

// Validate all drivers
var errors = driver.ValidateDrivers();
foreach (var error in errors)
    Console.WriteLine($"{error.Code}: {error.Message}");

// Attach the extension to the compiler with .Use(...) — this is what makes the
// FalkDriverPackage table + rows emit into the compiled MSI.
Installer.Build(args, package =>
{
    package.Name = "Contoso Hardware Suite";
    package.Manufacturer = "Contoso";
    package.Version = new Version(1, 0, 0);

    package.Files(files => files
        .Add(@"drivers\contoso_usb.inf")
        .Add(@"drivers\contoso_printer.inf")
        .To(KnownFolder.ProgramFiles / "Contoso" / "Hardware Suite"));
}, new MsiCompiler().Use(driver));

10.9 COM Registration

Registers COM classes and type libraries during MSI installation. Located in src/FalkForge.Core/ (models in Models/, builders in Builders/). COM registration is a core MSI feature and is integrated directly into PackageBuilder rather than being a separate extension.

PackageBuilder COM Methods

MethodReturn TypeDescription
ComClass(Action<ComClassBuilder> configure)PackageBuilderAdds a COM class registration. Returns self for chaining.
TypeLib(Action<ComTypeLibBuilder> configure)PackageBuilderAdds a type library registration. Returns self for chaining.

ComClassBuilder

Configures a COM class (CLSID) registration in the Windows registry.

MethodReturn TypeDescription
ClassId(Guid classId)ComClassBuilderSets the CLSID for the COM class. Required.
InprocServer32()ComClassBuilderSets the server type to in-process (DLL). This is the default.
LocalServer32()ComClassBuilderSets the server type to local (EXE).
ProgId(string progId)ComClassBuilderSets the programmatic identifier (e.g., "Contoso.Widget.1").
Description(string desc)ComClassBuilderSets the human-readable description for the COM class.
ThreadingModel(ComThreadingModel model)ComClassBuilderSets the threading model. Default: Apartment.
AppId(Guid appId)ComClassBuilderAssociates the class with a DCOM application ID.
ComponentRef(string componentRef)ComClassBuilderAssociates the COM class with a specific component.

ComServerType

ValueDescription
InprocServer32In-process COM server (DLL loaded into the client process).
LocalServer32Local COM server (separate EXE process).

ComThreadingModel

ValueDescription
ApartmentSingle-threaded apartment (STA). The default and most common for UI components.
FreeMulti-threaded apartment (MTA). For thread-safe components.
BothSupports both STA and MTA. The object can be used from any apartment type.
NeutralNeutral-threaded apartment (NTA). Available on Windows 2000 and later.

ComTypeLibBuilder

Configures a COM type library (LIBID) registration.

MethodReturn TypeDescription
TypeLibId(Guid id)ComTypeLibBuilderSets the type library GUID (LIBID). Required.
Version(int major, int minor)ComTypeLibBuilderSets the type library version. Default: 1.0.
Language(int lcid)ComTypeLibBuilderSets the locale identifier. Default: 0 (language-neutral).
Description(string desc)ComTypeLibBuilderSets the type library description.
ComponentRef(string componentRef)ComTypeLibBuilderAssociates the type library with a specific component.
Installer.Build(p => p
    .Name("Contoso COM Server")
    .UpgradeCode(Guid.Parse("..."))
    .Version("1.0.0")

    // Register an in-process COM class (DLL)
    .ComClass(c => c
        .ClassId(Guid.Parse("B7A5A3D1-8C4E-4F2A-9E1B-6D3C5A7F8E9D"))
        .InprocServer32()
        .ProgId("Contoso.Widget.1")
        .Description("Contoso Widget Control")
        .ThreadingModel(ComThreadingModel.Both)
        .ComponentRef("WidgetDll"))

    // Register a local COM server (EXE)
    .ComClass(c => c
        .ClassId(Guid.Parse("C8B6B4E2-9D5F-5G3B-AF2C-7E4D6B8G9FA0"))
        .LocalServer32()
        .ProgId("Contoso.Engine.1")
        .Description("Contoso Engine Service")
        .ThreadingModel(ComThreadingModel.Free))

    // Register a type library
    .TypeLib(t => t
        .TypeLibId(Guid.Parse("D9C7C5F3-AE6G-6H4C-BG3D-8F5E7C9HAB01"))
        .Version(2, 1)
        .Language(0)
        .Description("Contoso Automation Library")
        .ComponentRef("WidgetDll"))

    .Feature("Main", f => f
        .Files(files => files
            .Source(@"bin\Release\")
            .Include("ContosoWidget.dll")
            .Include("ContosoEngine.exe")
            .Include("Contoso.tlb"))));

10.10 How Extensions Work

An extension is a class implementing IFalkForgeExtension (src/FalkForge.Extensibility/IFalkForgeExtension.cs): a Name, and a Register(IExtensionRegistry registry) callback where it hands the compiler its contributions. MsiCompiler.Compile drives registration through ExtensionRegistration.Register, which rejects a duplicate Name or an unsupported MinHostVersion with a PluginCompatibilityException before Register is even called.

IExtensionRegistry offers five ways to contribute (10.1 above has the full member tables). Four of them shape the compiled MSI and are covered below; the fifth, RegisterDryRunContributor(IDryRunContributor), feeds forge build --dry-run and forge validate with a human-readable preview of what an install/uninstall would do and is not part of the compiled output. Each of the four below solves a different problem:

1. IMsiTableContributor — inspectable data

Contributes rows that land in an MSI table. The compiler (ExtensionTableEmitter, src/FalkForge.Compiler.Msi/Recipe/ExtensionTableEmitter.cs) routes each contributor by TableName, in one of two modes:

Both modes read row data from a plain MsiTableRow (a Set(column, value) / Get(column) bag) and fail the build loudly, never silently, on a bad row: a field that doesn't match any declared column, a missing value for a non-nullable column, or an integer column fed something that isn't convertible to int are all compile errors, not dropped data.

2. IComponentContributor — additional files (collected, not yet wired)

GetAdditionalFiles(ExtensionContext context) is meant to let an extension add extra FileEntryModels to the package. Be aware of its current status, honestly: the MSI recipe pipeline collects registered IComponentContributor instances but does not yet merge their output into the compiled package — doing so requires rebuilding the resolved PackageModel, which is out of scope for the current pipeline. If you register one, the compiler does not fail the build or drop it silently; it logs a Warning (code EXT002) so you are not misled by a green build. No shipped extension (Firewall, SQL, IIS, HTTP, Dependency, Util, Driver, .NET) currently uses this interface.

3. IExecutionContributor + ExecutionStep — the runtime execution seam

This is the piece a first-time extension author most often misses, and the crux of the whole system: native MSI tables like File, Registry, ServiceInstall, and Shortcut run automatically, because Windows Installer's own standard actions (InstallFiles, WriteRegistryValues, StartServices, CreateShortcuts, …) know how to interpret them. An extension's custom table does notWixFirewallException or SqlDatabase rows are just data sitting in the MSI until something executes them. IExecutionContributor is that "something": it is the reusable seam that turns extension configuration into custom actions Windows Installer actually runs.

Register an instance via IExtensionRegistry.RegisterExecutionContributor. Its GetExecutionSteps(ExtensionContext context) returns a list of ExecutionStep records (src/FalkForge.Extensibility/ExecutionStep.cs), which ExecutionStepEmitter (src/FalkForge.Compiler.Msi/Recipe/ExecutionStepEmitter.cs) turns into CustomAction and InstallExecuteSequence rows — itself just another IMsiTableContributor, so the generated rows flow through the same ExtensionTableEmitter path as any other contributor. The compiler gathers steps from every registered execution contributor in registration order and allocates sequence numbers from a single deterministic pool, so multiple extensions (Firewall, SQL, IIS, HTTP, Util, …) contributing execution in the same package never collide.

ExecutionStep memberTypeDescription
IdstringRequired. Base name of the generated custom action(s) (Id, Id_d, Id_rb, Id_un). Must be a valid MSI identifier, short enough to leave room for those suffixes. A duplicate Id across contributors fails the build loudly.
InstallCommandstringRequired. The full command line run on install — deferred and elevated, in the InstallExecuteSequence between InstallInitialize and InstallFinalize. Runs as SYSTEM for a per-machine install.
RollbackCommandstring?Optional. Undoes InstallCommand if a later step fails. Scheduled immediately before the install action so Windows Installer runs it automatically, in reverse, on failure.
UninstallCommandstring?Optional. Runs when the product is removed.
CustomActionDatastring?Optional. The secret / late-bound data channel — see below.
InstallConditionstring?MSI condition gating install (and rollback). Defaults to NOT Installed.
UninstallConditionstring?MSI condition gating uninstall. Defaults to REMOVE~="ALL".
ElevatedboolDefault true. Marks the generated actions no-impersonate (SYSTEM context), required to modify machine state. Set false only for work that must run as the installing user.
HiddenPropertiesIReadOnlyList<string>Default empty. Property names carrying a secret this step introduces — see below.

The secure secret channel

Never bake a credential literal into InstallCommand — a deferred action's command line is visible on the process command line and in a verbose msiexec /L*v log. Instead set CustomActionData to a formatted expression such as "[DB_PASSWORD]". The emitter generates an immediate type-51 SetProperty custom action (named Id_d) that copies the run-time value of that property into the deferred action's own CustomActionData, which the deferred action reads in-process (the SQL extension reads it as $args[0] in its PowerShell script). The value is supplied at run time — typically via IInstallerEngine.SetSecureProperty from a custom UI page (see 11.11 Property Passing) — and is never stored in the MSI. This channel feeds the install action only; rollback and uninstall commands needing their own secret are not covered by it (SQL's rollback/uninstall use integrated Windows authentication for exactly this reason). The extension author lists the source property (and the generated Id_d/Id property) in HiddenProperties so it is scrubbed from a verbose install log. The compiler aggregates HiddenProperties from every execution step of every extension in the package, together with every author-declared property whose PropertyBuilder.IsHidden flag is set (see 8.17 PropertyBuilder), into a single, deterministic MsiHiddenProperties property row — neither an extension nor a package author may author that row directly (PRP002); two independent MsiHiddenProperties rows would collide on the same primary key.

CommandLine escaping helpers

FalkForge.Extensibility.CommandLine (src/FalkForge.Extensibility/CommandLine.cs) provides the two escaping primitives every execution contributor should route untrusted values through, because the generated actions run elevated:

MethodDescription
PowerShellSingleQuote(string value)Wraps a value as a PowerShell single-quoted literal (doubling embedded quotes) so it cannot break out into script/command evaluation. Rejects an embedded NUL or a Unicode "smart quote" character, which PowerShell's tokenizer also treats as a delimiter.
MsiFormatEscape(string value)Escapes [/] so a value placed in an MSI Formatted field (like CustomAction.Target) can't be mistaken for a live [PROPERTY] substitution token.

Beyond that, the shipped extensions (Firewall, SQL, IIS, Util) all transport their actual script body via powershell.exe -EncodedCommand <base64(UTF-16LE script)>, invoked by the fully-qualified [SystemFolder]WindowsPowerShell\v1.0\powershell.exe path rather than a bare executable name — because a deferred action's working directory is TARGETDIR (author-controlled), and CreateProcess resolves a bare name relative to the working directory before PATH, so a planted powershell.exe in the install directory could otherwise run as SYSTEM. The base64 alphabet contains no shell metacharacter and none of the MSI Formatted specials ([ ] { } ~), closing off both injection surfaces at once. There is currently no shared helper for this encoding step in FalkForge.Extensibility — each extension (see FirewallCommandFactory.Encode, SqlPowerShellEncoder.Encode) defines its own, following this same shape. New extensions should do the same rather than passing a raw script or a bare interpreter name.

4. RegisterDialogStep — reserving an install-UI step name

An extension can reserve an install-UI step name via IExtensionRegistry.RegisterDialogStep(IDialogStepBuilder builder), a public interface with a single Name member. The registered Name becomes referenceable from DialogCustomization.InsertStep(name, after:) and satisfies DLG001 name-resolution validation, but implementing only IDialogStepBuilder supplies no visual layout — no dialog is actually emitted for it. Emitting real layout requires implementing IMsiDialogStepBuilder (src/FalkForge.Compiler.Msi/UI/Layout/IMsiDialogStepBuilder.cs, which extends IDialogStepBuilder with Build(DialogBuildContext)) — but that interface, its DialogBuildContext parameter, and its MsiDialogModel return type are all declared internal to FalkForge.Compiler.Msi, and that assembly's InternalsVisibleTo is granted only to FalkForge.Compiler.Msi.Tests and FalkForge.Decompiler — not to any extension assembly. In practice this means no extension living in a separate assembly (which includes every shipped extension: Firewall, SQL, IIS, HTTP, …) can implement IMsiDialogStepBuilder today; only code compiled directly into FalkForge.Compiler.Msi can. For virtually every real authoring need, PackageBuilder.AddCustomDialog(id, dlg => …) (see 14. MSI Dialog Templates) is the right tool: it authors a complete custom dialog directly from application code, with no extension plumbing and no assembly visibility constraint.

Diagnostics

Two extension-pipeline codes exist today (this is the complete list — there is no EXT003 or higher yet):

CodeMeaning
EXT001An IMsiTableContributor produced rows for a table that is not built-in, but declared no WriteColumns schema. Compile error — the rows cannot be emitted without a schema.
EXT002One or more IComponentContributors were registered, but the recipe pipeline does not yet consume GetAdditionalFiles. Non-fatal Warning, logged so the omission isn't silent.

A row whose field doesn't match any declared column, or that omits a value for a non-nullable column, also fails the build with a descriptive CompilationError — those don't currently carry a dedicated EXT… code, just a precise message naming the table, row index, and column. A contributor can still give its own failures a stable identity inside that generic message via ContributedColumn.MissingValueHint (see 10.10.1) — the Util extension's ODBC tables prefix their hint text with ODB001 — but that's a convention the contributor opts into, not a code the compiler itself assigns or validates.

Attaching an extension to a build

Registering contributions only takes effect once the extension is attached to the compiler with MsiCompiler.Use:

var sql = new SqlExtension();
sql.DefineDatabase(db => db.Id("AppDb").Server(".").Database("Demo").CreateOnInstall());

return Installer.Build(args, package => { /* ... */ }, new MsiCompiler().Use(sql));

// Multiple extensions:
new MsiCompiler().Use(sql, firewall, dependency);

Use mutates and returns the same compiler instance, so even a discarded return value still attaches the extension. The failure mode to watch for is the opposite mistake: configuring an extension object (sql.DefineDatabase(…)) but never passing it to .Use(…) — the extension's Register callback is never invoked, so none of its tables or execution steps reach the compiled MSI, and nothing about a green build warns you. If it feels like your extension's configuration "didn't do anything," check .Use first.

10.11 Writing Your Own Extension

A worked, end-to-end example: a small Audit extension that records install/uninstall events. It uses both contributor kinds from 10.10 — an IMsiTableContributor for an inspectable record, and an IExecutionContributor to make that record real by appending a line to a log file at install time and removing it at uninstall. Every type and method referenced below is a real member documented in 10.1 and 10.10.

1. Define the extension's public surface

using FalkForge.Extensibility;

namespace Contoso.Installer.Extensions;

public sealed class AuditExtension : IFalkForgeExtension
{
    private readonly List<(string Id, string Message)> _entries = [];

    public string Name => "Contoso.Audit";

    public void Register(IExtensionRegistry registry)
    {
        registry.RegisterTableContributor(new AuditTableContributor(_entries));
        registry.RegisterExecutionContributor(new AuditExecutionContributor(_entries));
    }

    /// <summary>Records an audit entry that is written into the compiled MSI and appended to the
    /// machine audit log at install time.</summary>
    public void LogEntry(string id, string message)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(id);
        ArgumentException.ThrowIfNullOrWhiteSpace(message);
        _entries.Add((id, message));
    }
}

2. Contribute inspectable table data

AuditEntry is not a built-in MSI table, so the contributor must declare WriteColumns:

using FalkForge.Extensibility;

namespace Contoso.Installer.Extensions;

internal sealed class AuditTableContributor(IReadOnlyList<(string Id, string Message)> entries)
    : IMsiTableContributor
{
    public string TableName => "AuditEntry";

    public IReadOnlyList WriteColumns { get; } =
    [
        ContributedColumn.Key("Id"),
        ContributedColumn.Text("Message"),
    ];

    public IReadOnlyList GetRows(ExtensionContext context)
        => entries
            .Select(e => new MsiTableRow().Set("Id", e.Id).Set("Message", e.Message))
            .ToList();
}

3. Make it live with an execution contributor

Without this step the AuditEntry rows would just sit in the MSI — this is the seam described in 10.10 that actually appends to the log file. It follows the same fully-qualified-interpreter-path + base64 -EncodedCommand pattern as the shipped extensions, and quotes the author-supplied message with CommandLine.PowerShellSingleQuote because it is untrusted input reaching a SYSTEM-elevated command line:

using System.Text;
using FalkForge.Extensibility;

namespace Contoso.Installer.Extensions;

internal sealed class AuditExecutionContributor(IReadOnlyList<(string Id, string Message)> entries)
    : IExecutionContributor
{
    private const string LogPath = @"C:\ProgramData\Contoso\audit.log";

    public IReadOnlyList GetExecutionSteps(ExtensionContext context)
    {
        var steps = new List(entries.Count);
        foreach (var (id, message) in entries)
        {
            string line = $"{DateTime.UtcNow:O}  {message}";
            string appendScript =
                $"Add-Content -Path {CommandLine.PowerShellSingleQuote(LogPath)} " +
                $"-Value {CommandLine.PowerShellSingleQuote(line)}";

            steps.Add(new ExecutionStep
            {
                Id = $"Audit_{id}",
                InstallCommand = Encode(appendScript),
                UninstallCommand = Encode(
                    $"Remove-Item -Path {CommandLine.PowerShellSingleQuote(LogPath)} -ErrorAction SilentlyContinue"),
            });
        }

        return steps;
    }

    // No shared encoder ships in FalkForge.Extensibility today (see 10.10) — every extension defines
    // its own, matching this exact shape: fully-qualified interpreter path + base64 UTF-16LE script.
    private static string Encode(string script)
        => "[SystemFolder]WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -NonInteractive -EncodedCommand " +
           Convert.ToBase64String(Encoding.Unicode.GetBytes(script));
}
Credentials: if your extension needs a password or other secret at install time (a database connection, a service account), do not interpolate it into the script like message above. Follow the SQL extension's pattern instead: pass CustomActionData = "[YOUR_SECRET_PROPERTY]" on the ExecutionStep, read it inside the script as the first argument, and list both the generated step id and the source property in HiddenProperties. See 10.10 and src/FalkForge.Extensions.Sql/SqlCommandFactory.cs for the full pattern.

4. Attach it to a build

var audit = new AuditExtension();
audit.LogEntry("Install", "Contoso App 1.0.0 installed");

return Installer.Build(args, p => p
    .Name("Contoso App")
    .UpgradeCode(Guid.Parse("6C3F2B1A-9D4E-4A7C-8B5D-1E2F3A4B5C6D"))
    .Version("1.0.0")
    .Feature("Main", f => f
        .Files(files => files
            .Source(@"bin\Release\")
            .Include("ContosoApp.exe"))),
    new MsiCompiler().Use(audit));

5. Test it

Two levels are worth testing, mirroring how the shipped extensions are tested (tests/FalkForge.Extensions.Firewall.Tests/, tests/FalkForge.Compiler.Msi.Tests/Recipe/ExecutionStepEmissionTests.cs).

a) Registration, without compiling anything. A minimal in-test IExtensionRegistry double proves Register hands the compiler exactly the contributors you expect:

[Fact]
public void Register_RegistersTableAndExecutionContributor()
{
    var extension = new AuditExtension();
    var registry = new FakeExtensionRegistry();

    extension.Register(registry);

    Assert.Single(registry.TableContributors);
    Assert.Equal("AuditEntry", registry.TableContributors[0].TableName);
    Assert.Single(registry.ExecutionContributors);
}

private sealed class FakeExtensionRegistry : IExtensionRegistry
{
    public List TableContributors { get; } = [];
    public List ExecutionContributors { get; } = [];

    public void RegisterTableContributor(IMsiTableContributor c) => TableContributors.Add(c);
    public void RegisterComponentContributor(IComponentContributor c) { }
    public void RegisterExecutionContributor(IExecutionContributor c) => ExecutionContributors.Add(c);
    public void RegisterDryRunContributor(IDryRunContributor c) { }
    public void RegisterDialogStep(IDialogStepBuilder b) { }
}

b) The compiled MSI, structurally. On Windows, compile through the real pipeline and query the resulting tables with MsiDatabase (src/FalkForge.Compiler.Msi/MsiDatabase.cs) — the same technique ExecutionStepEmissionTests uses to prove Firewall's rule becomes a real deferred action:

[Fact]
[SupportedOSPlatform("windows")]
public void AuditEntry_EmitsCustomTableRowAndDeferredInstallAction()
{
    string root = Path.Combine(Path.GetTempPath(), $"AuditTest_{Guid.NewGuid():N}");
    string sourceFile = Path.Combine(root, "source", "AuditTestApp.exe");
    string outputDir = Path.Combine(root, "output");
    Directory.CreateDirectory(Path.GetDirectoryName(sourceFile)!);
    Directory.CreateDirectory(outputDir);
    File.WriteAllText(sourceFile, "test payload");

    var audit = new AuditExtension();
    audit.LogEntry("Install", "test entry");

    var package = InstallerTestHost.BuildPackage(p =>
    {
        p.Name = "AuditTestApp";
        p.Manufacturer = "Contoso";
        p.Version = new Version(1, 0, 0);
        p.Files(f => f.Add(sourceFile).To(KnownFolder.ProgramFiles / "Contoso" / "AuditTestApp"));
    });

    var result = new MsiCompiler(new WindowsFileSystem()).Use(audit).Compile(package, outputDir);
    Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : "");

    using var db = MsiDatabase.Open(result.Value, readOnly: true).Value;

    var tableRows = db.QueryRows("SELECT `Id`, `Message` FROM `AuditEntry`", 2).Value;
    Assert.Contains(tableRows, r => r[0] == "Install" && r[1] == "test entry");

    var actions = db.QueryRows("SELECT `Action` FROM `CustomAction` WHERE `Action`='Audit_Install'", 1).Value;
    Assert.Single(actions);
}

For the full accounting of what a deferred/elevated custom action row looks like (type bits, Source/Target, sequencing between InstallInitialize and InstallFinalize, rollback ordering), read tests/FalkForge.Compiler.Msi.Tests/Recipe/ExecutionStepEmissionTests.cs directly — it exercises every ExecutionStep field against a real compiled MSI.

Real, shipped exemplars to read next

PatternExtensionFiles
Simplest end-to-end execution seam (no secrets)Firewallsrc/FalkForge.Extensions.Firewall/FirewallExtension.cs, FirewallTableContributor.cs, FirewallExecutionContributor.cs, FirewallCommandFactory.cs
Secret-credential channel (SQL auth password)SQLsrc/FalkForge.Extensions.Sql/SqlExtension.cs, SqlExecutionContributor.cs, SqlCommandFactory.cs
Merge into a built-in table + immediate (non-deferred) check, no execution seamDependencysrc/FalkForge.Extensions.Dependency/DependencyExtension.cs, DependencyTableContributor.cs, DependencyVersionCheckPlanner.cs

12. Engine Architecture

The FalkForge Engine is a NativeAOT-compiled runtime that orchestrates the installation lifecycle. It uses a 3-process architecture for security isolation: the UI process communicates with the engine process via named pipes, and the engine spawns an elevated companion process when administrative privileges are required.

UI Process
WPF + ReactiveUI
FalkForge.Ui.csproj
Named Pipe A
HMAC-SHA256
Engine Process
NativeAOT (~3-5 MB)
FalkForge.Engine.csproj
Named Pipe B
HMAC-SHA256
Elevated Engine
NativeAOT (elevated)
FalkForge.Engine.Elevation.csproj

Phases

A single installer run flows through up to seven phases, in this order. Each phase is implemented as a dedicated step class (see Phase Steps, below); ordering and entry conditions are enforced by IInstallerPipeline rather than by a state machine.

Initializing
Detecting
Planning
Elevating
Applying
Completing
Shutdown
Error path: any phase → FailedRollingBackShutdown

EngineSession

EngineSession (src/FalkForge.Engine/EngineSession.cs) is the public facade for running the installer engine. It owns the lifetime of the pipeline, the UI channel, the logger, and the journal store, and exposes a single RunUntilShutdown entry point that returns an EngineOutcome. Construction is via static factories; the constructor is private.

public sealed class EngineSession : IAsyncDisposable
{
    public static EngineSession BindToPipe(
        string? pipeName,
        string manifestPath,
        EngineSessionOptions? options = null);

    public async Task<EngineOutcome> RunUntilShutdown(CancellationToken ct = default);

    public async ValueTask DisposeAsync();
}

public readonly record struct EngineOutcome(
    EngineTerminalState State,        // Completed | Cancelled | RolledBack | Failed
    Error? Error,
    RollbackSummary? Rollback,
    TimeSpan Duration,
    IReadOnlyList<string> LogFiles);

public sealed record EngineSessionOptions
{
    public IFalkLogger? Logger { get; init; }
    public string? LogDirectory { get; init; }
    public string? LogPath { get; init; }
    public LogLevel? MinimumLogLevel { get; init; }
    public PipeConnectionOptions? PipeOptions { get; init; }
    public TimeSpan? HandshakeTimeout { get; init; }
    public bool WriteJournal { get; init; } = true;
}

BindToPipe performs the following construction sequence: builds an EngineLogger with a pipe-fanout callback, stamps a fresh SessionCorrelationId (Guid.NewGuid()) on both the logger and the channel, loads the manifest from disk, acquires InstanceLock, creates the NamedPipeUiChannel (or a null channel when pipeName is omitted), wires MsiExecutor / MsuExecutor / MspExecutor / BundleExecutor / ExeExecutor / NetRuntimeExecutor into a single PackageExecutor, creates the FileSystemJournalStore and undo operations, conditionally creates the NamedPipeElevationGateway if a companion .exe is available, and finally calls InstallerPipelineBuilder.Build().

InstanceLock

InstanceLock (src/FalkForge.Engine/InstanceLock.cs) prevents two concurrent engine instances from running against the same bundle, which would otherwise produce MSI error 1618 ("Another installation is already in progress"). The lock is implemented as a machine-wide Semaphore(1, 1) (not a Mutex — so it is intentionally not thread re-entrant) named Global\FalkForge_Install_{bundleId}. If SeCreateGlobalPrivilege is not held, it falls back to the Local\ namespace.

public static class InstanceLock
{
    public static bool TryAcquire(string bundleId, out IDisposable? lockHandle);
}

EngineSession.BindToPipe calls TryAcquire immediately after loading the manifest. If acquisition fails an InvalidOperationException is propagated to the caller. Disposing the handle releases the semaphore at session teardown.

Installer Pipeline

IInstallerPipeline (src/FalkForge.Engine/Pipeline/IInstallerPipeline.cs) is the top-level phase coordinator. It enforces phase ordering (DetectPlanApply, with optional Elevate between Plan and Apply, and Rollback on failure) and exposes each phase as an async Result<Unit> method rather than throwing on failure.

public interface IInstallerPipeline : IAsyncDisposable
{
    Task<Result<Unit>> DetectAsync(CancellationToken ct);
    Task<Result<Unit>> PlanAsync(UiRequest.Plan request, CancellationToken ct);
    Task<Result<Unit>> ElevateAsync(CancellationToken ct);
    Task<Result<Unit>> ApplyAsync(CancellationToken ct);
    Task<Result<Unit>> RollbackAsync(CancellationToken ct);
    Result<Unit> ExportPlan(string? outputPath);
}

The default implementation, InstallerPipeline, holds references to up to five phase steps and uses a private Phase enum to guard against out-of-sequence calls. Out-of-order invocations return ErrorKind.EngineError instead of throwing. Calling ElevateAsync when no elevation gateway was supplied is a successful no-op.

InstallerPipelineBuilder

A fluent builder that accepts port implementations and conditionally constructs phase steps based on which dependencies were supplied. Missing steps become successful no-op pass-throughs. All With* methods return this for chaining.

public sealed class InstallerPipelineBuilder
{
    public InstallerPipelineBuilder WithJournalStore(IRollbackJournalStore store);
    public InstallerPipelineBuilder WithUiChannel(IUiChannel channel);
    public InstallerPipelineBuilder WithElevationGateway(IElevatedCommandGateway gateway);
    public InstallerPipelineBuilder WithManifest(InstallerManifest manifest);
    public InstallerPipelineBuilder WithRegistry(IRegistry registry);
    public InstallerPipelineBuilder WithPackageExecutor(PackageExecutor executor);
    public InstallerPipelineBuilder WithVariableStore(VariableStore variables);
    public InstallerPipelineBuilder WithUndoOperations(IReadOnlyList<IUndoOperation> ops);
    public InstallerPipelineBuilder WithLogger(IFalkLogger logger);
    public InstallerPipelineBuilder WithTrustStoreAdvanceOnVerifiedApply(bool enabled = true);
    public InstallerPipelineBuilder WithPayloadRoot(string payloadRoot);
    public IInstallerPipeline Build();
}

Conditional wiring: a DetectStep is created only if both manifest and registry are present; an ElevateStep is created only if an elevation gateway was supplied; both ApplyStep and RollbackStep are created only if a journal store was supplied. This design lets the same pipeline type drive production runs, plan-only export, and unit-test scenarios with stub channels.

PipelineRunner

PipelineRunner (src/FalkForge.Engine/Pipeline/PipelineRunner.cs) is the production event loop that drives the pipeline. It consumes UiRequest events from IUiChannel and invokes the matching phase method on IInstallerPipeline.

public sealed class PipelineRunner
{
    public PipelineRunner(
        IInstallerPipeline pipeline,
        IUiChannel uiChannel,
        IFalkLogger? logger = null,
        bool isPlanOnly = false,
        string? planOnlyOutputPath = null);

    public async Task<int> RunAsync(CancellationToken ct);
}

The event loop is an await foreach over IUiChannel.ReadRequestsAsync(ct):

Inbound requestBehavior
UiRequest.DetectCalls DetectAsync. On failure, emits a Failed event and exits with code 1.
UiRequest.PlanCalls PlanAsync, then automatically calls ElevateAsync if elevation is required by the plan.
UiRequest.ApplyCalls ApplyAsync. On success emits PhaseChanged(Completing) and shuts down.
UiRequest.Cancel / UiRequest.ShutdownSends a shutdown frame to the UI and returns exit code 0.

If the CancellationToken fires during ApplyAsync, the runner calls RollbackAsync(CancellationToken.None) so rollback cannot itself be cancelled, then returns exit code 3. EngineMeter.RecordError is called on every failure path.

Exit codes returned by RunAsync:

CodeMeaning
0Success, or clean cancellation before apply
1Phase error (detection, planning, elevation, or apply without rollback)
3Rolled back after cancellation or failure during apply

Phase Steps

The pipeline holds up to five phase steps, each an internal sealed class. All receive a PipelineContext — a mutable state bag threaded through the run — as the first argument to ExecuteAsync.

StepResponsibility
DetectStepRuns PackageDetector against the manifest and the optional update check. Populates ctx.Detection, ctx.RelatedBundles, ctx.AvailableUpdate.
PlanStepRuns the Planner, checks architecture compatibility against the host machine, populates ctx.Plan.
ElevateStepCalls IElevatedCommandGateway.StartAsync, which launches the companion .exe and performs the HMAC handshake. Sets ctx.ElevationGateway.
ApplyStepExecutes every PlanAction via PackageExecutor, drives RestartManager, and journals undo entries.
RollbackStepReads the journal in reverse and dispatches each entry to the matching IUndoOperation.

PipelineContext

PipelineContext (src/FalkForge.Engine/Pipeline/PipelineContext.cs) replaces the older shared-context singleton. Each step reads and writes the fields it owns; downstream steps consume the populated state.

PropertyTypeOwner
ManifestInstallerManifestset at construction
DetectionDetectionResult?DetectStep
RelatedBundlesIReadOnlyList<RelatedBundleInfo>DetectStep
AvailableUpdateUpdateCheckResult?DetectStep
PlanRequestUiRequest.Plan?PlanStep
PlanInstallPlan?PlanStep
IsDryRunboolPlanStep
ElevationGatewayIElevatedCommandGateway?ElevateStep
RebootRequiredboolApplyStep

IUiChannel

IUiChannel (src/FalkForge.Engine/Pipeline/IUiChannel.cs) is the cross-process UI communication port. It hides binary message framing, the HMAC pipe handshake, and the EngineMessage wire-frame subtypes from pipeline code.

public interface IUiChannel : IAsyncDisposable
{
    void SetSessionCorrelationId(Guid id);
    Task SendAsync(PipelineEvent evt, CancellationToken ct);
    IAsyncEnumerable<UiRequest> ReadRequestsAsync(CancellationToken ct);
}

The production implementation, NamedPipeUiChannel, performs the HMAC handshake on StartAsync, translates outbound PipelineEvent values into the corresponding EngineMessage frame, and translates inbound frames back into UiRequest values. A null implementation is available for headless and CLI scenarios via NamedPipeUiChannel.CreateNullChannel().

IElevatedCommandGateway

IElevatedCommandGateway (src/FalkForge.Engine/Pipeline/IElevatedCommandGateway.cs) is the elevated-process port. It hides HMAC handshake, PID and process-start-time verification, child-process spawning, and pipe framing from ElevateStep and ApplyStep.

public interface IElevatedCommandGateway : IAsyncDisposable
{
    Task<Result<Unit>> StartAsync(CancellationToken ct);
    Task<Result<byte[]>> SendCommandAsync(
        string commandName,
        byte[] payload,
        IProgress<int>? progress,
        CancellationToken ct);
}

The production implementation, NamedPipeElevationGateway, launches FalkForge.Engine.Elevation.exe as a child process, performs the HMAC handshake, and verifies that the resulting process matches the expected PID and start time. Start-time verification defends against PID recycling. The progress callback receives values in [0, 100] during long-running MSI commands.

NativeAOT Constraints: The engine targets NativeAOT compilation with PublishAot: true, InvariantGlobalization: true, and IlcOptimizationPreference: Size. No reflection, no dynamic types, no BinaryFormatter. All dependency injection is manual (constructor injection), all serialization uses the binary protocol in MessageSerializer, and the channel and gateway abstractions exist so the pipeline can be unit-tested without spawning real pipes or processes.

Detection

The detection phase identifies whether packages are already installed and what version is present.

PackageDetector

src/FalkForge.Engine/Detection/PackageDetector.cs

MethodReturnsDescription
Detect(InstallerManifest)DetectionResultDetects overall bundle install state and version
DetectPerPackage(InstallerManifest)Dictionary<string, InstallState>Per-package install state
DetectRelatedBundles(InstallerManifest)Result<IReadOnlyList<RelatedBundleInfo>>Finds related bundles by upgrade code
CompareVersions(string, string)InstallStateCompares installed vs target version

MsiDetector

src/FalkForge.Engine/Detection/MsiDetector.cs

Checks the Windows registry uninstall key (SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{ProductCode}) in both HKLM and HKCU to determine if a product is installed and retrieve its DisplayVersion.

Planning

The planning phase creates an InstallPlan that defines the sequence of package operations to execute, organized into rollback segments.

Planner

src/FalkForge.Engine/Planning/Planner.cs

public sealed class Planner
{
    public Result<InstallPlan> CreatePlan(
        InstallerManifest manifest,
        DetectionResult detection,
        InstallAction action,
        VariableStore? variables = null,
        IReadOnlyList<RelatedBundleInfo>? detectedRelatedBundles = null,
        IReadOnlyDictionary<string, bool>? featureSelections = null);
}

Planning behavior per action:

ActionBehavior
InstallUninstalls related upgrade bundles first, then installs packages forward. Respects feature selections and install conditions.
UninstallRemoves packages in reverse order. Ignores feature selections (all packages are candidates).
RepairRepairs packages forward. Respects feature selections.
ModifyInstalls packages forward with updated feature selections.

InstallPlan

public sealed class InstallPlan
{
    public required IReadOnlyList<PlanAction> Actions { get; init; }
    public IReadOnlyList<RollbackSegment> Segments { get; init; }
    public long TotalDiskSpaceRequired { get; init; }
}

PlanAction

public sealed class PlanAction
{
    public required string PackageId { get; init; }
    public required PlanActionType ActionType { get; init; }  // Install, Uninstall, Repair
    public required PackageInfo Package { get; init; }
    public Dictionary<string, string> Properties { get; init; }
}

RollbackSegment

public sealed class RollbackSegment
{
    public required string BoundaryId { get; init; }
    public bool Vital { get; init; } = true;
    public List<PlanAction> Actions { get; }
}

Execution

The execution subsystem runs the planned actions using type-specific executors. PackageExecutor dispatches to the correct executor based on package type.

PackageExecutor

src/FalkForge.Engine/Execution/PackageExecutor.cs

public sealed class PackageExecutor
{
    public async Task<Result<ExecutionOutcome>> ExecuteAsync(PlanAction action, CancellationToken ct);
}
ExecutorPackage TypeToolDescription
MsiExecutorMsiPackageIMsiApi (P/Invoke)Installs/uninstalls/repairs MSI packages via in-process MsiInstallProduct/MsiConfigureProduct P/Invoke. Properties and secrets flow through named pipes, never on the command line. Supports elevation via IElevationClient.
MsuExecutorMsuPackagewusa.exeInstalls/uninstalls Windows Update packages. Uninstall requires KbArticle. Recognizes exit codes 0, 3010 (reboot required), and 2359302 (already installed).
MspExecutorMspPackagemsiexec.exeApplies/removes MSI patches. Uninstall requires TargetProductCode and PatchCode (validated as GUIDs).
BundleExecutorBundlePackage(EXE path)Executes nested bundle EXEs with /quiet /norestart flags. Supports cancellation via process tree kill.
Distributed install (payload path resolution). Each executor reads PlanAction.EffectiveSourcePath (ResolvedSourcePath ?? Package.SourcePath, src/FalkForge.Engine/Planning/PlanAction.cs) rather than the build-time SourcePath, so a package installs from wherever the bundle actually extracted it on the target machine, not from the build box. The extracted location is computed by PayloadPathResolver.Resolve (src/FalkForge.Engine/Pipeline/PayloadPathResolver.cs), which combines the payload extraction root with the package id and enforces path containment: a traversal (e.g. ..\evil), a rooted path, or a UNC/device-namespace package id resolves outside the root and is rejected with ErrorKind.SecurityError rather than being handed to an installer.

IProcessRunner

src/FalkForge.Engine/Execution/IProcessRunner.cs

public interface IProcessRunner
{
    Task<int> RunAsync(string fileName, string arguments, CancellationToken ct);
    Task<int> RunAsync(string fileName, string arguments,
                       Action<int>? onProcessStarted, CancellationToken ct);
}

This abstraction enables deterministic testing of all executors by replacing actual process execution with test doubles.

Variables & Conditions

VariableStore

src/FalkForge.Engine/Variables/VariableStore.cs

Thread-safe variable storage backed by ConcurrentDictionary<string, object> with case-insensitive key lookup. Supports three value types: string, long, and Version. Also supports secret variables stored in pinned, zeroed-on-dispose memory.

MethodDescription
Set(string name, string value)Store a string variable
Set(string name, long value)Store an integer variable
Set(string name, Version value)Store a version variable
Contains(string name)Check if variable exists
TryGet<T>(string name)Get typed variable with Result
GetString(string name)Get as string (auto-converts)
GetInt(string name)Get as long (parses strings)
GetVersion(string name)Get as Version (parses strings)
SetSecret(string name, string value)Store a secret (pinned memory)
SetSecret(string name, byte[] value)Store a secret from raw bytes (no string intermediate)
GetSecret(string name)Retrieve a secret value
IsSecret(string name)Check if variable is a secret
GetNames()List all variable names
Dispose()Zeros all secret memory

Built-in Variables

src/FalkForge.Engine/Variables/BuiltInVariables.cs

BuiltInVariables.Populate() initializes 30+ variables from the operating system, platform services, and session information. The pipeline calls it automatically during the Detect phase (DetectStep), before any InstallCondition is evaluated, so every install condition referencing a built-in sees real machine state.

NameTypeDescriptionExample Value
VersionNTVersionFull OS version10.0.22631
VersionNTMajorlongOS major version number10
VersionNTMinorlongOS minor version number0
ServicePackLevellongService pack level (always 0 on modern Windows)0
WindowsBuildNumberlongWindows build number22631
NativeMachinestringOS processor architecturex64
ProcessorArchitecturestringOS architecture (same as NativeMachine)x64
ProcessArchitecturestringProcess architecture (may differ under emulation)x64
Is64BitOperatingSystemlong1 if x64 or arm64 OS, 0 otherwise1
SystemFolderstringWindows system directoryC:\Windows\System32
WindowsFolderstringWindows directoryC:\Windows
ProgramFilesFolderstringProgram Files directoryC:\Program Files
ProgramFiles64Folderstring64-bit Program Files (empty on 32-bit OS)C:\Program Files
CommonFilesFolderstringCommon Files directoryC:\Program Files\Common Files
TempFolderstringTemporary files directoryC:\Users\User\AppData\Local\Temp\
DesktopFolderstringDesktop directoryC:\Users\User\Desktop
AdminToolsFolderstringAdministrative Tools directoryC:\Users\User\...\Administrative Tools
LocalAppDataFolderstringLocal application data directoryC:\Users\User\AppData\Local
AppDataFolderstringRoaming application data directoryC:\Users\User\AppData\Roaming
StartMenuFolderstringStart Menu directoryC:\Users\User\...\Start Menu
StartupFolderstringStartup programs directoryC:\Users\User\...\Startup
PersonalFolderstringDocuments directoryC:\Users\User\Documents
FontsFolderstringFonts directoryC:\Windows\Fonts
Privilegedlong1 if the install can perform privileged (per-machine) work: the process is elevated, or an elevation companion is configured and available1
TerminalServerlong1 if Terminal Server mode is active0
RemoteSessionlong1 if running in a remote desktop session0
ComputerNamestringMachine nameDESKTOP-ABC123
LogonUserstringCurrent Windows user nameJohnDoe
InstalledCulturestringCurrent culture nameen-US
UserLanguageIDlongCurrent user language LCID1033
SystemLanguageIDlongSystem UI language LCID1033
VersionMsiVersionEmulated MSI version (always 5.0)5.0
DatestringCurrent UTC date20260217
TimestringCurrent UTC time143022
RebootPendinglong1 if a reboot is pending from prior installations0

SecureVariable

src/FalkForge.Engine/Variables/SecureVariable.cs

Internal sealed class for storing sensitive values in memory. The underlying byte[] is GC-pinned via GCHandle.Alloc(Pinned) to prevent relocation, and zeroed via CryptographicOperations.ZeroMemory on dispose. Includes a GC.SuppressFinalize-guarded finalizer as a safety net.

ConstructorDescription
SecureVariable(string value)Converts string to UTF-8 bytes, pins in memory
SecureVariable(byte[] data)Takes ownership of byte array directly (no copy, no string intermediate)
Ownership: The byte[] constructor takes ownership of the caller’s array. The caller must not use the array after passing it. On Dispose(), the original array is zeroed.

Property Flow

User-defined properties flow from the UI process through the engine to MSI execution:

  1. UI Process: InstallerPage calls Engine.SetProperty() or Engine.SetSecureProperty()
  2. EngineClient: Serializes to SetPropertyMessage (0x0208) or SetSecurePropertyMessage (0x0209) over named pipe
  3. NamedPipeUiChannel: Validates the property name (regex check, built-in variable allowlist, length cap) and translates the frame into a UiRequest; PipelineRunner stores the value in VariableStore. Secrets are wrapped as SecureVariable and tracked separately so they can be redacted from logs.
  4. Planner: Copies user properties to PlanAction.Properties; secrets use bracket references ([DB_PASSWORD]) resolved at execution time
  5. MsiExecutor: Resolves bracket references from VariableStore, builds property string, calls IMsiApi.InstallProduct(packagePath, properties)
Phase gating: Properties are only accepted during Initializing, Detecting, or Planning phases. Attempts to set properties during Apply or later are rejected by the engine.

ConditionEvaluator

src/FalkForge.Engine/Variables/ConditionEvaluator.cs

A recursive-descent parser and evaluator for WiX-compatible condition expressions. Conditions are evaluated against the VariableStore at planning time to determine which packages should be included.

public static class ConditionEvaluator
{
    public static Result<bool> Evaluate(string? condition, VariableStore variables);
}

Grammar:

expr     -> or_expr
or_expr  -> and_expr (OR and_expr)*
and_expr -> not_expr (AND not_expr)*
not_expr -> NOT not_expr | primary
primary  -> comparison | '(' expr ')'
comparison -> value (op value)?
value    -> variable | literal
op       -> '=' | '<>' | '<' | '>' | '<=' | '>=' | '~='
OperatorDescription
=Equality (type-aware: version, integer, then string)
<>Inequality
<, >, <=, >=Ordered comparison
~=Case-insensitive string equality
ANDLogical AND
ORLogical OR
NOTLogical NOT

Truthiness rules: null/missing variables and empty strings are falsy. Integer zero is falsy. All other values are truthy.

Download & Update Checking

PayloadDownloader

src/FalkForge.Engine/Download/PayloadDownloader.cs

Downloads remote payloads with retry logic and SHA-256 verification.

public sealed class PayloadDownloader
{
    public PayloadDownloader(HttpClient httpClient, TimeSpan? timeoutPerAttempt = null);

    public async Task<Result<string>> DownloadAsync(
        string url,
        string expectedSha256,
        string targetPath,
        Action<long, long>? onProgress = null,
        CancellationToken ct = default);
}
FeatureDetail
Max retries3 attempts with exponential backoff (1s, 2s)
Timeout5 minutes per attempt (configurable)
Hash verificationSHA-256 computed after download, file deleted on mismatch
URL validationOnly http/https schemes allowed; path traversal blocked
Progress callbackAction<long bytesRead, long totalBytes>

UpdateChecker

src/FalkForge.Engine/Download/UpdateChecker.cs

Checks for available updates by fetching a JSON update feed from a configured URL. The feed is parsed by UpdateFeedParser, which validates the bundle ID, compares versions, and returns the best available update.

UpdateFeedParser

src/FalkForge.Engine/Download/UpdateFeedParser.cs

Parses the JSON update feed using AOT-safe System.Text.Json source generation. Validates bundle ID match, ensures download URLs use HTTPS, and selects the highest version entry that is newer than the current version.

Cache

PackageCache

src/FalkForge.Engine/Cache/PackageCache.cs

Caches package payloads to the local filesystem with SHA-256 integrity verification. Cached packages are reused during repair and uninstall operations.

public sealed class PackageCache
{
    public Result<string> CachePackage(Guid bundleId, PackageInfo package, string sourceFilePath);
    public bool IsCached(Guid bundleId, PackageInfo package, string fileName);
    public string? GetCachedPath(Guid bundleId, PackageInfo package, string fileName);
    public Result<string> CacheDownloadedPayload(Guid bundleId, PackageInfo package, string downloadedFilePath);
}

CacheLayout

src/FalkForge.Engine/Cache/CacheLayout.cs

Determines cache directory paths based on install scope:

Rollback Journal

RollbackJournal

src/FalkForge.Engine/Journal/RollbackJournal.cs

Records installation actions to a binary journal file, enabling rollback on failure. The journal is written sequentially during the apply phase and read in reverse during rollback.

public sealed class RollbackJournal : IDisposable
{
    public Result<Unit> Open();
    public Result<Unit> WriteEntry(JournalEntry entry);
    public Result<Unit> BeginSegment(string boundaryId);
    public IReadOnlyList<JournalEntry> GetSegmentEntries(string boundaryId);
    public static Result<JournalEntry[]> ReadEntries(string journalPath);
}

JournalEntryType

TypeDescriptionRequired Fields
PackageInstalledA package was installedPackageId
PackageUninstalledA package was uninstalledPackageId
MsiInstalledAn MSI package was installedPackageId, ProductCode
ExeInstalledAn EXE package was installedPackageId, UninstallCommand
PayloadCachedA payload was cachedPackageId, CachePath
FileCreatedA file was createdDescription (path)
FileModifiedA file was modifiedDescription, UndoData
RegistryKeyCreatedA registry key was createdDescription
RegistryValueSetA registry value was setDescription, UndoData
RegistryModifiedA registry entry was modifiedDescription, UndoData
ServiceInstalledA service was installedDescription
SegmentBoundaryRollback segment markerDescription (boundary ID)

RollbackExecutor

src/FalkForge.Engine/Journal/RollbackExecutor.cs

Processes journal entries in reverse order, dispatching each to the appropriate IUndoOperation. Rollback is best-effort: failures are logged but do not prevent processing of remaining entries.

Built-in undo operations:

Logging & Metrics

EngineLogger

EngineLogger (src/FalkForge.Engine/Logging/EngineLogger.cs) is a file-based logger with an optional pipe-fanout callback so log entries can also stream to the UI. Each entry carries a category, level, message, optional property bag, and the active SessionCorrelationId so engine, UI, and elevated logs can be correlated after the fact.

public sealed class EngineLogger : IFalkLogger, IDisposable
{
    public EngineLogger(
        string path,
        EngineLoggerOptions? options = null,
        Action<LogEntry>? pipeCallback = null);

    public Guid SessionCorrelationId { get; set; }

    public void SetMinimumLevel(LogLevel level);

    public void Log(LogLevel level, string category, string message,
                    IReadOnlyDictionary<string, string>? properties = null);

    public void Verbose(string category, string message);
    public void Debug(string category, string message);
    public void Info(string category, string message);
    public void Warning(string category, string message);
    public void Error(string category, string message);

    public static string GetDefaultLogPath(ISystemClock? clock = null);
    public void Dispose();
}

public sealed record EngineLoggerOptions
{
    public long RotationSizeThresholdBytes { get; init; } = long.MaxValue;
    public int RetentionCount { get; init; } = 5;
    public static EngineLoggerOptions Default { get; }
}

Log rotation is enabled by default for every production session. EngineSession.BindToPipe and EngineSession.BindToChannel both construct EngineLogger with RotationSizeThresholdBytes = 10 MB and RetentionCount = 5: when the active log file reaches 10 MB it is renamed to install_*.log.1 (shifting older backups up to .5, dropping the oldest) and a fresh file is opened. Up to five backup files are retained per session. EngineLoggerOptions.Default has RotationSizeThresholdBytes = long.MaxValue which effectively disables rotation, but EngineSession does not use Default — it always applies the 10 MB / 5-file policy. Pass a custom EngineLoggerOptions via EngineSessionOptions.Logger to override.

--log / --log-level CLI flags

The engine accepts two logging flags forwarded from the bootstrapper. Parsing is centralized in ProgramArgs (src/FalkForge.Engine.Protocol/ProgramArgs.cs) so the engine and the UI process share a single parser and the values cannot drift across processes.

FlagAliasesArgument
--log/log, /LFile path or directory (auto-appends engine.log); path traversal (..) is rejected
--log-level/lvLogLevel enum value (Verbose, Debug, Info, Warning, Error)

ProgramArgs.ToLogFlagsCommandLine() formats the parsed values back into a command-line string that Bootstrapper.BuildUiArgs uses when spawning the UI process, so the same log configuration flows transparently into every child.

EngineMeter (System.Diagnostics.Metrics)

EngineMeter (src/FalkForge.Engine/Logging/EngineMeter.cs) is the central metrics facade. It uses System.Diagnostics.Metrics (NativeAOT and trim-safe, no reflection) with instrument names following OpenTelemetry semantic conventions under the falkforge.engine.* prefix.

InstrumentKindRecording method
falkforge.engine.phase.transitionsCounterRecordPhaseTransition(EnginePhase, double ms)
falkforge.engine.phase.duration_msHistogramRecordPhaseTransition(EnginePhase, double ms)
falkforge.engine.payload.downloadsCounterRecordPayloadDownload(bool, long, PayloadKind)
falkforge.engine.payload.size_bytesHistogramRecordPayloadDownload(bool, long, PayloadKind)
falkforge.engine.retry.countCounterRecordRetry(RetryOperation)
falkforge.engine.error.countCounterRecordError(ErrorKind)

The static EngineMeter is consumed across the pipeline: PipelineRunner calls RecordError on every failure path; download and retry sites record their own metrics. Any host that wants to scrape these can subscribe to the falkforge.engine meter via MeterListener.

Session-end metrics snapshot

EngineMeter.Snapshot() returns a point-in-time IReadOnlyDictionary<string, long> of all in-process counters accumulated since process start (keys are the EngineMeter.SnapshotKey string constants). EngineMeter.FlushToLogger(IFalkLogger) calls Snapshot() and writes the result as a single structured log entry at LogLevel.Info with category "Metrics". EngineSession.DisposeAsync calls FlushToLogger automatically, so every session ends with a metrics summary in the log file regardless of whether an OpenTelemetry collector is configured. The snapshot properties are:

Key (SnapshotKey)Description
phase_transitionsTotal engine phase transitions this session.
payload_downloads_successSuccessful payload downloads this session.
payload_downloads_failureFailed payload downloads this session.
retriesTotal operation retries (download, MSI install, etc.) this session.
errorsTerminal pipeline errors this session.

The counters are process-lifetime scoped (implemented with Interlocked fields); call EngineMeter.ResetForTesting() in tests to reset them between runs.

SessionCorrelationId

A fresh Guid is generated by EngineSession.BindToPipe and stamped onto the logger via StampCorrelationId and onto the channel via IUiChannel.SetSessionCorrelationId. NamedPipeUiChannel then embeds it in every outbound LogMessage and PhaseChangedMessage frame, so engine, UI, and elevated log files can be reconciled by correlation ID without relying on timestamps.

13. Protocol & IPC

The engine protocol defines the binary wire format and message types used for inter-process communication between the UI, engine, and elevated companion processes.

Binary Protocol Format

All messages use a fixed-header binary format with length-prefix framing on the transport layer. Each frame consists of an 8-byte wire header followed by a codec-authored payload. The codec for each message type owns the payload layout, beginning with a SequenceId:u32.

Wire frame:
+----------------------+------------------+----------------------+----------+
| WireVersion (ushort) |  Type (ushort)   | PayloadLength (int32)|  Payload |
|       2 bytes        |     2 bytes      |        4 bytes       | variable |
+----------------------+------------------+----------------------+----------+

Codec body (first field is always SequenceId):
+--------------------+------------------------------+
|  SequenceId (u32)  |  Type-specific fields ...    |
|      4 bytes       |          variable            |
+--------------------+------------------------------+

Transport framing (length-prefix):
+------------------+---------------------------+
| Frame Length (4) | Serialized Message (N)    |
+------------------+---------------------------+
FieldSizeDescription
WireVersion2 bytesPer-message wire version. Multiple versions of the same MessageType can be registered side-by-side (see Codec Architecture).
Type2 bytesMessageType enum value
PayloadLength4 bytesPayload length in bytes (excludes header)
PayloadvariableCodec-authored body, starting with SequenceId:u32, then type-specific fields
Maximum payload size is 1 MB (MaxPayloadSize = 1 * 1024 * 1024). Collection counts within messages are capped at 10,000 items.

Message Types

Type IDNameDirectionPropertiesDescription
0x0101DetectBeginEngine → UI(none)Detection phase has started
0x0102DetectCompleteEngine → UIState, CurrentVersion, Features[]Detection results with install state and feature list
0x0103PlanBeginEngine → UIAction (InstallAction)Planning phase has started for the given action
0x0104PlanCompleteEngine → UITotalDiskSpaceRequired, PackageIds[]Plan is ready with disk space estimate and package list
0x0105ApplyBeginEngine → UITotalPackagesApply phase has started with total package count
0x0106ApplyCompleteEngine → UIExitCode, ErrorMessage?Apply phase completed with result
0x0107ProgressEngine → UIInstallProgress(Current, Total, CurrentPackage)Progress update during apply
0x0108ErrorEngine → UIMessage, Kind (ErrorKind)Error notification with categorized error kind
0x0109PhaseChangedEngine → UIPhase (EnginePhase)State machine phase transition notification
0x010ALogEngine → UIText, Level (LogLevel)Structured log message for UI display
0x010BShutdownResponseEngine → UIExitCodeEngine shutdown acknowledgement with final exit code
0x010CUpdateAvailableEngine → UIVersion, ReleaseNotes?, DownloadUrl, LocalPath?An update is available for download
0x010DUpdateReadyEngine → UIVersion, LocalPathUpdate has been downloaded and is ready to apply
0x010EUpdateDownloadProgressEngine → UIBytesReceived, TotalBytes, PercentCompleteProgress report while downloading an available update, produced by UpdateDownloader (see Update Feed)
0x010FLaunchUpdateUI → Engine(none)Tells the engine to launch a downloaded/ready update; sent by EngineClient.LaunchUpdate()
0x0110LicenseEngine ↔ UIAction (LicenseAction: Required/Accepted/Declined), LicenseContent?Engine presents license text to the UI (Action = Required); the UI replies with Accepted/Declined. RequestPlan is gated on acceptance
0x0111DetectPackageCompleteEngine → UIPackageId, State (InstallState), Version?Per-package detection result, emitted once per package in manifest chain order during Detect
0x0112DetectRelatedBundleEngine → UIBundleId, Relation (RelatedBundleRelation), InstalledVersionA related bundle (e.g. an older version eligible for upgrade) was found on the machine during Detect
0x0113PlanPackageBeginEngine → UIPackageId, DisplayName, PlannedActionPlanning is about to start for a single package during the Plan phase
0x0114PlanPackageCompleteEngine → UIPackageId, DisplayName, PlannedActionPlanning finished for a single package, sent immediately after its PlanPackageBegin
0x0115ApplyPackageBeginEngine → UIPackageId, DisplayNameA package's installer is about to run during the Apply phase, in execution order
0x0116ApplyPackageCompleteEngine → UIPackageId, DisplayName, SucceededA package's installer returned; Succeeded is false for the package whose failure aborts the apply
0x0117PackageMsiFeaturesEngine → UIPackageId, Features (MsiFeatureInfo[])Advertises the Feature rows found inside one MSI package, so the UI can present a per-package feature picker; the user's choice comes back as SetPackageFeatureSelection
0x0201CancelUI → Engine(none)User requested cancellation
0x0202ShutdownRequestUI → Engine(none)UI requests engine shutdown
0x0203SetInstallDirectoryUI → EngineDirectory (string)User changed install directory
0x0204SetFeatureSelectionUI → EngineFeatureId, IsSelectedUser toggled a feature
0x0205RequestDetectUI → Engine(none)UI requests detection to begin
0x0206RequestPlanUI → EngineAction (InstallAction)UI requests planning for a specific action
0x0207RequestApplyUI → Engine(none)UI confirms readiness to apply
0x0208SetPropertyUI → EnginePropertyName (string), Value (string)Sets an MSI property for package execution. Phase-gated: only accepted during Initializing, Detecting, or Planning.
0x0209SetSecurePropertyUI → EnginePropertyName (string), SecureValue (SensitiveBytes)Sets a secret property via named pipe. SecureValue is a SensitiveBytes struct that zeroes its backing array on dispose. The codec applies three-layer zeroing (write reveal, read scratch, PostWrite). Never appears on the command line.
0x020ASetPackageFeatureSelectionUI → EnginePackageId, SelectedFeatureIds (string[])Per-package feature selection: the full set of feature ids the user chose for one MSI package; the engine turns this into an ADDLOCAL property for that package's install action. Distinct from SetFeatureSelection (0x0204), which toggles a single whole-package feature
0x0301ElevateExecuteEngine → ElevatedCommandName, CommandPayload (byte[])Execute an elevated command
0x0302SessionStartEngine → ElevatedCorrelationId (Guid), StartedUtc (DateTimeOffset)Sent once right after the HMAC handshake completes; carries a correlation id so elevated-process logs can be matched back to engine/UI logs
0x0401ElevateResultElevated → EngineSuccess, ErrorMessage?, ResultPayload?Result of an elevated command execution
0x0402ElevateProgressElevated → EnginePercentProgress update sent while the elevated companion executes a privileged operation

Codec Architecture

The original LegacyMessageSerializer was a paired switch — one write-side method and one read-side method, each dispatching on every concrete message type by hand. Adding a field on one side without the other produced silent data corruption rather than a compile error, and there was no way to evolve the wire format without a flag day. The protocol layer was refactored in 2026 into a codec registry that fixes both problems.

Each message type now has a dedicated MessageCodec<T> in src/FalkForge.Engine.Protocol/Serialization/Codecs/. The codec owns the entire payload layout, including the leading SequenceId:u32. MessageSerializer writes the wire header, then hands a BinaryWriter to the codec; MessageDeserializer reads the header, resolves the codec via MessageCodecRegistry.ForRead(type, wireVersion), then hands a BinaryReader back.

public interface IMessageCodec
{
    MessageType Type        { get; }
    ushort      WireVersion { get; }
    Type        MessageClrType { get; }

    EngineMessage ReadErased(BinaryReader reader);
    Action<EngineMessage>? PostWrite { get; }
}

public sealed record MessageCodec<T> : IMessageCodec where T : EngineMessage
{
    public required MessageType                   Type;
    public required ushort                        WireVersion;
    public required ImmutableArray<FieldDescriptor> Fields;
    public required Action<BinaryWriter, T>       Write;
    public required Func<BinaryReader, T>         Read;
    public Action<T>?                             PostWrite;
}

public static class MessageCodecRegistry
{
    public static void Register(IMessageCodec codec);
    public static IMessageCodec ForRead(MessageType type, ushort wireVersion);
}

Version Negotiation

Each codec is keyed by (MessageType, WireVersion). ForRead tries an exact match first, then falls back to the nearest registered version below the requested version. Multiple versions of the same MessageType can be registered side-by-side, so an old peer running WireVersion = 1 and a new peer running WireVersion = 2 can coexist without a flag day.

Example: LogCodec and PhaseChangedCodec were promoted to WireVersion = 2 to append a 16-byte SessionCorrelationId (a Guid) to the payload. The V1 codec remains registered, so older peers continue to read those frames; the V2 codec adds the trailing GUID for newer peers.

Golden-Byte Tests

Every codec ships a pair of tests: a round-trip test (write then read, assert equality on all fields) and a golden-byte test (assert that the serialized wire bytes equal a hard-coded hex string). The golden-byte test is computed once and locked in source. If a future change accidentally reorders a field or swaps a width, the golden-byte test fails immediately rather than silently corrupting deployed installers. These tests replaced the old LegacyMessageSerializer parity suite when the codec migration completed.

Secure Property Lifecycle

SetSecurePropertyMessage is IDisposable; its SecureValue is a SensitiveBytes struct that wraps a byte[] and zeroes it on dispose. SetSecurePropertyCodec applies three layers of zeroing:

  1. Write-side reveal buffer. The codec borrows the plaintext as a scoped ReadOnlySpan<byte> via SensitiveBytes.Borrow(), writes it to the BinaryWriter, and the span goes out of scope at end of Write.
  2. Read-side scratch buffer. The codec rents from ArrayPool<byte>.Shared, reads into the rented buffer, copies into a fresh SensitiveBytes via SensitiveBytes.FromPlaintext, then zeroes the scratch with CryptographicOperations.ZeroMemory and returns it with clearArray: true.
  3. PostWrite hook. SetSecurePropertyCodec.PostWrite calls msg.Dispose(), which zeroes the message's SensitiveBytes immediately after framing completes. One-shot messages cannot leak even if the caller forgets a using block.

SessionCorrelationId Flow

EngineSession.BindToPipe generates a fresh Guid and stamps it on both the logger and the channel. NamedPipeUiChannel.TranslateEvent embeds the GUID in every outbound LogMessage (V2) and PhaseChangedMessage (V2) frame, so engine, UI, and elevated log files can be correlated after the fact without relying on timestamps.

Transport

Communication uses Windows Named Pipes with asynchronous I/O and HMAC-SHA256 mutual authentication.

PipeConnectionOptions

public sealed class PipeConnectionOptions
{
    public required string PipeName { get; init; }
    public required byte[] SharedSecret { get; init; }
    public int MaxMessageSize { get; init; } = 1_048_576; // 1 MB
    public TimeSpan ConnectionTimeout { get; init; } = TimeSpan.FromSeconds(30);
}

Handshake Protocol (mutual authentication)

  1. Server generates a 32-byte cryptographic nonce using RandomNumberGenerator and sends it to the client
  2. Client generates its own 32-byte nonce and sends clientNonce || tag_c, where tag_c = HMAC-SHA256(secret, LABEL_C2S || serverNonce || clientNonce)
  3. Server validates tag_c using constant-time comparison (CryptographicOperations.FixedTimeEquals), then replies with tag_s = HMAC-SHA256(secret, LABEL_S2C || serverNonce || clientNonce)
  4. Client validates tag_s before processing any message — a server that cannot prove knowledge of the secret is refused. Distinct labels (LABEL_C2SLABEL_S2C) prevent reflection of one party's proof as the other's

PipeSecurityValidator

src/FalkForge.Engine.Protocol/Transport/PipeSecurityValidator.cs

public static class PipeSecurityValidator
{
    public const int NonceSize = 32;
    public const int HmacSize = 32;
    public static ReadOnlySpan<byte> ClientProofLabel { get; }
    public static ReadOnlySpan<byte> ServerProofLabel { get; }
    public static byte[] GenerateNonce();
    public static byte[] ComputeProof(byte[] sharedSecret, ReadOnlySpan<byte> label,
        ReadOnlySpan<byte> serverNonce, ReadOnlySpan<byte> clientNonce);
    public static bool ValidateProof(byte[] sharedSecret, ReadOnlySpan<byte> label,
        ReadOnlySpan<byte> serverNonce, ReadOnlySpan<byte> clientNonce,
        ReadOnlySpan<byte> receivedProof);
}
Both PipeServer and PipeClient use PipeOptions.CurrentUserOnly to restrict pipe access to the current user, preventing cross-user attacks.

Manifest Types

InstallerManifest

src/FalkForge.Engine.Protocol/Manifest/InstallerManifest.cs

PropertyTypeDescription
NamestringBundle display name
ManufacturerstringPublisher name
VersionstringBundle version
BundleIdGuidUnique bundle identifier
UpgradeCodeGuidUpgrade code for related bundle detection
PackagesPackageInfo[]Package definitions
RelatedBundlesRelatedBundleEntry[]Related bundle entries for upgrade detection
ChainManifestChainItem[]Ordered chain of packages and rollback boundaries
VariablesManifestVariable[]Bundle variables with defaults
FeaturesManifestFeature[]Feature definitions with package mappings
DependencyProvidersManifestDependencyProvider[]Dependency provider registrations
DependencyConsumersManifestDependencyConsumer[]Dependency consumer declarations
LicenseFilestring?Path to license file
UpdateFeedManifestUpdateFeed?Update feed configuration
ScopeInstallScopePerMachine or PerUser

PackageInfo

PropertyTypeDescription
IdstringUnique package identifier
TypePackageTypeMsiPackage, ExePackage, MsuPackage, MspPackage, BundlePackage, NetRuntime
DisplayNamestringHuman-readable package name
Versionstring?Package version
VitalboolIf true, failure stops the installation (default: true)
SourcePathstringPath to the package file
Sha256HashstringSHA-256 hash for integrity verification
PropertiesDictionary<string, string>Package-specific properties (e.g., ProductCode)
InstallConditionstring?Condition expression for conditional installation
ExitCodesIReadOnlyDictionary<int, ExitCodeBehavior>?Custom exit code mappings
DownloadUrlstring?Remote URL for download
ContainerIdstring?Container that holds the payload

PackageType Enum

ValueDescription
MsiPackageWindows Installer MSI package
ExePackageExecutable package
NetRuntime.NET runtime prerequisite
MsuPackageWindows Update standalone package
MspPackageWindows Installer patch
BundlePackageNested bundle EXE

14. MSI Dialog Templates

FalkForge provides five built-in MSI dialog templates that generate the standard Windows Installer UI dialogs. Each template implements IDialogTemplate and produces a set of MsiDialogModel objects that are produced to the MSI database by DialogSetProducer via the recipe pipeline.

internal interface IDialogTemplate
{
    IReadOnlyList<MsiDialogModel> GetDialogs(PackageModel package);
}

// Usage in the fluent API:
package.UseDialogSet(MsiDialogSet.InstallDir);

Template Comparison

Template Dialogs License Install Dir Feature Tree Setup Type Install Scope Use Case
Minimal 3 No No No No No Quick one-click install
InstallDir 5 Yes Yes No No No Standard app with custom install path
FeatureTree 5 Yes No Yes No No Multi-feature product selection
Mondo 7 Yes Yes Yes Yes No Full-featured with Typical/Custom/Complete options
Advanced 8 Yes Yes Yes Yes Yes Enterprise installer with per-user/per-machine choice

Minimal Template

src/FalkForge.Compiler.Msi/UI/Templates/MinimalDialogTemplate.cs

The simplest template with just three dialogs for a one-click installation experience.

Dialog flow: WelcomeDlgProgressDlgExitDlg

InstallDir Template

src/FalkForge.Compiler.Msi/UI/Templates/InstallDirDialogTemplate.cs

Adds a license agreement and destination folder chooser to the flow.

Dialog flow: WelcomeDlgLicenseAgreementDlgInstallDirDlgProgressDlgExitDlg

FeatureTree Template

src/FalkForge.Compiler.Msi/UI/Templates/FeatureTreeDialogTemplate.cs

Includes a feature selection tree (SelectionTree control) with disk cost information.

Dialog flow: WelcomeDlgLicenseAgreementDlgCustomizeDlgProgressDlgExitDlg

Mondo Template

src/FalkForge.Compiler.Msi/UI/Templates/MondoDialogTemplate.cs

Full dialog set with setup type selection (Typical/Custom/Complete) and both feature tree and install directory dialogs.

Dialog flow: WelcomeDlgLicenseAgreementDlgSetupTypeDlg (Typical → Progress | Custom → CustomizeDlg | Complete → Progress) → InstallDirDlgProgressDlgExitDlg

Advanced Template

src/FalkForge.Compiler.Msi/UI/Templates/AdvancedDialogTemplate.cs

The most comprehensive template, adding an install scope dialog for choosing between per-machine (all users, requires admin) and per-user installation. Sets the ALLUSERS property accordingly.

Dialog flow: WelcomeDlgInstallScopeDlgLicenseAgreementDlgSetupTypeDlgCustomizeDlgInstallDirDlgProgressDlgExitDlg

All templates include a CancelDlg (spawned by the Cancel button) and use conditional enabling of the Next button on the license dialog (enabled only when LicenseAccepted = "1").

MsiRMFilesInUse Dialog (Restart Manager)

src/FalkForge.Compiler.Msi/UI/Layout/Builders/MsiRMFilesInUseDlgBuilder.cs

When EnableRestartManagerSupport() is set (see §8.2), DialogSetProducer appends one more dialog to whichever stock dialog set is active — MsiRMFilesInUse, 370×270 DLU, matching WiX's own MsiRMFilesInUse.wxs geometry. Unlike every other dialog in this section, it is not part of the wizard flow: Windows Installer creates it directly at InstallValidate when Restart Manager reports files in use at full UI, so it is reachable from no other dialog and carries KeepModeless (0x10) on top of the standard attributes (0x27), giving 0x37 — it must not tear down whatever modeless dialog is already on screen underneath it. It is skipped entirely when DialogSet is None, and appended for every other dialog set.

Choosing "close and restart the applications" does not guarantee every closed application comes back — only apps that registered for restart come back automatically. Restart Manager's RmRestart only relaunches processes that called Win32 RegisterApplicationRestart before Setup closed them; an unregistered application is simply closed and stays closed.
ControlTypeBound property / textControl_Next
TitleTextDialog.RestartManager.Title— (non-focusable)
DescriptionTextDialog.RestartManager.Description— (non-focusable)
TextTextDialog.RestartManager.Text— (non-focusable)
ListListBoxthe built-in FileInUseProcess property, populated by the Windows Installer engine at runtimeShutdownOption
ShutdownOptionRadioButtonGroupFalkForgeRMOptionOK
BottomLineLine— (the standard footer separator shared by every full-canvas wizard dialog; see DialogFooter.BottomLine())— (non-focusable)
OKPushButtonCancel
CancelPushButtonList

The tab ring is List → ShutdownOption → OK → Cancel → List, with Control_First = OK (the seeded default action). This is a deliberate deviation from WiX's documented behaviour, which marks the process list TabSkip="yes" to skip past it: FalkForge keeps List in the cycle on purpose, because the in-use process names are exactly the information a user needs to decide whether to let Setup close those applications. There is also no TabSkip-equivalent knob anywhere in FalkForge — the Windows Installer Control Attributes table has no such bit, and the type-based focusable/ non-focusable split above is the only lever.

ShutdownOption's two RadioButton table rows, both bound to FalkForgeRMOption:

OrderValueText
1UseRMDialog.RestartManager.CloseApps
2DontUseRMDialog.RestartManager.DontCloseApps

PropertyTableProducer seeds FalkForgeRMOption = "UseRM" as the Property table default — ICE34 requires a RadioButtonGroup's bound property to already equal one of its RadioButton values, or the group is not TAB-selectable and validation fails.

The three ControlEvent rows, in Ordering order per control:

ControlEventArgumentConditionOrdering
OKRMShutdownAndRestart0FalkForgeRMOption~="UseRM"1
OKEndDialogReturn12
CancelEndDialogExit11
This ordering is inverted relative to WiX, which publishes EndDialog before RMShutdownAndRestart. Both orders work — see ADR 0006, Decision 3: this is a readability choice (the row order reads in the causal order the two events actually happen), not a correctness fix, and there is no established functional difference from WiX's order.

Five localization keys back the dialog's text, seeded as defaults in both en-US.json and sv-SE.json (and as an internal fallback, so a package supplying its own LocalizationData without these keys still builds): Dialog.RestartManager.Title, Dialog.RestartManager.Description, Dialog.RestartManager.Text, Dialog.RestartManager.CloseApps, Dialog.RestartManager.DontCloseApps.

Building a package with Restart Manager enabled produces one expected, benign ICE17 warning: the List control's ListBox table has no rows for its FileInUseProcess property. That is correct — ICE17 itself documents this exact case as ignorable (the list is populated by the Windows Installer engine at runtime, not authored), and forge validate will surface the warning with no further explanation, so it is expected and does not indicate a defect.

Authoring a Custom Dialog (AddCustomDialog)

How MSI dialogs work

Windows Installer's UI is not a single window with pages — it's a chain of separate modal dialogs. Each dialog is entirely described by four database tables the compiler writes for you; there is no XAML, no HTML, no retained window object. If you've never touched WiX or raw MSI authoring, that's the whole mental model:

TableOne row per…FalkForge model
Dialoga dialog window (size, title, focus/default/cancel control)CustomDialogModel
Controlone control placed on a dialog (type, position, bound property, tab-order pointer)CustomDialogControlModel
ControlEventone action a control fires (usually on click), gated by a conditionCustomDialogControlEventModel
ControlConditionone show/hide/enable/disable rule that re-evaluates as properties changeCustomDialogControlConditionModel

Navigation between dialogs is itself just ControlEvent rows — there's no separate “wizard” concept. A button's NewDialog event closes the current dialog and opens another in its place (a normal Next/Back step); SpawnDialog opens a second dialog on top of the current one, which stays open underneath until the child closes (used for confirmation prompts like “Are you sure you want to cancel?”); EndDialog closes the dialog chain entirely with an outcome (Return to proceed with the install; Exit/Retry/Ignore for other exits).

Coordinates are dialog units (DLU), not pixels. X/Y/ Width/Height on both the dialog and its controls are DLU: a font-relative unit Windows Installer converts to pixels at render time using the dialog's font metrics, so the same authoring renders consistently across DPI and font-size settings. The MSI standard dialog is 370×270 DLU; lay controls out inside that.

Property binding is how a control exchanges data with the rest of the package: a data-bound control's Property column names an MSI property, and the control both initializes from and writes back to that property (a check box toggles it between 1 and empty; an edit field mirrors its text). Anything else in the package — a launch condition, another control's ControlCondition, a custom action's arguments — can then read that same property.

Tab order is a linked list, not a Z-order or reading-order convention: the dialog's Control_First names the control that gets initial focus, and each control's Control_Next points at the next control Tab should move to. Chase every Control_Next pointer and you get the full tab cycle; a control left out of the chain is simply unreachable by keyboard. FalkForge builds this chain automatically for every composed dialog (stock templates, the Restart Manager MsiRMFilesInUse modal, and author-defined custom dialogs alike): each dialog's focusable controls — everything except decorative/static types (Text, Line, Bitmap, Icon, ProgressBar, GroupBox, VolumeCostList) — are linked into one closed ring in geometric order: top to bottom, then left to right within a row. A dialog with at most one focusable control gets no Control_Next at all (nothing to tab to); see Next(controlName) below for how an author opts a dialog out of this automatic wiring.

When a dialog shows depends on whether it's reachable at all: a dialog only appears as the first screen of the install UI if it's placed in the InstallUISequence table at a sequence number — that's what .Sequence(n) does. Every other dialog is only reached by another dialog's NewDialog/SpawnDialog event. Custom dialogs and a stock MsiDialogSet are not mutually exclusive: you can add one extra custom dialog reachable from a stock flow, or set DialogSet to None and author every screen yourself, as demo 65-custom-dialog does.

Building a dialog

To author a complete dialog from scratch — your own controls, events, conditions, and tab order — call PackageBuilder.AddCustomDialog(id, dlg => …). This needs no extension and no reference to FalkForge.Compiler.Msi: the fluent builders live in FalkForge.Core and produce a public CustomDialogModel that the compiler translates into MSI Dialog/Control/ControlEvent/ControlCondition rows.

package.AddCustomDialog("LicenseKeyDlg", dlg => dlg
    .Title("Enter your license key")
    .Sequence(1100)                         // show as the first install-UI screen
    .FirstControl("KeyEdit")
    .Text("Prompt", 20, 20, 330, 20, "Please enter your license key:")
    .Edit("KeyEdit", 20, 50, 330, 18, property: "LICENSEKEY", b => b.Next("Next"))
    .PushButton("Next", 280, 240, 66, 17, "Next", b => b
        .EndDialog("Return")                // ControlEvent: proceed with the install
        .DisableWhen("LICENSEKEY = \"\"")));// ControlCondition: gate until a key is entered

Custom dialogs are emitted in addition to any active stock dialog set (and even when DialogSet is None). The full authoring surface — every control adder, event verb, and condition verb — is tabulated in What's possible below. See demo 65-custom-dialog for this exact example, walked step by step in the Custom MSI Dialogs tutorial.

What's possible

Dialog-level configuration, on CustomDialogBuilder:

MethodMaps toNotes
Title(title)Dialog.TitleWindow title; omit for an untitled dialog.
Size(width, height)Dialog.Width/HeightDialog units; defaults to the MSI standard 370×270.
Centering(h, v)Dialog.HCentering/VCentering0–100 percent; defaults to 50/50 (screen-centered).
Attributes(int)Dialog.AttributesRaw bitmask escape hatch; default 39 = Visible | Modal | Minimize | TrackDiskSpace.
FirstControl(name)Dialog.Control_FirstInitial focus; unset falls back to the first authored control.
DefaultControl(name)Dialog.Control_DefaultControl activated by Enter.
CancelControl(name)Dialog.Control_CancelControl activated by Esc.
Sequence(n)InstallUISequence row (Action = dialog Id)Makes the dialog an install-UI entry point; standard first-dialog slot is 1100.

Control adders, on CustomDialogBuilder (each takes a trailing Action<CustomDialogControlBuilder>? configure for tab order, events, conditions, and appearance):

AdderControl typeData-boundNotes
Text(name, x, y, w, h, text)TextNoStatic label.
PushButton(name, x, y, w, h, text)PushButtonNoWire navigation/actions with control events.
Line(name, x, y, width)LineNoHorizontal etched separator; height fixed at 0.
CheckBox(name, x, y, w, h, property, text)CheckBoxYesToggles the bound property between 1 and empty.
Edit(name, x, y, w, h, property)EditYesSingle-line text field.
PathEdit(name, x, y, w, h, property)PathEditYesEditable path field bound to a directory property.
ScrollableText(name, x, y, w, h, text)ScrollableTextNoScrollable read-only multi-line area (license bodies).
Bitmap(name, x, y, w, h, binaryKey)BitmapNo*Text names an embedded Binary stream, not a property.
Icon(name, x, y, w, h, binaryKey)IconNo*Text names an embedded Binary stream, not a property.
GroupBox(name, x, y, w, h, text)GroupBoxNoLabelled frame around related controls.
Control(type, name, x, y, w, h)any CustomControlTypedependsEscape hatch for types with no dedicated adder — RadioButtonGroup, ComboBox, ListBox, SelectionTree, DirectoryCombo, DirectoryList, MaskedEdit, VolumeCostList, ProgressBar. Several need a companion table (for example RadioButtonGroup needs a RadioButton table) authored separately — only the Control row is emitted here.
FalkForge's recipe pipeline now emits a RadioButton table (see §14's MsiRMFilesInUse dialog below for a worked example), so a RadioButtonGroup control is ICE34-clean when its rows come from the internal declarative dialog descriptor used by the stock templates. AddCustomDialog does not populate that table from a CustomDialogBuilder-authored RadioButtonGroup yet — the note above (“needs a companion table…authored separately”) still applies to that path.

Appearance/state modifiers, on the CustomDialogControlBuilder a control adder's configure callback receives (default attributes are Visible | Enabled):

MethodControl.Attributes bitEffect
Hidden()clears Visible (0x0001)Control starts invisible; typically paired with a ShowWhen condition.
Disabled()clears Enabled (0x0002)Control starts greyed out; typically paired with an EnableWhen condition.
Sunken()sets 0x0004Sunken 3-D border (used for the license body in demo 65).
Transparent()sets 0x00010000Transparent background, for text/bitmap drawn over another control.
NoPrefix()sets 0x00020000Disables & accelerator-prefix processing in the control text.
RightAligned()sets 0x0040Right-aligns the control text.
Attributes(int)replaces the whole bitmaskEscape hatch for any other Control table bit.

The same configure callback also has Text(text) (replaces the control text), Property(property) ((re)binds the MSI property), and Next(controlName) (sets Control_Next for tab order). Supplying Next(...) on any control of a custom dialog opts the whole dialog out of FalkForge's automatic tab-cycle wiring (see "Tab order" above) — the dialog is then authored exactly as the calls describe, with no auto-completion of the rest of the chain. This is deliberate: silently completing a partially-authored chain is how a broken half-cycle gets manufactured, so authoring even one link makes the whole dialog the author's responsibility.

Event verbs, chained off the same configure callback:

MethodMSI ControlEventArgumentWhen to use
NavigateTo(dialogId, condition?)NewDialogtarget dialog IdReplace the current dialog in place — a normal Next/Back step.
SpawnDialog(dialogId, condition?)SpawnDialogtarget dialog IdOpen a modal child on top of the current dialog (confirmation prompts).
EndDialog(exitCode = "Return", condition?)EndDialogReturn / Exit / Retry / IgnoreClose the dialog chain with an outcome — Return proceeds with the install.
DoAction(actionName, condition?)DoActioncustom action nameRun a custom action from a button click.
SetProperty(property, value, condition?)[PropertyName]value to assignAssign an MSI property directly, no custom action needed.
Reset(condition?)Reset0Reset every control on the dialog to its default state.
PublishEvent(event, argument, condition?, ordering = 1)any verbanyEscape hatch for any other ControlEvent row (for example AddLocal, Remove, SetTargetPath); ordering breaks ties when a control fires the same verb more than once.

An omitted condition compiles to the canonical always-true condition "1".

Condition verbs, chained off the same configure callback:

MethodControlCondition ActionEffect when the condition is true
ShowWhen(condition)ShowShows the control.
HideWhen(condition)HideHides the control.
EnableWhen(condition)EnableEnables the control.
DisableWhen(condition)DisableDisables (greys out) the control.
DefaultWhen(condition)DefaultRestores the control to its default/initial state.
When(action, condition)any CustomConditionActionEscape hatch taking the enum directly.

Conditions re-evaluate live as properties change — for example demo 65 wires DisableWhen("ACCEPTEULA <> \"1\"") on the Install button so it re-enables the moment the license check box is ticked, with no custom action involved.

Validation

Invalid authoring fails loud at validation time, before the compiler ever touches the MSI database:

CodeSeverityFires when
DLG010ErrorA custom dialog's Id is empty or whitespace.
DLG011ErrorA custom dialog's Id is not a valid MSI identifier (must start with a letter or underscore; only letters, digits, underscores, and periods after that).
DLG012ErrorTwo custom dialogs in the package share the same Id.
DLG013ErrorA custom dialog has zero controls — it would have no Control_First and could never be shown.
DLG014ErrorA control on a custom dialog has an empty or whitespace Name.
DLG015ErrorA control Name is not a valid MSI identifier.
DLG016ErrorTwo controls on the same dialog share a Name.
DLG017ErrorA control's Next(...) tab-order target does not name a control on the same dialog.
DLG018ErrorA data-bound control type (Edit, CheckBox, PathEdit, MaskedEdit, RadioButtonGroup, ComboBox, ListBox, DirectoryCombo, DirectoryList, SelectionTree) has no bound property.
DLG019ErrorA dialog's FirstControl, DefaultControl, or CancelControl names a control that isn't on the dialog.
DLG020ErrorA control event has an empty event verb.
DLG021ErrorA NewDialog/SpawnDialog/DoAction/EndDialog event has an empty argument.
DLG022ErrorA control condition has an empty condition expression.

These are a distinct rule family from DLG001–DLG003 below, which validate DialogCustomization step insertion rather than authored custom dialogs.

Dialog Customization API

Beyond picking a template, callers can insert reusable, extension-contributed dialog steps into a stock flow without copying a whole template. An extension-driven dialog-injection API extensions register named dialog builders, and the package author requests insertions by name in the fluent API. The compiler resolves each insertion against the registry at build time. If an insertion references a name that was not registered, the build fails with diagnostic DLG001.

// FalkForge.Extensibility/IDialogStepBuilder.cs — public contract
public interface IDialogStepBuilder
{
    string Name { get; }
}

Extensions that ship dialog steps register an IDialogStepBuilder with the extension context. Extensions that need to emit a full MsiDialogModel implement the compiler-side extension IMsiDialogStepBuilder (internal to FalkForge.Compiler.Msi) which adds a Build(DialogBuildContext) method. Plain IDialogStepBuilder registrations (name only) are accepted — they satisfy DLG001 name-only references but cannot produce dialog rows themselves; they exist so extension authors can reserve a name without coupling to compiler internals.

Composition surface in the fluent API:

package.UseDialogSet(MsiDialogSet.InstallDir, dialogs => dialogs
    .InsertStep("MyPostLicenseStep", after: StockDialog.License));

When the inserted step resolves to an IMsiDialogStepBuilder, the compiler calls its Build and emits the resulting dialog into the MSI UI tables; a name-only IDialogStepBuilder only reserves its name for DLG001 and emits nothing.

Internals (not part of the public API but documented here for diagnostic clarity): DialogStepRegistry is the frozen registry of named builders; DialogBuildContext threads the DialogCustomizationModel + registry through dialog composition; InsertedDialogStep is the model row produced by each InsertDialogStep call.

Validation

CodeSeverityDescription
DLG001ErrorAn InsertedDialogStep references a step name that is not registered in the DialogStepRegistry. Fix: register the builder via the owning extension before compiling.
DLG002ErrorDialogCustomizationModel.SuppressedDialogs is non-empty. DialogCustomization.SuppressDialog is not implemented — nothing downstream consumes the set, so every stock dialog is composed and emitted regardless (task #44 tracks the real implementation). The builder method is separately marked [Obsolete(error: true)], but the model's SuppressedDialogs is a public init property, so this rule is what actually blocks a populated set, including one set via an object initializer that never calls the method. Fix: remove all entries from SuppressedDialogs; the feature does not exist yet.
DLG003ErrorDialogCustomization.BannerBitmap/DialogBitmap/HeaderIcon names a Binary stream key that is not registered in PackageModel.Binaries (via PackageBuilder.Binary(name, sourcePath)). Fix: register the key with PackageBuilder.Binary() before compiling, or fix the typo.
DLG005WarningA culture in PackageModel.LocalizationData produced no localizable difference from the base culture during MST language transform generation (see MST Language Transforms), so no .mst was emitted for it. Fix: add !(loc.*) text to a dialog set or custom dialog for that culture, or drop the extra culture.

BannerBitmap/DialogBitmap/HeaderIcon synthesize real controls on the stock dialog templates (banner/background bitmaps, header icon) — they are not reserved or inert. See docs/dialog-template-architecture.md (“Customizing a Dialog Set”) for the full verb reference and exact placement rules.

15. CLI Tool

The forge CLI (src/FalkForge.Cli/) is built with Spectre.Console and provides commands for building, validating, inspecting, decompiling, and extracting installers, plus WinGet manifest generation and validation-rule discovery. It accepts both C# script files (.cs / .csx) and JSON configuration files as input.

Fifteen leaf commands are registered (some grouped under a rules or bundle branch): init, build, validate, inspect, decompile, extract, winget, rules list, rules explain, bundle detach, bundle reattach, migrate, plan, plan-diff, verify. All commands that emit results accept a --json flag which switches output to a CI-friendly JSON envelope.

Commands

init

Scaffolds a starter installer project: a .csproj referencing the single FalkForge meta-package at the CLI's own version, a minimal working fluent Program.cs (MSI or EXE bundle, including a Start Menu shortcut for the MSI case), and a payload/ folder — so forge init && dotnet run produces a real installer. Existing files are never overwritten without --force, and a refused init writes nothing.

forge init [options]

# Options:
#   -o, --output <DIR>        Directory to scaffold into (created if missing; default: current directory)
#   --type <msi|bundle>       Installer type to scaffold (default: msi)
#   --name <NAME>             Product name (default: the output directory name)
#   --from-publish <DIR>      Prefill payload/ with the contents of a published application folder
#   --force                   Overwrite files that already exist in the output directory

# Examples:
forge init --name "My App"
forge init --type bundle --name "My App"
forge init --from-publish bin/Release/net10.0/publish -o installer/

--from-publish copies a published build's output into payload/ instead of writing the placeholder sample file — a footgun guard refuses the copy if the output directory sits inside (or equals) the publish directory, since that would recurse the copy into itself.

Prefer a scaffolded, standalone project? dotnet new install FalkForge.Templates then dotnet new falkforge-msi -n MyInstaller --ProductName "My App" (or falkforge-bundle) does the same thing through the standard dotnet new template engine instead of the forge tool — useful when you don't want to install a global tool first. Both routes produce the same shape: one project referencing the FalkForge meta-package, ready for dotnet run.

build

Compiles an installer definition into an MSI, MSM, MSP, MST, MSIX, or bundle EXE.

forge build <project> [options]

# Arguments:
#   <project>                Path to .cs / .csx / .json installer definition

# Options:
#   -o, --output             Output directory path
#   -c, --configuration      Build configuration (default: Release)
#   --format                 Target format: msi | msix | bundle | msm | msp | mst
#   --verbose                Enable verbose output
#   --json                   Emit a JSON envelope to stdout (CI/automation)
#   --dry-run                Validate + plan, but do not write any output artifacts
#   --reproducible           Reproducible build (uses SOURCE_DATE_EPOCH or git HEAD)
#   --sbom                   Emit a CycloneDX SBOM sidecar
#   --no-sign                Skip the ECDSA integrity signature (and any Sigil SBOM attestation)
#   --no-engine              Bundle only: embed a design-time placeholder engine stub instead of
#                            the published NativeAOT engine (manifest still verifies; the result is
#                            NOT a runnable installer — for signing/verification tooling and CI
#                            without an engine publish)
#   --winget                 Emit a WinGet singleton manifest alongside the MSI
#   --winget-url <URL>       InstallerUrl to embed in the manifest
#   --ice                    Run ICE validation (default: on for .msi)
#   --no-ice                 Disable ICE validation
#   --ice-cub-path <PATH>    Override the default darice.cub
#   --ice-skip-when-cub-unavailable
#                            Silently skip ICE validation when darice.cub is not found (lenient
#                            mode). By default a missing darice.cub is a build failure — see
#                            the note below.
#   --suppress-ice <NAMES>   Comma-separated list of ICE names to suppress
#   --ice-warnings-as-errors Treat ICE warnings as errors
#   --ice-report <PATH>      Export ICE results to JSON

# Examples:
forge build installer.cs
forge build installer.cs -o ./output
forge build installer.cs --dry-run --json
forge build installer.cs --reproducible --sbom
forge build installer.cs --winget --winget-url https://example.com/app.msi

--no-sign skips FalkForge's own ECDSA integrity signature (and any Sigil-based SBOM attestation on top of it) — see Sigil: Optional SBOM Attestation & MSI Integrity Signing (23) for what Sigil is and when (if ever) you need it.

validate

Runs validators on an installer definition (or an already-built MSI) without producing output. Supports rule suppression and severity escalation for CI gating.

forge validate <project> [options]

# Arguments:
#   <project>                Path to .cs / .json / .msi

# Options:
#   --verbose                Enable verbose output
#   --json                   Emit a JSON envelope to stdout
#   --ignore <RULEID>        Suppress a rule (repeatable; also accepts comma-separated)
#   --warn-as-error          Promote all warnings to errors
#   --stop-on-first-error    Halt after the first error
#   --ice                    Run ICE on .msi input
#   --ice-cub-path <PATH>    Override the default darice.cub
#   --ice-skip-when-cub-unavailable
#                            Silently skip ICE validation when darice.cub is not found (lenient
#                            mode). By default, a missing darice.cub with --ice returns an error.
#   --suppress-ice <NAMES>   Comma-separated list of ICE names to suppress
#   --ice-warnings-as-errors Treat ICE warnings as errors
#   --ice-report <PATH>      Export ICE results to JSON

# Examples:
forge validate installer.cs
forge validate installer.cs --ignore PKG003 --ignore SVC001
forge validate installer.cs --warn-as-error --stop-on-first-error
forge validate package.msi --ice --ice-warnings-as-errors

inspect

Displays MSI metadata including summary info, feature tree, and table list. Windows-only (requires msi.dll).

forge inspect <file.msi> [options]

# Arguments:
#   <file.msi>    Path to MSI file

# Options:
#   --verbose     Show detailed table list
#   --json        Emit a JSON envelope to stdout
#   --sbom        Extract the SBOM from the MSI integrity table (if present). Its format follows
#                 the Integrity(i => i.Sbom(...)) setting the package was built with — CycloneDX 1.6
#                 by default, SPDX 2.3 on request. The Format column names which.

# Examples:
forge inspect package.msi
forge inspect package.msi --verbose
forge inspect package.msi --json
forge inspect package.msi --sbom

When the MSI carries an Integrity() signature (embedded table row or detached sidecar), inspect also prints its presence and format tag (e.g. falkforge-ecdsa-envelope-v2). Classical (ECDSA-P256) fingerprints are printed as Signing Key Fingerprint — the values forge verify --trusted-key matches against. Any hybrid post-quantum (ML-DSA) companion fingerprints are printed separately as PQ Companion Fingerprint (not usable with --trusted-key), since pasting one into --trusted-key produces a baffling INT001 — the two are never interchangeable. All of this is display only, not cryptographic verification; use forge verify <msi> to actually check the signature.

decompile

Decompiles an MSI or bundle EXE into C# source code.

forge decompile <file> [options]

# Arguments:
#   <file>        Path to .msi or .exe file

# Options:
#   -o, --output  Output file path for generated C# source (stdout if omitted)

# Examples:
forge decompile package.msi
forge decompile package.msi -o installer.cs
forge decompile bundle.exe -o installer.cs

extract

Extracts files from an MSI / MSM or packaged payloads from an EXE bundle. For bundles, --list enumerates payload package IDs without writing files, and --package can be repeated to extract specific packages only.

forge extract <file> [options]

# Arguments:
#   <file>        Path to .msi, .msm, or .exe bundle

# Options:
#   -o, --output  Output directory (required unless --list)
#   --list        List bundle packages without extracting (bundles only)
#   --package     Specific PackageId(s) to extract (repeatable; bundles only)

# Examples:
forge extract package.msi -o ./files
forge extract bundle.exe --list
forge extract bundle.exe --package WebApp --package SqlServer -o ./payloads

winget

Generates a WinGet singleton manifest (3-file YAML set) from an existing MSI. SHA-256 of the installer payload is computed during emission so the manifest is publishable as-is.

forge winget <file.msi> --id <PackageId> --license <SPDX> --desc <text> [options]

# Arguments:
#   <file.msi>    Path to MSI file

# Options (required):
#   --id          PackageIdentifier in "Publisher.PackageName" format
#   --license     SPDX license identifier (e.g. MIT, Apache-2.0)
#   --desc        Short description (single line)

# Options:
#   --url         InstallerUrl for the manifest
#   -o, --output  Output directory (default: current)

# Examples:
forge winget package.msi --id Acme.WebApp --license MIT --desc "Sample web app"
forge winget package.msi --id Acme.WebApp --license MIT --desc "Sample" \
                         --url https://acme.example/app.msi -o ./manifest

rules list

Enumerates validation rules. Useful for CI configuration and rule discovery.

forge rules list [options]

# Options:
#   --target      Target model: package | merge | patch | transform (default: package)
#   --section     Section filter (e.g. Service, Registry, CustomTable)
#   --severity    Severity filter (error | warning | info)
#   --prefix      Rule ID prefix filter (e.g. PKG, SVC, REG)
#   --json        Emit a JSON array instead of human-readable output

# Examples:
forge rules list
forge rules list --section Service
forge rules list --prefix PKG --severity error
forge rules list --target merge --json

rules explain

Prints the full metadata for a single validation rule (description, cause, fix, examples).

forge rules explain <ruleId>

# Examples:
forge rules explain PKG001
forge rules explain SVC003

loc export

Exports FalkForge's built-in MSI dialog localization JSON (en-US, sv-SE) to disk so an override file can be started from a real baseline instead of being written from scratch — see Overriding Built-In Strings (16). The written bytes are the embedded resource verbatim, byte-faithful. Cross-platform (reads embedded resources only, no msi.dll required).

forge loc export [options]

# Options:
#   --culture <name>   Culture to export (e.g. en-US). Case-insensitive. Omit to export every
#                       built-in culture.
#   -o, --output <path> Output directory (created if missing), or an exact .json file path when a
#                       single culture is exported via --culture. Defaults to the current directory.
#   --list              List available built-in cultures and exit

# Examples:
forge loc export --list
forge loc export                                    # every built-in culture -> ./en-US.json, ./sv-SE.json
forge loc export --culture en-US -o custom-en-US.json
forge loc export --culture SV-se -o strings/         # case-insensitive match; writes strings/sv-SE.json

A file at the target path is silently overwritten — export is an idempotent, non-destructive read of a built-in resource into a file you named yourself; re-running it is expected and safe, unlike forge init's clobber guard for hand-written project files. Passing an explicit .json --output path without --culture is a loud validation error rather than a guess: with more than one built-in culture and no culture selected, which culture's content would go into that one named file is ambiguous, so loc export refuses instead of silently picking one or creating a directory literally named something.json.

Typical override workflow: export a built-in culture, edit one string, then point AddJsonFile() at the edited copy:

forge loc export --culture en-US -o custom-en-US.json
// custom-en-US.json — edit just the one key you want to change
{
    "WelcomeTitle": "Welcome to Acme Setup"
}
package.Localization(loc => loc
    .AddBuiltInCultures()
    .AddJsonFile("custom-en-US.json"));  // your WelcomeTitle wins; every other built-in string
                                          // still resolves from the baseline tier

bundle detach / bundle reattach

Bundle signing operations for detaching and reattaching PE stubs.

# Detach PE stub from bundle for external signing
forge bundle detach installer.exe --stub stub.exe --data bundle.dat

# Reattach signed PE stub to bundle data
forge bundle reattach --stub signed.exe --data bundle.dat -o installer.exe

An alternative to detach/reattach: sign the fully assembled bundle directly. Once installer.exe is fully compiled (stub + FALKBUNDLE container already embedded), you can hand that single file straight to signtool — no detach step needed:

signtool sign /fd sha256 /f mycert.pfx /p <password> installer.exe

This is the recommended path when it fits your pipeline (you have the whole file and a local or CI-reachable signing tool at compile time): the Authenticode signature legitimately covers the entire file — stub and container both — with the certificate table appended at the genuine end of file, no trick involved. BundleReader locates the FALKBUNDLE footer even though the appended certificate table now sits after it (it falls back to the PE Security data directory when the footer isn't the file's last 24 bytes), so the bundle still self-extracts normally. Detach/reattach remains the right choice when the stub must be signed by a process that only ever sees the bare PE (e.g. a remote signing service that never receives your payloads, or a pipeline where signing happens before the payloads are known) — but that path relies on the CVE-2013-3900 certificate-padding leniency (see Demo 15 — bundle-signing's README) and is rejected on machines with the opt-in strict certificate-padding check. Signing the whole assembled bundle has no such caveat.

migrate

Converts an existing installer (.msi, .msm, or WiX Burn .exe bundle) into a buildable FalkForge C# project. The output directory contains a Program.cs, a .csproj, any extracted payload files, and a MIGRATION-REPORT.md describing what was mapped, what was approximated, and what requires manual follow-up. Intended for teams moving off WiX.

forge migrate <file> [options]

# Arguments:
#   <file>                   Path to .msi, .msm, or .exe (WiX Burn bundle)

# Options:
#   -o, --output <dir>       Output directory for the generated project (default: current)
#   --falkforge-src <path>   Path to a local FalkForge source checkout (uses NuGet if omitted)

# Examples:
forge migrate legacy.msi -o ./migrated
forge migrate product-setup.exe -o ./migrated --falkforge-src ../FalkForge

See also: Demo 54 — forge migrate.

plan

Computes and displays the install plan for a compiled bundle EXE without executing it. Shows which packages will be installed, repaired, or uninstalled based on the current machine state, and which rollback boundaries are in scope. Useful for verifying bundle logic before shipping.

forge plan <bundle> [options]

# Arguments:
#   <bundle>          Path to a compiled bundle EXE

# Options:
#   --output <path>   Write the plan to a file instead of stdout
#   --json            Emit a JSON envelope to stdout (CI/automation)

# Examples:
forge plan installer.exe
forge plan installer.exe --json
forge plan installer.exe --output plan.txt

See also: Demo 56 — verify and plan.

plan-diff

Compares two artifacts of the same type (.msi or .exe bundle) and reports the differences between their install plans — added, removed, and changed packages, features, registry entries, files, and conditions. Use --markdown to embed the diff in a pull-request comment; use --json for machine consumption.

forge plan-diff <old> <new> [options]

# Arguments:
#   <old>        Path to the baseline artifact (.msi or .exe)
#   <new>        Path to the updated artifact (same type as <old>)

# Options:
#   --markdown   Emit diff as a Markdown table (suitable for PR comments)
#   --json       Emit a JSON envelope to stdout

# Examples:
forge plan-diff v1.0/app.msi v1.1/app.msi
forge plan-diff old-setup.exe new-setup.exe --markdown
forge plan-diff v1.0/app.msi v1.1/app.msi --json

verify

Independently verifies a shipped artifact through one of two modes. Rebuild-and-compare (--rebuild <project>, .msi or .exe) rebuilds the project reproducibly and byte-compares the result against the artifact — the strongest proof, since it ties the shipped bytes back to source. Signature-only (.msi, --rebuild omitted) instead crypto-verifies the MSI's pure-.NET ECDSA integrity signature without needing the source project at all. A bundle (.exe) has no signature-only mode yet, so --rebuild stays required for it.

# Rebuild-and-compare (.msi or .exe):
forge verify <artifact> --rebuild <project> [options]

# Signature-only (.msi only, --rebuild omitted):
forge verify <artifact.msi> [--trusted-key <fingerprint>]... [options]

# Arguments:
#   <artifact>                     Path to the shipped .msi or .exe to verify

# Options (rebuild-and-compare):
#   --rebuild <project>            Installer project to rebuild from (.cs / .csx / .json / .csproj).
#                                   Omit for an .msi to run signature-only verification instead;
#                                   still required for an .exe bundle.
#   --source-date-epoch <epoch>    Unix timestamp to pin the reproducible build clock

# Options (signature-only, .msi):
#   --trusted-key <fingerprint>    Pin a trusted signing-key fingerprint (uppercase hex SHA-256 of
#                                   the SubjectPublicKeyInfo). Repeatable. Omitted = consistency-only
#                                   verification (tamper-evidence, not authorship). Rejected together
#                                   with --rebuild (it would silently have no effect there).

# Options (either mode):
#   --json                         Emit a JSON envelope to stdout

# Verdicts (rebuild-and-compare):
#   VERIFIED       (exit 0)  Artifact matches rebuild byte-for-byte
#   MISMATCH       (exit 1)  Artifact differs from rebuild
#   REBUILD-FAILED (exit 2)  Rebuild compilation failed
#   SETUP-ERROR    (exit 3)  Artifact or project not found / environment error

# Verdicts (signature-only, .msi):
#   VERIFIED (authorship verified)        (exit 0)  --trusted-key matched; publisher identity confirmed.
#                                                    Rendered green.
#   VERIFIED (tamper-evidence only)       (exit 0)  No --trusted-key; payload self-consistent but
#                                                    publisher identity NOT checked. Rendered yellow with
#                                                    the full label spelled out below.
#   NOT-SIGNED                            (exit 1)  No signature found (checked the embedded table,
#                                                    then the sidecar)
#   FAILED                                (exit 1)  Signature didn't verify, matched no trusted key, an
#                                                    oversized (>4 MiB) sidecar was refused, a payload
#                                                    file name is carried by more than one embedded file
#                                                    (unconditional tamper, ambiguous which is signed),
#                                                    or the payload no longer matches what was signed in
#                                                    EITHER direction (missing, added, altered, or an
#                                                    aliased duplicate)
#   (no verdict)                          (exit 3)  Setup failure: the MSI could not be opened at all

# Examples:
forge verify app-1.2.msi --rebuild installer.cs
forge verify setup.exe --rebuild installer.cs --source-date-epoch 1718150400
forge verify app-1.2.msi --rebuild installer.cs --json
forge verify app-1.2.msi                          # signature-only, consistency-only
forge verify app-1.2.msi --trusted-key A1B2C3...  # signature-only, authorship-pinned

Signature-only verification checks, in order: the envelope is located (embedded _FalkForgeIntegrity/ManifestSignature table row first, the detached <msi>.sig.json sidecar as fallback, capped at 4 MiB — an oversized sidecar is refused outright and reported FAILED rather than read unbounded into memory; this fallback is the normal path for a Reproducible()+Integrity() MSI, which carries no in-band table at all, see 8.29); cryptographic verification against any --trusted-key fingerprints supplied, else consistency-only (tamper-evidence, not authorship — see 23.2 for that distinction in general); a name-collision (ambiguity) check — the signed envelope has name-only granularity, so two or more embedded payload files resolving to the same name can never be distinguished by the signature, and any such collision is treated as unconditional tamper, refused before the content check below even runs; and bidirectional content binding — every embedded cabinet is re-extracted and each file's SHA-256 recomputed, and the result is checked both ways against the signed declaration: every declared file must be present with a matching hash (catches missing/altered files), and every actual embedded file must be declared (catches an undeclared file added after signing, which a one-directional check would silently pass as VERIFIED). Externally-referenced (disk-resident) cabinets are not re-extracted at all — the same limitation forge extract already has. That is not a quiet pass: because those files never enter the recomputed set, the first direction reports each of them as “not found in the MSI's embedded payload” and the verdict is FAILED. So an Integrity() package built with MediaTemplate(m => m.EmbedCabinet(false)) cannot pass forge verify today, and that failure looks identical to real tamper. External-cabinet content binding is unimplemented, not tolerated.

Two outcomes both print as a green-vs-yellow “VERIFIED” and must not be confused: a trusted-key match renders [green]VERIFIED (authorship verified)[/]; no trusted key supplied renders [yellow]VERIFIED (tamper-evidence only — authorship NOT established; pass --trusted-key to verify publisher)[/] — the payload is provably self-consistent but nothing established who signed it. With --json, the emitted result object always carries a boolean authorshipEstablished field on every signature-only verdict (not just VERIFIED) so a machine consumer never has to infer the distinction from field absence; fingerprint is present only when a trusted key matched.

See also: Demo 56 — verify and plan, Demo 57 — reproducible SBOM, Sigil (23) for how the signature itself is produced.

Exit Codes

CodeConstantDescription
0SuccessOperation completed successfully
1ValidationFailureValidation errors in the installer definition
2CompilationErrorCompilation failed
3RuntimeErrorRuntime error (file not found, platform error, etc.)

JSON Configuration Format

Work in progress. JSON configuration is an experimental, incomplete subset of the C# fluent API — the primary, fully supported authoring model (what forge init scaffolds). The fields below are the ones the loader actually reads and maps to the compiler; fields not listed here are not recognized. The extensions block now authors the firewall, iis, sql, and dotnet extensions: each is translated into the same real extension the C# API attaches via new MsiCompiler().Use(extension) and emitted into the compiled MSI. A dotnet block builds a real DotNetExtension with MSI-native detection (Signature/DrLocator/AppSearch) and its own LaunchCondition, gated by the block's message field (author-provided or a generated default) — the old JSN019 hard-fail for a dotnet block is gone.

The CLI accepts JSON files as an alternative to C# scripts. The schema is defined in src/FalkForge.Cli/Models/InstallerConfig.cs and its sibling Models/*.cs files — the fields below match those types exactly.

{
  "product": {
    "name": "string (required)",
    "manufacturer": "string (required)",
    "version": "string (default: 1.0.0)",
    "upgradeCode": "GUID string",
    "platform": "X86 | X64 | Arm64",
    "description": "string"
  },
  "ui": "None | Minimal | InstallDir | FeatureTree | Mondo | Advanced",
  "license": "path to .rtf license file",
  "installDirectory": "default install directory",
  "majorUpgrade": {
    "schedule": "MSI RemoveExistingProducts schedule, e.g. AfterInstallValidate"
  },
  "downgrade": {
    "allow": "bool (default: false)",
    "message": "string (block message, used when allow is false)"
  },
  "launchConditions": [
    { "condition": "MSI condition string", "message": "error message" }
  ],
  "features": [
    {
      "id": "string (required)",
      "title": "string",
      "description": "string",
      "default": "bool (default: true)",
      "required": "bool (default: false)",
      "files": [
        { "source": "path", "shortcut": { "name": "string", "location": "desktop | startmenu | startup", "description": "string", "icon": "path" } }
      ],
      "registry": [ { "root": "HKLM | HKCU | HKCR | HKU", "key": "path", "name": "string", "value": "string" } ],
      "services": [ { "name": "string", "displayName": "string", "description": "string", "executable": "path", "startType": "string", "account": "string" } ],
      "environmentVariables": [ { "name": "string", "value": "string", "action": "string", "system": "bool (default: true)" } ],
      "features": [ "nested feature objects, same shape" ]
    }
  ],
  "extensions": {
    "firewall": [ "authored -> WixFirewallException table" ],
    "iis": { "appPools": [ "..." ], "webSites": [ "..." ] },
    "sql": [ "..." ],
    "dotnet": [ { "runtimeType": "Runtime | AspNetCore | WindowsDesktop", "platform": "X64 | X86 | Arm64", "minimumVersion": "8.0.0", "variableName": "PUBLIC_MSI_PROPERTY", "message": "optional; default LaunchCondition text generated when omitted" } ]
  },
  "signing": "see Bundle Signing, Trust & Key Rotation (23)"
}

A file's shortcut is a single object nested on that file entry — there is no separate top-level shortcuts array on a feature. Extension sub-schemas (firewall/iis/sql/dotnet) are fully documented in the JSON Configuration tutorial; all four are now authored into the compiled MSI, including dotnet (real Signature/DrLocator/AppSearch detection plus a LaunchCondition gate — see 10.5 .NET Extension).

C# Scripting Support

C# script files (.cs) are compiled using Roslyn scripting. The ScriptLoader class evaluates the script, which calls Installer.Build() or Installer.BuildBundle() to produce output.

Environment Variables

Every environment variable FalkForge itself defines is listed here — the canonical, single source of truth. Before this table existed, 25 production call sites (22 reads + 3 writes) across 12 files each touched one of these with an inline string literal, so there was no one place to see the full set. All of them are centralized in EnvVarCatalog (src/FalkForge.Core/Configuration/EnvVarCatalog.cs), which owns the name constants and the shared parse/presence logic; each call site still decides its own policy for what to do with an absent or malformed value (throw, report and fall back, or silently ignore), so the “Effect” column below spells that out per variable rather than assuming one behavior fits every caller.

VariableType / EffectDefault
SOURCE_DATE_EPOCH Unix timestamp (integer seconds). Pins reproducible-build timestamps and content-digest IDs (PackageCode, SBOM serial/timestamp). PackageBuilder.Reproducible() / BundleBuilder.Reproducible() throw on a malformed value (ArgumentException, RPR001) or when absent with no explicit override (InvalidOperationException, RPR002). forge build --reproducible reports RPR001 and falls back to the git log HEAD timestamp when absent. The SBOM identity path (ReproducibleSbomIdentity) silently falls back to a fresh GUID/UTC-now on either absent or malformed, since SBOM generation must never fail just because reproducibility wasn’t established. Not set (non-reproducible build)
FALKFORGE_GENERATE_SBOM Presence flag — any non-empty value (including "0" or "false") means on; this is not a bool parse. Generates a CycloneDX 1.6 SBOM sidecar (<output>.cdx.json) alongside the compiled MSI/bundle, equivalent to .Sbom(). Set automatically by forge build --sbom. Not set (no SBOM unless .Sbom() is configured)
FALKFORGE_NO_SIGN Presence flag, same “any non-empty value” semantics as FALKFORGE_GENERATE_SBOM. Skips ECDSA payload/manifest integrity signing even when .Integrity() is configured. Set automatically by forge build --no-sign; also used by forge verify to compare unsigned bytes (Commands, above). Not set (signs when .Integrity() is configured)
FALKFORGE_ENGINE_STUB Path to FalkForge.Engine.exe, or a directory containing it. Authoritative when set at bundle-compile time — an unresolvable value is a configuration error, never a reason to probe elsewhere. Unset falls back to well-known locations beside the host app, then the repository publish output. Not set (auto-resolved)
FALKFORGE_ELEVATION_COMPANION Path to FalkForge.Engine.Elevation.exe, or a directory containing it. Authoritative when set; overrides the default resolution of the elevation companion payload a runnable bundle embeds (Bundle Signing, Trust & Key Rotation, 23). Not set (auto-resolved beside the embedded engine)
SIGNSERVER_URL Base URL of the Keyfactor SignServer instance for remote signing (SignServerConfig.FromEnvironment()). Required, with SIGNSERVER_WORKER, to configure SignServer signing via environment variables rather than a JSON signing config section. Not set (SGN024 fail loud if referenced without it)
SIGNSERVER_WORKER SignServer PlainSigner worker name or numeric id. Not set (SGN024 fail loud)
SIGNSERVER_AUTH Enum string: none | clientcert | basic | bearer. Selects which of the credential variables below are required. none — matches SignServer’s NOAUTH mode; local CE test containers only, never production
SIGNSERVER_BEARER_TOKEN Bearer token, required when SIGNSERVER_AUTH=bearer. Not set (SGN024 fail loud if auth is bearer)
SIGNSERVER_BASIC_USER / SIGNSERVER_BASIC_PASS Basic-auth username/password, both required when SIGNSERVER_AUTH=basic. Not set (SGN024 fail loud if auth is basic)
SIGNSERVER_CLIENT_CERT / SIGNSERVER_CLIENT_CERT_PASSWORD Path to a PFX client certificate (and its optional password), required when SIGNSERVER_AUTH=clientcert, for mTLS. Not set (SGN024 fail loud if auth is clientcert)
SIGNSERVER_KEY_ID Operator-facing key label copied verbatim into the signature envelope’s keyId. Informational only — never trusted, never affects verification. Empty string
The nine SIGNSERVER_* credential variables carry secrets (tokens, passwords, certificate PFX paths). Never hardcode their values in a script, JSON config, or CI YAML file — set them in your CI provider’s secret store or an operator’s local shell profile. This mirrors the JSON signing config path (23), which enforces the same rule at compile time (JSN015–JSN019): a config carries only an environment-variable name or a file path, never a secret value inline.
Scope. Two categories of environment-variable read are deliberately not in this table. SESSIONNAME (Remote Desktop session detection during Detecting) is a Windows-owned session variable, not something FalkForge defines, and is read through the existing testable platform abstraction rather than this catalog. Dynamically-named variables — a JSON signing config’s keyEnv/pqKeyEnv/bearerTokenEnv/etc. fields each name an environment variable whose value is the secret — have no fixed name to catalog here, since the config supplies the name itself; see Bundle Signing, Trust & Key Rotation (23) for that mechanism.

16. Localization

The localization system (src/FalkForge.Localization/) provides JSON-based string tables with automatic culture fallback. Localized strings are referenced in installer definitions using the !(loc.StringId) pattern.

How It Works

  1. Define string tables in JSON files named name.culture.json (e.g., strings.en-US.json, strings.de.json)
  2. Register them with the LocalizationBuilder, specifying a default culture
  3. The LocalizedStringResolver resolves !(loc.StringId) references at build time
  4. If a string is not found in the requested culture, the fallback chain is followed

JSON File Format

Each JSON file is a flat dictionary of string ID to string value. The culture is extracted from the filename.

// strings.en-US.json
{
    "ProductName": "My Application",
    "WelcomeTitle": "Welcome to !(loc.ProductName)",
    "WelcomeDescription": "Click Install to begin."
}

// strings.de.json
{
    "ProductName": "Meine Anwendung",
    "WelcomeTitle": "Willkommen bei !(loc.ProductName)",
    "WelcomeDescription": "Klicken Sie auf Installieren, um zu beginnen."
}
All values must be strings. Non-string JSON values produce error LOC004. The filename format must be name.culture.json where culture segments contain only letters separated by hyphens (e.g., en-US, zh-Hans-CN).

Culture Fallback Chain

src/FalkForge.Localization/CultureFallbackChain.cs

The fallback chain walks from the most specific culture to the default:

Input: culture="de-AT", default="en-US"
Chain: ["de-AT", "de", "en-US"]

Input: culture="zh-Hans-CN", default="en-US"
Chain: ["zh-Hans-CN", "zh-Hans", "zh", "en-US"]

Input: culture="en-US", default="en-US"
Chain: ["en-US"]  (no fallback needed)

Builder API

src/FalkForge.Localization/LocalizationBuilder.cs

var builder = new LocalizationBuilder();
builder
    .DefaultCulture("en-US")
    .AddJsonFile("strings.en-US.json")
    .AddJsonFile("strings.de.json")
    .AddJsonFile("strings.fr.json")
    .AddCulture("ja", new Dictionary<string, string>
    {
        ["ProductName"] = "...",
        ["WelcomeTitle"] = "..."
    });

Result<IReadOnlyList<LocalizationModel>> result = builder.Build();

Overriding Built-In Strings

AddBuiltInCultures() (src/FalkForge.Compiler.Msi/BuiltInLocalizationExtensions.cs) registers FalkForge's own MSI dialog strings (en-US, sv-SE) into a separate baseline tier via LocalizationBuilder.AddBaselineCulture() — distinct from the user tier populated by AddCulture()/AddJsonFile(). The two tiers merge with a simple rule: a user-tier string with the same culture and key silently wins over the baseline, regardless of which tier was registered with the builder first. A single line is enough to override one built-in string:

package.Localization(loc => loc
    .AddBuiltInCultures()
    .AddJsonFile("custom-en-US.json"));  // overrides only the keys it defines; everything else
                                          // still falls back to the built-in en-US strings

Duplicate keys are still an error within a single tier — two baseline registrations (or two user registrations) defining the same culture+key still produce LOC001. It is only the baseline-vs-user relationship that is a silent override, since that is the entire point of having a baseline tier: a caller should never have to enumerate and reproduce every built-in string just to change one.

Integration with PackageBuilder

src/FalkForge.Localization/PackageBuilderExtensions.cs

package.Localization(loc => loc
    .DefaultCulture("en-US")
    .AddJsonFile("strings.en-US.json")
    .AddJsonFile("strings.de.json"));

String Resolution

src/FalkForge.Localization/LocalizedStringResolver.cs

Resolves !(loc.StringId) patterns with recursive nesting support and circular reference detection. The regex pattern is !\(loc\.([A-Za-z0-9_.]+)\).

var resolver = new LocalizedStringResolver(models, "en-US");
Result<string> result = resolver.Resolve("Welcome to !(loc.ProductName)", "de");

Automatic Culture Detection: DetectCulture()

Both localization builders expose a DetectCulture(bool detect = true) fluent method that, instead of always falling back to the configured default culture, tries to pick a matching culture from CultureInfo.CurrentUICulture — first an exact match (e.g. "sv-SE"), then the neutral parent culture (e.g. "sv") if no exact match is registered. The two builders live in different assemblies and default this differently:

BuilderDetectCulture defaultUsed for
FalkForge.Localization.LocalizationBuilderfalseCompile-time MSI dialog string resolution (the builder documented above)
FalkForge.Ui.Localization.UiLocalizationBuildertrueRuntime custom-UI string resolution (src/FalkForge.Ui/Localization/), consumed via UiLocalizationConfig on InstallerUIBuilder
// Compile-time (MSI dialogs): opt in explicitly
package.Localization(loc => loc
    .DefaultCulture("en-US")
    .AddJsonFile("strings.en-US.json")
    .AddJsonFile("strings.sv-SE.json")
    .DetectCulture());   // false by default -- must be called to enable

// Runtime custom UI: on by default, matching the OS/thread UI culture at startup
builder.Localization(loc => loc
    .DefaultCulture("en-US")
    .AddJsonResources());   // auto-discovers embedded lang.strings.*.json resources
    // DetectCulture(false) to force DefaultCulture regardless of the OS locale

See Demo 08 — localization for a runnable multi-language example and the Localization tutorial for a narrative walkthrough of the whole culture fallback + detection story.

MST Language Transforms

When a package has more than one culture (PackageModel.LocalizationData.Count > 1) and CompileOptions.EmitLanguageTransforms is left at its default (true), MsiAuthoring Step 6.6 (src/FalkForge.Compiler.Msi/Recipe/MsiAuthoring.LanguageTransforms.cs) rebuilds the package once per extra culture in a throwaway directory, then diffs each localized build against the pristine base MSI via LanguageTransformGenerator (which wraps the native MsiDatabaseGenerateTransform) to emit one .mst transform per culture next to the base MSI. A culture that produces no localizable difference from the base (e.g. no !(loc.*) text reaches a dialog set or custom dialog) emits no .mst file and a DLG005 warning instead of silently shipping a single-language installer.

Error Codes

CodeDescription
LOC001Duplicate string ID in a culture
LOC002Default culture not specified or not defined
LOC003Circular reference or unresolved localization string
LOC004Invalid filename format, invalid JSON, null content, or non-string values

17. Decompiler

The decompiler (src/FalkForge.Decompiler/) reverse-engineers existing MSI databases, native FalkForge .exe bundles, and WiX Burn .exe bundles into PackageModel / BundleModel objects or fluent C# source code that can be fed back into the FalkForge build pipeline. MSI decompilation is Windows-only ([SupportedOSPlatform("windows")]); bundle and WiX Burn decompilation are cross-platform.

Quick Start

Most callers only need two methods on MsiDecompiler: Decompile(msiPath) to get a structured PackageModel you can inspect or feed into further tooling, or DecompileToCSharp(msiPath) to get buildable fluent C# source directly:

// Structured model — inspect features, files, registry keys, etc.
var decompiler = new MsiDecompiler();
Result<PackageModel> modelResult = decompiler.Decompile("legacy.msi");
if (modelResult.IsSuccess)
{
    PackageModel model = modelResult.Value;
    Console.WriteLine($"{model.Name} {model.Version} — {model.Features.Count} feature(s)");
}

// Fluent C# source — ready to save as Program.cs and build
Result<string> csharpResult = decompiler.DecompileToCSharp("legacy.msi");
if (csharpResult.IsSuccess)
    File.WriteAllText("Program.cs", csharpResult.Value);

Decompile validates the path, opens the MSI via MsiTableAccess.Open, reads every table into an MsiReadRecipe snapshot, then reconstructs a PackageModel from that snapshot (a pure, cross-platform step — see Recipe Pipeline below). DecompileToCSharp calls Decompile internally and, on success, runs the result through CSharpEmitter to produce source text; a decompile failure propagates through unchanged. Both take a single msiPath parameter, unless an IMsiTableAccess was injected via constructor (a test seam), in which case msiPath is ignored in favor of that access.

forge decompile legacy.msi is a thin CLI wrapper around DecompileToCSharp, writing the resulting source to stdout or -o. forge migrate goes one step further and generates a whole buildable project (Program.cs, .csproj, extracted payload, and a MIGRATION-REPORT.md of what could and couldn't be mapped) — see CLI Reference § migrate and Demo 54 — forge migrate for the full round-trip workflow.

MIGRATION-REPORT.md honesty contract: the report's "Not yet migrated" section now also names every MSI table present in the source database that no core schema or extension contributor demonstrably reads (see IMsiTableAccess.GetTableNames and MsiReadRecipe.AllTableNames below), in addition to the model feature categories it already listed. The positive All present features were mapped. line is only emitted when both lists are empty — a nonzero unmapped-table list on its own is now enough to suppress the all-clear. This is stage 1 of task #34: the migrator still drops extension configuration during migration (it always did); this change only stops the report from claiming otherwise for tables nothing reads. It does not make migrate preserve that data.

MsiDecompiler

src/FalkForge.Decompiler/MsiDecompiler.cs

[SupportedOSPlatform("windows")]
public sealed class MsiDecompiler
{
    public MsiDecompiler();
    public MsiDecompiler(IMsiTableAccess tableAccess);
    public MsiDecompiler(IReadOnlyList<IMsiTableContributor> contributors);
    public MsiDecompiler(IMsiTableAccess tableAccess,
                         IReadOnlyList<IMsiTableContributor> contributors);

    public Result<PackageModel>    Decompile(string msiPath);
    public Result<MsiReadRecipe>   DecompileToRecipe(string msiPath);
    public Result<string>          DecompileToCSharp(string msiPath);
}

Construction is overloaded so the caller can mix in custom IMsiTableContributors — each one supplies an optional ReadSchema, so extension tables (Firewall, SQL, etc.) are round-trip without the decompiler core knowing about them.

IMsiTableAccess

src/FalkForge.Decompiler/IMsiTableAccess.cs

Abstraction for MSI database reads, enabling unit tests without a real MSI file. Extends ITableQuery (from FalkForge.Extensibility) so extension ReadSchemas can be evaluated without referencing the Decompiler assembly.

public interface IMsiTableAccess : IDisposable, ITableQuery
{
    Result<string?> GetSummaryProperty(int propertyId);

    // Every table name present in the database (from the _Tables system catalog), including
    // MSI-internal catalog tables. Feeds MsiReadRecipe.AllTableNames so the migration report
    // can name tables nothing above reads — see the honesty contract note above.
    Result<IReadOnlyList<string>> GetTableNames();

    // Inherited from ITableQuery:
    //   Result<bool>              TableExists(string tableName);
    //   Result<List<string?[]>>  QueryTable(string tableName, string[] columns);
}

// Production implementation:
[SupportedOSPlatform("windows")]
public sealed class MsiTableAccess : IMsiTableAccess
{
    public static Result<MsiTableAccess> Open(string msiPath);
}

Recipe Pipeline (MsiReadRecipe + Schemas)

Per-table readers were replaced by a single schema-driven engine. Each table is described by a TableReadSchema<TRow>, and the static TableReadEngine drives any schema against an ITableQuery. The collected typed rows are bundled in an MsiReadRecipe snapshot, which the internal MsiPackageReconstructor turns into a PackageModel. MsiDecompiler.DecompileToRecipe exposes the snapshot stage so callers can inspect the raw rows.

Alongside the typed row collections, the recipe also carries AllTableNames — every table name actually present in the source database, from IMsiTableAccess.GetTableNames. MigrationProjectGenerator diffs this against the tables the core schemas and any registered extension contributors demonstrably read to compute the unmapped-table list that MIGRATION-REPORT.md now names by table — see the honesty contract note in Quick Start above. If the underlying table-name query fails, DecompileToRecipe returns a Result failure instead of a recipe with an empty AllTableNames. A database that FalkForge cannot even enumerate must surface as a failure, never silently degrade into a false "all mapped" claim.

// Recipe/TableReadEngine.cs
public static class TableReadEngine
{
    public static Result<List<TRow>> ReadOne<TRow>(
        TableReadSchema<TRow> schema,
        ITableQuery access);
}

// Recipe/TableReadSchema.cs
public sealed record TableReadSchema<TRow>(
    string                       TableName,
    ImmutableArray<ReadColumn>   Columns,
    RowMapper<TRow>              Map,
    string                       DiagnosticCode = "DEC003")
    : ITableReadSchema;

// Recipe/MsiReadRecipe.cs
public sealed record MsiReadRecipe
{
    public required IReadOnlyList<PropertyRow>           Properties        { get; init; }
    public required IReadOnlyList<DirectoryRow>          Directories       { get; init; }
    public required IReadOnlyList<ComponentRow>          Components        { get; init; }
    public required IReadOnlyList<FileRow>               Files             { get; init; }
    public required IReadOnlyList<FeatureRow>            Features          { get; init; }
    public required IReadOnlyList<FeatureComponentsRow>  FeatureComponents { get; init; }
    public required IReadOnlyList<RegistryRow>           RegistryEntries   { get; init; }
    public required IReadOnlyList<ServiceRow>            Services          { get; init; }
    public required IReadOnlyList<ShortcutRow>           Shortcuts         { get; init; }
    public required IReadOnlyList<UpgradeRow>            Upgrades          { get; init; }
    public IReadOnlyDictionary<string, IReadOnlyList<object>> ExtensionRows { get; init; }

    // Every table name present in the source MSI (from IMsiTableAccess.GetTableNames),
    // including MSI-internal catalog tables. Required: MsiDecompiler short-circuits as a
    // Result failure before ever constructing a recipe if the table-name query fails, so a
    // hand-built recipe must state its intent explicitly rather than defaulting to empty.
    public required IReadOnlyList<string> AllTableNames { get; init; }
}

Schemas shipped in Recipe/Schemas/:

SchemaRow typeMSI table
PropertySchemaPropertyRowProperty
DirectorySchemaDirectoryRowDirectory
ComponentSchemaComponentRowComponent
FileSchemaFileRowFile
FeatureSchema / FeatureComponentsSchemaFeatureRow / FeatureComponentsRowFeature / FeatureComponents
RegistrySchemaRegistryRowRegistry
ServiceSchemaServiceRowServiceInstall
ShortcutSchemaShortcutRowShortcut
UpgradeSchemaUpgradeRowUpgrade

Extension Round-Trip (ITableQuery + ITableReadSchema)

Custom MSI tables contributed by extensions are round-trip via two interfaces in FalkForge.Extensibility:

// Extensibility/ITableQuery.cs — minimal read abstraction
public interface ITableQuery
{
    Result<bool>              TableExists(string tableName);
    Result<List<string?[]>>   QueryTable(string tableName, string[] columns);
}

// Extensibility/ITableReadSchema.cs — type-erased schema marker
public interface ITableReadSchema
{
    string TableName { get; }
    Result<IReadOnlyList<object>> ReadErased(ITableQuery query);
}

// Extensibility/IMsiTableContributor.cs — gained ReadSchema property
public interface IMsiTableContributor
{
    string                          TableName  { get; }
    IReadOnlyList<MsiTableRow>      GetRows(ExtensionContext context);
    ITableReadSchema?               ReadSchema => null;
}

Each contributor that opts into round-trip exposes a ReadSchema. Currently: FirewallTableContributor (WixFirewallException), SqlDatabaseTableContributor (SqlDatabase), and SqlScriptTableContributor (SqlScript). The schema is consumed via the erased ReadErased bridge so neither the engine nor the decompiler core needs to know the concrete TRow type.

CSharpEmitter

src/FalkForge.Decompiler/CSharpEmitter.cs

Converts a PackageModel into fluent C# source code using the FalkForge builder API. Emits using directives, builder property assignments, and method calls for features, registry entries, services, shortcuts, properties, and major-upgrade configuration.

BundleDecompiler (native FalkForge bundles)

src/FalkForge.Decompiler/BundleDecompiler.cs

Decompiles a native FalkForge self-extracting .exe bundle. Parses the footer magic, reads the JSON installer manifest, walks the TOC entries, and maps the result to a BundleModel. Cross-platform — no Windows-only dependencies.

public sealed class BundleDecompiler
{
    public Result<BundleModel> Decompile(string bundlePath);
    public Result<string>      DecompileToCSharp(string bundlePath);
}

public interface IBundleAccess
{
    Result<InstallerManifest> ReadManifest();
    Result<TocEntry[]>        ReadToc();
}

Internal helpers: BundleAccess reads the binary format; ManifestMapper maps the parsed InstallerManifest + TOC into a BundleModel; BundleCSharpEmitter generates fluent C#.

WixBundleDecompiler (WiX Burn bundles)

src/FalkForge.Decompiler/WixBundleDecompiler.cs

Decompiles a WiX Burn (.wixburn PE section) bundle. The PE parser locates the .wixburn section, extracts the UX cabinet, parses the Burn manifest XML, and maps the document to a BundleModel. Burn elements without a FalkForge equivalent are collected into WixUnmappedFeature records so they appear as comments in the emitted C# instead of being silently dropped.

public sealed class WixBundleDecompiler
{
    public Result<BundleModel> Decompile(string bundlePath);
    public Result<string>      DecompileToCSharp(string bundlePath);
}

public interface IWixBurnAccess
{
    Result<XDocument> ReadManifest();
}

public sealed record WixUnmappedFeature(
    string Category,
    string Description,
    string OriginalXml);

Unmapped categories include Burn Variable, Search, BootstrapperApplication, MsiProperty, ExitCode, ApprovedExeForElevation, and BootstrapperExtension.

Error Codes

CodeSubsystemDescription
DEC001MSICannot open MSI file (null path, file not found, or open failure)
DEC003MSIRow shape or type mismatch in a table read (default schema diagnostic)
BDC001BundleInvalid or missing bundle path
BDC002BundleInvalid bundle format / magic mismatch
BDC003BundleManifest deserialization failure
BDC004BundleTOC read failure
WBD001BurnInvalid or missing bundle path
WBD002BurnPE header invalid or too small
WBD003BurnNo .wixburn section in PE
WBD004Burn.wixburn magic mismatch / no containers
WBD005BurnUX container extraction failed
WBD006BurnManifest file not found in UX cabinet
WMM001BurnManifest XML has no root element

18. Error Code Reference

FalkForge uses structured error codes across all subsystems. Each code has a unique prefix identifying the source module.

Package Validation (PKG)

CodeDescription
PKG001Package name is required
PKG002Package manufacturer is required
PKG003Package version is required
PKG004UpgradeCode is required
PKG005Package must have at least one file or component
PKG006Duplicate component ID
PKG007File source path not found
PKG008Invalid install scope for component configuration
PKG009Directory reference not found
PKG010Invalid license file path
PKG011Invalid architecture for install scope

Feature Validation (FEA)

CodeDescription
FEA001Feature ID is required
FEA002Feature title is required
FEA003Duplicate feature ID
FEA004Feature parent reference not found
FEA005Feature has no components

Service Validation (SVC)

CodeDescription
SVC001Service Name is required
SVC002Service Executable is required
SVC003Service with Account=User requires a UserName
SVC004Service name must not exceed 256 characters
SVC005Plaintext service password (warning)
SVC009Service Arguments is empty string (use null for no arguments)
SVC010Both AccountProperty and UserName specified (AccountProperty takes precedence)
SVC011Service ComponentCondition must not be empty (use null to omit)
SVC012Custom account specified without a password (warning)

Permission Validation (PRM)

CodeDescription
PRM001Permission LockObject is required
PRM002Permission must have either SDDL or User specified
PRM003Invalid permission Table (must be File, Registry, CreateFolder, or ServiceInstall)

Registry Validation (REG)

CodeDescription
REG001Registry entry Key is required
REG002Duplicate registry entry: same root+key+value-name with identical data (warning; message notes possible feature-exclusivity when the entries are gated to different components/features)
REG003Conflicting registry entry: same root+key+value-name with different data (error when both entries resolve to the same component/feature scope, since they are certain to co-install; downgraded to warning when the scopes differ, since that may be an intentional mutually-exclusive-feature pattern)
REG007Registry value references a sensitive MSI property (warning)

RemoveRegistry Validation (RRG)

CodeDescription
RRG001RemoveRegistry Id is required
RRG002RemoveRegistry Key is required
RRG003RemoveValue action requires a Name

Custom Table Validation (CTB)

CodeDescription
CTB001Table name is required
CTB002Table must have at least one column
CTB003Column name is required
CTB004Table must have a primary key
CTB005Duplicate column name
CTB006Invalid column type
CTB007Row data has wrong number of values
CTB008Row primary key is required
CTB009Duplicate table name
CTB010Reserved table name (conflicts with MSI standard tables)

Major Upgrade (MUP)

CodeDescription
MUP001Downgrade error message is required
MUP002Invalid schedule for RemoveExistingProducts
MUP003Major upgrade requires an UpgradeCode

Merge Module Validation (MSM)

CodeDescription
MSM001Merge module name is required
MSM002Merge module version is required
MSM003Merge module must have components
MSM004Invalid merge module GUID

Patch Validation (MSP)

CodeDescription
MSP001Patch requires a baseline MSI
MSP002Patch requires a target MSI
MSP003Invalid patch GUID
MSP004Patch description is required

Transform Validation (MST)

CodeDescription
MST001Transform requires a base MSI
MST002Transform must define at least one modification

Bundle Validation (BDL)

CodeDescription
BDL001Bundle name is required
BDL002Bundle manufacturer is required
BDL003Invalid version format
BDL004Bundle must contain at least one package
BDL005Duplicate package IDs in chain
BDL006Invalid container or boundary references
BDL007Custom UI requires a project path
BDL008BundleId must not be empty GUID
BDL009UpgradeCode must not be empty GUID
BDL010Variable name is required
BDL011Duplicate variable names
BDL012Variable default value type mismatch
BDL013Secret variable cannot be persisted
BDL014Feature ID is required
BDL015Duplicate feature IDs
BDL016Feature references unknown package IDs
BDL017Required feature has no packages
BDL018Feature title is required
BDL019Dependency provider key must not be empty
BDL020Dependency provider has invalid version
BDL021Duplicate dependency provider key
BDL022Dependency consumer provider key must not be empty
BDL023Dependency consumer key must not be empty
BDL024Update feed URL is not a valid absolute URI
BDL025Update feed URL must use HTTPS
BDL027EnableFeatureSelection is only valid for MsiPackage type

Bundle Payload Integrity (INT)

CodeDescription
INT001No trusted signature validates the manifest (tampering, untrusted publisher key, or locally-revoked key)
INT002Signed hash does not match the manifest package hash, or a signed entry has no matching package
INT003Malformed integrity envelope (parse failure, no signatures, or embedded manifest deserialization failure)
INT004A manifest package, or a bundle TOC payload, is not covered by the integrity signature (set-coverage violation)
INT006Bundle TOC payload hash does not match the ECDSA-signed manifest hash (post-signing overlay tamper)
INT007A signature is required on this path (e.g. update) but the manifest carries none
INT008Bundle key-epoch is below the highest epoch this machine has accepted (downgrade/replay) — see §23 (Safe Updates)
INT009A signature is required but the engine's effective trusted set is empty (fail closed)
INT010The signing quorum for this operation is not satisfied (roles/quorum policy, see §23)
INT011A trusted key is pinned as hybrid (post-quantum companion required), but the ML-DSA companion signature is missing, from the wrong key, or invalid — treated as a strip/downgrade attack, see §23

Bundle Signing (SGN) & Hybrid Trusted-Key Build Checks (FALKPQ)

CodeDescription
SGN001PFX certificate file embeds a private key (Authenticode signing options; warning)
SGN002Signing key/certificate material missing: Authenticode signing configured without CertificatePath or CertificateThumbprint, or a payload-signing PEM key file was not found or failed to load
SGN003Authenticode DigestAlgorithm must be sha256, sha384, or sha512
SGN010An asynchronous signature provider (e.g. SignServer) was configured on the synchronous build path — use BuildBundleAsync/CompileAsync
SGN011ML-DSA signing is not supported by the build machine's OS/crypto stack — signing a hybrid bundle requires an ML-DSA-capable build machine (no fallback)
FALKPQ001Build-time warning: the baked trusted-key set mixes hybrid keys (with PqFingerprint=) and non-hybrid keys — a non-hybrid key is the quantum weakest link
FALKPQ002Build-time error: a FalkForgeTrustedKey item has a malformed PqFingerprint (must be the 64-hex SHA-256 of the ML-DSA public key)
FALKPQ003Build-time error: duplicate trusted-key fingerprint in the baked FalkForgeTrustedKey set

SBOM Generation (SBM)

CodeDescription
SBM001Failed to compute SHA-256 hash for SBOM component
SBM002Failed to write SBOM output file
SBM003SPDX output requested but a file component has no SHA-1 digest (SPDX 2.3 §8.4 makes it mandatory). Supply sha1 on the component or request SbomFormat.CycloneDx
SBM004An SBOM component carries a digest that is not shaped like a hash (SHA-256 must be 64 hex characters, SHA-1 40). A checksum field is an integrity claim, so it is never serialized unexamined

Plan Export / Dry-Run (PLN)

CodeDescription
PLN001Detection phase failed during plan-only mode
PLN002Planning phase failed during plan-only mode
PLN003Failed to serialize plan to JSON
PLN004Dry-run mode blocked: one or more extensions do not support it

Bundle Detach/Reattach (BDS)

CodeDescription
BDS001Bundle file not found, file too small, magic marker not found, or detach failed
BDS002Signed stub or data file not found, data file corrupted, or reattach validation failed
BDS003Reattach verification failed (payload offsets, TOC offsets out of bounds)

Bundle Decompiler (BDC)

CodeDescription
BDC001Bundle path is null, empty, or file not found

WiX Bundle Decompiler (WBD)

CodeDescription
WBD001Bundle file not found or path is null/empty
WBD002Invalid PE: file too small, bad header, bad signature, or open failure
WBD003.wixburn PE section not found
WBD004Invalid .wixburn magic or no containers
WBD005UX container extraction/read failure
WBD006Manifest file not found in UX container

WiX Manifest Mapper (WMM)

CodeDescription
WMM001Manifest XML has no root element

Decompiler (DEC)

CodeDescription
DEC001MSI path is null/empty or file not found
DEC003Failed to read an MSI table (Component, Directory, Feature, File, Property, Registry, Service, Shortcut, Upgrade)

Firewall Extension (FWL)

CodeDescription
FWL001Firewall rule ID is required
FWL002Firewall rule name is required
FWL003Firewall rule must specify port or program
FWL004Duplicate firewall rule ID

.NET Detection Extension (NET)

CodeDescription
NET001VariableName is required
NET002MinimumVersion is required
NET003Duplicate VariableName
NET004RuntimeType is Sdk (no shared-framework directory to search — the SDK is versioned via dotnet\sdk\{version}\, a different layout) or an unrecognized value; supported values are Runtime, AspNetCore, WindowsDesktop
NET005VariableName is not a valid PUBLIC MSI property identifier ([A-Z_][A-Z0-9_.]*, all uppercase — e.g. DOTNET8_FOUND)
NET006Search model passed to DotNetExtension.AddSearch is null
NET007Platform is not a recognized value; supported values are X64, X86, Arm64

IIS Extension (IIS)

CodeDescription
IIS001WebSite must have a Description
IIS002WebSite must have at least one binding
IIS003Binding must have a valid Port (1-65535)
IIS004HTTPS binding must have a CertificateRef
IIS005AppPool must have a Name
IIS006SpecificUser identity requires UserName
IIS007Certificate must have an Id
IIS008Certificate must have a FindValue
IIS009SpecificUser identity requires Password
IIS010Reference to undefined app pool
IIS011Binding references undefined certificate

SQL Extension (SQL)

CodeDescription
SQL001Database requires either Server or ConnectionString
SQL002Script/SqlString requires a DatabaseRef
SQL003Script requires either SourceFile or SqlContent (not both)
SQL004Database name is required
SQL005SqlString requires a Sql statement
SQL006Duplicate database Id
SQL007Duplicate script Id
SQL008Duplicate SqlString Id
SQL009Script SqlContent exceeds maximum length
SQL010SqlString Sql exceeds maximum length
SQL011Database Id is required
SQL012Script Id is required
SQL013SqlString Id is required

XML Config (XCF)

CodeDescription
XCF001XPath expression must not be empty
XCF002FilePath must not be empty
XCF003CreateElement action requires ElementName
XCF004SetAttribute action requires AttributeName and Value
XCF005XPath expression exceeds maximum length
XCF006DeleteAttribute action requires AttributeName
XCF007SetValue action requires Value
XCF008BulkSetValue action requires Value
XCF009Duplicate XmlConfig Id

File Share (FSH)

CodeDescription
FSH001FileShare Id is required
FSH002FileShare Name is required
FSH003FileShare Directory is required

Internet Shortcut (ISC)

CodeDescription
ISC001InternetShortcut Id is required
ISC002InternetShortcut Name is required
ISC003InternetShortcut Target URL is required
ISC004InternetShortcut Directory is required

Quiet Exec (QEX)

CodeDescription
QEX001QuietExec Id is required
QEX002QuietExec CommandLine is required
QEX003QuietExec CommandLine exceeds maximum length
QEX004QuietExec RollbackCommandLine exceeds maximum length

Remove Folder (RFX)

CodeDescription
RFX001RemoveFolderEx Id is required
RFX002RemoveFolderEx requires either Directory or Property

User Management (USR)

CodeDescription
USR001User Name is required
USR002Password is required when creating a new user

Group Management (GRP)

CodeDescription
GRP001Group Name is required

Localization (LOC)

CodeDescription
LOC001Duplicate string ID in a culture
LOC002Default culture not specified or not defined
LOC003Circular reference or unresolved localization string
LOC004Invalid filename format, invalid JSON, null content, or non-string values

JSON Configuration (JSN)

CodeDescription
JSN001Invalid JSON syntax
JSN002Missing required field: product.name
JSN003Missing required field: product.manufacturer
JSN004Invalid version format
JSN005Invalid upgrade code GUID
JSN006Invalid UI dialog set value
JSN007Invalid platform value
JSN009Feature must have an id
JSN010Configuration error during mapping
JSN011Firewall rule missing required fields
JSN012IIS configuration missing required fields
JSN013SQL configuration missing required fields
JSN014.NET detection missing required fields
JSN015signing.provider missing or unknown (valid: none, pem, signserver)
JSN016Inline secret material in the signing config — keys and credentials must be referenced by file path or environment-variable name, never pasted into the JSON
JSN017Ambiguous or missing key source for the pem provider (exactly one of keyPath/keyEnv; at most one of pqKeyPath/pqKeyEnv)
JSN018Invalid signserver signing config (missing baseUrl/worker, unknown/incomplete authMode, or pqKeyPath/pqKeyEnv on the signserver provider — hybrid post-quantum signing via JSON requires provider pem)
JSN019Fail-closed guard: a signing key or auth material is unresolvable at build time (e.g. a referenced environment variable is unset) — the build fails closed, never falling back to unsigned output. (Previously also covered a JSON extensions.dotnet block; that block now emits real MSI-native detection instead of failing — see NET001–NET007 and JSN014.)

Update Feed (UPD)

CodeDescription
UPD001Failed to fetch update feed (HTTP error, timeout, or exceeds max size)
UPD002Failed to parse update feed JSON, null feed, or invalid current version
UPD003Update feed bundle ID mismatch
UPD004Update entry download URL is not a valid HTTPS URI
UPD005Update launch failed — update path is outside the cache root (security) or Process.Start failed

19. Demo Gallery

FalkForge ships with 63 C# demo projects and 7 JSON configuration demos that collectively showcase the framework's full capabilities — from minimal MSI installers to complex multi-project bundles with custom WPF UI.

#NameDescriptionKey Features
01Hello World Minimum installer — one file, media template, reproducible build Files MediaTemplate Reproducible RestartManager Minimal UI
02Notepad Clone App with shortcuts, DWord registry, RemoveRegistry, startup shortcut Shortcuts Registry RemoveRegistry License MajorUpgrade InstallDir UI
03Client-Server Services with arguments, config file protection (NeverOverwrite/Permanent) Features Services EnvVars LaunchConditions FeatureTree UI
04Dev Toolkit Developer tools with nested features and extensions NestedFeatures FileAssociations CustomActions Registry Mondo UI
05Enterprise Suite Full enterprise IDE with 15 features and 71 files 15 Features CustomTables Fonts LaunchConditions Advanced UI
#NameDescriptionKey Features
06Product Suite Bundle wrapping multiple MSI packages into a single EXE BundleBuilder MsiPackage RollbackBoundary Built-in WPF UI
10Advanced Bundle All chain package types: ExePackage, MsuPackage, MspPackage, containers ExePackage MsuPackage MspPackage ExitCodeMapping RelatedBundles Containers
#NameDescriptionKey Features
11Custom UI Simple Custom WPF installer UI with page navigation and localization InstallerApp.Run InstallerPage<T> Localization AccentColor
12Custom UI VS-Style Visual Studio-style installer with workload selection Borderless DarkTheme Workloads Localization
13Glass UI Borderless acrylic/glass window with pill shape CustomWindow Transparency CornerRadius DragMove
14Lifecycle Hooks Bundle lifecycle event hooks with custom configuration page OnDetect/Plan/Apply SetProperty SetSecureProperty PasswordBridge
#NameDescriptionKey Features
15Bundle Signing Detach/sign/reattach workflow with Store, Timestamp, Algorithm Signing Detach Reattach Timestamp
15-msixMSIX Basic Basic MSIX package with manifest, visual assets, and capabilities MSIX AppxManifest Capabilities VisualAssets
16Features Feature tree with AllowSameVersionUpgrades, Schedule, MigrateFeatures NestedFeatures MajorUpgrade tuning
17Services Complete service API showcase: arguments, AccountProperty, conditional install, service permissions ServiceInstall FailureActions ServiceControl Credentials AccountProperty Arguments ComponentCondition Permissions
18Environment Variables System and user-scoped environment variables Set Append UserScope
19File Associations Register file extension with verbs and icons FileAssociation Verbs ContentType
20Custom Actions DllFromBinary, ExeFromBinary, Binary, Commit, ContinueOnError DllFromBinary ExeFromBinary Deferred Rollback Commit
21Launch Conditions Block install unless conditions are met (admin, OS version) Require IsPrivileged IsWindows10OrLater
22INI Files Write INI file entries during installation IniFile CreateEntry
23Permissions NTFS permissions via SDDL strings and ForTable Permission SDDL ForTable
24Fonts Register TrueType fonts with Title override Font TitleOverride
25File Operations MoveFile, DuplicateFile, RemoveFile, CreateFolder, ComponentCondition MoveFile DuplicateFile RemoveFile CreateFolder
26Custom Tables Typed custom MSI tables with row data CustomTable TypedColumns RowData
27GAC Assembly Register assemblies in the Global Assembly Cache Assembly GAC
28Sequence Scheduling ExecuteSequence and UISequence ordering ExecuteSequence UISequence Conditions
#NameDescriptionKey Features
29Ext: Firewall Windows Firewall inbound TCP rule configuration FirewallExtension AddRule TCP Inbound
30Ext: IIS IIS application pool and web site with HTTP binding IisExtension AppPool WebSite Binding
31Ext: SQL SQL Server database creation and schema scripts SqlExtension DefineDatabase Script
32Ext: .NET .NET runtime detection via factory pattern with launch conditions DotNetExtension SearchForRuntime LaunchCondition
33Ext: Util XmlConfig transformation (XPath-based attribute setting) XmlConfig XPath SetAttribute
34Ext: Dependency Provider/consumer registration with version constraints DependencyExtension Provides Requires
#NameDescriptionKey Features
35Bundle Simple Basic bundle with RelatedBundle and DependencyProvider RelatedBundle DependencyProvider
36Bundle EXE Package EXE prerequisite with exit code mapping ExePackage ExitCode
37Bundle MSU Package Windows Update (.msu) hotfix prerequisite MsuPackage KB Article
38Bundle Nested Nested child bundle inside a parent bundle BundlePackage Nested
39Bundle Remote Payload Download package from URL at install time RemotePayload Hash Size
40Bundle Variables Secret, Hidden, and Persisted bundle variables Secret Hidden Persisted InstallCondition
41Bundle Rollback Rollback boundaries isolating failure domains RollbackBoundary Isolation
42Bundle Update Feed Automatic update checking from a JSON feed URL UpdateFeed NotifyOnly
43Bundle Layout Named containers for offline layout scenarios Container Layout
#NameDescriptionKey Features
44Merge Module Reusable .msm component package with Dependency BuildMergeModule Dependency
45Patch Delta .msp patch with Classification and AllowRemoval BuildPatch Classification AllowRemoval
46Transform .mst transform for MSI property customization BuildTransform SetProperty
#NameDescriptionKey Features
47PowerShell Actions Inline script, file-based script, and deferred PowerShell custom actions PowerShell InlineScript Deferred
48COM Registration In-proc server, local server, ProgId, and threading model registration COM ProgId ThreadingModel
49HTTP Extension URL reservation and SNI SSL certificate binding HttpExtension UrlReservation SslBinding
50Driver Install INF-based device driver installation with NeverOverwrite on INF files DriverExtension PlugAndPlay ForceInstall NeverOverwrite
51ICE Validation Post-build ICE validation with rule suppression and JSON reporting IceValidation Suppress WarningsAsErrors
52MSIX Advanced Advanced MSIX packaging with visual assets and capabilities MSIX Advanced Capabilities
53Delta Updates Delta bundle updates via Octodiff: builds a v1 base bundle and a v2 delta bundle containing only binary diffs of changed payloads. Demonstrates BundleBuilder.DeltaFrom(v1Path), DeltaBundleCompiler, and delta-first download with automatic full-bundle fallback. DeltaFrom DeltaBundleCompiler Octodiff UpdateFeed
#NameDescriptionKey Features
54forge migrate Decompiles an existing MSI (or WiX Burn EXE) into a buildable FalkForge C# project via a single CLI command forge migrate RoundTrip MIGRATION-REPORT
55WinGet Manifest Generation Generates the 3-file WinGet manifest set at compile time with an automatic SHA-256 of the compiled MSI PackageBuilder.WinGet SHA-256 winget-pkgs
56Verify and Plan — Provability Commands The three provability CLI commands: forge verify --rebuild (byte-for-byte rebuild proof), forge plan-diff (version-to-version diff), and forge plan (pre-run install plan) forge verify forge plan-diff forge plan Reproducible
57Reproducible Builds + SBOM Reproducible builds pinned to SOURCE_DATE_EPOCH, a CycloneDX SBOM sidecar with per-file SHA-256 hashes, and ECDSA payload integrity notes for bundles Reproducible Sbom CycloneDX ECDSA
58Project References References another project's build output via the generated ProjectOutputs class instead of a hardcoded bin/ path ProjectOutputs Source Generator ProjectReference
#NameDescriptionKey Features
59Bundle Integrity Signing ECDSA payload signing via BundleBuilder.Integrity(): an ephemeral (throwaway) key for zero-setup tamper evidence next to a stable PEM key for real authorship, with the manifest signature verified back Integrity SigningKey ECDSA
60Trusted-Key Rotation Rotation-safe dual-signing (AddSigningKey/SigningKeys), trusted-key pinning via -p:FalkForgeTrustedKey and EngineTrustAnchor, plus key roles and quorum notes Rotation Dual-Sign EngineTrustAnchor Roles
61SignServer Remote Signing Remote signing through SignServerSignatureProvider/SignServerConfig on the async build pipeline; falls back to a local key when no SignServer is configured SignServer BuildBundleAsync HSM-ready
62Require-Signed Updates Update-trust authoring with Integrity().Epoch()/.Revoke() and UpdateFeed(); runtime enforcement notes for require-signed, anti-downgrade, and revocation (INT001/INT007/INT008) Epoch Revoke UpdateFeed Require-Signed
63Hybrid Post-Quantum Signing Dual-signs a bundle with ECDSA P-256 plus an ML-DSA-65 (FIPS 204) companion via Integrity(i => i.HybridKey(...)), pins the companion in the engine trust anchor, then proves the strip attack is rejected with INT011 HybridKey ML-DSA-65 TrustHybridKey INT011

Declarative JSON files validated and built by the forge CLI. No C# code required. Located in demo/json/. JSON configuration is a work-in-progress subset (see JSON Configuration Format) that now authors the Firewall, IIS, SQL, and .NET extensions: demo 06 (06-web-server.json) adds a firewall rule plus an IIS app pool and web site, and demo 07 (07-database-app.json) provisions a SQL database and install script — all emitted into the compiled MSI. A dotnet block now emits real MSI-native runtime detection the same way (see 10.5 .NET Extension); the demo gallery does not yet include a dedicated JSON dotnet demo — the C# fluent equivalent is Demo 32.

#FileDescriptionDialog Set
0101-minimal.jsonMinimal UI, single fileMinimal
0202-installdir.jsonInstallDir UI, desktop shortcut, registryInstallDir
0303-featuretree.jsonFeatureTree UI, nested features, servicesFeatureTree
0404-mondo.jsonMondo UI, environment variables, license, servicesMondo
0505-advanced.jsonAdvanced UI, nested services, env vars, all featuresAdvanced
0606-web-server.jsonBasic file installer (JSON can't author IIS/firewall — see C# Demo 07)InstallDir
0707-database-app.jsonBasic file installer (JSON can't author SQL/.NET detection — see C# Demo 07)InstallDir

ICE Validation

ICE (Internal Consistency Evaluators) are a set of rules built into the Windows Installer SDK that check an MSI database for common authoring errors. FalkForge integrates ICE validation through both the fluent API and the forge CLI.

When to Use ICE Validation

ICE validation requires the Windows SDK darice.cub file. The validator automatically searches C:\Program Files (x86)\Windows Kits\10\bin and related paths. What happens when it's not found depends on how you invoke ICE, and the two paths disagree by design:
  • The parameterless fluent overload — IceValidator.Validate(msiPath), with no IceConfiguration supplied — treats ICE as opt-in and skips gracefully when darice.cub is missing.
  • Everything that goes through an explicit IceConfiguration — the package.Ice(...) builder shown below, forge build, and forge validate --ice — is strict by default: a missing darice.cub is a hard build/validate failure. Pass SkipWhenCubUnavailable(true) (API) or --ice-skip-when-cub-unavailable (CLI) to restore the lenient, skip-when-missing behavior.

API: IceConfigurationBuilder

Configure ICE validation on the PackageBuilder using the fluent Ice() method:

return Installer.Build(args, package =>
{
    package.Name = "My Application";
    package.Manufacturer = "Contoso";
    package.Version = new Version(1, 0, 0);

    package.Files(files => files
        .Add("payload/app.exe")
        .To(KnownFolder.ProgramFiles / "Contoso" / "MyApp"));

    // Configure ICE validation
    package.Ice(ice => ice
        .Suppress("ICE03", "ICE82")     // Suppress known false positives
        .WarningsAsErrors()              // Promote warnings to errors
        .ReportPath("ice-report.json")); // Export results to JSON

}, new MsiCompiler());

The IceConfigurationBuilder supports:

MethodDescription
Suppress(params string[] iceNames)Exclude specific ICE rules from results (e.g., "ICE03", "ICE82")
WarningsAsErrors(bool value = true)Promote all ICE warnings to errors, causing build failure
ReportPath(string path)Export results to a JSON report file
CubFilePath(string path)Use a custom .cub file instead of auto-detected darice.cub
SkipWhenCubUnavailable(bool value = true)Waive ICE validation instead of failing when darice.cub can't be found. Default is false (strict) — a missing darice.cub is a typed Result failure, not a silent skip
Disable()Disable ICE validation entirely

CLI: forge validate

Validate an existing .msi file from the command line:

# Basic ICE validation
forge validate myapp.msi --ice

# With suppression and warnings-as-errors
forge validate myapp.msi --ice --suppress-ice ICE03,ICE82 --ice-warnings-as-errors

# Custom CUB file path
forge validate myapp.msi --ice --ice-cub-path "C:\MySDK\darice.cub"

# Export results to JSON
forge validate myapp.msi --ice --ice-report ice-results.json

The forge build command also supports ICE flags:

# Build with ICE validation (enabled by default)
forge build installer.csx -o ./output

# Build with ICE disabled
forge build installer.csx -o ./output --no-ice

# Build with strict ICE validation
forge build installer.csx -o ./output --ice-warnings-as-errors --ice-report report.json

JSON Report Format

When ReportPath is set (API) or --ice-report is used (CLI), results are exported as a JSON file:

{
  "isValid": true,
  "messages": [
    {
      "iceName": "ICE33",
      "severity": "Warning",
      "description": "No data for default language in File table.",
      "table": "File",
      "column": "FileName",
      "primaryKeys": "file_app.exe"
    }
  ],
  "summary": {
    "errors": 0,
    "warnings": 1,
    "failures": 0,
    "information": 0
  }
}

ICE Message Severities

SeverityMeaningEffect on IsValid
ErrorThe MSI violates a Windows Installer ruleFails validation
FailureThe ICE rule itself could not executeFails validation
WarningPotential issue that may cause runtime problemsPasses (unless WarningsAsErrors)
InformationAdvisory message, no action requiredPasses

PowerShell Custom Actions

FalkForge supports running PowerShell scripts as MSI custom actions during installation. Scripts execute via powershell.exe with hardened defaults: -NoProfile -NonInteractive -ExecutionPolicy Bypass.

PackageBuilder.CustomAction(string id, Action<CustomActionBuilder> configure) takes the action's id as an explicit first argument — there is no .Id(...) method on CustomActionBuilder. After/Before/Condition are settable properties, not fluent methods; actual scheduling always comes from where the action's id appears in ExecuteSequence(...)/UISequence(...) — setting After/Before on the builder is metadata only, so every custom action must also be placed explicitly in a sequence. See Demo 47 — powershell-actions for a complete, buildable example covering every pattern below.

Inline Script: PowerShellScript()

Embed a PowerShell script directly in the custom action definition. The script text is stored in the MSI CustomAction table.

package.CustomAction("LogInstallStart", ca =>
{
    ca.PowerShellScript("Write-EventLog -LogName Application -Source 'MSIInstaller' " +
                         "-EventId 1000 -Message 'PowerShell Demo: installation started'");
});

package.ExecuteSequence(seq => seq
    .Action("LogInstallStart").After("CostFinalize").Condition(Condition.IsInstalling));

External File: PowerShellFile()

Read a .ps1 file at build time and embed its content as an inline script. This keeps complex scripts in separate files for easier editing and testing.

package.CustomAction("RunSetupScript", ca =>
{
    ca.PowerShellFile("payload/setup.ps1");
    ca.Deferred();
    ca.NoImpersonate();
});

package.ExecuteSequence(seq => seq
    .Action("RunSetupScript").After("InstallFiles"));
PowerShellFile() reads the file at build time and delegates to PowerShellScript() internally. The .ps1 file does not need to be present on the target machine — its content is embedded in the MSI.

Deferred Execution & the CustomActionData Staging Pattern

Deferred actions (see Install Sequencing) run inside the installation script and can no longer see the live property table, so any data they need — a password, a computed path — must be staged into their CustomActionData by an immediate action scheduled just before them. MSI's own convention drives this: an immediate SetProperty action whose property name equals the deferred action's id becomes that action's CustomActionData automatically, readable inside the deferred action's own Target via the literal [CustomActionData] token:

// Immediate: stage a secret into the deferred action's CustomActionData. The property
// name here ("ConfigureDatabase") must match the deferred action's id below.
package.CustomAction("StageDbPassword", ca =>
{
    ca.SetProperty("ConfigureDatabase", "[DB_PASSWORD]");
});

// Deferred: [CustomActionData] is substituted with the staged value before powershell.exe
// ever sees the command line -- the script itself never touches the live property table
// (deferred actions can't; see Install Sequencing).
package.CustomAction("ConfigureDatabase", ca =>
{
    ca.PowerShellScript("$password = '[CustomActionData]'; Set-DbCredential -Password $password");
    ca.Deferred();
    ca.NoImpersonate();
});

package.ExecuteSequence(seq => seq
    .Action("StageDbPassword").Before("ConfigureDatabase")
    .Action("ConfigureDatabase").After("InstallFiles"));

Rollback Scripts

Register a rollback script that reverses the PowerShell action if installation fails. The rollback action must be scheduled before the forward action it undoes — MSI rollback actions run in reverse of install order, so they need to already be in the script when the forward action that might fail is reached:

// Rollback action (scheduled first, per MSI rollback-scheduling rules)
package.CustomAction("UndoConfigureSettings", ca =>
{
    ca.PowerShellScript("Remove-Item -Path 'C:\\App\\config.json' -Force -ErrorAction SilentlyContinue");
    ca.Rollback();
    ca.NoImpersonate();
});

// Forward action
package.CustomAction("ConfigureSettings", ca =>
{
    ca.PowerShellScript("New-Item -Path 'C:\\App\\config.json' -ItemType File");
    ca.Deferred();
    ca.NoImpersonate();
});

package.ExecuteSequence(seq => seq
    .Action("UndoConfigureSettings").Before("ConfigureSettings")
    .Action("ConfigureSettings").After("InstallFiles"));

Execution Modes

MethodDescription
PowerShellScript(string)Inline PowerShell script text
PowerShellFile(string)Read .ps1 file at build time, embed as inline script
Deferred()Run during installation script phase (elevated context)
NoImpersonate()Run as LocalSystem instead of impersonating the user
Rollback()Run only if the installation fails and rolls back
Commit()Run only after successful installation
ContinueOnError()Do not fail the installation if the script returns a non-zero exit code
Implementation detail: PowerShell custom actions use MSI type 34 (ExeInDir) targeting powershell.exe in [SystemFolder]. Double quotes in the script are escaped as \" for safe embedding in the command line.

Update Feed

FalkForge bundles can check for updates at startup by fetching a JSON feed from an HTTPS endpoint. When a newer version is available, the engine notifies the UI, which can prompt the user or (in future) auto-download and launch the update.

Feed JSON Format

The feed is a JSON document with a bundleId (must match the bundle's BundleId GUID) and an array of version entries:

{
  "bundleId": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
  "entries": [
    {
      "version": "2.1.0",
      "url": "https://releases.example.com/myapp/2.1.0/setup.exe",
      "sha256": "a1b2c3d4e5f6...64-char-hex-hash",
      "size": 15728640,
      "releaseNotes": "Bug fixes and performance improvements.",
      "published": "2026-03-01T00:00:00Z",
      "minVersion": "1.5.0",
      "deltaUrl": "https://releases.example.com/myapp/2.1.0/setup-delta-from-2.0.1.exe",
      "deltaSha256": "c3d4e5f6a7b8...64-char-hex-hash",
      "deltaSize": 1048576
    },
    {
      "version": "2.0.1",
      "url": "https://releases.example.com/myapp/2.0.1/setup.exe",
      "sha256": "b2c3d4e5f6a7...64-char-hex-hash",
      "size": 15200000,
      "releaseNotes": "Security patch.",
      "published": "2026-02-15T00:00:00Z"
    }
  ]
}

Feed Entry Fields

FieldRequiredDescription
versionYesSemantic version string (parsed via System.Version)
urlYesHTTPS download URL for the updated bundle EXE. Non-HTTPS URLs are rejected (UPD004).
sha256YesSHA-256 hash of the download for integrity verification
sizeNoFile size in bytes (used for progress display)
releaseNotesNoHuman-readable release notes for UI display
publishedNoISO 8601 publication date (reserved for future UI display)
minVersionNoMinimum currently-installed version that can upgrade to this entry. If the installed version is below minVersion, the entry is skipped.
deltaUrlNoHTTPS download URL for a delta bundle (see Demo 53 — delta-updates) that reconstructs this version from a previously-installed base bundle. Rejected (falls back to a full download) unless it parses as an absolute https:// URI.
deltaSha256No, but required alongside deltaUrlSHA-256 hash of the delta download. A deltaUrl without a non-blank deltaSha256 is dropped entirely — the parser then behaves as if no delta was offered
deltaSizeNoDelta file size in bytes (used for progress display). Only carried through when deltaUrl/deltaSha256 both validated

Update Policy

The UpdatePolicy enum controls what happens when an update is found:

PolicyBehavior
NotifyOnlyCheck for updates and send an UpdateAvailable message to the UI. No automatic download. The UI decides what to show the user.
DownloadAndPromptDownload the update to cache, then send UpdateReady to the UI for user confirmation. (Currently behaves as NotifyOnly.)
AutoUpdateDownload and auto-launch the update. (Currently behaves as NotifyOnly.)

Configuration Flags

PropertyDefaultDescription
FeedUrl(required)HTTPS URL of the JSON feed
PolicyNotifyOnlyUpdate behavior policy
AllowResumeDownloadtrueAllow resuming interrupted downloads
ShowDownloadProgresstrueSend progress messages to the UI during download
ShowDownloadErrorsfalseSend download error details to the UI (off by default to avoid user confusion)
PromptBeforeAutoUpdatefalseWhen AutoUpdate policy is active, prompt the user before launching the downloaded update

BundleBuilder Configuration

var bundle = new BundleBuilder()
    .Name("My Application")
    .Manufacturer("Contoso")
    .Version("2.0.0")
    .BundleId(new Guid("E1F2A3B4-C5D6-4E7F-8A9B-0C1D2E3F4A5B"))
    .UpgradeCode(new Guid("F4A5B6C7-D8E9-4F0A-1B2C-3D4E5F6A7B8C"))
    .UpdateFeed(
        "https://releases.example.com/myapp/update-feed.json",
        UpdatePolicy.NotifyOnly)
    .Chain(chain => chain
        .MsiPackage("myapp.msi", p => p
            .Id("MainApp")
            .Vital(true)))
    .Build();

The UpdateFeed() method accepts three parameters:

BundleBuilder UpdateFeed(
    string feedUrl,                              // HTTPS URL
    UpdatePolicy policy = UpdatePolicy.NotifyOnly,
    bool allowResume = true)

How It Works

The update check flow runs during the engine's Detecting phase:

  1. The engine reads the UpdateFeed configuration from the embedded manifest.
  2. It fetches the JSON feed from FeedUrl over HTTPS.
  3. The UpdateFeedParser deserializes the feed (AOT-safe via UpdateFeedJsonContext).
  4. It verifies the bundleId matches the running bundle (UPD003 on mismatch).
  5. It selects the highest version entry that is greater than the currently installed version, respecting minVersion constraints.
  6. If an update is found, an UpdateAvailable message is sent to the UI via the named pipe.
  7. Under DownloadAndPrompt/AutoUpdate policies, the UpdateDownloader downloads the file with SHA-256 verification and optional resume support.

Delta-first, full-bundle fallback: when the selected entry carries a validated deltaUrl/deltaSha256 pair, UpdateDownloader tries the delta download before the full bundle:

  1. It downloads the delta artifact (itself a complete self-extracting bundle EXE whose payloads are Octodiff delta blobs) to a separate .delta-suffixed cache path, verified against deltaSha256.
  2. On success, the verified delta artifact is moved onto the final bundle cache path. The delta payloads are reconstructed against the previously-installed base bundle at install time by the engine's delta applicator (basis-pinned, with a reconstructed-hash check) — this download step only proves the delta artifact itself downloaded intact, not that it will apply.
  3. If the delta download fails, or the move to the final cache path fails, UpdateDownloader logs a warning and falls back to downloading the full bundle from url/sha256 instead — the update still completes, just without the smaller transfer.
  4. If no valid delta was offered, the full bundle is downloaded directly, same as before delta support existed.

See Demo 53 — delta-updates for a worked example that builds both a full and a delta feed entry, and Delta Updates for how delta bundles themselves are produced at build time.

Error Codes

CodeDescription
UPD002Failed to parse update feed JSON or current version string
UPD003Feed bundleId does not match the running bundle
UPD004Update entry download URL is not a valid HTTPS URI

Hosting the Feed

Security: The engine only accepts HTTPS URLs for download. Non-HTTPS URLs are rejected with error UPD004. Downloaded files are verified against the sha256 hash in the feed entry.

20. FalkForge Studio

FalkForge Studio is a visual IDE for designing Windows installers without writing code. Built as a WPF application using the MVVM pattern, it provides 22 specialized editors for every aspect of installer authoring — from product metadata and feature trees to dialog layout, bundle orchestration, and CI/CD pipeline generation. Projects are stored as JSON files (.ffstudio) and can be compiled directly to MSI or EXE bundles via the same compiler pipeline used by the fluent API and CLI.

Architecture

20.1 Getting Started

Launch Studio by running the FalkForge.Studio project. The main window presents a three-panel layout: a navigation tree on the left, the active editor in the center, and an output/validation panel docked at the bottom.

Creating a New Project

Select File → New Project to create a blank project. Studio generates a StudioProject with a random UpgradeCode and a single required “Main” feature. The project type defaults to msi.

Opening an Existing Project

Select File → Open... and choose a .ffstudio JSON file. Studio deserializes it via StudioProjectLoader.LoadFromFile() and rebuilds the navigation tree, including bundle-specific nodes when projectType is "bundle" or MSIX-specific nodes when "msix".

Project Types

TypeValueDescription
MSI Package"msi"Standard Windows Installer package. Default project type.
EXE Bundle"bundle"Self-extracting bootstrapper. Adds Bundle Settings, Bundle Packages, and Bundle Chain editors to the tree.
MSIX Package"msix"Modern Windows packaging. Adds MSIX Applications and Capabilities editors to the tree.

20.2 Project Structure

A Studio project is a single JSON file with the following top-level sections. Each section maps to one or more editors in the navigation tree.

JSON PropertyTypeDescription
projectTypestringProject type: "msi", "bundle", or "msix"
productProductSectionName, manufacturer, version, upgrade code, architecture, scope, support info
installDirectorystring?Target installation directory path
featuresFeatureSection[]Feature tree with IDs, titles, install levels, required/default flags
registryRegistryEntrySection[]Registry keys and values to create during installation
servicesServiceSection[]Windows service definitions (install, start type, dependencies)
shortcutsShortcutSection[]Start menu and desktop shortcut definitions
environmentEnvironmentVariableSection[]Environment variable create/set/append operations
customActionsCustomActionSection[]Custom action definitions (DLL, EXE, script, property)
sqlDatabasesSqlDatabaseSection[]SQL Server database creation and script execution
firewallRulesFirewallRuleSection[]Windows Firewall rule definitions
xmlConfigsXmlConfigSection[]XML file modifications (XPath-based)
scheduledTasksScheduledTaskSection[]Windows Task Scheduler entries
perfCountersPerfCounterSection[]Performance counter categories and counters
odbcDriversOdbcDriverSection[]ODBC driver registrations
odbcDataSourcesOdbcDataSourceSection[]ODBC data source (DSN) definitions
bundleSettingsBundleSettingsSection?Bundle-specific settings (only for "bundle" projects)
bundlePackagesBundlePackageSection[]Packages included in a bundle chain
uiUiSectionDialog template selection, custom dialog definitions
buildBuildSectionOutput path, compression, signing, ICE validation settings
Studio projects are plain JSON. They can be version-controlled, diffed, and merged with standard tools. The StudioProjectLoader class handles serialization with WriteIndented = true for readable diffs.

20.3 Editors

Studio provides 22 editors, each accessible from the navigation tree. Selecting a tree node loads the corresponding ViewModel into the content area via WPF DataTemplate matching.

EditorTree NodeDescription
Product Product Tabbed editor for product metadata. General tab: name, manufacturer, version, upgrade code, architecture, scope. Support Info tab: description, help URL, about URL, update URL, phone, email, comments, license file. Also sets the project type (MSI/Bundle/MSIX).
Features Features Hierarchical tree editor for the feature tree. Each feature has an ID, title, description, install level, and required/default flags. Supports drag-and-drop reordering and nested child features.
Files Files DataGrid editor for file entries. Columns include source path, target directory, feature assignment, and the vital flag. Supports add/remove and file browsing.
Registry Registry DataGrid editor for registry entries. Fields: root (HKLM/HKCU/HKCR), key path, value name, value type (String/DWORD/etc.), and value data.
Services Services Editor for Windows service definitions. Configure service name, display name, executable path, start type (Auto/Manual/Disabled), and service dependencies.
Shortcuts Shortcuts Editor for Start menu and desktop shortcuts. Fields: name, target, arguments, working directory, icon, and location (Start Menu/Desktop).
Environment Environment DataGrid editor for environment variable operations. Supports create, set, and append actions with variable name and value fields.
Custom Actions Custom Actions Editor for custom action definitions. Configure type (DLL, EXE, script, property set), source, target, execution options, and sequence placement.
SQL Extensions → SQL Editor for SQL Server databases and scripts. Define database connections, creation settings, and SQL scripts to execute during install/uninstall.
Firewall Extensions → Firewall Editor for Windows Firewall rules. Configure rule name, direction (inbound/outbound), action (allow/block), protocol, port, and program path.
XML Config Extensions → XML Config Editor for XML file modifications using XPath expressions. Define target file, XPath, value, and action (set/add/remove).
Scheduled Tasks Extensions → Scheduled Tasks Editor for Windows Task Scheduler entries. Configure task name, executable, arguments, schedule trigger, and run-as settings.
Perf Counters Extensions → Perf Counters Editor for performance counter categories and individual counters. Define category name, type, and counter entries.
ODBC Extensions → ODBC Editor for ODBC driver registrations and data source (DSN) definitions. Supports both driver and DSN entries in a single view.
UI & Dialogs UI & Dialogs Configuration for the installer UI. Select a built-in dialog template, configure license display, and set watermark/banner images.
Dialog Editor Dialog Editor WYSIWYG dialog designer. Add, remove, and configure individual MSI dialog definitions and their controls. Load from template presets: Minimal, InstallDir, FeatureTree, Mondo, and Advanced.
Build Settings Build Settings Configure output path, compression level, code signing options, and ICE validation settings for the compilation step.
Bundle Settings Bundle Settings Bundle-specific configuration (bundle projects only). Set bundle name, version, update feed URL, and UI type.
Bundle Packages Bundle Packages Editor for packages included in a bundle (bundle projects only). Add MSI, MSU, MSP, and nested bundle packages with install conditions.
Bundle Chain Bundle Chain Visual chain orchestration editor (bundle projects only). Define installation order, rollback boundaries, and package sequencing with drag-and-drop.
Diff Viewer Tools menu Side-by-side project comparison. Loaded via Tools → Compare Projects... to diff two .ffstudio files.
Table Inspector Tools menu Browse compiled MSI tables. Loaded via Tools → MSI Table Inspector to examine the internal structure of a built MSI.
Dependency Graph Tools menu Interactive feature-to-file dependency visualization. Displays nodes for features, files, services, registry entries, and shortcuts with edges showing relationships. Detects orphaned files not assigned to any feature.

20.4 Import & Export

Studio can import existing installer projects from multiple sources and export to code or CI/CD pipeline definitions. All import/export operations are available from the File menu.

Import from MSI

File → Import MSI... uses the MsiImporter class, which delegates to MsiDecompiler to decompile an .msi file into a PackageModel, then maps it to a StudioProject. This is a Windows-only operation since it requires msi.dll P/Invoke.

Import from WiX Source

File → Import WiX Source... uses the WixImporter class to parse .wxs files. Supports both WiX v3 (http://schemas.microsoft.com/wix/2006/wi) and WiX v4 (http://wixtoolset.org/schemas/v4/wxs) XML namespaces. Returns a Result<StudioProject> with validation errors for malformed input.

Export to C# Script

File → Export to C# Script... uses CSharpExporter to generate a .csx file that uses the FalkForge fluent API. The exported script calls Installer.Build(args, package => { ... }, new MsiCompiler()) and can be compiled directly with forge build or dotnet run.

Export CI/CD Pipeline

File → Export CI/CD Pipeline... uses CiCdExporter to generate pipeline definitions for three platforms:

PlatformOutputDescription
GitHub Actions.github/workflows/build.ymlYAML workflow with .NET SDK setup, build, and artifact upload steps
Azure DevOpsazure-pipelines.ymlYAML pipeline with .NET SDK task, build, and publish artifact steps
JenkinsJenkinsfileDeclarative pipeline with .NET SDK stage, build, and archive artifacts
The CI/CD exporter derives the project file name from the product name and selects the artifact pattern (*.msi or *.exe) based on the project type.

20.5 Analysis Tools

Studio includes analysis tools accessible from the Tools menu for inspecting, comparing, and visualizing installer projects.

Project Diff

Tools → Compare Projects... opens the Diff Viewer, which uses ProjectDiffer.Diff() to perform a deep JSON comparison of two StudioProject instances. Both projects are serialized to JsonNode trees and compared recursively, producing a list of DiffEntry records with the JSON path, left value, and right value for each difference.

Dependency Graph

Tools → Dependency Graph opens an interactive visualization built by DependencyGraphBuilder. The graph displays nodes for features, files, services, registry entries, and shortcuts, connected by edges showing ownership and dependency relationships. Key capabilities:

MSI Table Inspector

Tools → MSI Table Inspector opens a read-only browser for the internal tables of a compiled .msi file. Useful for verifying that Studio’s compilation produced the expected table structure.

Live Validation Panel

The bottom panel includes a validation tab that displays errors and warnings with severity, code, and message columns. Double-clicking a validation entry navigates to the responsible editor. Validation codes use the STU prefix:

20.6 Workflow Features

Undo / Redo

Studio supports full undo/redo via UndoManager. Each edit snapshots the entire project as serialized JSON onto an undo stack (maximum 50 states). Undo pops the previous state and pushes the current state onto the redo stack. Keyboard shortcuts: Ctrl+Z (undo) and Ctrl+Y (redo). Both commands are also available from the Edit menu.

Async Build

Select Build → Build MSI to compile the project. The build runs asynchronously on a background thread via Task.Run(), keeping the UI responsive. Build output streams to the Output tab in the bottom panel. The Build menu item is automatically disabled while a build is in progress. After compilation completes, validation runs automatically to report any issues.

Navigation Tree

The left panel displays a hierarchical tree with all editor nodes. Extension editors (SQL, Firewall, XML Config, Scheduled Tasks, Perf Counters, ODBC) are grouped under an “Extensions” parent node. Bundle-specific editors (Bundle Settings, Bundle Packages, Bundle Chain) appear only when the project type is "bundle". MSIX-specific editors appear only when the project type is "msix". Tree nodes support two-way IsSelected and IsExpanded binding.

Menu Structure

MenuItems
FileNew Project, Open..., Save, Import MSI..., Import WiX Source..., Export to C# Script..., Export CI/CD Pipeline..., Exit
EditUndo (Ctrl+Z), Redo (Ctrl+Y)
ToolsCompare Projects..., MSI Table Inspector, Dependency Graph
BuildBuild MSI

21. MSIX Packaging

Experimental. FalkForge.Compiler.Msix is wired into the fluent API and FalkForge Studio, but the forge CLI dispatch is not yet implemented and no demo projects ship for MSIX. The diagnostic code FF_MSIX001 is emitted when MSIX features are used. Expect API churn before MSIX leaves experimental status. Use it for feedback, not production deployment.

FalkForge supports building MSIX packages, the modern Windows application packaging format. MSIX provides clean install/uninstall, automatic updates via .appinstaller files, containerized execution with a Virtual File System (VFS), and Store-ready distribution. The FalkForge.Compiler.Msix project generates MSIX and MSIX Bundle outputs using the Windows AppX Packaging COM API.

MSIX requires Windows 10 version 1809 (build 17763) or later. The MsixCompiler and MsixBundleCompiler are Windows-only ([SupportedOSPlatform("windows")]) because they use COM interop with the AppX packaging APIs and signtool.exe.

When to Use MSIX vs MSI vs Bundle

FormatUse CaseWindows Requirement
.msi Traditional desktop apps, enterprise deployment via Group Policy, SCCM/Intune. Full system access, services, COM registration. Windows XP+
.msix Modern desktop apps with clean install/uninstall, auto-update, Microsoft Store distribution. Containerized VFS execution. Windows 10 1809+
.exe bundle Multi-package orchestration (MSI + prerequisites + .NET runtime). Rollback boundaries, elevated install chain. Windows 7+

21.1 MSIX Builder API

The entry point for MSIX packaging is InstallerMsix.BuildMsix(), defined in src/FalkForge.Compiler.Msix/InstallerMsix.cs. It follows the same pattern as Installer.Build(): configure a builder, validate the model, and pass it to a compile function.

MethodSignatureDescription
BuildMsix int BuildMsix(string[] args, Action<MsixBuilder> configure, Func<MsixModel, string, Result<string>> compile) Builds an MSIX package. Configures the builder, validates via MsixValidator, and compiles. Returns 0 on success, 1 on failure.
BuildMsixBundle int BuildMsixBundle(string[] args, Action<MsixBundleBuilder> configure, Func<MsixBundleModel, string, Result<string>> compile) Builds an MSIX bundle (.msixbundle) containing multiple architecture-specific packages. Returns 0 on success, 1 on failure.

Both entry points support -o / --output command-line arguments to specify the output directory.

MsixBuilder

The fluent builder for defining an MSIX package model. Located in src/FalkForge.Compiler.Msix/Builders/MsixBuilder.cs.

MethodReturn TypeDescription
Name(string name)MsixBuilderPackage identity name (e.g., "Company.AppName"). Must be unique in the Store.
Publisher(string publisher)MsixBuilderPublisher identity in certificate subject format (must start with "CN="). Must match the signing certificate.
DisplayName(string name)MsixBuilderHuman-readable package name shown to users.
PublisherDisplayName(string name)MsixBuilderHuman-readable publisher name.
Version(Version version)MsixBuilderPackage version. Must have 4 parts (Major.Minor.Build.Revision). Default: 1.0.0.0.
Architecture(ProcessorArchitecture arch)MsixBuilderTarget processor architecture. Default: X64.
Description(string description)MsixBuilderPackage description for the manifest Properties element.
LogoPath(string path)MsixBuilderPath to the package logo image (StoreLogo).
MinWindowsVersion(string version)MsixBuilderMinimum Windows version. Default: "10.0.17763.0" (Windows 10 1809).
MaxVersionTested(string version)MsixBuilderMaximum Windows version tested against. Default: "10.0.26100.0".
Application(string id, string executable, Action<MsixApplicationBuilder> configure)MsixBuilderAdds an application entry point to the package. At least one is required.
Files(Action<FileSetBuilder> configure)MsixBuilderAdds files using the standard FileSetBuilder from Core.
RegistryEntry(string key, string? valueName, string? value, MsixRegistryValueType type, string root)MsixBuilderAdds a registry entry to the package. Default root: "HKCU", default type: String.
Signing(Action<SigningOptionsBuilder> configure)MsixBuilderConfigures code signing. MSIX packages must be signed (MSIX008).
Capability(string capability)MsixBuilderDeclares a general capability (e.g., "internetClient").
RestrictedCapability(string capability)MsixBuilderDeclares a restricted capability (requires Store approval).
Dependency(string name, string publisher, Version? minVersion)MsixBuilderDeclares a package dependency on another MSIX package.
Sbom(Action<SbomOptions>? configure)MsixBuilderOpts in to a CycloneDX 1.6 SBOM sidecar (<package>.msix.cdx.json) listing every packaged file with its SHA-256.
VfsMapping(VfsMappingMode mode)MsixBuilderSets the VFS mapping mode. Default: Auto.
VfsOverride(string sourceDir, string packageRelPath)MsixBuilderAdds a custom VFS path override for files from a specific source directory.
UpdateSettings(string appInstallerUri, Action<MsixUpdateSettingsBuilder>? configure)MsixBuilderConfigures auto-update via .appinstaller file generation.
Build()MsixModelBuilds and returns the final MsixModel for compilation.
// Minimal MSIX package
InstallerMsix.BuildMsix(args, msix =>
{
    msix
        .Name("Contoso.MyApp")
        .Publisher("CN=Contoso Ltd")
        .DisplayName("My Application")
        .PublisherDisplayName("Contoso")
        .Version(new Version(1, 0, 0, 0))
        .Architecture(ProcessorArchitecture.X64)
        .Application("App", "MyApp.exe", app => app
            .DisplayName("My Application")
            .Square44x44Logo("assets/Square44x44Logo.png")
            .Square150x150Logo("assets/Square150x150Logo.png"))
        .Capability("internetClient")
        .Signing(s => s.CertificatePath("contoso.pfx"));
}, (model, outputPath) =>
{
    var compiler = new MsixCompiler();
    return compiler.Compile(model, outputPath);
});

MsixApplicationBuilder

Configures an application entry point within the MSIX package. Created via MsixBuilder.Application(). Located in src/FalkForge.Compiler.Msix/Builders/MsixApplicationBuilder.cs.

MethodReturn TypeDescription
EntryPoint(string entryPoint)MsixApplicationBuilderSets the entry point class (for UWP/WinUI apps). Optional for desktop apps.
DisplayName(string displayName)MsixApplicationBuilderDisplay name shown in Start menu and taskbar. Defaults to the application ID.
Description(string description)MsixApplicationBuilderApplication description for the visual elements.
BackgroundColor(string color)MsixApplicationBuilderTile background color. Default: "transparent".
Square150x150Logo(string path)MsixApplicationBuilderPath to the 150x150 medium tile logo.
Square44x44Logo(string path)MsixApplicationBuilderPath to the 44x44 app list logo (taskbar, Start menu).
Wide310x150Logo(string path)MsixApplicationBuilderPath to the 310x150 wide tile logo.
FileTypeAssociation(string name, params string[] fileTypes)MsixApplicationBuilderRegisters a file type association. name is the lowercase logical association name; each file type includes its leading dot (".cdoc").
FileTypeAssociation(string name, string? displayName, string? logo, IReadOnlyList<string> fileTypes)MsixApplicationBuilderAs above, with a user-visible name and/or icon for the association.
Protocol(string name, string? displayName, string? logo)MsixApplicationBuilderRegisters a URI scheme handled by this application. name is the scheme alone — "contoso", not "contoso://".

21.2 Capabilities & Extensions

MSIX packages declare their required permissions via capabilities, and register system integration points via extensions. These map directly to elements in the generated AppxManifest.xml.

Capabilities

General capabilities are declared with Capability(). Restricted capabilities that require Microsoft Store approval use RestrictedCapability().

CapabilityCategoryDescription
internetClientGeneralOutbound internet access.
internetClientServerGeneralOutbound and inbound internet access.
privateNetworkClientServerGeneralInbound and outbound access on home/work networks.
webcamGeneralAccess to the device camera.
microphoneGeneralAccess to the device microphone.
runFullTrustRestrictedRun as a full-trust desktop application (required for most desktop apps).
allowElevationRestrictedAllow the app to request elevation at runtime.
broadFileSystemAccessRestrictedAccess to the broad file system beyond app-specific folders.
msix
    .Capability("internetClient")
    .Capability("privateNetworkClientServer")
    .RestrictedCapability("runFullTrust");

Extensions

Extensions register the application for system integration points. They are declared per application — that is where AppxManifest puts them — and FalkForge exposes the two categories it can emit correctly today.

CategoryFluent APIEmitted markup
windows.fileTypeAssociation app.FileTypeAssociation(...) <uap:Extension Category="windows.fileTypeAssociation"> containing a <uap:FileTypeAssociation> with <uap:SupportedFileTypes>.
windows.protocol app.Protocol(...) <uap:Extension Category="windows.protocol"> containing a <uap:Protocol>.
msix.Application("App", "MyApp.exe", app => app
    .DisplayName("My Application")
    .FileTypeAssociation("contoso.doc", ".cdoc", ".cdocx")
    .Protocol("contoso", displayName: "Contoso Handler"));

Why no generic Extension(category, entryPoint)? There was one, and it emitted nothing at all. Each extension category dictates both its XML namespace (uap, uap5, desktop, …) and the child elements it requires — windows.protocol needs a <uap:Protocol Name="…">, windows.startupTask needs a <desktop:StartupTask>. A bare category string cannot supply either, so the API was removed rather than left accepting input the compiler could not honour. Categories beyond the two above are not yet supported; declaring one is a compile error instead of a silent no-op.

Package Dependencies

Declare dependencies on other MSIX packages that must be present on the system. Dependencies are emitted as <PackageDependency> elements in the manifest.

msix.Dependency(
    name: "Microsoft.VCLibs.140.00",
    publisher: "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US",
    minVersion: new Version(14, 0, 30704, 0));

21.3 Virtual File System

MSIX packages use a Virtual File System (VFS) to map application files to well-known Windows folders without actually writing to those system locations. The VfsMapper (src/FalkForge.Compiler.Msix/Packaging/VfsMapper.cs) resolves file placements based on the configured mapping mode.

Mapping Modes

ModeBehavior
VfsMappingMode.Auto Default. Automatically maps files to VFS paths based on their KnownFolder target directory. Files targeting ProgramFilesFolder go to VFS/ProgramFilesX64/, etc. Falls back to package root for unrecognized folders.
VfsMappingMode.Manual No automatic VFS mapping. Files are placed at the package root unless a VfsOverride is specified. Use this for full control over package layout.

Known Folder Mappings

In Auto mode, the following MSI folder tokens are mapped to MSIX VFS paths. For X86 architecture, ProgramFilesFolder and CommonFilesFolder remap to their X86 counterparts.

MSI Folder TokenVFS Path (X64)VFS Path (X86)
ProgramFilesFolderVFS/ProgramFilesX64VFS/ProgramFilesX86
ProgramFiles64FolderVFS/ProgramFilesX64VFS/ProgramFilesX64
ProgramFilesX86FolderVFS/ProgramFilesX86VFS/ProgramFilesX86
CommonFilesFolderVFS/ProgramFilesCommonX64VFS/ProgramFilesCommonX86
CommonFiles64FolderVFS/ProgramFilesCommonX64VFS/ProgramFilesCommonX64
SystemFolder / System64FolderVFS/SystemX64
WindowsFolderVFS/Windows
CommonAppDataFolderVFS/CommonAppData
AppDataFolderVFS/AppData
LocalAppDataFolderVFS/LocalAppData
FontsFolderVFS/Fonts

VFS Overrides

Custom overrides take precedence over automatic mapping. Use VfsOverride() to map files from a specific source directory to a custom package-relative path.

msix
    .VfsMapping(VfsMappingMode.Auto)
    .VfsOverride("plugins/", "VFS/AppData/MyApp/plugins");

21.4 Registry Support

MSIX packages support virtualized registry entries via a Registry.dat hive file embedded in the package. The RegistryHiveBuilder (src/FalkForge.Compiler.Msix/Registry/RegistryHiveBuilder.cs) constructs this hive from MsixRegistryEntry records.

MsixRegistryEntry

PropertyTypeDefaultDescription
Rootstring"HKCU"Registry root hive ("HKCU" or "HKLM").
KeystringrequiredRegistry key path (e.g., "Software\MyApp").
ValueNamestring?nullValue name. null for the default value.
Valuestring?nullValue data.
TypeMsixRegistryValueTypeStringRegistry value type.

Supported Value Types

MsixRegistryValueTypeRegistry Type
StringREG_SZ
DWordREG_DWORD
QWordREG_QWORD
BinaryREG_BINARY
ExpandStringREG_EXPAND_SZ
MultiStringREG_MULTI_SZ
msix
    .RegistryEntry("Software\\MyApp", "InstallPath", "C:\\Program Files\\MyApp")
    .RegistryEntry("Software\\MyApp", "Version", "42", MsixRegistryValueType.DWord)
    .RegistryEntry("Software\\MyApp\\Settings", "Theme", "Dark", root: "HKCU");

21.5 Compilation Pipeline

The MsixCompiler (src/FalkForge.Compiler.Msix/MsixCompiler.cs) orchestrates a 7-step pipeline to produce a signed MSIX package:

  1. Validate — Runs MsixValidator.Validate() to check all required fields and constraints.
  2. Resolve VFS layoutVfsMapper.Resolve() maps files to their package-relative paths based on the VFS mapping mode.
  3. Generate AppxManifest.xmlAppxManifestGenerator.Generate() produces the XML manifest with identity, properties, dependencies, capabilities, and applications.
  4. Build Registry.datRegistryHiveBuilder.Build() creates the registry hive file (only if registry entries exist).
  5. Create MSIX packageAppxPackageWriter.CreatePackage() produces the MSIX file via COM interop with IAppxPackageWriter.
  6. Sign the package — Invokes signtool.exe with the configured signing options.
  7. Generate .appinstallerAppInstallerGenerator.Generate() creates the auto-update manifest (only if UpdateSettings is configured).

The compiler returns Result<string> with the path to the generated .msix file on success. The output file is named {DisplayName}-{Version}.msix (with invalid filename characters sanitized).

Internal Components

ComponentLocationPurpose
AppxManifestGeneratorManifest/Generates AppxManifest.xml with Windows 10 foundation, UAP, restricted capabilities, and desktop namespaces.
AppxPackageWriterPackaging/Creates the MSIX package file using IAppxPackageWriter COM interface.
VfsMapperPackaging/Resolves file-to-VFS-path mappings for Auto and Manual modes.
ContentTypeMapperPackaging/Maps file extensions to content types for the package content types stream.
RegistryHiveBuilderRegistry/Builds a Registry.dat hive file from MsixRegistryEntry records.
AppInstallerGeneratorManifest/Generates .appinstaller XML for auto-update configuration.
AppxInteropInterop/COM interface definitions for the AppX packaging API (IAppxPackageWriter, IAppxBundleFactory).

21.6 Signing

MSIX packages must be signed (enforced by validation rule MSIX008). The compiler invokes signtool.exe to sign the output package after creation. The standard SigningOptionsBuilder from Core is reused.

The Publisher identity in the MSIX manifest must exactly match the Subject field of the signing certificate. A mismatch will cause installation to fail on Windows with a trust error.

Signing can be configured by certificate file path or by certificate store thumbprint:

// Sign with a PFX file
msix.Signing(s => s
    .CertificatePath("certs/myapp.pfx")
    .TimestampUrl("http://timestamp.digicert.com")
    .DigestAlgorithm("SHA256"));

// Sign with a certificate from the Windows certificate store
msix.Signing(s => s
    .CertificateThumbprint("A1B2C3D4E5...")
    .StoreName("My")
    .TimestampUrl("http://timestamp.digicert.com"));

21.7 Auto-Update (.appinstaller)

MSIX supports automatic updates via .appinstaller files. When configured, the compiler generates an .appinstaller XML file alongside the .msix package. Users install by opening the .appinstaller file, and Windows periodically checks the URI for updates.

MsixUpdateSettingsBuilder

Configured via MsixBuilder.UpdateSettings(). Located in src/FalkForge.Compiler.Msix/Builders/MsixUpdateSettingsBuilder.cs.

MethodReturn TypeDescription
HoursBetweenUpdateChecks(int hours)MsixUpdateSettingsBuilderHow often Windows checks for updates. Default: 24 hours.
ShowPrompt(bool show)MsixUpdateSettingsBuilderShow a prompt to the user when an update is available. Default: false.
UpdateBlocksActivation(bool blocks)MsixUpdateSettingsBuilderBlock app launch until the update is applied. Default: false.
AutomaticBackgroundTask(bool automatic)MsixUpdateSettingsBuilderCheck for updates via a background task. Default: false.
ForceUpdateFromAnyVersion(bool force)MsixUpdateSettingsBuilderAllow updating from any version (including downgrade). Default: false.
msix.UpdateSettings("https://releases.contoso.com/myapp/latest.appinstaller", update =>
{
    update
        .HoursBetweenUpdateChecks(6)
        .ShowPrompt()
        .AutomaticBackgroundTask();
});

The generated .appinstaller file uses the http://schemas.microsoft.com/appx/appinstaller/2021 namespace and includes the MainPackage reference and UpdateSettings with OnLaunch configuration.

21.8 MSIX Bundles

An MSIX bundle (.msixbundle) packages multiple architecture-specific MSIX packages into a single distributable file. Windows automatically selects and installs the correct architecture at install time.

MsixBundleBuilder

Located in src/FalkForge.Compiler.Msix/Builders/MsixBundleBuilder.cs.

MethodReturn TypeDescription
Name(string name)MsixBundleBuilderBundle identity name.
Version(Version version)MsixBundleBuilderBundle version. Default: 1.0.0.0.
Package(string filePath, ProcessorArchitecture arch)MsixBundleBuilderAdds an architecture-specific MSIX package to the bundle.
Signing(Action<SigningOptionsBuilder> configure)MsixBundleBuilderConfigures code signing for the bundle.
Build()MsixBundleModelBuilds the final MsixBundleModel.

MsixBundleCompiler

The MsixBundleCompiler (src/FalkForge.Compiler.Msix/MsixBundleCompiler.cs) creates the .msixbundle file using COM interop with IAppxBundleFactory. Each package is added via IAppxBundleWriter.AddPayloadPackage(). The compiler validates that the bundle name is present, at least one package exists, and all package files are accessible.

InstallerMsix.BuildMsixBundle(args, bundle =>
{
    bundle
        .Name("Contoso.MyApp")
        .Publisher("CN=Contoso Ltd")
        .Version(new Version(1, 0, 0, 0))
        .Package("output/MyApp-x64.msix", ProcessorArchitecture.X64)
        .Package("output/MyApp-arm64.msix", ProcessorArchitecture.Arm64)
        .Signing(s => s.CertificatePath("contoso.pfx"));
}, (model, outputPath) =>
{
    var compiler = new MsixBundleCompiler();
    return compiler.Compile(model, outputPath);
});

21.9 CLI Integration

Not reachable through forge build — by design, not just “not implemented yet.” forge build --format msix fails loud with a validation error for every input type; it never silently falls back to an MSI build. There are two independent reasons this input path cannot carry MSIX output:

  1. MsixModel needs data PackageModel has no equivalent for — a Publisher distinguished name (CN=..., distinct from the free-text MSI Manufacturer), one or more Applications (executable + visual elements per app), and a Capabilities list. Neither JsonConfigLoader nor the script-loaded PackageModel carries this, so there is no lossless way to derive an MsixModel from what forge build already loads.
  2. Even for .cs/.csx input, ScriptLoader.LoadPackageModel evaluates the script via CSharpScript.EvaluateAsync<PackageModel> — the script's final expression must be a PackageModel. A script whose entry point calls InstallerMsix.BuildMsix() (which returns int, not PackageModel) can never satisfy that contract, so it can't run through forge build even in principle.
To actually produce MSIX output today, run the script directly as a standalone dotnet-script invocation (or a compiled console program) that calls InstallerMsix.BuildMsix() / InstallerMsix.BuildMsixBundle() — see 21.11 Complete Example and demo/15-msix-basic. This is a completely separate invocation from forge build, not a flag on it.
# forge build --format msix fails loud for every input -- it never produces an MSI as a
# silent fallback and it never produces an MSIX either:
forge build myapp.csx --format msix
# error: MSIX packages cannot be built via 'forge build --format msix': forge's script loader
# requires the script to evaluate to a PackageModel, and MsixModel needs MSIX-specific data
# (Publisher distinguished name, per-application executable + visual elements, capabilities)
# that PackageModel does not carry. Run the script directly instead, e.g.
# 'dotnet script your-package.csx -- -o <output>', calling InstallerMsix.BuildMsix() /
# InstallerMsix.BuildMsixBundle() (see FalkForge.Compiler.Msix.InstallerMsix, demo/15-msix-basic).

forge build msix-config.json --format msix
# error: MSIX packages cannot be built from JSON configuration, and 'forge build --format msix'
# cannot build them from any input today: ... (same guidance as above)

# The actual MSIX build path -- a standalone script run, not `forge build`:
dotnet script demo/15-msix-basic/msix-basic.csx -- -o ./output

SDK Integration

<FalkOutputType>Msix</FalkOutputType> / <FalkOutputType>MsixBundle</FalkOutputType> is not a working path — and the SDK now fails the build loudly instead of pretending to build anything. Sdk.targets' FalkGenerateInstaller target raises an MSBuild <Error> as soon as it sees FalkOutputType set to Msix or MsixBundle — before printing any “Generating…” message and before the Exec that invokes --forge-build (which is conditioned on '$(FalkOutputType)' == 'Msi' or '$(FalkOutputType)' == 'Bundle' and has no MSIX branch). The error message matches the CLI's fail-loud guidance above: build MSIX with the standalone script/program approach instead.
<!-- Fails the build loudly: FalkGenerateInstaller has no MSIX Exec branch. Use the
     standalone script/program approach (InstallerMsix.BuildMsix()) instead -- see above. -->
<PropertyGroup>
    <FalkOutputType>Msix</FalkOutputType>
</PropertyGroup>

<!-- Same caveat for the bundle variant -->
<PropertyGroup>
    <FalkOutputType>MsixBundle</FalkOutputType>
</PropertyGroup>

21.10 Validation Rules

The MsixValidator (src/FalkForge.Compiler.Msix/MsixValidator.cs) enforces the following rules before compilation:

CodeRule
MSIX001Package Name is required.
MSIX002Publisher is required.
MSIX003Publisher must start with "CN=" (certificate subject format).
MSIX004Version must have 4 parts (Major.Minor.Build.Revision).
MSIX005At least one Application is required.
MSIX006DisplayName is required.
MSIX007PublisherDisplayName is required.
MSIX008MSIX packages must be signed. Provide SigningOptions.
MSIX010Application Id is required (for each application entry).
MSIX011Application Executable is required (for each application entry).
MSIX012MinWindowsVersion must be a valid version string.
MSIX013File type association name must use lowercase letters, digits, ., - or _.
MSIX014A file type association must declare at least one file type. Each entry must include the leading dot, be lowercase, be at most 64 characters, contain no second dot, and exclude the reserved characters < > : % " / \ | ? *.
MSIX015Protocol name must be the lowercase URI scheme alone (contoso, not contoso://), 2-39 characters long.

21.11 Complete Example

The following example from demo/15-msix-basic/msix-basic.csx demonstrates a minimal MSIX package definition:

#r "nuget: FalkForge.Core"
#r "nuget: FalkForge.Compiler.Msix"

using FalkForge;
using FalkForge.Compiler.Msix;
using FalkForge.Compiler.Msix.Builders;

InstallerMsix.BuildMsix(Args.ToArray(), msix =>
{
    msix
        .Name("FalkForge.Demo.MsixBasic")
        .Publisher("CN=FalkForge Demo")
        .DisplayName("MSIX Basic Demo")
        .PublisherDisplayName("FalkForge")
        .Version(new Version(1, 0, 0, 0))
        .Architecture(ProcessorArchitecture.X64)
        .Application("App", "hello.exe", app => app
            .DisplayName("Hello World")
            .Square44x44Logo("assets/Square44x44Logo.png")
            .Square150x150Logo("assets/Square150x150Logo.png"))
        .Capability("internetClient")
        .MinWindowsVersion("10.0.17763.0")
        .Signing(s => s.CertificatePath("demo-cert.pfx"));
}, (model, outputPath) =>
{
    var compiler = new MsixCompiler();
    return compiler.Compile(model, outputPath);
});

22. Plugin System

The plugin system lets the UI layer pull in extra services — SQL discovery, ODBC management, folder pickers — without those services being baked into the core. Plugins register typed services into a frozen registry at startup; consuming code resolves them by interface and never sees the concrete implementation.

Unlike extensions (Section 9), which contribute MSI tables and validators at compile time, plugins contribute runtime services to the installer process. Both systems exist because compile-time and runtime concerns have different lifetimes, dependencies, and registration models.

Interfaces

// Core/Plugins/IInstallerPlugin.cs
public interface IInstallerPlugin
{
    string Name { get; }
    void RegisterServices(IPluginServiceRegistry registry);
}

// Core/Plugins/IPluginServiceRegistry.cs — write surface
public interface IPluginServiceRegistry
{
    void Register<TService>(TService instance) where TService : class;
    void Register<TService>(Func<TService> factory) where TService : class;
}

// Core/Plugins/IPluginServices.cs — read surface
public interface IPluginServices
{
    TService? GetService<TService>() where TService : class;
    TService GetRequiredService<TService>() where TService : class; // throws if not registered
}

Registration is first-registration-wins: if two plugins try to register the same service type, the first one is kept and the second is silently ignored. This makes load order deterministic and avoids surprises from accidental override. Call Freeze() on the registry once startup is complete; further Register calls throw InvalidOperationException.

PluginRegistry

PluginRegistry (src/FalkForge.Core/Plugins/PluginRegistry.cs) is the AOT-safe bulk-composition helper — an immutable, ordered list of IInstallerPlugin instances built via Create(...), all supplied at compile time (no Assembly.GetTypes(), no Activator.CreateInstance(string)). Call RegisterAll(registry) to run every plugin's RegisterServices against a shared IPluginServiceRegistry in list order:

var registry = PluginRegistry.Create(
    new SqlPlugin(),
    new OdbcPlugin(),
    new FileSystemPlugin());

var serviceRegistry = new PluginServiceRegistry();
registry.RegisterAll(serviceRegistry);
serviceRegistry.Freeze();

// Consumers resolve by interface:
var sql     = serviceRegistry.GetService<ISqlServerDiscovery>();
var browser = serviceRegistry.GetService<IFolderBrowser>();

PluginServiceRegistry (src/FalkForge.Core/Plugins/PluginServiceRegistry.cs) is the one concrete class that implements both IPluginServiceRegistry (write) and IPluginServices (read) — it is what you compose by hand above, and it is exactly what InstallerApp.Run builds for you automatically (next section).

Shipped Plugins

PluginProjectRegistersNotes
SqlPlugin FalkForge.Plugins.Sql ISqlServerDiscovery, IDatabaseLister, IConnectionTester Enumerates SQL Server instances via SQL Browser broadcast, lists databases, validates connection strings. Depends on Microsoft.Data.SqlClient.
OdbcPlugin FalkForge.Plugins.Odbc IOdbcManager Checks for ODBC drivers + user/system DSNs and can launch the ODBC Data Source Administrator. Windows-only.
FileSystemPlugin FalkForge.Plugins.FileSystem IFolderBrowser Wraps the modern Windows folder-picker dialog (IFileOpenDialog with FOS_PICKFOLDERS). WPF, Windows-only.
Plugins are optional — consuming code should always use GetService (nullable return) and check for null, or accept the throw from GetRequiredService when the service is truly mandatory. A custom-UI page that asks for ISqlServerDiscovery via GetService when the SQL plugin is not loaded simply gets null back and can fall back to manual entry.

Wiring Plugins Into a Custom UI

The two registration paths on InstallerUIBuilder (Plugin<T>() for a single compile-time-known type, RegisterPlugins(PluginRegistry) for a bulk list) don't build the PluginServiceRegistry themselves — they just record what was asked for. The actual composition happens inside InstallerApp.Run, once, after all pages are constructed:

  1. Every Plugin<T>()-registered plugin's RegisterServices runs first, in registration order.
  2. Then RegisterPlugins(PluginRegistry)'s bulk list runs its own RegisterServices calls against the same registry — first-registration-wins overall, so a Plugin<T>() call always beats a same-typed service coming from the bulk registry.
  3. The registry is frozen (further Register calls throw), then assigned to every page's InstallerPage.PluginServices property (internal set, populated by the framework — not constructor-injected).
// Program.cs — composing InstallerUIBuilder with plugins
using FalkForge.Plugins;
using FalkForge.Plugins.Sql;
using FalkForge.Plugins.Odbc;
using FalkForge.Plugins.FileSystem;
using FalkForge.Ui;

return InstallerApp.Run(args, builder =>
{
    builder
        .Window(w => w.Size(800, 600).Title("MyOrg Installer"))
        .Pages(p => p.Add<WelcomePage>().Add<DatabasePickerPage>())
        .RegisterPlugins(PluginRegistry.Create(
            new SqlPlugin(),
            new OdbcPlugin(),
            new FileSystemPlugin()));
});

// DatabasePickerPage.cs — a custom InstallerPage reading a plugin service.
// PluginServices is populated AFTER all pages are constructed, so don't read it
// from the page constructor — use it from a lifecycle hook or command handler.
public sealed class DatabasePickerPage : InstallerPage<DatabasePickerView>
{
    protected internal override async Task<bool> OnDetectBeginAsync()
    {
        var discovery = PluginServices.GetRequiredService<ISqlServerDiscovery>();
        var servers = await discovery.DiscoverServersAsync();
        // bind servers.Value into the view...
        return true;
    }
}
No shipped demo currently exercises this wiring end-to-end through InstallerApp.Run — the plugin composition logic is covered by unit tests (FalkForge.Ui.Tests, FalkForge.Core.Tests) that replicate the framework's drain-and-freeze sequence by hand, not by a runnable custom-UI demo. If you build a custom host that doesn't go through InstallerApp.Run, you must replicate that sequence yourself, as shown at the top of this section.

23. Bundle Signing, Trust & Key Rotation

This section is a plain-language guide to keeping your installer trustworthy. You do not need to know any cryptography to follow it. We explain why each piece exists before we show how to use it, and every code snippet is a real FalkForge API. If you read nothing else, read the first two parts — they cover the whole idea.

Why Installer Trust Matters

You build an installer, put it on a download page, and users run it. Later you ship an update and the installed copy fetches it automatically. In both moments the user's machine is about to run your code with their permissions. So there is one question that has to have a good answer:

How does the user's machine know the installer — and every update after it — really came from you, and that nobody changed a single byte on the way?

An installer travels a long way: a CDN, a mirror, a corporate proxy, a home router, a USB stick a colleague handed over. Anywhere along that path, someone could swap a file inside it for a malicious one. You want the machine to notice and refuse. That is what signing is for. Signing does not encrypt or hide anything — it is a tamper-evident seal plus a name tag: “these exact bytes were sealed by this publisher, and here is proof.”

A word people expect here: Authenticode. Authenticode is Microsoft's code-signing. It is what makes Windows show a green “verified publisher” name in the User Account Control prompt instead of “Unknown publisher.” It is genuinely useful — but it has a blind spot that matters a lot for bundles, and understanding that blind spot is the key to this whole section.

A FalkForge bundle is a single self-extracting .exe. Inside, it is laid out like this:

[PE stub]  [Magic] [Manifest JSON] [compressed payloads] [TOC] [Footer]
   ^                                        ^
   |                                        |
 the small program that            your actual files (the MSIs,
 unpacks and installs              EXEs, data) live here, glued on
                                   AFTER the PE stub was built

Authenticode covers the installer file as it existed when it was signed — but it does not cover anything added to the file afterward. FalkForge glues your real files (the payloads) onto the end after the launcher is signed, past the point the signature protects. So an Authenticode signature can still look perfectly valid even if someone replaced every payload behind it. The seal is on the envelope; the letter inside was swapped. That is why FalkForge adds its own signature that covers the payloads.

The Two Kinds of Signing

There are two separate signatures you can apply to a bundle. They cost different amounts and protect different things. You usually want both, but only one of them stops payload tampering.

SignatureCostsProtectsGives you
Authenticode A paid Windows code-signing certificate The file as it was signed — but NOT payloads attached afterward The “verified publisher” name in the UAC prompt and better SmartScreen reputation
FalkForge payload signature Free — your own key, made with openssl or .NET The actual files that get installed Proof that the payload bytes are unchanged and came from you

Here is the whole picture in one diagram — who signs what:

[PE stub]  [Magic] [Manifest JSON] [compressed payloads] [TOC] [Footer]
   ^                     ^                  ^
   |                     |                  |
 Authenticode      FalkForge's        added AFTER the stub was
 covers file up    signature covers   signed, so Authenticode
 to its signature  these file hashes  is blind to any change here
A valid Authenticode signature does NOT mean the payloads are untampered. The payloads are added after the stub is signed. Protecting the payload bytes is the FalkForge payload signature's job, not Authenticode's. This is the single most important idea in this section.

One more plain-language term you will meet below: a fingerprint. A fingerprint is just a short, fixed-length summary (a SHA-256 hash) of a public key, written as hex. Two different keys practically never share a fingerprint, so it is a safe stand-in for “this exact key.” It is computed from a public key, so publishing it or baking it into a program leaks nothing secret. And ECDSA (which you will see as “ECDSA P-256”) is simply the signing algorithm FalkForge uses — a modern, fast, free public-key scheme. You never have to think about the math.

Signing Your Bundle

The payload signature is free and uses a key you own. There are four small steps: make a key, keep the private half secret, turn on signing for the bundle, and tell the engine which public key to trust. Steps 1–3 are below; step 4 (trust) gets its own part next because it is where the real security lives.

Step 1 — make a keypair. A keypair is two matching halves: a private key (kept secret, used to sign) and a public key (shared freely, used to check). One command:

# Make an ECDSA P-256 private key (the PEM file holds both halves)
openssl ecparam -name prime256v1 -genkey -noout -out falkforge-signing.pem

Step 2 — keep the private key secret. Never commit it to your repo and never ship it inside a bundle. Put it in a vault or your CI secret store, and have your release pipeline read it at build time. Anyone who steals this file can sign fakes as you, so treat it like a password.

Step 3 — turn on signing for the bundle. Point the integrity builder at your PEM file. That is the whole change:

return Installer.BuildBundle(args, outputPath =>
{
    var bundle = new BundleBuilder()
        .Name("My App")
        .Manufacturer("My Company")
        .Version("1.0.0")
        .BundleId(Guid.NewGuid())
        .UpgradeCode(Guid.NewGuid())
        // ... chain your packages, UI, etc. ...
        .Integrity(i => i.SigningKey("falkforge-signing.pem"))
        .Build();

    return new BundleCompiler().Compile(bundle, outputPath);
});
Just want tamper-detection with zero setup? Call .Integrity(i => { }) with no key. FalkForge makes a throwaway key for that one build, so the bundle is internally tamper-evident — but because the key is random each time it proves nothing about who built it. Use a stable SigningKey(path) the moment you care about authorship (which is almost always). See Demo 59 (bundle-integrity-signing) for both modes side by side.

That is it for producing a signed bundle. Under the hood, FalkForge hashes every payload, lists those hashes in the manifest, and signs the list with your private key. If any payload byte changes later, its hash no longer matches the signed list, and verification fails loud. (One hardening detail you never have to think about: every ECDSA signature is stored and accepted only in its single canonical “low-S” form, so the same signature can never exist in two different-looking encodings.)

Who the Installer Trusts

A signature by itself only proves “some key sealed these bytes.” It does not say whose key. If the engine accepted any key that happened to be inside the bundle, an attacker could rewrite your payloads, sign them with a fresh key of their own, drop that key in the bundle, and everything would check out. So the engine must decide, from outside the bundle, which keys it will trust. This is called pinning, and it is the heart of the whole model.

You pin by baking your public-key fingerprint into the engine when you build it. Two ways, and you can use either or both:

Way A — a build parameter. Pass the fingerprint as an MSBuild property. It is a public value, so this leaks nothing. It is repeatable, because trust is a set of keys, not one (that is what makes rotation safe — see Key Rotation below):

# Build your engine trusting one publisher key (repeat the flag for more)
dotnet publish src/FalkForge.Engine -c Release -p:FalkForgeTrustedKey=A1B2C3...

Way B — register it in code. Since you already rebuild the engine per publisher, you can add trusted keys from your own bootstrap code. This runs at the very top of the engine's startup, before any bundle is read, and it adds to (never replaces) the baked set:

// src/FalkForge.Engine/Program.TrustConfig.cs
static partial void ConfigureTrust()
{
    // Any of these register the same key. Pick whichever form you have on hand.
    EngineTrustAnchor.TrustFingerprint("A1B2C3...");   // 64-hex SHA-256 of the key
    EngineTrustAnchor.TrustPublicKeyPem("-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----");
    EngineTrustAnchor.TrustPublicKey(Convert.FromBase64String("<base64 public key>"));
}
Trust only ever comes from compiled code — never from a bundle. You cannot add a trusted key from a manifest, a downloaded update, or any file the installer reads. The trusted set is frozen the first time it is used, so nothing an attacker controls can widen it. A key found inside a bundle is only ever used to check a signature — never to decide whether that signature is trusted.

What pinning buys you, in one paragraph. The installed product accepts updates signed by your exact key and nothing else. Not “a key from a company with the same name.” Not a different certificate. Not an attacker's fresh key, even if they re-hash and re-sign every payload perfectly — their key is not in your pinned set, so it is rejected. And because the pinned key is public and baked in, this check needs no internet at all: verification is fully offline. The engine never phones home to decide whom to trust.

How the check actually runs, in four plain points:

Remote Signing with a Signing Server (Keeping the Key Off Build Machines)

Everything above assumes your private key sits in a PEM file on whatever machine builds the bundle. That is fine to start, but a build agent that can read the key is a build agent that can leak it. A signing server fixes that: the private key lives on the server (optionally inside a hardware security module), and it never leaves. FalkForge sends the bytes-to-be-signed over to the server, the server signs them, and only the signature comes back.

FalkForge integrates with Keyfactor SignServer for exactly this. It is not a different trust model — it is the same free ECDSA payload signature, just produced somewhere safer:

AspectLocal PEM keySignServer
Where the private key lives A file the build agent can read The signing server only (optionally an attached HSM)
Signing operation In-process, instant A network call to the server, asynchronous
How the engine verifies Identical, and fully offline — the engine never contacts the server

Why route signing through a server: the key never touches a build agent or a laptop (removing the biggest key-leak risk in CI); an HSM can make the key non-exportable even for the server's own admins; every signing request is centrally logged (useful evidence for compliance regimes like the EU Cyber Resilience Act); and rotating a key becomes “reconfigure one server” instead of “re-distribute a file to every build agent.” Crucially, offline verification is preserved: the signer's public key still travels inside the signed manifest, so the installed engine verifies with no server contact at all.

This path is proven end to end: an integration test signs a real bundle against a live keyfactor/signserver-ce container (a PlainSigner worker, ECDSA P-256) and verifies the result through the very same codec the engine uses. It is Docker/Podman-gated — it skips (never fails) when no container runtime is present, so a normal dotnet test is unaffected. See Demo 61 (signserver-remote-signing).

Configuring the provider. SignServerConfig holds the endpoint, worker name, and authentication; SignServerAuthMode supports None, Bearer, Basic, and ClientCert (mTLS). FromEnvironment() reads SIGNSERVER_URL, SIGNSERVER_WORKER, SIGNSERVER_AUTH, and the matching credentials, so CI needs no secret in source:

using FalkForge.Signing.SignServer;

// From explicit values:
var config = new SignServerConfig
{
    BaseUrl = "https://signserver.example.com:8443",
    Worker = "PlainECDSA",              // the PlainSigner worker name or numeric id
    AuthMode = SignServerAuthMode.Bearer,
    BearerToken = bearerToken,          // pulled from your secret store, never hard-coded
    KeyId = "release-signer"            // optional, informational only
};

// Or, for CI, from SIGNSERVER_URL / SIGNSERVER_WORKER / SIGNSERVER_AUTH / ...:
var configResult = SignServerConfig.FromEnvironment();

Wiring it into a build. Add the provider via IntegrityBuilder.SigningProvider(...). Because the server call is real network I/O, this provider is genuinely asynchronous, so the build must go through Installer.BuildBundleAsync and BundleCompiler.CompileAsync. The synchronous path fails loud (SGN010) rather than block a thread on a network call:

using var provider = new SignServerSignatureProvider(config);

return await Installer.BuildBundleAsync(args, async outputPath =>
{
    var model = new BundleBuilder()
        .Name("My App")
        .Manufacturer("My Company")
        .Version("1.0.0")
        // ... chain packages, UI, etc. ...
        .Integrity(i => i.SigningProvider(provider))
        .Build();

    return await new BundleCompiler().CompileAsync(model, outputPath);
});
Also available from the JSON/CLI path. A top-level "signing" section in a forge build JSON config produces a signed bundle without any C# — provider "pem" for a local key or "signserver" for remote signing, with all secrets referenced by environment-variable name or file path, never inline (violations fail the build with JSN015–JSN019). The bundle embeds the published NativeAOT engine (resolved via FALKFORGE_ENGINE_STUB, an engine directory beside forge, or the repository’s artifacts/publish/engine output) and is a runnable installer; the build fails loud when no engine resolves, and --no-engine opts into a non-runnable design-time placeholder stub instead. See the JSON configuration tutorial (docs/tutorials/json-config.html, “Signed Bundle Output”) for the full field reference.

The engine does not know or care where a signature was produced. A local PEM key and a signing server yield the same signature, checked the same way against the same pinned set. SignServer changes where signing happens, never what the engine trusts or how it verifies.

Safe Updates

A fresh install is a file the user picked on purpose. An update is different: it arrives automatically, over the network, and replaces software the machine already trusts. Nobody clicks “yes” on each one. That makes updates the highest-value target, so FalkForge holds them to the strict rule.

Require-signed. On the update path the engine requires a signature that verifies against its pinned trusted set. An update that is unsigned, has had its signature stripped, or is re-signed by an attacker's untrusted key is rejected — the download is discarded and the machine keeps running the version it already has. Nothing worse than “no update today” happens.

Who does the checking matters. The already-installed, already-trusted engine verifies the downloaded update in-process, before it launches it. It does not hand the decision to the downloaded file (which could just lie). If verification fails, the staged bundle is thrown away and never executed.

Two more attacks are now blocked, and both protections are active in this release:

AttackWhat the attacker triesWhat the engine does
Downgrade / replay Serve you an older, still-validly-signed release that has a known vulnerability The engine remembers a monotonic “epoch” counter and refuses any bundle older than the newest one it has accepted
Revoked key Re-use a key you have since retired because it leaked A verified update can declare a key revoked; from then on any bundle signed only by that key is rejected, even though the key is still in older engines' pinned sets
This anti-downgrade and revocation store is kept in a per-machine file that is protected by a strict Windows access-control list (only SYSTEM and Administrators can write it) and advanced only by the elevated helper after a genuinely verified update. If a run does not elevate, the store simply does not advance that time and the engine says so — it never pretends to have protection it does not have. See Demo 62 (require-signed-updates).

Key Rotation (Changing Keys Without Breaking Installs)

Keys do not last forever. You might retire one on a schedule, or in a hurry because it leaked. The problem: machines already in the field trust your old key. If you just start signing with a new key, those machines will reject your updates — you have stranded them. Rotation is how you switch keys with nobody left behind.

Two facts make it safe. First, trust is a set — an engine can trust several keys at once. Second, one release can be signed by more than one key at the same time. The golden rule is “trust leads, signing follows”: widen trust to include the new key before you sign anything with it alone, and stop signing with the old key before you drop it from trust. Do it in that order and no machine is ever handed a bundle it cannot verify.

1. Steady state   engines trust {K_old}                 releases signed by K_old
2. Add trust      ship an engine trusting {K_old, K_new}  still signed by K_old
                  (wait until this engine is the fleet baseline)
3. Dual-sign      sign each release with BOTH K_old + K_new
                  old engines accept via K_old, new engines via either
4. Signing        sign releases with K_new only
   follows        ship an engine trusting {K_new} (or {K_old, K_new} for a grace period)
5. Retire K_old   drop K_old from the baked set in the next engine build

During the dual-sign window a single bundle carries two signatures over the identical files, so a machine trusting either key is happy. Because you widen trust first and narrow it last, there is never a gap. A fresh install is always fine by construction — the bundle carries the engine that trusts the key it was signed with — so rotation only ever concerns the update path. See Demo 60 (trusted-key-rotation).

Stronger: Roles & Quorum

So far, every trusted key is equal and all-powerful: any one of them can sign anything. If just one leaks — a CI secret, a developer key baked in for convenience — an attacker can ship a fully rewritten update, and every check above still passes. Roles and quorum close that gap. The idea, borrowed from how big projects protect their release keys, is simple:

Give keys jobs, and require a combination of jobs for risky actions. A normal update needs one release signature. Changing keys needs a release key and a separately-held recovery key. A deliberate downgrade needs release plus a security key. So one stolen key, on its own, cannot ship the dangerous stuff.

A key can hold one or more roles. The roles are: release (the everyday signer), recovery (an offline key, kept separately, that co-signs key changes), security (co-signs downgrades and revocations), emergency-revoke (a break-glass key that can only revoke), ci (an automated build key, deliberately not release), developer (local test builds only, never counts for production), and timestamp (attests when something was signed, never counts toward a quorum).

A quorum is just “how many, of which roles, must sign.” The built-in default policy, once you turn roles on, is:

ActionSignatures required (all by distinct keys)
Install / Update (routine)1 release
Key change (rotation)1 release and 1 recovery
Downgrade (deliberate)1 release and 1 security
Revoke a key1 release and 1 security or emergency-revoke

“Distinct keys” is the crux: one key that happens to hold both release and recovery does not satisfy a rule that needs both — you genuinely need two different private keys, ideally in two different custodies. That is what makes a single compromise survivable.

You tag a key's roles either as build metadata or in code — the same two channels as plain trust:

<!-- In the engine project: give each pinned key its role(s) -->
<ItemGroup>
  <FalkForgeTrustedKey Include="A1B2C3..." Roles="Release" />
  <FalkForgeTrustedKey Include="D4E5F6..." Roles="Recovery;Security" />
</ItemGroup>
// ...or in Program.TrustConfig.cs, a key that holds two roles:
EngineTrustAnchor.TrustFingerprint("D4E5F6...", TrustRole.Recovery | TrustRole.Security);
Backward compatible by default. If you configure no roles, every trusted key is treated as a release key and every action needs just one signature — exactly the behavior from the parts above. Roles are opt-in governance you layer on when you are ready.
If you turn on roles, make sure at least one key holds the Release role. The moment any key carries an explicit role, the default policy engages — and routine install/update then requires a Release signature. If no trusted key holds Release, the engine can never satisfy that rule and will refuse to verify anything (a permanent self-lockout). This is a fail-safe, and it is caught early: the engine refuses to start (at bootstrap) if roles are configured with no Release key, rather than shipping a broken installer. A leave-it-un-roled key counts as Release, so the simplest safe habit is to tag one key Release in the same change that introduces any other role.

Post-Quantum Hybrid Signing (Future-Proofing Your Keys)

Everything above rests on ECDSA. ECDSA is excellent today — but a large, fault-tolerant quantum computer, if one is ever built, could break it, and at that point an attacker could forge your signature on a malicious bundle. Nobody knows if or when that happens. What we do know is that the trusted keys you bake into engines live for years in the field. Hybrid signing is the conservative answer: add a second signature, made with an algorithm designed to resist quantum attack, next to the classical one. It is a forward-looking safety layer for future forgery under a trusted key — not an urgent fix for anything broken today.

What a hybrid bundle carries. Two signatures over the exact same manifest content: the classical ECDSA P-256 signature you already know, plus an ML-DSA-65 signature. ML-DSA (FIPS 204) is the lattice-based signature scheme NIST standardized for the post-quantum era; FalkForge uses the middle parameter set (65) by default. On an up-to-date machine, both must check out — the bundle is trusted only while either algorithm remains unbroken. An ML-DSA signature is about 3.3 KB and the companion public key about 2 KB, so a hybrid signer grows the bundle by roughly 7 KB — negligible next to any real payload.

The anti-strip guarantee (the part that actually matters). Two signatures are worthless if an attacker can simply delete the post-quantum one and present the still-valid classical signature alone. FalkForge closes that hole the same way it closes every self-describing-bundle hole in this section: the bundle proves, the installed engine decides. The fact “this publisher signs hybrid” is pinned in the engine's baked trust list — never read from the bundle. Nothing in the bundle declares the companion, so nothing in the bundle can un-declare it. If a trusted key is pinned as hybrid and the ML-DSA signature is missing, from the wrong key, or fails to verify, the install is refused with INT011 — even though the classical signature is cryptographically perfect. A trusted key without a pinned companion verifies exactly as before, so hybrid rollout is per-key and fully backward compatible.

Protection is only as strong as the weakest pinned key. If an engine trusts one hybrid key and one plain classical key, a future quantum forger simply targets the plain one. The build warns (FALKPQ001) when a baked trusted set mixes hybrid and non-hybrid keys — aim to make the whole set hybrid once you start.

What about old Windows? ML-DSA verification is done by the operating system's crypto stack, and Windows versions that predate its post-quantum additions cannot do it at all. On such a machine the engine verifies the classical signature and writes a loud log entry saying the post-quantum check was skipped because the OS cannot perform it — and the install proceeds. This is safe to allow because the decision hinges on the machine's real OS capability, which nothing in a bundle can influence: an attacker cannot make a modern machine pretend to be an old one. On any ML-DSA-capable OS the companion rule is strictly enforced.

Turning it on. Signing side, one fluent call replaces SigningKey — give it the classical PEM and the ML-DSA PEM, and both sign the manifest (call it repeatedly to dual-sign during a rotation):

return Installer.BuildBundle(args, outputPath =>
{
    var bundle = new BundleBuilder()
        .Name("My App")
        .Manufacturer("My Company")
        .Version("2.0.0")
        .BundleId(Guid.NewGuid())
        .UpgradeCode(Guid.NewGuid())
        // ... chain your packages, UI, etc. ...
        .Integrity(i => i.HybridKey("falkforge-signing.pem", "falkforge-mldsa.pem"))
        .Build();

    return new BundleCompiler().Compile(bundle, outputPath);
});

You can generate the ML-DSA key with a few lines of .NET (or with OpenSSL 3.5+, openssl genpkey -algorithm ML-DSA-65):

using System.Security.Cryptography;

using var key = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65);
File.WriteAllText("falkforge-mldsa.pem", key.ExportPkcs8PrivateKeyPem());
File.WriteAllText("falkforge-mldsa.pub.pem", key.ExportSubjectPublicKeyInfoPem());

The same hybrid bundle can come out of a forge build JSON config — add pqKeyPath (or pqKeyEnv) to the pem signing provider. Its presence is what makes the build hybrid. The usual secret rule applies: these fields hold a file path or an environment variable name, never pasted key material:

"signing": {
    "provider": "pem",
    "keyPath": "keys/release.pem",
    "pqKeyPath": "keys/release-mldsa.pem"
}

Pinning the companion uses the same two channels as plain trust. As build metadata, the trusted-key item gains a PqFingerprint (the ML-DSA public key's SHA-256 fingerprint) — note that the short-form -p:FalkForgeTrustedKey=<fp> property stays classical-only, so hybrid pinning uses the item form or code:

<ItemGroup>
  <FalkForgeTrustedKey Include="A1B2C3..." Roles="release"
                       PqFingerprint="C3D4E5..." PqAlgorithm="ML-DSA-65" />
</ItemGroup>
// ...or in Program.TrustConfig.cs:
EngineTrustAnchor.TrustHybridKey(classicalSpkiBytes, pqSpkiBytes);
// ...or by fingerprints:
EngineTrustAnchor.TrustHybridFingerprint("A1B2C3...", "C3D4E5...");

Pinning the companion is your cutover statement: from that engine onward, nothing of yours verifies classically alone anymore — including your own older, classical-only releases, which is why the rotation order from Key Rotation above applies here too (dual-sign first, pin the companion in a later engine release). A hybrid pair counts as one signer for roles and quorum — the ML-DSA half is a validity condition on the classical identity, never a second vote. See Demo 63 (hybrid-pq-signing) for the whole flow, including the strip attack being rejected.

SignServer and post-quantum: not yet. Remote signing of the ML-DSA half through SignServer is not available. SignServer can produce ML-DSA signatures, but not with the domain-separation context (falkforge/manifest) FalkForge's manifest signatures are bound to, so its output can never satisfy the companion check — and the JSON config refuses the combination (JSN018) rather than silently shipping a bundle that is less protected than you think. The supported setup today: SignServer signs the classical half, a local ML-DSA key signs the post-quantum half — two independent providers over the same manifest.

Do I Need to Buy a Certificate?

No. The signature that protects the files that actually get installed uses your own free ECDSA key. Making a keypair with openssl and baking the public fingerprint into the engine costs nothing, and it is the part that stops payload tampering.

A paid Authenticode certificate is optional and does a different job: it gives the Windows “verified publisher” experience — a recognized name in the UAC prompt and better SmartScreen reputation. It is nice for the install experience, but it does not protect the payload bytes and is not a substitute for the FalkForge payload signature. Most publishers eventually want both: Authenticode for the friendly prompt, the free payload signature for real tamper protection.

Sigil: Optional SBOM Attestation & MSI Integrity Signing

You'll see references to a sigil CLI elsewhere in this manual (the --no-sign flag, the MSI compile pipeline). Sigil is a separate, optional tool by the same author — not part of FalkForge itself, and never required for the payload signature described above in this section.

What works without it. Payload signing is always pure-.NET ECDSA-P256 (EcdsaManifestSigner) on both sides — BundleBuilder.Integrity() for bundles and PackageBuilder.Integrity() for MSI packages (IntegritySigner, embedded as the MSI's _FalkForgeIntegrity/ManifestSignature row, format tag falkforge-ecdsa-envelope-v2). No external tool is ever required to produce a verifiable manifest signature for either artifact type. Plain SBOM generation (.Sbom() / --sbom) has no Sigil dependency either: it always writes a CycloneDX 1.6 sidecar (<output>.cdx.json) next to the compiled artifact. That sidecar is CycloneDX by definition and takes no SbomFormat — the format setting applies to the Integrity() attestation document described below, not to the sidecar.

For MSI packages, Integrity(i => i.Sbom(format)) now selects the document that is actually emitted. It previously selected only a label: SbomWriter hardcoded the CycloneDX writer, so every attestation predicate was CycloneDX regardless of the setting, while the --type flag passed to sigil attest and the Format column of the MSI's _FalkForgeIntegrity/SbomAttestation row dutifully reported what had been asked for. SbomFormat.Spdx used to be the default, so an ordinary Integrity() MSI shipped a row declaring Format="spdx" over CycloneDX bytes. If you have consumers keying off that column, they were being told the wrong schema; re-verify them against a freshly built package.

The default output is unchanged: CycloneDX 1.6. The default moved from SbomFormat.Spdx to SbomFormat.CycloneDx precisely so that it would stay unchanged — the old default's label was wrong, but its bytes were CycloneDX and that is what existing consumers parse. SPDX 2.3 is now available on request via Integrity(i => i.Sbom(SbomFormat.Spdx)). A build that never called .Sbom(...) emits exactly the document it always did, now correctly labelled cyclonedx. (The SbomFormat enum members are deliberately not renumbered: Spdx remains 0, so any persisted numeric value keeps its meaning.)

Bundle attestations are CycloneDX 1.6 regardless of Sbom(format). BundleBuilder.Integrity() does not honour a SPDX request — and it now says so instead of claiming otherwise. A bundle payload entry carries only a SHA-256; there is no SHA-1 anywhere in the bundle pipeline, and SPDX 2.3 §8.4 makes a per-file SHA1 mandatory, so a SPDX bundle document could not be generated at all. (Previously the bundle path wrote CycloneDX bytes and passed the requested format to sigil attest --type, putting a false predicateType inside the signed DSSE envelope.) One value now drives both the writer and the tag, so a bundle attestation's label always matches its content. Giving bundles genuine SPDX output requires capturing a SHA-1 while payloads are hashed, the way CabinetBuilder does for MSI; that is tracked separately.

What SPDX output now contains. A conformant SPDX 2.3 document: a SHA1 checksum per file (§8.4 makes it mandatory — SHA256 rides along as an additional checksum), a relationships array with DESCRIBES and per-file CONTAINS edges, a packageVerificationCode over the sorted file digests (§7.9), package-root-relative file names (§8.1), and NOASSERTION for every licence and copyright field — FalkForge has no licence data for a packaged payload and does not invent any.

The SHA1 comes from the packaged bytes. It is accumulated in the same cabinet-compressor callbacks as the SHA-256 (CabinetBuilder.PackagedFileSha1Hashes), so it inherits the same time-of-check/time-of-use guarantee. SHA-1 is collision-broken and appears only because SPDX mandates it as a file identifier: no FalkForge trust decision reads it, and the payload signature and forge verify both use SHA-256. A file component with no SHA-1 makes SPDX generation fail rather than emit a document that announces SPDX-2.3 while omitting a mandatory field; the attestation is then skipped with a warning, and the payload signature is unaffected (it never depends on the SBOM).

Removed (breaking). IntegritySbomGenerator, SbomPackageInfo and SbomFileEntry are gone from FalkForge.Core. They were a second, string-returning SBOM path with no production caller whose SPDX output lacked all of the above; keeping it would have left a non-conformant SPDX emitter on the public API surface. Use SbomWriter.WriteToString(document, format) / WriteToFile(document, path, format) with an SbomDocument, or SpdxSbomGenerator / CycloneDxSbomGenerator directly.
An MSI Integrity() build now fails if any payload file has no packaging-time digest. The (name, sha256) pairs the MSI signature commits to come from the digests CabinetBuilder captured while the native cabinet compressor read each file's bytes — not from re-opening the source path at signing time. If a resolved file is missing from that map, IntegritySigner aborts the compile with an IntegrityError naming the file and its File-table id, instead of signing the remaining files. Previously such a file was silently dropped and an MSI was still produced.

Why fail rather than sign the rest: a signature's declared set is the statement of what is covered, and nothing in the emitted artifact would disclose that the set had been narrowed. Filling the gap by re-reading the source file is not an option either — that re-read is exactly the time-of-check / time-of-use hole this behaviour closes.

A correct build cannot hit this: every resolved file is routed through a cabinet, and a failure to add one already aborts the compile. If you do see it, treat it as a FalkForge bug and report it rather than working around it — there is no opt-out switch, by design. (--no-sign / FALKFORGE_NO_SIGN skips integrity signing entirely, which is a different thing: it produces an unsigned MSI rather than an incompletely signed one.)

What Sigil adds, when it's on PATH. FalkForge probes sigil --version once per process (SigilDetector, result cached) and, if found, wraps the already-written SBOM in a Sigil DSSE attestation envelope and embeds it in the manifest as SbomAttestation (BundleIntegritySigner for bundles, IntegritySigner for MSI — identical shape on both sides). This is purely additive on both artifact types: if Sigil is missing, or the attestation step fails for any reason, the build still succeeds with the payload signature and the plain SBOM sidecar intact — the attestation is simply absent. Sigil never gates the payload signature itself for either MSI or bundles — do not install it "to get a signed MSI"; it's only needed if you specifically want the extra DSSE SBOM attestation.

forge build --no-sign (or the FALKFORGE_NO_SIGN environment variable) skips Sigil-based signing and attestation too, on both MSI and bundle builds — the same switch that disables the native ECDSA signer above.

Installing it. Sigil ships as a .NET global tool on NuGet:

dotnet tool install -g Sigil.Sign

Source and documentation: github.com/Falkesand/Sigil. Sigil is licensed AGPL-3.0-only — a separate license from FalkForge's own — so review its terms before depending on it in your build pipeline.

Sigil is build-time-only: the FalkForge engine that runs on end-user machines never calls it and has no dependency on it being installed anywhere but your build machine.

What's Coming (Planned, Not Yet Shipped)

Two hardening features are designed but not active yet — do not rely on them today:

For clarity on what is shipped: the free payload signature, trusted-key pinning (build parameter and in-code), fully offline verification, remote signing via SignServer, config-driven signing from forge build JSON, require-signed updates, active anti-downgrade and revocation, key rotation, roles-and-quorum governance, and post-quantum hybrid signing (ML-DSA-65 companion signatures with the INT011 anti-strip rule) are all live in this release. The two items above are the only forward-looking pieces in this section.

24. Architecture

Concepts explained the MSI vocabulary — components, codes, elevation, sequencing — and the sections since then have documented each piece of FalkForge as its own API surface. This section is the map that ties them together: how a call to PackageBuilder.Build() actually becomes bytes on disk, how a distributed .exe turns into an installed application, and where the trust boundaries sit along the way. It assumes you've read Concepts and is deliberately a systems-level tour rather than a new API reference — every subsection links to the existing chapter that documents its subject in full depth.

24.1 Solution Map

Solution Structure lists every project with its own description and a dependency tree; this is the same 33-project solution seen through a different lens — grouped by the role each project plays, which is the more useful question when you're trying to find where a piece of behavior lives.

Authoring core        Core, Extensibility
                       The fluent API, domain model, validation, and the interfaces an
                       extension implements. No compiler, no runtime, no UI — just the
                       shape of an installer definition.

Compilers              Compiler.Msi, Compiler.Bundle, Compiler.Msix (experimental)
                       Turn a PackageModel / BundleModel into bytes: an .msi via msi.dll
                       P/Invoke, a self-extracting .exe, or (experimental) a .msix.

Engine runtime         Engine, Engine.Elevation, Engine.Protocol, Engine.Runtime,
                       Platform, Platform.Windows
                       The NativeAOT processes that actually run an install: detect,
                       plan, elevate, apply, roll back. Engine.Runtime carries the
                       published win-x64 runtime assets the Meta package depends on.

UI                     Ui.Abstractions, Ui, Studio
                       The default WPF installer chrome, the custom-UI framework authors
                       build on, and the separate visual authoring tool (Studio) that
                       edits installer definitions rather than running them.

Extensions             Extensions.Util / Dependency / Firewall / DotNet / Iis / Sql /
                       Driver / Http
                       Optional domain-specific authoring + compile-time table
                       injection + runtime execution, all through the same
                       Extensibility contract (24.5).

Tooling & distribution  Cli, Decompiler, Localization, Sdk, Signing.SignServer,
                       Plugins.Sql / Odbc / FileSystem, Testing, Meta, Templates
                       The `forge` CLI, MSI/Bundle/WiX-Burn decompiler, JSON
                       localization, the MSBuild SDK for ProjectOutputs generation,
                       remote-signing client, IDE-facing discovery plugins, and the
                       `FalkForge` meta-package + `dotnet new` templates that make
                       "add one package reference" a working setup.

Dependencies flow in one direction through these layers: extensions and compilers depend on Core, never the reverse; the engine runtime depends on the AOT-safe Engine.Protocol but not on Core (the engine never sees a PackageBuilder — it only ever sees a compiled manifest); the UI depends on Ui.Abstractions, never directly on Engine, so a custom UI author can swap EngineClient for a test double without touching engine internals. See Solution Structure for the full per-project dependency arrows.

24.2 From C# to .msi: The Compile Pipeline

PackageBuilder.Build() (8.2) produces an immutable PackageModel — a description, not an installer. Handing that model to MsiCompiler.Compile(model, outputPath) (or the CLI's forge build) drives it through a fixed pipeline, implemented as MsiAuthoring.Compile (src/FalkForge.Compiler.Msi/Recipe/MsiAuthoring.cs) with a Result<string> return at every step — nothing throws for an ordinary authoring mistake, and the pipeline stops at the first failure rather than compiling a half-valid MSI:

 1. Register extensions    Each IFalkForgeExtension.Register() runs; table/execution/dialog-step
                            contributors and extension ValidationRules are collected.
 2. Validate the model     Core + extension ValidationRules run in one pass (ModelValidator.Inspect).
                            Any PKG/FEA/SVC/… rule violation fails the build here — nothing
                            downstream ever sees an invalid model.
 3. Validate dialog UI     If a DialogSet/DialogCustomization is present, DLG001/DLG002 checks run
                            against the merged built-in + extension step registry.
 4. Resolve components     ComponentResolver turns Files into ResolvedComponent/ResolvedFile records
                            with deterministic GUIDs (one component per file — 2.1).
 5. Build the recipe       MsiRecipeBuilder runs ~40 built-in ITableProducers in a fixed order
                            (Directory, Component, File, Feature, Registry, ServiceInstall, …),
                            appends CustomTablesProducer + DialogSetProducer output, then routes every
                            registered IMsiTableContributor through ExtensionTableEmitter (24.5) —
                            producing an immutable MsiDatabaseRecipe: a table/row/column model with no
                            open file handle yet.
 6. Build cabinets         CabinetPlanner decides disk/cabinet layout from MediaTemplate; CabinetBuilder
                            compresses each cabinet's file slice (cabinet.dll P/Invoke). Embedded cabs
                            become streams on the recipe; external cabs are written beside the .msi.
 7. Write the database     MsiDatabase.Create opens a fresh OLE compound file via msi.dll; MsiRecipeExecutor
                            issues CREATE TABLE + INSERT for every recipe table, then the database commits.
 8. Reproducible timestamps  (opt-in) SummaryInfoPatcher overwrites the FILETIME fields msi.dll always
                            stamps with the current time, so a ReproducibleOptions build is byte-identical
                            across machines and calendar days.
 9. Sign                   Authenticode (CodeSigner, if Signing is configured) and FalkForge integrity
                            signing (IntegritySigner: always pure-.NET ECDSA when Integrity() is configured
                            — Sigil is not required; sidecar-only when Reproducible() is also set) —
                            see 24.4.
10. ICE validate            Native ICE custom actions run against the finished .msi (skipped for
                            reproducible builds, since ICE can perturb bytes non-deterministically).
11. SBOM + WinGet            Non-fatal, opt-in sidecars: a CycloneDX SBOM next to the .msi, and a 3-file
                            WinGet manifest (8.28) if WinGet() was configured.

Step 9's integrity signature is always produced by FalkForge's own pure-.NET ECDSA signer, never gated on an external tool — see Sigil: Optional SBOM Attestation & MSI Integrity Signing (23) for the one thing Sigil optionally adds on top (a DSSE SBOM attestation), and Why Reproducible Builds Matter (8.29) for how Step 9 changes when Reproducible() is also configured.

One detail matters if you're debugging a build: cabinet compression here is single-threaded — one CabinetBuilder call per planned cabinet, in a loop. Steps 10 and 11 are intentionally non-fatal on infrastructure failure (missing darice.cub, no SBOM writer available) — a build still succeeds without ICE or an SBOM, but a Warning-level log entry says so, so a green build never silently means "ICE didn't run" without a trace.

See Demo 09 — advanced-msi for custom actions/tables/sequences exercising most of this pipeline in one package, and Demo 26 — custom-tables for CustomTablesProducer specifically.

24.3 From .exe to Installed System: Bundle Runtime

A bundle is a different kind of artifact from an .msi: it's not something Windows Installer interprets directly, it's a self-extracting launcher FalkForge itself understands. On disk it is one file laid out as [PE stub] [Magic] [Manifest JSON] [compressed payloads] [TOC] [Footer] (full byte layout and why Authenticode can't cover the payload section: 23). Running it starts the chain of processes and phases that Engine Architecture (12) and Protocol & IPC (13) document in full API detail; this subsection is the narrative version — what happens, in order, from double-click to installed application.

double-click bundle.exe
     |
     v
 [UI Process]  <== Named Pipe A (HMAC-SHA256) ==>  [Engine Process]
 unprivileged, WPF                                   unprivileged, NativeAOT
                                                            |
                                                            | only when Applying needs
                                                            | a privileged operation
                                                            v
                                              [Elevated Engine]  <== Pipe B (HMAC-SHA256) ==>
                                              admin token, whitelisted commands only

The Engine process is the one that matters for this story — it owns the install lifecycle end to end via IInstallerPipeline, expressed as a fixed sequence of phase steps (DetectStepPlanStep → optional ElevateStepApplyStep, with RollbackStep on failure). The UI never talks to the Elevated process directly and never performs a privileged operation itself — it only ever asks the Engine to detect, plan, or apply, and the Engine decides when elevation is actually required (2.3).

Downloaded and extracted payloads live in the PackageCache under a content-addressed layout (Cache) so a repair or a second install of the same version doesn't re-download anything already verified on disk. For update scenarios, the same cache is where a delta payload gets reconstructed against its declared basis version before its hash is ever trusted — see 24.4.

See Demo 06 — product-suite and Demo 10 — advanced-bundle for the chain/rollback-boundary authoring side, and Demo 43 — bundle-layout for how containers map onto the payload section of the file layout above.

24.4 Security Architecture

FalkForge's security model is layered: each layer answers a different question about trust, and a reader who only skims one of the existing chapters (Concepts, Protocol, Bundle Signing & Trust) can come away thinking one mechanism does more than it does. Read top to bottom, in the order they actually engage during a run:

LayerQuestion it answersMechanism
Process transport Is the process on the other end of this pipe really the Engine / Elevated companion I expect, and not something else on the machine that guessed the pipe name? HMAC-SHA256 handshake on both Named Pipe A (UI↔Engine) and Named Pipe B (Engine↔Elevated), plus parent-process identity verification with start-time capture on the elevated side, so a recycled PID can't impersonate the real parent (13.3).
Elevated command surface Given a trusted pipe, what is the Engine actually allowed to ask the Elevated process to do? A fixed, whitelisted command set — MSI install/uninstall, service install, registry write, file write — dispatched by ElevatedCommandExecutor. There is no "run arbitrary command" verb; an attacker who fully compromises the unprivileged Engine still cannot make the Elevated process do anything outside that list.
Elevation companion identity Is the .exe about to be launched with an admin token actually the one this bundle shipped, and not something planted on disk? BootstrapCompanionResolver requires bytes on disk == the TOC hash the extractor verified == the hash the manifest declares for the reserved companion payload id (InstallerManifest.EngineCompanionSha256), and — on a signed bundle — that declared hash is itself inside the ECDSA-signed set. A manifest that declares no companion never wires one, even if a payload happens to sit under the reserved id (fail-closed, not fail-open); a declared companion whose hash disagrees aborts with ErrorKind.SecurityError rather than falling back to an ambient beside-the-engine binary.
Payload integrity & authorship Did every payload byte in this bundle come from a publisher I trust, unmodified? The FalkForge payload signature — ECDSA-P256 (optionally hybrid with ML-DSA-65 for post-quantum resistance) over the TOC's per-payload SHA-256 hashes, checked against a baked-in trusted-key set with optional roles-and-quorum policy. This is a separate signature from Authenticode and is the one that actually covers payloads glued on after the launcher stub was signed (23.2).
Update trust Should this machine trust and install an update it downloaded, possibly over an untrusted network? StagedUpdateVerifier flips TrustPolicy.RequireSigned to true on the update path specifically — a fresh install tolerates an unsigned bundle if the user chose to run it, but an unsigned or untrusted-key update is rejected outright (23.6). A delta update additionally pins its basis version: the reconstructed file's SHA-256 is checked against ReconstructedSha256Hash before the reconstructed bytes are trusted at all, so a delta can't be used to smuggle bytes that don't match the declared basis.

None of these layers substitutes for another. A valid HMAC handshake proves you're talking to the real Engine process, not that the bundle's payloads are untampered; a valid payload signature proves the payload bytes are what the publisher shipped, not that the elevated companion launching them is the one the manifest declared. Removing any one layer narrows what an attacker needs to forge, which is why the engine fails closed at each of them independently rather than trusting an earlier layer's pass as a substitute.

For the full authoring and operational picture — generating keys, rotation, quorum policy, remote signing via SignServer, and the post-quantum hybrid mode — see Bundle Signing, Trust & Key Rotation (23), and Demo 62 — require-signed-updates / Demo 63 — hybrid-pq-signing / Demo 53 — delta-updates for runnable proofs of each row above.

24.5 Extension Model

Extensions plug into two different points in the pipeline above, and conflating them is the most common source of confusion for a first-time extension author — 10.10 covers this in full depth; here is where each piece sits architecturally.

Compile-time (MSI pipeline, step 5 above): an extension implementing IFalkForgeExtension registers IMsiTableContributors, which ExtensionTableEmitter routes into the recipe — merged into a built-in table (Registry, CustomAction, …) when the contributor's TableName matches one, or materialized as a brand-new custom table (with a CREATE TABLE the emitter generates from the contributor's declared WriteColumns schema) otherwise. This is purely data: rows land in the compiled .msi, inspectable with any MSI tool, but nothing runs them yet.

Runtime (inside the compiled MSI's own execute sequence): an extension's custom table is inert until something schedules a custom action to read it. IExecutionContributor is that seam — it returns ExecutionStep records that ExecutionStepEmitter turns into deferred, elevated CustomAction + InstallExecuteSequence rows (themselves just another table contribution, flowing through the same emitter). This is why Extensions.Firewall's WixFirewallException table and Extensions.Sql's SqlDatabase table both compile cleanly on their own, but require their execution contributor registered too before the rule or the database actually gets created at install time.

IComponentContributor is defined and collected today but not yet wired into the recipe pipeline — registering one logs a Warning (EXT002) rather than silently dropping your files, but no shipped extension currently relies on it. See 10.10 for the up-to-date status of every extension seam.

See Extension System (10) for the full authoring guide and API reference, and Demo 34 — ext-dependency for a Provides/Requires extension exercising both seams end to end.

24.6 UI Stack

The default WPF installer and any custom UI you author both sit behind the same contract: IInstallerEngine (src/FalkForge.Ui.Abstractions/IInstallerEngine.cs) — DetectAsync / PlanAsync / ApplyAsync, observable Phase / Progress / StatusMessage streams, and property-passing methods (SetProperty, SetSecureProperty) that reach the running MSI session without ever putting a secret on a command line or in a log (11.11). EngineClient is the production implementation: it speaks the Engine.Protocol binary message format over Named Pipe A and turns the Engine's phase and progress messages into the observables the UI layer subscribes to. Because the contract is an interface, a custom UI (or a test) can swap in NullInstallerEngine or a hand-rolled double without touching anything in FalkForge.Ui.

InstallerPage (and the generic InstallerPage<TView>) is the base a custom page derives from: it exposes Engine, SharedState, and DetectedState, and a set of lifecycle hooks — OnDetect/Plan/ApplyBeginAsync and ...CompleteAsync — called by CustomShellViewModel at the matching points in the phase sequence from 24.3, so a page can react to (or veto, by returning false from a Begin hook) detection, planning, or apply without knowing anything about pipes or processes. InstallerApp.Run(args, configure) is the static entry point that wires a set of pages, a theme, and localization into a runnable custom installer UI in a few lines.

See Custom UI Framework (11) for the full page/view/builder API, and Demo 11 — custom-ui-simple and Demo 14 — lifecycle-hooks for the lifecycle hooks and property passing shown working end to end.