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
| Approach | Best For | Description |
|---|---|---|
| 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. |
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
- Self-contained Compiler — Generates production-ready
.msi,.msix, and.exefiles via direct P/Invoke. No WiX, no InstallShield, no external tools. - Six Output Formats — MSI, MSIX, MSM (merge module), MSP (patch), MST (transform), and EXE bundle.
- NativeAOT Bundle Engine — Self-extracting EXE bootstrapper with a 3-process architecture (UI, Engine, Elevated companion) communicating over named pipes with HMAC-SHA256 handshake.
- WPF Installer UI — Built-in classic wizard theme with watermark/banner customization, plus a custom UI framework for fully bespoke installer experiences.
- Extension System — Firewall rules, IIS, SQL Server, .NET detection, HTTP/URL reservations, device drivers, COM registration, and utility actions.
- Prerequisite Management — Built-in package groups for .NET Framework, VC++ Redistributable, ODBC drivers, and SQL Server Express.
- Type-Safe Conditions & Properties —
MsiPropertyandConditionclasses with operator overloads for compile-time-safe condition expressions. - CLI Tool — The
forgecommand supports build, validate, inspect, and decompile workflows.
Technology Stack
| Aspect | Detail |
|---|---|
| Runtime | .NET 10, C# latest |
| Nullable | Enabled project-wide |
| Package Management | Central Package Management (Directory.Packages.props) |
| Engine/Elevation | NativeAOT (~3-5 MB), no reflection, no dynamic |
| UI Framework | WPF + ReactiveUI |
| Testing | xUnit 2.9.3 (~7,000+ tests) |
| Build Policy | TreatWarningsAsErrors — 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.
| Code | Identifies | FalkForge 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. |
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.
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
-
The old installer isn't an MSI. Inno Setup, NSIS, and similar tools don't register
an
UpgradeCodeat all — there's nothing to reuse, becauseMajorUpgradedetection is an MSI-only mechanism. The new FalkForge MSI and the old EXE are two unrelated installations as far as Windows Installer is concerned. Practical options: detect the old install some other way you control (a registry key or file it's known to leave behind, wired into apackage.Require()launch condition or a custom action) and drive its silent uninstall yourself before proceeding —QuietExecin Extensions.Util (10) is built for exactly this — or, if you're willing to introduce a bundle, chain "uninstall the old EXE" as its own step ahead of the new MSI in a bundle chain (9) instead of trying to fold the transition into MSI upgrade detection at all. -
Version comparison only looks at three fields. The
Upgradetable's version range is written fromVersion.ToString(3), and Windows Installer's ownProductVersioncomparison only ever considers Major.Minor.Build — a fourth (Revision) component on a .NETVersionis silently ignored for upgrade-detection purposes. Don't rely on a fourth component to distinguish versions fromMajorUpgrade's perspective. -
Downgrades are blocked by default. Installing an older
Versionthan what Windows Installer sees on the machine fails validation unless you opt in:package.Downgrade(d => d.Allow()), or keep it blocked with your own message viapackage.Downgrade(d => d.Block("A newer version is already installed.")). This bites migrations specifically when the old product's version numbering and the new FalkForge numbering don't line up — e.g. resetting to1.0.0under the sameUpgradeCodethe old4.xproduct used reads as a downgrade and is blocked. Decide your version numbering before wiring up the migration, not after. -
Per-machine and per-user context must match.
MajorUpgradedetection only sees products installed in the same scope (below) as the new package. A per-user old install and a per-machine new install — or vice versa — won't detect each other viaUpgradeCodeat all, no matter how carefully the GUID was copied.
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.
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 actions —
CostInitialize, 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
| Requirement | Detail |
|---|---|
| .NET SDK | 10.0.103 or later (pinned via global.json) |
| Operating System | Windows (required for MSI compilation via msi.dll P/Invoke) |
| Architecture | x64 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
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:
PublishAot: trueInvariantGlobalization: trueIlcOptimizationPreference: Size
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:
| Command | Description |
|---|---|
forge init --name "My App" | Scaffold a starter installer project (csproj + Program.cs + payload) referencing the FalkForge meta-package |
forge build installer.csx | Build installer from C# script (Roslyn scripting) |
forge build installer.json | Build installer from JSON config file |
forge validate installer.csx | Validate without compiling |
forge inspect Package.msi | Display MSI metadata with tree views (Windows-only) |
forge decompile Package.msi | Decompile MSI to PackageModel or C# source (Windows-only) |
forge migrate Package.msi | Convert existing .msi/.msm/.exe (WiX Burn) into a buildable FalkForge C# project |
forge plan bundle.exe | Compute and display the install plan for a bundle without running it |
forge plan-diff a.exe b.exe | Diff two installers of the same type and report changes |
forge verify bundle.exe | Independently 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.json | Export a built-in localization JSON file as a starting point for an override |
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Validation error |
| 2 | Compilation error |
| 3 | Runtime 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)
| Project | Description |
|---|---|
FalkForge.Core | Domain model, fluent builder API, validation, core types (Result, Error, MsiProperty, Condition) |
FalkForge.Compiler.Msi | MSI/MSM/MSP/MST generation via msi.dll P/Invoke, cabinet building, ICE validation |
FalkForge.Compiler.Bundle | Self-extracting EXE bundle compiler with manifest generation and payload embedding |
FalkForge.Compiler.Msix | Experimental MSIX and .msixbundle compiler. Wired into Studio; full CLI dispatch not yet implemented. Entry points: InstallerMsix.BuildMsix() / BuildMsixBundle(). |
FalkForge.Engine | NativeAOT installer runtime — pipeline, phase steps, detection, planning, execution, rollback, logging, metrics |
FalkForge.Engine.Elevation | NativeAOT elevated companion process for privileged operations |
FalkForge.Engine.Protocol | IPC message types, binary serialization, named pipe transport with HMAC-SHA256 handshake, ProgramArgs log-flag parser |
FalkForge.Platform | OS abstractions (IFileSystem, IRegistry) |
FalkForge.Platform.Windows | Windows P/Invoke implementations: IFileSystem, IRegistry, IMsiApi (MsiInstallProduct/MsiConfigureProduct) |
FalkForge.Extensibility | Extension system interfaces (IFalkForgeExtension, IComponentContributor, IMsiTableContributor, ITableQuery, ITableReadSchema) |
FalkForge.Extensions.Util | XmlConfig, UserManagement, FileShare, QuietExec, RemoveFolderEx, InternetShortcut |
FalkForge.Extensions.Dependency | Provider/consumer ref-counting (WiX-compatible) via HKLM registry |
FalkForge.Extensions.Firewall | Windows Firewall rule definitions and validation |
FalkForge.Extensions.DotNet | .NET runtime detection via registry and filesystem probing |
FalkForge.Extensions.Driver | Windows driver install/uninstall actions (.inf / DIFx) |
FalkForge.Extensions.Http | HTTP listener / URL ACL reservations (http.sys) |
FalkForge.Extensions.Iis | IIS AppPool, WebSite, WebBinding, Certificate configuration |
FalkForge.Extensions.Sql | SQL Server database creation, script execution, string execution |
FalkForge.Ui.Abstractions | IInstallerEngine, base ViewModels, PageResult, InstallerState, SensitiveBytes |
FalkForge.Ui | WPF + ReactiveUI installer UI, custom UI framework (InstallerPage, InstallerApp) |
FalkForge.Sdk | MSBuild SDK targets for source generation (netstandard2.0) |
FalkForge.Studio | WPF authoring environment for editing installer definitions visually |
FalkForge.Testing | Test utilities, mocks, shared test infrastructure |
FalkForge.Localization | JSON-based localization with culture fallback chains |
FalkForge.Decompiler | MSI / Bundle / WiX Burn decompiler producing PackageModel / BundleModel / C# source |
FalkForge.Cli | Spectre.Console CLI: build, validate, inspect, decompile, extract, winget, rules, bundle commands |
FalkForge.Plugins.Sql | SQL Server discovery, listing, connection testing |
FalkForge.Plugins.Odbc | ODBC DSN checking and admin launcher (Windows-only) |
FalkForge.Plugins.FileSystem | Folder browser dialog (WPF, Windows-only) |
Test Projects (26)
| Project | Tests For |
|---|---|
FalkForge.Core.Tests | Core domain model, builders, validation |
FalkForge.Compiler.Msi.Tests | MSI compiler, table emission, cabinet building |
FalkForge.Compiler.Msix.Tests | MSIX compiler (experimental) |
FalkForge.Compiler.Bundle.Tests | Bundle compiler, manifest, payload embedding |
FalkForge.Engine.Tests | Engine session, pipeline, phase steps, runner, rollback |
FalkForge.Engine.Elevation.Tests | Elevated command dispatch |
FalkForge.Engine.Protocol.Tests | Protocol serialization, transport, golden-byte parity |
FalkForge.Ui.Abstractions.Tests | PageResult, InstallerState |
FalkForge.Ui.Tests | WPF UI, custom UI framework |
FalkForge.Studio.Tests | Studio editors and view-models |
FalkForge.Integration.Tests | End-to-end integration scenarios |
FalkForge.Extensibility.Tests | Extension interfaces, table contributors, rule merging |
FalkForge.Extensions.Util.Tests | Utility extension actions |
FalkForge.Extensions.Dependency.Tests | Dependency provider/consumer validation |
FalkForge.Extensions.Firewall.Tests | Firewall rule validation |
FalkForge.Extensions.DotNet.Tests | .NET detection logic |
FalkForge.Extensions.Driver.Tests | Driver extension actions |
FalkForge.Extensions.Http.Tests | HTTP listener / URL ACL extension |
FalkForge.Extensions.Iis.Tests | IIS configuration validation |
FalkForge.Extensions.Sql.Tests | SQL extension validation |
FalkForge.Localization.Tests | Localization loading, fallback, resolution |
FalkForge.Decompiler.Tests | MSI/bundle/Burn decompilation and C# emission |
FalkForge.Cli.Tests | CLI commands, JSON config loading |
FalkForge.Platform.Windows.Tests | IMsiApi contract, P/Invoke bindings |
FalkForge.Plugins.Odbc.Tests | ODBC DSN management |
FalkForge.Plugins.Sql.Tests | SQL 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
| Member | Type | Description |
|---|---|---|
IsSuccess | bool | True when the result holds a value (no error). |
IsFailure | bool | True when the result holds an error. |
Value | T | The success value. Throws InvalidOperationException if IsFailure. |
Error | Error | The error. Throws InvalidOperationException if IsSuccess. |
Static Methods
| Method | Description |
|---|---|
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
| Method | Signature | Description |
|---|---|---|
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
| Operator | Description |
|---|---|
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
| Member | Type | Description |
|---|---|---|
Kind | ErrorKind | The category of error. |
Message | string | Human-readable error description. |
ToString() | string | Returns "{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
| Method | Signature | Description |
|---|---|---|
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. |
-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:
- Instantiate the appropriate builder
- Call the user's configure action
- Build the model from the builder
- Validate the model (print errors/warnings to stderr)
- Compile if a compiler is provided
- 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
| Instance | MSI Token | Display Name |
|---|---|---|
ProgramFiles | ProgramFilesFolder | Program Files |
ProgramFiles64 | ProgramFiles64Folder | Program Files (64-bit) |
CommonAppData | CommonAppDataFolder | ProgramData |
LocalAppData | LocalAppDataFolder | Local AppData |
AppData | AppDataFolder | AppData |
SystemFolder | SystemFolder | System32 |
System64Folder | System64Folder | System64 |
WindowsFolder | WindowsFolder | Windows |
TempFolder | TempFolder | Temp |
DesktopFolder | DesktopFolder | Desktop |
StartMenuFolder | StartMenuFolder | Start Menu |
ProgramMenuFolder | ProgramMenuFolder | Programs |
StartupFolder | StartupFolder | Startup |
CommonFilesFolder | CommonFilesFolder | Common Files |
CommonFiles64Folder | CommonFiles64Folder | Common Files (64-bit) |
FontsFolder | FontsFolder | Fonts |
Operators
| Operator | Description |
|---|---|
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
| Member | Type | Description |
|---|---|---|
Root | KnownFolder | The root known folder. |
RelativePath | string | The relative path from the root (forward slashes, no trailing slash). |
Segments | IReadOnlyList<string> | All directory segments from root to this path. |
InstallPath / string | InstallPath | Appends 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
| Member | Type | Description |
|---|---|---|
Name | string | The MSI property name (e.g., "ProductName", "INSTALLFOLDER"). |
Factory Method
| Method | Description |
|---|---|
Custom(string name) | Creates a user-defined property reference. Throws if name is null or whitespace. |
Static Instances — Product
| Instance | MSI Property Name | Description |
|---|---|---|
ProductName | ProductName | Product display name |
ProductCode | ProductCode | Product GUID |
ProductVersion | ProductVersion | Product version string |
ProductLanguage | ProductLanguage | Product language LCID |
Manufacturer | Manufacturer | Product manufacturer name |
UpgradeCode | UpgradeCode | Upgrade code GUID for major upgrades |
Static Instances — Directories
| Instance | MSI Property Name | Description |
|---|---|---|
InstallFolder | INSTALLFOLDER | Primary installation directory |
InstallDir | INSTALLDIR | Alternative install directory property |
TargetDir | TARGETDIR | Root target directory |
Static Instances — OS Version
| Instance | MSI Property Name | Description |
|---|---|---|
VersionNT | VersionNT | Windows NT version number |
VersionNT64 | VersionNT64 | 64-bit Windows version number |
ServicePackLevel | ServicePackLevel | Installed service pack level |
WindowsBuildNumber | WindowsBuildNumber | Windows build number |
Static Instances — Architecture
| Instance | MSI Property Name | Description |
|---|---|---|
Intel | Intel | x86 processor detected |
Intel64 | Intel64 | Itanium processor detected |
MsiAMD64 | MsiAMD64 | x64 (AMD64) processor detected |
Msix64 | Msix64 | 64-bit processor detected (x64 or Arm64) |
Static Instances — System Folders
| Instance | MSI Property Name | Description |
|---|---|---|
ProgramFilesFolder | ProgramFilesFolder | Program Files directory |
ProgramFiles64Folder | ProgramFiles64Folder | 64-bit Program Files |
CommonFilesFolder | CommonFilesFolder | Common Files directory |
SystemFolder | SystemFolder | System32 directory |
System64Folder | System64Folder | 64-bit System directory |
WindowsFolder | WindowsFolder | Windows directory |
TempFolder | TempFolder | Temporary directory |
AppDataFolder | AppDataFolder | Roaming AppData |
LocalAppDataFolder | LocalAppDataFolder | Local AppData |
CommonAppDataFolder | CommonAppDataFolder | ProgramData directory |
DesktopFolder | DesktopFolder | User desktop |
StartMenuFolder | StartMenuFolder | Start Menu root |
ProgramMenuFolder | ProgramMenuFolder | Programs folder in Start Menu |
StartupFolder | StartupFolder | Startup folder |
FontsFolder | FontsFolder | Fonts directory |
PersonalFolder | PersonalFolder | User documents folder |
Static Instances — Session
| Instance | MSI Property Name | Description |
|---|---|---|
Privileged | Privileged | Set when installer runs with elevated privileges |
AdminUser | AdminUser | Set when user is an administrator |
TerminalServer | TerminalServer | Set when running on Terminal Server |
RemoteAdminTS | RemoteAdminTS | Set for remote administration via Terminal Server |
Static Instances — User
| Instance | MSI Property Name | Description |
|---|---|---|
ComputerName | ComputerName | Machine name |
LogonUser | LogonUser | Current logged-on user |
UserSID | UserSID | User security identifier |
Static Instances — State
| Instance | MSI Property Name | Description |
|---|---|---|
Installed | Installed | Set when product is already installed |
UILevel | UILevel | UI level (2=none, 3=basic, 4=reduced, 5=full) |
REMOVE | REMOVE | Features being removed (e.g., "ALL") |
REINSTALL | REINSTALL | Features being reinstalled |
CustomActionData | CustomActionData | Data 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+
| Operator | MSI Expression | Value Types |
|---|---|---|
== | Name = "value" or Name = value | string, int |
!= | Name <> "value" or Name <> value | string, int |
> | Name > value | int |
< | Name < value | int |
>= | Name >= value | int |
<= | Name <= value | int |
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
| Method | Description |
|---|---|
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
| Instance | Expression | Description |
|---|---|---|
Is64BitOS | VersionNT64 OR Msix64 | True on 64-bit Windows (x64 or Arm64) |
IsPrivileged | Privileged | True when running with elevated privileges |
IsAdmin | AdminUser | True when user has administrator rights |
IsTerminalServer | TerminalServer | True on Terminal Server |
IsWindows10OrLater | VersionNT >= 603 | True on Windows 10 or later |
IsWindows11OrLater | WindowsBuildNumber >= 22000 | True on Windows 11 or later |
IsInstalled | Installed | True when the product is already installed |
IsInstalling | NOT Installed | True during first-time installation |
IsUninstalling | REMOVE="ALL" | True during complete uninstallation |
IsRepairing | REINSTALL | True during repair |
Logical Operators
| Operator | Result | Description |
|---|---|---|
& | (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>.
InstallScope
namespace FalkForge — Determines whether the package installs per-machine or per-user.
CompressionLevel
namespace FalkForge — Cabinet file compression level.
ProcessorArchitecture
namespace FalkForge — Target processor architecture for the package.
RegistryRoot
namespace FalkForge — Registry hive root for registry entries.
RegistryValueType
namespace FalkForge — Registry value data type.
ServiceAccount
namespace FalkForge — Windows service logon account type.
ServiceStartMode
namespace FalkForge — Windows service startup type.
ShortcutLocation
namespace FalkForge — Target location for installed shortcuts.
InstallAction
namespace FalkForge — Top-level installer action requested by the user.
ExitCodeBehavior
namespace FalkForge — How the engine interprets a package exit code.
FailureAction
namespace FalkForge — Service failure recovery action.
RelatedBundleRelation
namespace FalkForge — Relationship type between related bundles.
FalkForge.Core / Models
AssemblyType
namespace FalkForge.Models — Type of assembly for side-by-side registration.
CustomActionType
namespace FalkForge.Models — Static class with MSI custom action type constants (bitmask values).
| Constant | Value | Description |
|---|---|---|
DllFromBinary | 1 | Call a DLL entry point from a Binary table stream |
ExeFromBinary | 2 | Run an EXE from a Binary table stream |
JScriptFromBinary | 5 | Execute JScript from a Binary table stream |
VBScriptFromBinary | 6 | Execute VBScript from a Binary table stream |
ExeInDir | 34 | Run an EXE located in a directory |
SetProperty | 51 | Set a property value |
SetDirectory | 35 | Set a directory path |
Continue | 0x040 (64) | Execution flag: continue on failure |
InScript | 0x100 (256) | Execution flag: deferred execution |
Rollback | 0x200 (512) | Execution flag: rollback custom action |
Commit | 0x400 (1024) | Execution flag: commit custom action |
NoImpersonate | 0x800 (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.
EnvironmentVariableAction
namespace FalkForge.Models — How an environment variable is modified.
IniFileAction
namespace FalkForge.Models — INI file modification action.
RemoveRegistryAction
namespace FalkForge.Models — Registry removal granularity.
MsiDialogSet
namespace FalkForge.Models — Built-in MSI dialog set templates.
SequenceTable
namespace FalkForge.Models — MSI sequence table for custom action scheduling.
RemoveExistingProductsSchedule
namespace FalkForge.Models — When to remove existing products during a major upgrade.
PatchClassification
namespace FalkForge.Models — Classification for MSP patch packages.
ServiceControlEvent
namespace FalkForge.Models — [Flags] enum for service control actions during install/uninstall.
| Value | Numeric | Description |
|---|---|---|
None | 0 | No service control action |
StartOnInstall | 1 | Start the service during install |
StopOnInstall | 2 | Stop the service during install |
DeleteOnInstall | 8 | Delete the service during install |
StartOnUninstall | 16 | Start the service during uninstall |
StopOnUninstall | 32 | Stop the service during uninstall |
DeleteOnUninstall | 128 | Delete the service during uninstall |
FalkForge.Core / Validation
ValidationSeverity
namespace FalkForge.Validation — Severity level for validation results.
FalkForge.Compiler.Msi
IceMessageSeverity
namespace FalkForge.Compiler.Msi.Validation — Severity of ICE validation messages.
FalkForge.Compiler.Bundle
BundlePackageType
namespace FalkForge.Compiler.Bundle — Type of package within a bundle chain.
BundleUiType
namespace FalkForge.Compiler.Bundle — UI mode for bundle installers.
BundleVariableType
namespace FalkForge.Compiler.Bundle — Data type for bundle variables.
FalkForge.Engine.Protocol
MessageType
namespace FalkForge.Engine.Protocol — : ushort. IPC message type identifiers organized by direction.
| Value | Hex | Direction | Description |
|---|---|---|---|
DetectBegin | 0x0101 | Engine → UI | Detection phase started |
DetectComplete | 0x0102 | Engine → UI | Detection phase completed |
PlanBegin | 0x0103 | Engine → UI | Planning phase started |
PlanComplete | 0x0104 | Engine → UI | Planning phase completed |
ApplyBegin | 0x0105 | Engine → UI | Apply phase started |
ApplyComplete | 0x0106 | Engine → UI | Apply phase completed |
Progress | 0x0107 | Engine → UI | Progress update |
Error | 0x0108 | Engine → UI | Error notification |
PhaseChanged | 0x0109 | Engine → UI | Engine phase transition |
Log | 0x010A | Engine → UI | Log message |
ShutdownResponse | 0x010B | Engine → UI | Acknowledgment of shutdown request |
UpdateAvailable | 0x010C | Engine → UI | Update available notification |
UpdateReady | 0x010D | Engine → UI | Update downloaded and ready |
Cancel | 0x0201 | UI → Engine | User cancelled the operation |
ShutdownRequest | 0x0202 | UI → Engine | Request engine shutdown |
SetInstallDirectory | 0x0203 | UI → Engine | Set the installation directory |
SetFeatureSelection | 0x0204 | UI → Engine | Set feature selection state |
RequestDetect | 0x0205 | UI → Engine | Request detection phase |
RequestPlan | 0x0206 | UI → Engine | Request planning phase |
RequestApply | 0x0207 | UI → Engine | Request apply phase |
ElevateExecute | 0x0301 | Engine → Elevated | Execute elevated command |
ElevateResult | 0x0401 | Elevated → Engine | Elevated command result |
EnginePhase
namespace FalkForge.Engine.Protocol — Engine state machine phases.
InstallState
namespace FalkForge.Engine.Protocol — Detected installation state of a package.
LogLevel
namespace FalkForge.Engine.Protocol — Engine log message severity.
FalkForge.Engine.Protocol / Manifest
PackageType (Manifest)
namespace FalkForge.Engine.Protocol.Manifest — Package type within an installer manifest.
UpdatePolicy
namespace FalkForge.Engine.Protocol.Manifest — Update check behavior policy for bundles.
FalkForge.Engine
PlanActionType
namespace FalkForge.Engine.Planning — Planned action for a package.
JournalEntryType
namespace FalkForge.Engine.Journal — Type of operation recorded in the rollback journal.
TokenType (Condition Lexer)
namespace FalkForge.Engine.Variables — Token types produced by the condition expression lexer.
FalkForge.Ui.Abstractions
PageResultKind
namespace FalkForge.Ui.Abstractions — Navigation result kind for custom UI pages.
FalkForge.Extensions.Firewall
FirewallRuleAction
namespace FalkForge.Extensions.Firewall — Whether a firewall rule allows or blocks traffic.
FirewallProtocol
namespace FalkForge.Extensions.Firewall — Network protocol for a firewall rule.
FirewallProfile
namespace FalkForge.Extensions.Firewall — [Flags] enum for network profiles a firewall rule applies to.
| Value | Numeric | Description |
|---|---|---|
Domain | 1 | Domain network profile |
Private | 2 | Private network profile |
Public | 4 | Public network profile |
All | 7 | All profiles (Domain | Private | Public) |
FirewallDirection
namespace FalkForge.Extensions.Firewall — Traffic direction for a firewall rule.
FalkForge.Extensions.DotNet
DotNetRuntimeType
namespace FalkForge.Extensions.DotNet — Type of .NET runtime to detect.
DotNetPlatform
namespace FalkForge.Extensions.DotNet — Target platform for .NET runtime detection.
FalkForge.Extensions.Iis
AppPoolIdentityType
namespace FalkForge.Extensions.Iis.Models — Identity under which an IIS application pool runs.
ManagedPipelineMode
namespace FalkForge.Extensions.Iis.Models — Managed pipeline mode for an IIS application pool.
CertificateFindType
namespace FalkForge.Extensions.Iis.Models — How to locate a certificate in a store.
CertificateStoreName
namespace FalkForge.Extensions.Iis.Models — Certificate store name.
CertificateStoreLocation
namespace FalkForge.Extensions.Iis.Models — Certificate store location.
FalkForge.Extensions.Util
XmlConfigAction
namespace FalkForge.Extensions.Util.XmlConfig — XML configuration file modification action.
RemoveFolderExInstallMode
namespace FalkForge.Extensions.Util.RemoveFolderEx — When to remove folders.
FileSharePermissionLevel
namespace FalkForge.Extensions.Util.FileShare — Permission level for file share access.
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.
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:
| Method | Signature | Description |
|---|---|---|
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
| Property | Type | Default | Description |
|---|---|---|---|
Name | string | "" | Product name displayed in Programs and Features. |
Manufacturer | string | "" | Publisher / manufacturer name. |
Version | Version | 1.0.0 | Product version (major.minor.build). |
UpgradeCode | Guid? | Deterministic | Upgrade code. Auto-generated from Name + Manufacturer if not set. |
ProductCode | Guid? | Guid.NewGuid() | Unique product code per build. Auto-generated if not set. |
Scope | InstallScope | PerMachine | Install scope: PerMachine or PerUser. |
Architecture | ProcessorArchitecture | X64 | Target processor architecture. |
DefaultInstallDirectory | InstallPath? | ProgramFiles/Manufacturer/Name | Default installation directory path. |
Compression | CompressionLevel | High | Cabinet compression level. |
Description | string? | null | Product description for summary information stream. |
Comments | string? | null | Comments for summary information stream. |
Contact | string? | null | Contact information. |
HelpUrl | string? | null | Help/support URL shown in Programs and Features. |
AboutUrl | string? | null | Product information URL. |
UpdateUrl | string? | null | Update information URL. |
LicenseFile | string? | null | Path to RTF license file for the license dialog. |
EnableRestartManager | bool | false | Whether to enable Windows Restart Manager integration (sets MSIRMSHUTDOWN=0 and authors the MsiRMFilesInUse dialog). Set via EnableRestartManagerSupport(), not directly. |
Fluent Methods
| Method | Return Type | Description |
|---|---|---|
Files(Action<FileSetBuilder>) | PackageBuilder | Adds files to the package at a target directory. |
Shortcut(string name, string targetFile) | ShortcutBuilder | Creates a shortcut builder for the specified file. Chain with OnDesktop(), OnStartMenu(), etc. |
Service(string name, Action<ServiceBuilder>) | PackageBuilder | Defines a Windows service to install. |
ServiceControl(Action<ServiceControlBuilder>) | PackageBuilder | Defines service start/stop/delete control operations. |
Feature(string id, Action<FeatureBuilder>) | PackageBuilder | Defines a feature node in the feature tree. If no features are defined, an implicit "Complete" feature is created. |
Registry(Action<RegistryBuilder>) | PackageBuilder | Writes registry values during installation. |
RemoveRegistry(Action<RemoveRegistryBuilder>) | PackageBuilder | Removes registry keys or values during installation. |
EnvironmentVariable(string name, string value, Action<EnvironmentVariableBuilder>?) | PackageBuilder | Sets or modifies an environment variable. |
EnvironmentVariable(string name, MsiProperty property, Action<EnvironmentVariableBuilder>?) | PackageBuilder | Sets an environment variable using an MSI property reference. |
Property(string name, string value, Action<PropertyBuilder>?) | PackageBuilder | Defines an MSI property. |
Require(string condition, string message) | PackageBuilder | Adds a launch condition. Installation aborts with message if condition evaluates to false. |
Require(Condition condition, string message) | PackageBuilder | Adds a launch condition using a type-safe Condition object. |
Upgrade(Action<UpgradeBuilder>) | PackageBuilder | Configures legacy upgrade detection (version ranges). |
MajorUpgrade(Action<MajorUpgradeBuilder>) | PackageBuilder | Configures standard major upgrade behavior. |
Font(string fileName, Action<FontBuilder>?) | PackageBuilder | Registers a font file for installation. |
IniFile(string fileName, Action<IniFileBuilder>) | PackageBuilder | Creates or modifies INI file entries. |
Permission(string lockObject, Action<PermissionBuilder>) | PackageBuilder | Sets permissions on an installed object (file, folder, registry key). |
FileAssociation(string extension, Action<FileAssociationBuilder>) | PackageBuilder | Registers a file type association (e.g., .myext). |
CustomAction(string id, Action<CustomActionBuilder>) | PackageBuilder | Defines a custom action with explicit configuration. |
CustomAction(string binaryPath, string entryPoint, Action<CustomActionBuilder>?) | PackageBuilder | Simplified overload: auto-registers the binary and creates a DllFromBinary action. |
RemoveFile(Action<RemoveFileBuilder>) | PackageBuilder | Schedules file removal on install or uninstall. |
CreateFolder(Action<CreateFolderBuilder>) | PackageBuilder | Creates an empty folder during installation. |
MoveFile(Action<MoveFileBuilder>) | PackageBuilder | Moves or copies a file during installation. |
DuplicateFile(Action<DuplicateFileBuilder>) | PackageBuilder | Duplicates an installed file to another location. |
GacAssembly(Action<AssemblyBuilder>) | PackageBuilder | Registers an assembly in the Global Assembly Cache. |
CustomTable(Action<CustomTableBuilder>) | PackageBuilder | Defines a custom MSI database table with schema and data. |
ExecuteSequence(Action<SequenceBuilder>) | PackageBuilder | Inserts actions into the InstallExecuteSequence table. |
UISequence(Action<SequenceBuilder>) | PackageBuilder | Inserts actions into the InstallUISequence table. |
MediaTemplate(Action<MediaTemplateBuilder>) | PackageBuilder | Configures cabinet and media settings. |
Binary(string name, string sourcePath) | PackageBuilder | Adds a binary resource (DLL, EXE, bitmap) to the Binary table. |
EnableRestartManagerSupport() | PackageBuilder | Enables 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>) | PackageBuilder | Configures Authenticode code signing for the output MSI. |
UseDialogSet(MsiDialogSet dialogSet) | PackageBuilder | Selects the built-in MSI dialog template set. |
SetLocalizationData(IReadOnlyList<LocalizationData>) | PackageBuilder | Applies localization string tables for multi-language support. |
Integrity(Action<IntegrityBuilder>) | PackageBuilder | Enables ECDSA-P256 payload signing. See §23 for key generation, baking, and rotation. |
Sbom(Action<SbomOptions>? configure = null) | PackageBuilder | Emits 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() | PackageModel | Finalizes configuration and produces the immutable PackageModel. |
MsiDialogSet Enum
| Value | Description |
|---|---|
None | No UI dialogs (silent install). |
Minimal | Progress dialog only. |
InstallDir | Welcome, license, install directory selection, and progress. |
FeatureTree | Welcome, license, feature tree selection, and progress. |
Mondo | Full dialog set: typical/custom/complete install modes with feature tree. |
Advanced | Extended 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
| Property | Type | Default | Description |
|---|---|---|---|
Title | string | "" | Display title. Falls back to the feature ID if empty. |
Description | string? | null | Description shown in the feature selection dialog. |
IsRequired | bool | false | If true, the feature cannot be deselected by the user. |
IsDefault | bool | true | If true, the feature is selected by default. |
Methods
| Method | Return Type | Description |
|---|---|---|
Feature(string id, Action<FeatureBuilder>) | FeatureBuilder | Adds a nested sub-feature. |
Condition(string condition, int level) | FeatureBuilder | Adds a conditional install level. When condition is true, the feature's install level is set to level. |
Condition(string condition) | FeatureBuilder | Adds a condition with level 0 (disabled when true). |
Condition(Condition condition, int level) | FeatureBuilder | Type-safe overload using a Condition object. |
Condition(Condition condition) | FeatureBuilder | Type-safe overload with level 0. |
Files(Action<FileSetBuilder>) | FeatureBuilder | Adds 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.
| Method | Return Type | Description |
|---|---|---|
Add(string filePath) | FileSetBuilder | Adds a single file by path. |
FromDirectory(string sourcePath) | FileSetBuilder | Adds all files from a source directory. Resolved at compile time. |
To(InstallPath targetDirectory) | FileSetBuilder | Sets the target installation directory. Required -- build throws if not specified. |
NeverOverwrite() | FileSetBuilder | Prevents overwriting the file on upgrade if it already exists. Maps to MSI component attribute msidbComponentAttributesNeverOverwrite. |
Permanent() | FileSetBuilder | File is never removed on uninstall. Maps to MSI component attribute msidbComponentAttributesPermanent. Use for config files and user data. |
ComponentCondition(string condition) | FileSetBuilder | Applies a condition to all components generated from this file set. |
To() method accepts an InstallPath, which is constructed using
KnownFolder instances and the / operator for path composition:
KnownFolder.ProgramFiles / "Company" / "Product".
KnownFolder Reference
| Static Property | MSI Token | Description |
|---|---|---|
KnownFolder.ProgramFiles | ProgramFilesFolder | Program Files directory |
KnownFolder.ProgramFiles64 | ProgramFiles64Folder | Program Files (64-bit) directory |
KnownFolder.CommonAppData | CommonAppDataFolder | ProgramData directory |
KnownFolder.LocalAppData | LocalAppDataFolder | Local AppData directory |
KnownFolder.AppData | AppDataFolder | Roaming AppData directory |
KnownFolder.SystemFolder | SystemFolder | System32 directory |
KnownFolder.System64Folder | System64Folder | 64-bit System directory |
KnownFolder.WindowsFolder | WindowsFolder | Windows directory |
KnownFolder.TempFolder | TempFolder | Temp directory |
KnownFolder.DesktopFolder | DesktopFolder | Desktop directory |
KnownFolder.StartMenuFolder | StartMenuFolder | Start Menu root |
KnownFolder.ProgramMenuFolder | ProgramMenuFolder | Programs menu directory |
KnownFolder.StartupFolder | StartupFolder | Startup directory |
KnownFolder.CommonFilesFolder | CommonFilesFolder | Common 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.
OnDesktop(), OnStartMenu(), OnStartup())
immediately registers a shortcut with the parent PackageBuilder. You can chain
multiple locations to create a shortcut in each place.
| Method | Return Type | Description |
|---|---|---|
OnDesktop() | ShortcutBuilder | Places a shortcut on the user's desktop. |
OnStartMenu(string? subfolder) | ShortcutBuilder | Places a shortcut in the Start Menu, optionally under a subfolder. |
OnStartup() | ShortcutBuilder | Places a shortcut in the Startup folder (auto-launch on login). |
WithArguments(string arguments) | ShortcutBuilder | Sets command-line arguments for the shortcut target. |
WithDescription(string description) | ShortcutBuilder | Sets the shortcut tooltip description. |
WithIcon(string iconFile, int iconIndex) | ShortcutBuilder | Sets the shortcut icon. iconIndex defaults to 0. |
WithWorkingDirectory(string workingDirectory) | ShortcutBuilder | Sets 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
| Property | Type | Default | Description |
|---|---|---|---|
DisplayName | string | "" | Service display name. Falls back to service name if empty. |
Executable | string | "" | Service executable file reference. |
Description | string? | null | Service description shown in Services console. |
StartMode | ServiceStartMode | Automatic | Startup type: Automatic, Manual, Disabled, DelayedAutomatic. |
Account | ServiceAccount | LocalSystem | Service account: LocalSystem, LocalService, NetworkService, User. |
UserName | string? | null | Username when Account is User. |
Password | string? | null | Password when Account is User. |
Arguments | string? | null | Command-line arguments passed to the service executable at startup. Supports MSI property references: "DSN=[ODBCNAME]". |
Dependencies | List<string> | Empty | Legacy string-based dependency list. |
Methods
| Method | Return Type | Description |
|---|---|---|
DependsOn(string serviceName) | ServiceBuilder | Adds a dependency on another service. |
DependsOnGroup(string groupName) | ServiceBuilder | Adds a dependency on a service group. |
AccountProperty(string propertyRef) | ServiceBuilder | Sets the service account via an MSI property reference (e.g., [SERVICEACCOUNT]) resolved at install time. Overrides the Account enum. |
Condition(string condition) | ServiceBuilder | MSI condition controlling whether the service is installed. Example: "ASSERVICE ~= \"true\"". |
Condition(Condition condition) | ServiceBuilder | Type-safe overload using the Condition builder. |
Permission(Action<PermissionBuilder>) | ServiceBuilder | Configures service access control. Auto-sets ForTable("ServiceInstall"). Use SDDL or User/Domain for fine-grained permissions. |
FailureActions(Action<ServiceFailureActionsBuilder>) | ServiceBuilder | Configures 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.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | ServiceControlBuilder | Sets the unique identifier for this service control entry. |
ServiceName(string serviceName) | ServiceControlBuilder | Sets the name of the service to control. |
StartOnInstall() | ServiceControlBuilder | Starts the service after installation. |
StopOnInstall() | ServiceControlBuilder | Stops the service before installation (for upgrades). |
DeleteOnInstall() | ServiceControlBuilder | Deletes the service during installation. |
StartOnUninstall() | ServiceControlBuilder | Starts the service during uninstall. |
StopOnUninstall() | ServiceControlBuilder | Stops the service during uninstall. |
DeleteOnUninstall() | ServiceControlBuilder | Deletes the service on uninstall. |
Wait(bool wait) | ServiceControlBuilder | Whether to wait for the service operation to complete. Defaults to true. |
Arguments(string arguments) | ServiceControlBuilder | Sets command-line arguments passed to the service on start. |
ComponentRef(string componentRef) | ServiceControlBuilder | Associates 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.
| Property | Type | Default | Description |
|---|---|---|---|
OnFirstFailure | FailureAction | None | Action on first failure: None, Restart, Reboot, RunCommand. |
OnSecondFailure | FailureAction | None | Action on second failure. |
OnSubsequentFailures | FailureAction | None | Action on all subsequent failures. |
ResetPeriod | TimeSpan | 1 day | Time after which the failure counter resets. |
RestartDelay | TimeSpan | 1 minute | Delay before restarting the service. |
Command | string? | null | Command to execute when FailureAction.RunCommand is specified. |
RebootMessage | string? | null | Message 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
| Method | Return Type | Description |
|---|---|---|
Key(RegistryRoot root, string key, Action<RegistryKeyBuilder>) | RegistryBuilder | Opens a registry key path for value definition. Can be called multiple times for different keys. |
RegistryKeyBuilder
| Method | Return Type | Description |
|---|---|---|
Value(string name, string value, RegistryValueType type) | RegistryKeyBuilder | Adds a named value. type defaults to String. Supports: String, ExpandString, MultiString, DWord, QWord, Binary. |
Value(string name, MsiProperty property) | RegistryKeyBuilder | Adds a named value resolved from an MSI property at install time. |
DWord(string name, int value) | RegistryKeyBuilder | Adds a DWORD (32-bit integer) value. |
DefaultValue(string value) | RegistryKeyBuilder | Sets the default (unnamed) value of the key. |
RegistryRoot Enum
| Value | Hive |
|---|---|
LocalMachine | HKLM |
CurrentUser | HKCU |
ClassesRoot | HKCR |
Users | HKU |
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.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | RemoveRegistryBuilder | Sets the unique identifier. |
Root(RegistryRoot root) | RemoveRegistryBuilder | Sets the registry hive. |
Key(string key) | RemoveRegistryBuilder | Sets the registry key path. |
Name(string name) | RemoveRegistryBuilder | Sets the value name to remove (for RemoveValue action). |
RemoveKey() | RemoveRegistryBuilder | Removes the entire key and all its values. This is the default action. |
RemoveValue() | RemoveRegistryBuilder | Removes only the specified named value. |
ComponentRef(string componentRef) | RemoveRegistryBuilder | Associates 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
| Property | Type | Default | Description |
|---|---|---|---|
Target | string? | null | DLL entry point, EXE arguments, or property value. |
Condition | string? | null | Condition expression controlling execution. |
Sequence | int? | null | Explicit sequence number in the execution sequence. |
After | string? | null | Schedule this action after the named action. |
Before | string? | null | Schedule this action before the named action. |
Source Type Methods
| Method | Return Type | Description |
|---|---|---|
DllFromBinary(string binaryName, string entryPoint) | CustomActionBuilder | Calls a DLL function from a binary embedded in the Binary table. Type value: 1. |
ExeFromBinary(string binaryName) | CustomActionBuilder | Executes an EXE from a binary embedded in the Binary table. Type value: 2. |
SetProperty(string propertyName, string value) | CustomActionBuilder | Sets an MSI property to a value. Type value: 51. |
SetDirectory(string directoryId, string value) | CustomActionBuilder | Sets 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) | CustomActionBuilder | Runs an inline PowerShell script via ExeInDir (type 34) targeting powershell.exe in the SystemFolder directory. See PowerShell Custom Actions. |
PowerShellFile(string filePath) | CustomActionBuilder | Reads a PowerShell script from disk at build time and embeds its content inline via PowerShellScript. |
Execution Flag Methods
| Method | Return Type | Description |
|---|---|---|
Deferred() | CustomActionBuilder | Runs during the installation script phase (in-script). Required for actions that modify the system. |
Rollback() | CustomActionBuilder | Runs only if installation fails after this point. Automatically sets the InScript flag. |
Commit() | CustomActionBuilder | Runs only after a successful installation. Automatically sets the InScript flag. |
NoImpersonate() | CustomActionBuilder | Runs with elevated SYSTEM privileges instead of impersonating the user. Only meaningful for deferred/rollback/commit actions. |
ContinueOnError() | CustomActionBuilder | Continues 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
| Method | Return Type | Description |
|---|---|---|
Name(string name) | CustomTableBuilder | Sets the table name. |
Column(string name, CustomTableColumnType type, Action<ColumnOptions>?) | CustomTableBuilder | Adds a column. Column names must match [A-Za-z_][A-Za-z0-9_]*. |
Row(Action<RowBuilder>) | CustomTableBuilder | Adds a data row. |
ColumnOptions
| Method | Return Type | Description |
|---|---|---|
PrimaryKey() | ColumnOptions | Marks the column as part of the primary key. |
Nullable() | ColumnOptions | Allows null values in the column. |
Width(int width) | ColumnOptions | Sets the maximum column width. Defaults to 255. |
LocalizedDescription(string description) | ColumnOptions | Sets a localizable column description. |
RowBuilder
| Method | Return Type | Description |
|---|---|---|
Set(string column, object? value) | RowBuilder | Sets a column value for the current row. |
CustomTableColumnType Enum
| Value | Description |
|---|---|
String | Variable-length string. |
Int16 | 16-bit integer. |
Int32 | 32-bit integer. |
Binary | Binary data (file reference). |
Stream | Binary 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.
| Method | Return Type | Description |
|---|---|---|
Action(string actionName) | SequenceBuilder | Begins defining an action entry. Flushes any previously started action. Action names must be 72 characters or fewer. |
After(string referenceAction) | SequenceBuilder | Positions the action after the named reference action. |
Before(string referenceAction) | SequenceBuilder | Positions the action before the named reference action. |
At(int sequenceNumber) | SequenceBuilder | Positions the action at an explicit sequence number. |
Condition(string condition) | SequenceBuilder | Sets a condition expression that must be true for the action to execute. |
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.
| Method | Return Type | Description |
|---|---|---|
AllowSameVersionUpgrades() | MajorUpgradeBuilder | Allows reinstalling the same version. |
Schedule(RemoveExistingProductsSchedule schedule) | MajorUpgradeBuilder | When to remove the previous version. Defaults to AfterInstallValidate. |
MigrateFeatures(bool migrate) | MajorUpgradeBuilder | Whether to migrate feature selections from the old install. Defaults to true. |
RemoveExistingProductsSchedule Enum
| Value | Description |
|---|---|
AfterInstallValidate | Remove early, before file installation. Cleanest upgrade but slower. |
AfterInstallInitialize | Remove after initialization. |
AfterInstallExecute | Remove after file installation. Old and new versions briefly coexist. |
AfterInstallExecuteAgain | Remove after the second execution pass. |
AfterInstallFinalize | Remove 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
| Method | Return Type | Description |
|---|---|---|
Allow() | DowngradeBuilder | Allows installing an older version over a newer one. |
Block(string message) | DowngradeBuilder | Blocks 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.
| Property | Type | Default | Description |
|---|---|---|---|
AllowDowngrades | bool | false | Whether to allow downgrades. |
AllowSameVersion | bool | false | Whether to allow reinstalling the same version. |
MinimumVersion | string? | null | Minimum version to detect for upgrade. |
MaximumVersion | string? | null | Maximum version to detect for upgrade. |
DowngradeErrorMessage | string? | "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.
| Property | Type | Default | Description |
|---|---|---|---|
IsSystem | bool | true | If true, sets a system-wide variable; otherwise, per-user. |
Action | EnvironmentVariableAction | Set | Operation: Set (replace), Append, or Prepend. |
Separator | string? | null | Separator 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.
| Property | Type | Default | Description |
|---|---|---|---|
IsSecure | bool | false | If 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. |
IsAdmin | bool | false | If 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. |
IsHidden | bool | false | If 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 / Method | Type | Default | Description |
|---|---|---|---|
Sddl | string? | null | SDDL security descriptor string for fine-grained permission control. |
Domain | string? | null | Domain for the user/group. |
User | string? | null | User or group name to grant permissions to. |
Permission | int | 0 | Permission 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.
| Method | Return Type | Description |
|---|---|---|
CabinetTemplate(string template) | MediaTemplateBuilder | Cabinet file name template. Defaults to "cab{0}.cab". The {0} placeholder is replaced with the cabinet index. |
MaxCabinetSizeMB(int sizeMB) | MediaTemplateBuilder | Maximum cabinet file size in MB. 0 means unlimited. |
MaxUncompressedMediaSize(int size) | MediaTemplateBuilder | Maximum uncompressed media size. |
CompressionLevel(CompressionLevel level) | MediaTemplateBuilder | Cabinet compression level. Defaults to High. |
EmbedCabinet(bool embed) | MediaTemplateBuilder | Whether 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.
| Method | Return Type | Description |
|---|---|---|
Certificate(string pfxPath) | SigningOptionsBuilder | Path to a PFX certificate file. |
Thumbprint(string thumbprint) | SigningOptionsBuilder | Certificate thumbprint for store-based signing. |
Store(string storeName) | SigningOptionsBuilder | Certificate store name. Defaults to "My". |
Timestamp(string url) | SigningOptionsBuilder | RFC 3161 timestamp server URL. |
Algorithm(string algorithm) | SigningOptionsBuilder | Digest algorithm. Defaults to "sha256". |
WithDescription(string description, string? url) | SigningOptionsBuilder | Description and optional URL embedded in the signature. |
Properties (Direct Access)
| Property | Type | Description |
|---|---|---|
CertificatePath | string? | PFX file path. |
CertificateThumbprint | string? | Certificate thumbprint. |
StoreName | string | Certificate store name. |
TimestampUrl | string? | Timestamp server URL. |
DigestAlgorithm | string | Digest algorithm name. |
AdditionalArguments | string? | Additional arguments passed to the signing tool. |
Description | string? | Signature description. |
DescriptionUrl | string? | 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
| Property | Type | Default | Description |
|---|---|---|---|
Description | string? | null | File type description shown in Explorer. |
IconFile | string? | null | Icon file for the file type. |
IconIndex | int | 0 | Icon index within the icon file. |
ContentType | string? | null | MIME content type (e.g., "text/xml"). |
Methods
| Method | Return Type | Description |
|---|---|---|
ProgId(string progId) | FileAssociationBuilder | Sets the programmatic identifier (e.g., "Contoso.Document"). |
Verb(string verb, string? argument, Action<VerbBuilder>?) | FileAssociationBuilder | Adds a shell verb (e.g., "open", "edit", "print"). |
VerbBuilder
| Property | Type | Description |
|---|---|---|
Command | string? | Display text for the verb in context menus. |
Argument | string? | Command-line argument pattern (e.g., "%1"). |
Sequence | int | Verb 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.
| Property | Type | Default | Description |
|---|---|---|---|
Title | string? | null | Font 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.
| Method | Return Type | Description |
|---|---|---|
Section(string section) | IniFileBuilder | Sets the INI section name. |
Key(string key) | IniFileBuilder | Sets the key name within the section. |
Value(string value) | IniFileBuilder | Sets the value to write. |
Action(IniFileAction action) | IniFileBuilder | Sets the action type. Defaults to CreateEntry. |
IniFileAction Enum
| Value | Description |
|---|---|
CreateLine | Adds the entire line to the section (no key=value parsing). |
CreateEntry | Creates or updates a key=value entry. Default behavior. |
RemoveLine | Removes the entire line matching the key. |
RemoveTag | Removes 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.
| Method | Return Type | Description |
|---|---|---|
FileRef(string fileRef) | AssemblyBuilder | File reference for the assembly. |
Type(AssemblyType type) | AssemblyBuilder | Assembly type: DotNetAssembly (default) or Win32Assembly. |
Private(string applicationFileRef) | AssemblyBuilder | Makes this a private (non-GAC) assembly, referencing the application file. |
Name(string name) | AssemblyBuilder | Assembly name for the manifest. |
Version(string version) | AssemblyBuilder | Assembly version string. |
Culture(string culture) | AssemblyBuilder | Assembly culture (e.g., "neutral", "en-US"). |
PublicKeyToken(string publicKeyToken) | AssemblyBuilder | Public key token for strong-named assemblies. |
Architecture(string architecture) | AssemblyBuilder | Processor 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.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | MoveFileBuilder | Unique identifier. |
SourceDirectory(string directory) | MoveFileBuilder | Source directory path or property reference. |
SourceFileName(string fileName) | MoveFileBuilder | Source file name (supports wildcards). |
DestDirectory(string directory) | MoveFileBuilder | Destination directory path or property reference. |
DestFileName(string fileName) | MoveFileBuilder | Destination file name (optional rename). |
AsCopy() | MoveFileBuilder | Copies the file instead of moving it (options = 0). |
AsMove() | MoveFileBuilder | Moves the file. This is the default behavior (options = 1). |
ComponentRef(string componentRef) | MoveFileBuilder | Associates with a specific component. |
DuplicateFileBuilder
Duplicates an installed file to another location. Located at
src/FalkForge.Core/Builders/DuplicateFileBuilder.cs.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | DuplicateFileBuilder | Unique identifier. |
FileRef(string fileRef) | DuplicateFileBuilder | Reference to the source file in the File table. |
DestDirectory(string directory) | DuplicateFileBuilder | Destination directory. |
DestFileName(string fileName) | DuplicateFileBuilder | Destination file name (optional rename). |
ComponentRef(string componentRef) | DuplicateFileBuilder | Associates with a specific component. |
RemoveFileBuilder
Schedules file removal on install or uninstall. Located at
src/FalkForge.Core/Builders/RemoveFileBuilder.cs.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | RemoveFileBuilder | Unique identifier. |
Directory(string directory) | RemoveFileBuilder | Directory containing the file to remove. |
FileName(string fileName) | RemoveFileBuilder | File name to remove (supports wildcards). |
OnInstall() | RemoveFileBuilder | Remove the file during installation. |
OnUninstall() | RemoveFileBuilder | Remove the file during uninstallation. |
ComponentRef(string componentRef) | RemoveFileBuilder | Associates with a specific component. |
CreateFolderBuilder
Creates empty folders during installation. Located at
src/FalkForge.Core/Builders/CreateFolderBuilder.cs.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | CreateFolderBuilder | Unique identifier. |
Directory(string directory) | CreateFolderBuilder | Directory to create. |
ComponentRef(string componentRef) | CreateFolderBuilder | Associates 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).
| Method | Return Type | Description |
|---|---|---|
Id(Guid id) | MergeModuleBuilder | Sets the merge module GUID identifier. |
Language(int language) | MergeModuleBuilder | Language code. Defaults to 1033 (English). |
Version(Version version) | MergeModuleBuilder | Module version. Defaults to 1.0.0. |
Manufacturer(string manufacturer) | MergeModuleBuilder | Module manufacturer name. |
Description(string description) | MergeModuleBuilder | Module description. |
Component(string componentId) | MergeModuleBuilder | Adds a component to the module. |
Dependency(string dependencyId) | MergeModuleBuilder | Declares 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).
| Method | Return Type | Description |
|---|---|---|
Id(Guid id) | PatchBuilder | Sets the patch GUID identifier. |
Classification(PatchClassification classification) | PatchBuilder | Patch type: Hotfix, Update (default), or SecurityUpdate. |
Description(string description) | PatchBuilder | Patch description. |
Manufacturer(string manufacturer) | PatchBuilder | Patch manufacturer. |
TargetProduct(Guid productCode) | PatchBuilder | Product code of the MSI to patch. |
TargetVersion(string version) | PatchBuilder | Version of the original MSI. |
UpdatedVersion(string version) | PatchBuilder | Version of the patched MSI. |
TargetMsi(string path) | PatchBuilder | Path to the original (baseline) MSI file. |
UpdatedMsi(string path) | PatchBuilder | Path to the updated MSI file. |
AllowRemoval(bool allow) | PatchBuilder | Whether 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).
| Method | Return Type | Description |
|---|---|---|
Id(string id) | TransformBuilder | Sets the transform identifier. |
BaseMsi(string path) | TransformBuilder | Path to the base MSI file. |
TargetMsi(string path) | TransformBuilder | Path to the target (modified) MSI file. |
Description(string description) | TransformBuilder | Transform description. |
SetProperty(string name, string value) | TransformBuilder | Sets 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
| Scenario | PackageCode value | Behavior |
|---|---|---|
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:
-
Supply-chain trust. Anyone — not just the team that ran the original build
— can rebuild your installer from the published source and byte-compare it against the artifact
you shipped.
forge verify <artifact> --rebuild <project>(CLI, 15) automates exactly that comparison and reportsVERIFIED/MISMATCH/REBUILD-FAILED. Without reproducibility there is nothing to compare against — a legitimate rebuild and a compromised build machine produce equally "different" bytes from the original, so a mismatch would prove nothing. -
Auditability and attestations.
package.Sbom()emits a CycloneDX SBOM listing every installed file's SHA-256; under a reproducible build its own serial number and timestamp are derived from build content rather than the wall clock, so the SBOM itself doesn't introduce non-determinism into an otherwise-reproducible artifact. The same logic applies one layer up to any release pipeline — including FalkForge's own (.github/workflows/release.yml) — that publishes SLSA-style build-provenance attestations: an attestation asserts "these bytes came from this source, this build"; reproducibility is what lets a third party independently confirm that assertion by rebuilding, instead of trusting it on faith. - Debugging and support. Given a customer's shipped version number, you can rebuild the exact bytes they're running, months or years later, for forensics — without having archived the original build machine's output.
-
Trustworthy version diffs.
forge plan-diff old newreports what genuinely changed between two releases' install plans. If builds aren't reproducible, re-running a build of the same version can itself introduce noise (a freshPackageCode, a different embedded timestamp) that has nothing to do with the version bump you actually care about reviewing. -
Smaller, meaningful deltas.
BundleBuilder.DeltaFrom()computes a binary delta with Octodiff — a raw byte-level (rsync-style) diff between the old and new payload files. Non-deterministic bytes scattered through an otherwise little-changed file (a randomPackageCode, a fresh save timestamp in the MSI's OLE compound structure) reduce how much of the old and new payloads actually match block-for-block, inflating the delta with noise unrelated to your real changes. A reproducible build removes that noise, so the delta reflects the content that actually changed.
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.
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:
FalkOutputType | Resolves to | Notes |
|---|---|---|
Msi | $(TargetDir)$(AssemblyName).msi | Default when Sdk.props is imported |
Bundle | $(TargetDir)$(AssemblyName).exe | EXE bundle output |
Module | $(TargetDir)$(AssemblyName).msm | Merge module |
Patch | $(TargetDir)$(AssemblyName).msp | MSP 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).msix | MSIX package (experimental) |
MsixBundle | $(TargetDir)$(AssemblyName).msixbundle | MSIX bundle (experimental) |
None | — | Disables 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));
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.
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.
| Method | Return Type | Description |
|---|---|---|
Name(string name) | BundleBuilder | Sets the bundle display name. |
Manufacturer(string manufacturer) | BundleBuilder | Sets the bundle manufacturer. |
Version(string version) | BundleBuilder | Sets the bundle version string. Default: "1.0.0". |
BundleId(Guid id) | BundleBuilder | Sets the bundle identifier. Default: auto-generated GUID. |
UpgradeCode(Guid code) | BundleBuilder | Sets the upgrade code for related bundle detection. Default: auto-generated GUID. |
Scope(InstallScope scope) | BundleBuilder | Sets the install scope. Default: InstallScope.PerMachine. |
Chain(Action<ChainBuilder> configure) | BundleBuilder | Configures 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) | BundleBuilder | Uses the built-in WPF installer UI with optional customization for license, logo, theme color, watermark, banner, and banner icon. |
UseSilentUI() | BundleBuilder | Configures the bundle for silent (no-UI) installation. |
UseCustomUI(string uiProjectPath) | BundleBuilder | Registers a custom WPF UI project for the bundle. Throws if path is null or whitespace. |
UpdateFeed(string feedUrl, UpdatePolicy policy) | BundleBuilder | Configures an update feed URL and policy. Default policy: UpdatePolicy.NotifyOnly. |
RelatedBundle(string bundleId, Action<RelatedBundleBuilder>? configure) | BundleBuilder | Declares a related bundle by string ID. |
RelatedBundle(Guid bundleId, Action<RelatedBundleBuilder>? configure) | BundleBuilder | Declares a related bundle by GUID (formatted as uppercase brace-style). |
Container(string id, Action<ContainerBuilder>? configure) | BundleBuilder | Adds a named payload container. |
DefineContainer(string id, Action<ContainerBuilder>? configure) | ContainerRef | Defines a container with explicit ID and returns a typed reference handle for compile-time-safe cross-referencing. |
DefineContainer(Action<ContainerBuilder>? configure) | ContainerRef | Defines a container with auto-generated ID (Container_1, Container_2, ...) and returns a typed reference. |
DefineRollbackBoundary(string id) | RollbackBoundaryRef | Creates a rollback boundary reference with an explicit ID. The boundary is registered when passed to ChainBuilder.RollbackBoundary(). |
DefineRollbackBoundary() | RollbackBoundaryRef | Creates a rollback boundary reference with auto-generated ID (RollbackBoundary_1, ...). |
Variable(string name, Action<BundleVariableBuilder> configure) | BundleBuilder | Declares a bundle variable with configuration for type, default value, persistence, and visibility. |
Feature(string id, Action<BundleFeatureBuilder> configure) | BundleBuilder | Declares a bundle feature that groups packages for optional selection. |
DependencyProvider(string key, string version, string? displayName) | BundleBuilder | Registers this bundle as a dependency provider with the given key and version. |
DependencyConsumer(string providerKey, string consumerKey) | BundleBuilder | Declares a dependency on another provider identified by key. |
Sbom(Action<SbomOptions>? configure = null) | BundleBuilder | Emits 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>) | BundleBuilder | Enables 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() | BundleBuilder | Bakes 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() | BundleModel | Builds 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.
| Method | Return Type | Description |
|---|---|---|
MsiPackage(string sourcePath, Action<BundlePackageBuilder> configure) | ChainBuilder | Adds an MSI package to the chain. |
ExePackage(string sourcePath, Action<BundlePackageBuilder> configure) | ChainBuilder | Adds an EXE package to the chain. |
NetRuntime(string sourcePath, Action<BundlePackageBuilder> configure) | ChainBuilder | Adds a .NET runtime redistributable package to the chain. |
MsuPackage(string sourcePath, Action<MsuPackageBuilder> configure) | ChainBuilder | Adds a Windows Update (MSU) package to the chain. |
MspPackage(string sourcePath, Action<MspPackageBuilder> configure) | ChainBuilder | Adds a patch (MSP) package to the chain. |
BundlePackage(string sourcePath, Action<NestedBundlePackageBuilder> configure) | ChainBuilder | Adds a nested bundle package to the chain. |
RollbackBoundary(string id, Action<RollbackBoundaryBuilder>? configure) | ChainBuilder | Inserts a rollback boundary at the current chain position by string ID. |
RollbackBoundary(RollbackBoundaryRef boundaryRef, Action<RollbackBoundaryBuilder>? configure) | ChainBuilder | Inserts 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.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | BundlePackageBuilder | Sets the package identifier. Default: filename without extension. |
DisplayName(string name) | BundlePackageBuilder | Sets the display name shown in the UI. Default: same as ID. |
Version(string version) | BundlePackageBuilder | Sets the package version. |
Vital(bool vital) | BundlePackageBuilder | Whether failure of this package should abort the entire bundle. Default: true. |
InstallCondition(string condition) | BundlePackageBuilder | Sets a condition expression that must evaluate to true for this package to install. |
InstallCondition(Condition condition) | BundlePackageBuilder | Sets an install condition using a type-safe Condition object. |
ExitCode(int code, ExitCodeBehavior behavior) | BundlePackageBuilder | Maps a specific exit code to a behavior (success, error, reboot required, etc.). |
Property(string key, string value) | BundlePackageBuilder | Sets an MSI property to pass to the package during installation. |
RemotePayload(string url, string sha256, long size) | BundlePackageBuilder | Declares a remote payload with download URL, SHA-256 hash, and size. Remote payloads are not embedded in the bundle. |
Container(string containerId) | BundlePackageBuilder | Assigns this package to a named container by string ID. |
Container(ContainerRef containerRef) | BundlePackageBuilder | Assigns this package to a container using a typed reference handle. |
DetectionMode(DetectionMode mode) | BundlePackageBuilder | Sets the detection strategy. Default uses product code (MSI) or ARP (EXE). SearchOnly relies solely on search conditions. |
SearchCondition(Action<SearchConditionBuilder>) | BundlePackageBuilder | Adds a search condition for package detection. Multiple conditions are ANDed. See SearchConditionBuilder. |
AuthenticodeThumbprint(string) | BundlePackageBuilder | Sets the expected Authenticode certificate thumbprint for payload verification. |
Prerequisite(bool) | BundlePackageBuilder | Marks the package as a prerequisite that must succeed before the main chain runs. Default: false. |
Permanent(bool) | BundlePackageBuilder | Package is never uninstalled when the bundle is removed. Use for shared runtimes or setup utilities. Default: false. |
EnableFeatureSelection(bool) | BundlePackageBuilder | Allows 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.
| Method | Return Type | Description |
|---|---|---|
FileExists(string path) | SearchConditionBuilder | Detects presence of a file at the specified path. |
FileVersion(string path, string comparison, string version) | SearchConditionBuilder | Compares a file's version against an expected version (e.g., ">=", "14.0.0"). |
DirectoryExists(string path) | SearchConditionBuilder | Detects presence of a directory. |
RegistryExists(RegistryRoot, string key, string? valueName) | SearchConditionBuilder | Detects presence of a registry key or value. Used for prerequisite detection. |
RegistryValue(RegistryRoot, string key, string valueName, string comparison, string expected) | SearchConditionBuilder | Compares 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.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | PackageGroupBuilder | Sets the group identifier. |
ExePackage(string sourcePath, Action<BundlePackageBuilder>) | PackageGroupBuilder | Adds an EXE package to the group. |
MsiPackage(string sourcePath, Action<BundlePackageBuilder>) | PackageGroupBuilder | Adds an MSI package to the group. |
ChainBuilder Integration
| Method | Return Type | Description |
|---|---|---|
PackageGroup(Action<PackageGroupBuilder>) | ChainBuilder | Defines and flattens a custom package group inline. |
PackageGroup(PackageGroupModel) | ChainBuilder | Flattens 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.
| Method | Returns | Description |
|---|---|---|
BuiltInPrerequisites.NetFx472() | PackageGroupModel | .NET Framework 4.7.2 offline installer. Detection: HKLM\...\NDP\v4\Full, Release >= 461808. Silent: /q /norestart. |
BuiltInPrerequisites.VCRedist14x64() | PackageGroupModel | Visual C++ 2015-2022 Redistributable (x64). Detection: HKLM\...\VC\Runtimes\x64, Installed = 1. Silent: /install /quiet /norestart. |
BuiltInPrerequisites.OdbcDriver17() | PackageGroupModel | Microsoft ODBC Driver 17 for SQL Server. Detection: HKLM\...\ODBCINST.INI\ODBC Driver 17. Silent MSI install. |
BuiltInPrerequisites.SqlExpress2017() | PackageGroupModel | SQL 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]"));
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:
- Uninstall is refused while another installed product's consumer registration still references a provider key this bundle declares. The refusal names both the blocking provider key and the dependent key(s) so the operator can identify what to remove first.
- Install is refused when a required dependency provider is missing, or installed at a version outside the declared range.
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.
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.
--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.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | MsuPackageBuilder | Sets the package identifier. Default: filename without extension. |
DisplayName(string name) | MsuPackageBuilder | Sets the display name. |
Vital(bool vital) | MsuPackageBuilder | Whether failure should abort the bundle. Default: true. |
KbArticle(string kbArticle) | MsuPackageBuilder | Sets the KB article number for the Windows update. |
InstallCondition(string condition) | MsuPackageBuilder | Sets a condition expression for conditional installation. |
InstallCondition(Condition condition) | MsuPackageBuilder | Sets 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.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | MspPackageBuilder | Sets the package identifier. Default: filename without extension. |
DisplayName(string name) | MspPackageBuilder | Sets the display name. |
Vital(bool vital) | MspPackageBuilder | Whether failure should abort the bundle. Default: true. |
PatchCode(string patchCode) | MspPackageBuilder | Sets the patch code (GUID) for this patch. |
TargetProductCode(string targetProductCode) | MspPackageBuilder | Sets the target product code that this patch applies to. |
InstallCondition(string condition) | MspPackageBuilder | Sets a condition expression for conditional installation. |
InstallCondition(Condition condition) | MspPackageBuilder | Sets 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.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | NestedBundlePackageBuilder | Sets the package identifier. Default: filename without extension. |
DisplayName(string name) | NestedBundlePackageBuilder | Sets the display name. |
Vital(bool vital) | NestedBundlePackageBuilder | Whether failure should abort the parent bundle. Default: true. |
InstallCondition(string condition) | NestedBundlePackageBuilder | Sets a condition expression for conditional installation. |
InstallCondition(Condition condition) | NestedBundlePackageBuilder | Sets 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.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | ContainerBuilder | Sets the container identifier. |
DownloadUrl(string url) | ContainerBuilder | Sets the remote download URL for this container. |
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.
9.8 RelatedBundleBuilder
Declares a relationship to another bundle (upgrade, addon, detect, or patch).
Located in src/FalkForge.Compiler.Bundle/Builders/RelatedBundleBuilder.cs.
| Method | Return Type | Description |
|---|---|---|
BundleId(string bundleId) | RelatedBundleBuilder | Sets the related bundle identifier. |
Relation(RelatedBundleRelation relation) | RelatedBundleBuilder | Sets 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.
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.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | RollbackBoundaryBuilder | Sets the boundary identifier. |
Vital(bool vital) | RollbackBoundaryBuilder | Whether 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.
| Method | Return Type | Description |
|---|---|---|
String() | BundleVariableBuilder | Sets the variable type to string (default). |
Numeric() | BundleVariableBuilder | Sets the variable type to numeric. |
Version() | BundleVariableBuilder | Sets the variable type to version. |
Default(string value) | BundleVariableBuilder | Sets the default value of the variable. |
Persisted() | BundleVariableBuilder | Marks the variable as persisted across sessions (stored in the registry). |
Hidden() | BundleVariableBuilder | Hides the variable from the command line. |
Secret() | BundleVariableBuilder | Marks 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.
| Method | Return Type | Description |
|---|---|---|
Title(string title) | BundleFeatureBuilder | Sets the feature display title. |
Description(string description) | BundleFeatureBuilder | Sets the feature description. |
Default(bool isDefault) | BundleFeatureBuilder | Whether the feature is selected by default. Default: true. |
Required() | BundleFeatureBuilder | Marks the feature as required (always installed, implies default). |
Package(string packageId) | BundleFeatureBuilder | Associates 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.
| Type | Property | Description |
|---|---|---|
ContainerRef | string Id { get; } | Typed reference to a container. Created by BundleBuilder.DefineContainer(). Accepted by BundlePackageBuilder.Container(ContainerRef). |
RollbackBoundaryRef | string 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);
});
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.
| Member | Type | Description |
|---|---|---|
Name | string | Unique extension name. |
Version | string | Optional semantic version of the extension implementation (defaults to "0.0.0"). Surfaces in error messages and diagnostics. |
MinHostVersion | string | Optional 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) | void | Called 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.
| Method | Return Type | Description |
|---|---|---|
RegisterTableContributor(IMsiTableContributor contributor) | void | Registers an MSI table contributor. |
RegisterComponentContributor(IComponentContributor contributor) | void | Registers a component contributor. |
RegisterExecutionContributor(IExecutionContributor contributor) | void | Registers 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) | void | Registers a dry-run contributor used by forge build --dry-run and forge validate. |
RegisterDialogStep(IDialogStepBuilder builder) | void | Registers 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
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
| Method | Return Type | Description |
|---|---|---|
GetAdditionalFiles(ExtensionContext context) | IReadOnlyList<FileEntryModel> | Returns additional files to include in the MSI during compilation. |
IMsiTableContributor
| Member | Type | Description |
|---|---|---|
TableName | string | Name 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. |
WriteColumns | IReadOnlyList<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. |
ReadSchema | ITableReadSchema? | 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.
| Member | Type | Description |
|---|---|---|
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.
| Property | Type | Description |
|---|---|---|
Package | PackageModel | The package model being compiled. |
OutputDirectory | string | Output directory for the compiled installer. |
SourceDirectory | string | Source directory of the project. |
MsiTableRow
A flexible row container for custom MSI table data.
| Method | Return Type | Description |
|---|---|---|
Set(string column, object? value) | MsiTableRow | Sets a column value. Returns self for chaining. |
Get(string column) | object? | Gets a column value by name. |
Fields | IReadOnlyDictionary<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
| Method | Return Type | Description |
|---|---|---|
Name | string | Returns "Firewall". |
AddRule(Action<FirewallRuleBuilder> configure) | void | Adds a firewall rule definition. |
ValidateRules() | IReadOnlyList<FirewallValidationError> | Validates all configured rules and returns any errors. |
Register(IExtensionRegistry registry) | void | Registers the table contributor with the compiler. |
FirewallRuleBuilder
| Method | Return Type | Description |
|---|---|---|
Id(string id) | FirewallRuleBuilder | Sets the rule identifier. |
Name(string name) | FirewallRuleBuilder | Sets the display name of the firewall rule. |
Description(string description) | FirewallRuleBuilder | Sets the rule description. |
Protocol(FirewallProtocol protocol) | FirewallRuleBuilder | Sets the protocol. Default: FirewallProtocol.Tcp. |
Port(string port) | FirewallRuleBuilder | Sets the local port or port range (e.g., "80", "8080-8090"). |
RemotePort(string remotePort) | FirewallRuleBuilder | Sets the remote port or port range. |
LocalAddress(string localAddress) | FirewallRuleBuilder | Sets the local address filter. |
RemoteAddress(string remoteAddress) | FirewallRuleBuilder | Sets the remote address filter. |
Program(string program) | FirewallRuleBuilder | Sets the program path the rule applies to. |
Profile(FirewallProfile profile) | FirewallRuleBuilder | Sets the firewall profile. Default: FirewallProfile.All. |
Direction(FirewallDirection direction) | FirewallRuleBuilder | Sets the direction. Default: FirewallDirection.Inbound. |
Action(FirewallRuleAction action) | FirewallRuleBuilder | Sets the action. Default: FirewallRuleAction.Allow. |
ComponentRef(string componentRef) | FirewallRuleBuilder | Associates the rule with a specific component. |
Condition(string condition) | FirewallRuleBuilder | Sets 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
| Method | Return Type | Description |
|---|---|---|
Name | string | Returns "Iis". |
AddWebSite(Action<WebSiteBuilder> configure) | IisExtension | Adds a web site definition. Returns self for chaining. |
AddAppPool(Action<AppPoolBuilder> configure) | IisExtension | Adds an application pool definition. |
DefineAppPool(Action<AppPoolBuilder> configure) | AppPoolRef | Defines an app pool and returns a typed reference for cross-referencing. |
AddCertificate(Action<CertificateBuilder> configure) | IisExtension | Adds a certificate definition. |
DefineCertificate(Action<CertificateBuilder> configure) | CertificateRef | Defines a certificate and returns a typed reference for cross-referencing. |
Validate() | Result<Unit> | Validates all configured IIS resources. |
Register(IExtensionRegistry registry) | void | IIS is model-only at compile time; actual management happens via custom actions at install time. |
AppPoolBuilder
| Method | Return Type | Description |
|---|---|---|
Id(string id) | AppPoolBuilder | Sets the app pool identifier. Defaults to the name if not set. |
Name(string name) | AppPoolBuilder | Sets the app pool name as it appears in IIS. |
Runtime(string version) | AppPoolBuilder | Sets the managed runtime version (e.g., "v4.0"). Default: "v4.0". |
NoManagedCode() | AppPoolBuilder | Sets managed runtime to empty string (no managed code, for .NET Core/reverse proxy). |
PipelineMode(ManagedPipelineMode mode) | AppPoolBuilder | Sets the pipeline mode. Default: ManagedPipelineMode.Integrated. |
Enable32Bit() | AppPoolBuilder | Enables 32-bit applications on 64-bit Windows. |
Identity(AppPoolIdentityType type) | AppPoolBuilder | Sets the identity type. Default: ApplicationPoolIdentity. |
Identity(AppPoolIdentityType type, string userName, string password) | AppPoolBuilder | Sets the identity type with specific credentials. |
MaxProcesses(int count) | AppPoolBuilder | Sets the maximum number of worker processes. Default: 1. |
RecycleMinutes(int minutes) | AppPoolBuilder | Sets the recycling interval in minutes. Default: 1740 (29 hours). |
IdleTimeout(int minutes) | AppPoolBuilder | Sets the idle timeout in minutes. Default: 20. |
WebSiteBuilder
| Method | Return Type | Description |
|---|---|---|
Id(string id) | WebSiteBuilder | Sets the web site identifier. Defaults to the description if not set. |
Description(string description) | WebSiteBuilder | Sets the web site description / display name. |
Directory(string directory) | WebSiteBuilder | Sets the physical root directory. |
Binding(Action<WebBindingBuilder> configure) | WebSiteBuilder | Adds a binding using the full builder API. |
Binding(int port, string protocol, string? hostHeader) | WebSiteBuilder | Adds a binding with shorthand parameters. Default protocol: "http", IP: "*". |
AppPool(string appPool) | WebSiteBuilder | Associates the site with an app pool by string ID. |
AppPool(AppPoolRef appPoolRef) | WebSiteBuilder | Associates the site with an app pool using a typed reference. |
AutoStart(bool autoStart) | WebSiteBuilder | Whether the site starts automatically. Default: true. |
ConnectionTimeout(int seconds) | WebSiteBuilder | Sets the connection timeout in seconds. Default: 120. |
AddApplication(Action<WebApplicationBuilder> configure) | WebSiteBuilder | Adds a web application under this site. |
WebBindingBuilder
| Method | Return Type | Description |
|---|---|---|
Protocol(string protocol) | WebBindingBuilder | Sets the protocol. Default: "http". |
Port(int port) | WebBindingBuilder | Sets the port number. |
HostHeader(string hostHeader) | WebBindingBuilder | Sets the host header for name-based virtual hosting. |
IpAddress(string ipAddress) | WebBindingBuilder | Sets the IP address. Default: "*" (all). |
Certificate(string certificateRef) | WebBindingBuilder | Associates a certificate by string ID. Automatically sets protocol to "https". |
Certificate(CertificateRef certificateRef) | WebBindingBuilder | Associates a certificate using a typed reference. |
CertificateBuilder
| Method | Return Type | Description |
|---|---|---|
Id(string id) | CertificateBuilder | Sets the certificate identifier. |
Store(CertificateStoreName storeName, CertificateStoreLocation storeLocation) | CertificateBuilder | Sets the certificate store. Default: My / LocalMachine. |
FindByThumbprint(string thumbprint) | CertificateBuilder | Finds the certificate by its thumbprint. |
FindBySubjectName(string subjectName) | CertificateBuilder | Finds the certificate by subject name. |
Exportable() | CertificateBuilder | Marks the certificate as exportable. |
WebApplicationBuilder
| Method | Return Type | Description |
|---|---|---|
Id(string id) | WebApplicationBuilder | Sets the web application identifier. Defaults to the alias if not set. |
Alias(string alias) | WebApplicationBuilder | Sets the virtual path alias (e.g., /api). |
Directory(string directory) | WebApplicationBuilder | Sets the physical directory. |
AppPool(string appPool) | WebApplicationBuilder | Associates the application with an app pool by string ID. |
AppPool(AppPoolRef appPoolRef) | WebApplicationBuilder | Associates the application with an app pool using a typed reference. |
IIS Typed Reference Handles
| Type | Property | Description |
|---|---|---|
AppPoolRef | string Id { get; } | Typed reference to an app pool. Created by IisExtension.DefineAppPool(). Accepted by WebSiteBuilder.AppPool() and WebApplicationBuilder.AppPool(). |
CertificateRef | string 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
| Method | Return Type | Description |
|---|---|---|
Name | string | Returns "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) | void | Registers database, script, and string table contributors. |
SqlDatabaseBuilder
| Method | Return Type | Description |
|---|---|---|
Id(string id) | SqlDatabaseBuilder | Sets the database definition identifier. |
Server(string server) | SqlDatabaseBuilder | Sets the SQL Server hostname or address. |
Database(string database) | SqlDatabaseBuilder | Sets the database name. |
Instance(string instance) | SqlDatabaseBuilder | Sets the named instance. |
ConnectionString(string connectionString) | SqlDatabaseBuilder | Sets a full connection string (alternative to Server/Database/Instance). |
CreateOnInstall(bool create) | SqlDatabaseBuilder | Whether to create the database on install. Default: false. |
DropOnUninstall(bool drop) | SqlDatabaseBuilder | Whether to drop the database on uninstall. Default: false. |
ConfirmOverwrite(bool confirm) | SqlDatabaseBuilder | Whether to prompt before overwriting an existing database. Default: false. |
ComponentRef(string componentRef) | SqlDatabaseBuilder | Associates with a specific component. |
Build() | Result<SqlDatabaseModel> | Validates and builds the database model. |
SqlScriptBuilder
| Method | Return Type | Description |
|---|---|---|
Id(string id) | SqlScriptBuilder | Sets the script identifier. |
Database(string databaseRef) | SqlScriptBuilder | Associates with a database by string ID. |
Database(SqlDatabaseRef databaseRef) | SqlScriptBuilder | Associates with a database using a typed reference. |
SourceFile(string sourceFile) | SqlScriptBuilder | Path to the SQL script file to execute. |
InlineSql(string sqlContent) | SqlScriptBuilder | Inline SQL content (alternative to SourceFile). |
ExecuteOnInstall(bool execute) | SqlScriptBuilder | Whether to execute during install. Default: false. |
ExecuteOnReinstall(bool execute) | SqlScriptBuilder | Whether to execute during reinstall. Default: false. |
ExecuteOnUninstall(bool execute) | SqlScriptBuilder | Whether to execute during uninstall. Default: false. |
RollbackScript(string rollbackSourceFile) | SqlScriptBuilder | Path to a rollback script for undo on failure. |
Sequence(int sequence) | SqlScriptBuilder | Sets the execution order within the same database. |
ContinueOnError(bool continueOnError) | SqlScriptBuilder | Whether to continue if the script fails. Default: false. |
ComponentRef(string componentRef) | SqlScriptBuilder | Associates with a specific component. |
Build() | Result<SqlScriptModel> | Validates and builds the script model. |
SqlStringBuilder
| Method | Return Type | Description |
|---|---|---|
Id(string id) | SqlStringBuilder | Sets the SQL string identifier. |
Database(string databaseRef) | SqlStringBuilder | Associates with a database by string ID. |
Database(SqlDatabaseRef databaseRef) | SqlStringBuilder | Associates with a database using a typed reference. |
Sql(string sql) | SqlStringBuilder | Sets the inline SQL statement to execute. |
ExecuteOnInstall(bool execute) | SqlStringBuilder | Whether to execute during install. Default: false. |
ExecuteOnUninstall(bool execute) | SqlStringBuilder | Whether to execute during uninstall. Default: false. |
Sequence(int sequence) | SqlStringBuilder | Sets the execution order. |
ContinueOnError(bool continueOnError) | SqlStringBuilder | Whether to continue if the statement fails. Default: false. |
Build() | Result<SqlStringModel> | Validates and builds the SQL string model. |
SqlDatabaseRef
| Type | Property | Description |
|---|---|---|
SqlDatabaseRef | string 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: Runtime →
coreclr.dll under Microsoft.NETCore.App, AspNetCore
→ Microsoft.AspNetCore.dll under Microsoft.AspNetCore.App,
WindowsDesktop → PresentationCore.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
| Member | Type | Description |
|---|---|---|
Name | string | Returns "DotNet". |
SearchForRuntime() | DotNetCoreSearchBuilder | Factory 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) | void | Registers 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.
| Method | Return Type | Description |
|---|---|---|
RuntimeType(DotNetRuntimeType runtimeType) | DotNetCoreSearchBuilder | The 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) | DotNetCoreSearchBuilder | The platform architecture: X64, X86, or Arm64 (NET007 if unrecognized). |
MinVersion(Version minimumVersion) | DotNetCoreSearchBuilder | Minimum acceptable version, compared against the sentinel file's on-disk version. Required (NET002 if missing). |
Variable(string variableName) | DotNetCoreSearchBuilder | The 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) | DotNetCoreSearchBuilder | Optional. 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
| Member | Type | Description |
|---|---|---|
Name | string | Returns "Util". |
XmlConfig | XmlConfigTableContributor | Access to the XML configuration table contributor. |
Register(IExtensionRegistry registry) | void | Registers the XML configuration table contributor. |
XmlConfigBuilder
Configures an XML file modification to execute during installation.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | XmlConfigBuilder | Sets the XML config entry identifier. |
File(string filePath) | XmlConfigBuilder | Path to the XML file to modify. |
XPath(string xPath) | XmlConfigBuilder | XPath expression selecting the target node. |
CreateElement(string elementName) | XmlConfigBuilder | Creates a new child element at the XPath location. |
DeleteElement() | XmlConfigBuilder | Deletes the element at the XPath location. |
SetAttribute(string attributeName, string value) | XmlConfigBuilder | Sets or creates an attribute on the target element. |
DeleteAttribute(string attributeName) | XmlConfigBuilder | Deletes an attribute from the target element. |
SetValue(string value) | XmlConfigBuilder | Sets the inner text of the target element. |
BulkSetValue(string value) | XmlConfigBuilder | Sets the inner text on all elements matching the XPath. |
Sequence(int sequence) | XmlConfigBuilder | Controls execution order. |
ComponentRef(string componentRef) | XmlConfigBuilder | Associates with a specific component. |
Build() | Result<XmlConfigModel> | Validates and builds the model. |
QuietExecBuilder
Configures a silent command-line execution during installation.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | QuietExecBuilder | Sets the quiet exec identifier. Required (QEX001). |
Command(string commandLine) | QuietExecBuilder | Sets the command line to execute. Required (QEX002). Max 32,767 characters (QEX003). |
WorkingDir(string workingDirectory) | QuietExecBuilder | Sets the working directory for the command. |
Condition(string condition) | QuietExecBuilder | Sets a condition expression for conditional execution. |
RollbackCommand(string rollbackCommandLine) | QuietExecBuilder | Sets a command to execute on rollback. Max 32,767 characters (QEX004). |
FileShareBuilder
Creates a Windows file share during installation.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | FileShareBuilder | Sets the file share identifier. Required (FSH001). |
Name(string name) | FileShareBuilder | Sets the share name. Required (FSH002). |
Description(string description) | FileShareBuilder | Sets the share description. |
Directory(string directory) | FileShareBuilder | Sets the physical directory to share. Required (FSH003). |
GrantRead(string user) | FileShareBuilder | Grants read permission to a user or group. |
GrantChange(string user) | FileShareBuilder | Grants change permission to a user or group. |
GrantFull(string user) | FileShareBuilder | Grants full control permission to a user or group. |
UserBuilder
Creates or updates a Windows user account during installation.
| Method | Return Type | Description |
|---|---|---|
Name(string name) | UserBuilder | Sets the user account name. |
Password(string password) | UserBuilder | Sets the user password as a literal string (discouraged — produces warning USR010; prefer PasswordProperty). |
PasswordProperty(string propertyName) | UserBuilder | Sources 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) | UserBuilder | Sets the domain for the user account. |
Description(string description) | UserBuilder | Sets the account description. |
CanNotChangePassword() | UserBuilder | Prevents the user from changing their password. |
Disabled() | UserBuilder | Creates the account in a disabled state. |
PasswordExpired() | UserBuilder | Forces a password change at first logon. |
PasswordNeverExpires() | UserBuilder | Sets the password to never expire. |
RemoveOnUninstall() | UserBuilder | Removes the user account on uninstall. |
UpdateIfExists() | UserBuilder | Updates the account if it already exists instead of failing. |
MemberOf(string groupName) | UserBuilder | Adds the account to a local group at creation time. Repeatable for multiple groups. |
ComponentRef(string componentRef) | UserBuilder | Associates with a specific component. |
GroupBuilder
Creates or references a Windows group during installation.
| Method | Return Type | Description |
|---|---|---|
Name(string name) | GroupBuilder | Sets the group name. Required (GRP001). |
Domain(string domain) | GroupBuilder | Sets the domain for the group. |
Description(string description) | GroupBuilder | Sets the group description. |
UpdateIfExists() | GroupBuilder | Updates the group if it already exists instead of failing. |
RemoveOnUninstall() | GroupBuilder | Removes the group on uninstall. |
ComponentRef(string componentRef) | GroupBuilder | Associates with a specific component. |
InternetShortcutBuilder
Creates a .url internet shortcut file during installation.
| Method | Return Type | Description |
|---|---|---|
Id(string id) | InternetShortcutBuilder | Sets the shortcut identifier. Required (ISC001). |
Name(string name) | InternetShortcutBuilder | Sets the shortcut display name. Required (ISC002). |
Target(string url) | InternetShortcutBuilder | Sets the target URL. Required (ISC003). |
Directory(string directory) | InternetShortcutBuilder | Sets the directory where the shortcut is created. Required (ISC004). |
Icon(string iconFile, int iconIndex) | InternetShortcutBuilder | Sets the icon file and index. Default index: 0. |
RemoveFolderExBuilder
Recursively removes a folder during install or uninstall (handles non-empty directories).
| Method | Return Type | Description |
|---|---|---|
Id(string id) | RemoveFolderExBuilder | Sets the identifier. Required (RFX001). |
Directory(string directory) | RemoveFolderExBuilder | Sets the directory path. Either Directory or Property is required (RFX002). |
Property(string property) | RemoveFolderExBuilder | Sets a property containing the directory path. |
OnInstall() | RemoveFolderExBuilder | Removes the folder during installation. |
OnUninstall() | RemoveFolderExBuilder | Removes the folder during uninstallation (default). |
OnBoth() | RemoveFolderExBuilder | Removes 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.
| Method | Return Type | Description |
|---|---|---|
DriverName(string name) | OdbcDriverBuilder | Sets the driver's display name (ODBCDriver.Description). |
FileName(string fileName) | OdbcDriverBuilder | References 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) | OdbcDriverBuilder | Optional 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).
| Method | Return Type | Description |
|---|---|---|
Name(string name) | OdbcDataSourceBuilder | Sets the data source's display name (ODBCDataSource.Description). |
DriverName(string driver) | OdbcDataSourceBuilder | Names the driver this data source uses (ODBCDataSource.DriverDescription). |
Registration(OdbcRegistration reg) | OdbcDataSourceBuilder | Sets PerMachine (default) or PerUser registration. |
Property(string key, string value) | OdbcDataSourceBuilder | Adds 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.
| Method | Return Type | Description |
|---|---|---|
Run(string[] args, Action<InstallerUIBuilder> configure) | int | Static 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.
| Method | Return Type | Description |
|---|---|---|
Window(Action<InstallerWindowBuilder> configure) | InstallerUIBuilder | Configures window appearance (size, colors, title, icon, images). |
Pages(Action<PageRegistrar> configure) | InstallerUIBuilder | Registers the ordered sequence of pages to display. |
Plugin<T>() where T : IInstallerPlugin, new() | InstallerUIBuilder | Registers a single plugin type by generic parameter. See Plugin System (22) for the full wiring path into InstallerPage.PluginServices. |
RegisterPlugins(PluginRegistry registry) | InstallerUIBuilder | Bulk-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.
| Method | Return Type | Description |
|---|---|---|
Size(double width, double height) | InstallerWindowBuilder | Sets the window dimensions. Default: 600 x 400. |
Borderless() | InstallerWindowBuilder | Removes window chrome for a fully custom-drawn window. |
CornerRadius(double radius) | InstallerWindowBuilder | Sets the window corner radius. |
Background(string hex) | InstallerWindowBuilder | Sets the window background color from a hex string (e.g., "#1E1E1E"). |
Accent(string hex) | InstallerWindowBuilder | Sets the accent color from a hex string (e.g., "#7B68EE"). |
Title(string title) | InstallerWindowBuilder | Sets the window title text. |
Icon(string iconPath) | InstallerWindowBuilder | Sets the window icon file path. |
CustomWindow<TWindow>() | InstallerWindowBuilder | Uses a completely custom WPF Window subclass as the shell. TWindow must derive from Window and have a parameterless constructor. |
WatermarkImage(string path) | InstallerWindowBuilder | Sets the watermark image path for the classic wizard exterior pages (164px left panel). |
BannerImage(string path) | InstallerWindowBuilder | Sets the banner image path for the classic wizard interior pages (59px top banner). |
BannerIcon(string path) | InstallerWindowBuilder | Sets 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.
| Method | Return Type | Description |
|---|---|---|
Add<TPage>() | PageRegistrar | Registers 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.
| Member | Type / Return | Description |
|---|---|---|
Title (abstract) | string | Page title displayed in the window. Must be implemented by subclasses. |
Engine | IInstallerEngine | The installer engine for performing detect/plan/apply operations. |
SharedState | InstallerState | Thread-safe shared state bag for cross-page data. |
DetectedState | InstallState | Current detected install state (not installed, installed, etc.). |
OnNext() (virtual) | PageResult | Called when the user clicks Next. Default: PageResult.Next. |
OnBack() (virtual) | PageResult | Called when the user clicks Back. Default: PageResult.Previous. |
OnNavigatedToAsync() (virtual) | Task | Lifecycle hook called when navigating to this page. |
OnNavigatingFromAsync() (virtual) | Task | Lifecycle hook called when navigating away from this page. |
CanGoNext (virtual) | bool | Whether the Next button is enabled. Default: true. |
CanGoBack (virtual) | bool | Whether the Back button is enabled. Default: true. |
SetField<T>(ref T field, T value, string? name) | bool | Helper for property change notification. Sets the field, raises PropertyChanged, returns whether the value changed. |
OnPropertyChanged(string? name) | void | Raises 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:
- Phase-level hooks — one pair per phase (begin / complete). The three
begin hooks return
Task<bool>; returningfalsecancels the whole operation at that phase boundary (nothing further runs). - Per-package / per-related-bundle hooks — fire once per package (and per
detected related bundle), interleaved inside the corresponding phase, matching WiX Burn
bootstrapper granularity. These are observational: they notify the page but
cannot veto or skip a package. (A per-package veto would require a bidirectional
per-package handshake through the engine pipe, which is not wired end-to-end; the hooks therefore
return
Task, notTask<bool>, so they never pretend to gate what they cannot.)
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) | Signature | Argument | Fires | Veto? |
|---|---|---|---|---|
OnDetectBeginAsync | Task<bool> | — | Before detection starts. | Yes — false cancels the operation. |
OnDetectPackageCompleteAsync | Task | PackageDetectInfo (PackageId, State, Version?) | Once per package, in manifest chain order, as detection completes. | No (observational). |
OnDetectRelatedBundleAsync | Task | RelatedBundleInfo (BundleId, Relation, InstalledVersion) | Once per related bundle found on the machine, after the per-package detect notifications. | No (observational). |
OnDetectCompleteAsync | Task | DetectResult | After detection completes. | No. |
OnPlanBeginAsync | Task<bool> | InstallAction | Before planning starts. | Yes — false cancels the operation. |
OnPlanPackageBeginAsync | Task | PackagePlanInfo (PackageId, DisplayName, PlannedAction) | Once per package as its planning begins. | No (observational). |
OnPlanPackageCompleteAsync | Task | PackagePlanInfo | Once per package after its planning completes. | No (observational). |
OnPlanCompleteAsync | Task | PlanResult | After planning completes. | No. |
OnApplyBeginAsync | Task<bool> | — | Before applying starts. | Yes — false cancels the operation. |
OnApplyPackageBeginAsync | Task | PackageApplyBeginInfo (PackageId, DisplayName) | Once per package, in execution order, immediately before its installer runs. | No (observational). |
OnApplyPackageCompleteAsync | Task | PackageApplyCompleteInfo (PackageId, DisplayName, Succeeded) | Once per package immediately after its installer returns (including the failing package that aborts the apply). | No (observational). |
OnApplyCompleteAsync | Task | ApplyResult | After 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.
| Member | Type / Return | Description |
|---|---|---|
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. |
Phase | IObservable<EnginePhase> | Observable stream of engine phase changes. |
Progress | IObservable<InstallProgress> | Observable stream of installation progress updates. |
StatusMessage | IObservable<string> | Observable stream of status messages for display. |
Manifest | InstallerManifest | The installer manifest containing package and feature metadata. |
DetectedState | InstallState | The current detected installation state. |
Features | IReadOnlyList<FeatureState> | Feature states with selection status. |
InstallDirectory | string | Gets or sets the installation directory. |
Cancel() | void | Cancels the current operation. |
ShutdownAsync() | Task<int> | Shuts down the engine and returns the process exit code. |
SetProperty(string name, string value) | void | Sets 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) | void | Sets 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.
| Member | Kind | Description |
|---|---|---|
PageResult.Next | Next | Navigate to the next page in order. |
PageResult.Previous | Previous | Navigate to the previous page in order. |
PageResult.Finish | Finish | Close the installer (success). |
PageResult.Cancel | Cancel | Cancel and close the installer. |
PageResult.Install | Install | Trigger the install action and advance. |
PageResult.Uninstall | Uninstall | Trigger the uninstall action and advance. |
PageResult.Repair | Repair | Trigger the repair action and advance. |
PageResult.Stay(string? message) | Stay | Stay on the current page (e.g., validation error). Optional message for display. |
PageResult.GoTo<TPage>() | GoTo | Jump to a specific page type, bypassing sequential order. |
PageResult Properties
| Property | Type | Description |
|---|---|---|
Kind | PageResultKind | The result kind: Next, Previous, Stay, GoTo, Finish, Cancel, Install, Uninstall, Repair. |
Message | string? | Optional message (used with Stay). |
TargetType | Type? | 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.
| Member | Type / Return | Description |
|---|---|---|
InstallDirectory | string? | 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) | void | Sets a typed value by key. T must be non-null. |
ContainsKey(string key) | bool | Checks whether a key exists in the state. |
Remove(string key) | bool | Removes 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;
}
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.
| Member | Type | Description |
|---|---|---|
Span | ReadOnlySpan<byte> | Read-only view of the byte data |
Length | int | Number of bytes |
IsEmpty | bool | True if null or zero-length |
Dispose() | void | Zeros the underlying byte array |
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.
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
| Method | Return Type | Description |
|---|---|---|
Name | string | Returns "Http". |
AddUrlReservation(string url, Action<UrlReservationBuilder> configure) | HttpExtension | Adds a URL ACL reservation. Returns self for chaining. |
AddSniSslBinding(string hostname, int port, Action<SniSslBindingBuilder> configure) | HttpExtension | Adds an SNI SSL certificate binding. Returns self for chaining. |
Validate() | Result<Unit> | Validates all configured reservations and bindings. |
Register(IExtensionRegistry registry) | void | Registers 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 /.
| Method | Return Type | Description |
|---|---|---|
AllowNetworkService() | UrlReservationBuilder | Grants access to the NETWORK SERVICE account (SDDL: D:(A;;GX;;;NS)). |
AllowLocalService() | UrlReservationBuilder | Grants access to the LOCAL SERVICE account (SDDL: D:(A;;GX;;;LS)). |
AllowLocalSystem() | UrlReservationBuilder | Grants access to the LOCAL SYSTEM account (SDDL: D:(A;;GX;;;SY)). |
AllowEveryone() | UrlReservationBuilder | Grants access to Everyone (SDDL: D:(A;;GX;;;WD)). |
AllowBuiltinUsers() | UrlReservationBuilder | Grants access to the BUILTIN\Users group (SDDL: D:(A;;GX;;;BU)). |
AllowUser(string sddlOrUser) | UrlReservationBuilder | Grants 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.
| Method | Return Type | Description |
|---|---|---|
Thumbprint(string thumbprint) | SniSslBindingBuilder | Sets the certificate thumbprint (40 hex characters). Required (HTTP007, HTTP008). |
AppId(Guid appId) | SniSslBindingBuilder | Sets the application GUID. Auto-derived from hostname:port if not set. |
CertStoreName(string storeName) | SniSslBindingBuilder | Sets the certificate store name. Default: "MY". |
Validation Error Codes
| Code | Description |
|---|---|
HTTP001 | URL reservation URL must not be empty. |
HTTP002 | URL reservation URL must start with http:// or https://. |
HTTP003 | URL reservation URL must end with /. |
HTTP004 | URL reservation User/SDDL string must not be empty. |
HTTP005 | SNI SSL binding Hostname must not be empty. |
HTTP006 | SNI SSL binding Port is outside valid range 1-65535. |
HTTP007 | SNI SSL binding CertificateThumbprint must not be empty. |
HTTP008 | SNI SSL binding CertificateThumbprint must be exactly 40 hexadecimal characters. |
HTTP009 | SNI SSL binding AppId must not be an empty GUID. |
HTTP010 | Value 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.
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
| Method | Return Type | Description |
|---|---|---|
Name | string | Returns "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) | void | Registers the table contributor and dry-run contributor. |
DriverBuilder
| Method | Return Type | Description |
|---|---|---|
Id(string id) | DriverBuilder | Sets the driver identifier. Required (DRV001). |
InfFilePath(string infFilePath) | DriverBuilder | Sets the path to the driver INF file relative to INSTALLDIR. Required (DRV002). Must end with .inf (DRV003). |
Force() | DriverBuilder | Forces driver installation even if a newer version is already present. Adds /force flag to both install and uninstall commands. |
PlugAndPlay() | DriverBuilder | Installs the driver and triggers Plug and Play enumeration. Adds /install flag to the install command. |
Description(string description) | DriverBuilder | Sets an optional description for the driver. |
Condition(string condition) | DriverBuilder | Sets a condition expression for conditional driver installation. |
DriverInstallFlags
| Value | Description |
|---|---|
None | No special flags. Default behavior. |
ForceInstall | Force installation even if a newer driver is already installed. Maps to pnputil /force. |
PlugAndPlay | Install the driver and trigger Plug and Play device enumeration. Maps to pnputil /install. |
Validation Error Codes
| Code | Description |
|---|---|
DRV001 | Driver Id must not be empty. |
DRV002 | Driver InfFilePath must not be empty. |
DRV003 | Driver InfFilePath must end with .inf. |
DRV004 | Duplicate 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
| Method | Return Type | Description |
|---|---|---|
ComClass(Action<ComClassBuilder> configure) | PackageBuilder | Adds a COM class registration. Returns self for chaining. |
TypeLib(Action<ComTypeLibBuilder> configure) | PackageBuilder | Adds a type library registration. Returns self for chaining. |
ComClassBuilder
Configures a COM class (CLSID) registration in the Windows registry.
| Method | Return Type | Description |
|---|---|---|
ClassId(Guid classId) | ComClassBuilder | Sets the CLSID for the COM class. Required. |
InprocServer32() | ComClassBuilder | Sets the server type to in-process (DLL). This is the default. |
LocalServer32() | ComClassBuilder | Sets the server type to local (EXE). |
ProgId(string progId) | ComClassBuilder | Sets the programmatic identifier (e.g., "Contoso.Widget.1"). |
Description(string desc) | ComClassBuilder | Sets the human-readable description for the COM class. |
ThreadingModel(ComThreadingModel model) | ComClassBuilder | Sets the threading model. Default: Apartment. |
AppId(Guid appId) | ComClassBuilder | Associates the class with a DCOM application ID. |
ComponentRef(string componentRef) | ComClassBuilder | Associates the COM class with a specific component. |
ComServerType
| Value | Description |
|---|---|
InprocServer32 | In-process COM server (DLL loaded into the client process). |
LocalServer32 | Local COM server (separate EXE process). |
ComThreadingModel
| Value | Description |
|---|---|
Apartment | Single-threaded apartment (STA). The default and most common for UI components. |
Free | Multi-threaded apartment (MTA). For thread-safe components. |
Both | Supports both STA and MTA. The object can be used from any apartment type. |
Neutral | Neutral-threaded apartment (NTA). Available on Windows 2000 and later. |
ComTypeLibBuilder
Configures a COM type library (LIBID) registration.
| Method | Return Type | Description |
|---|---|---|
TypeLibId(Guid id) | ComTypeLibBuilder | Sets the type library GUID (LIBID). Required. |
Version(int major, int minor) | ComTypeLibBuilder | Sets the type library version. Default: 1.0. |
Language(int lcid) | ComTypeLibBuilder | Sets the locale identifier. Default: 0 (language-neutral). |
Description(string desc) | ComTypeLibBuilder | Sets the type library description. |
ComponentRef(string componentRef) | ComTypeLibBuilder | Associates 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:
-
Merge into a built-in table —
TableNamematches a table the built-in pipeline already produces (Registry,CustomAction, …). Rows are appended after the built-in rows, mapped against that table's known column set.WriteColumnsis ignored here; the compiler already knows the schema. TheDependencyextension's provider/consumer keys work this way — they merge straight intoRegistry. -
Create a new custom table —
TableNameis not built-in. The contributor must declare its columns viaWriteColumns(IReadOnlyList<ContributedColumn>) so the compiler can emit theCREATE TABLEstatement.ContributedColumnhas three factories —Key(name, width = 72)for a non-nullable primary-key string column,Text(name, width = 255, nullable = true)for a string column, andInt(name)for a non-nullable 32-bit integer — plus aTypeenum (String/Int16/Int32/Binary) andNullable/PrimaryKeyflags for anything the factories don't cover. Column order is authoritative and drives deterministic, reproducible-build column layout. Firewall'sWixFirewallExceptiontable is the reference example. A column can also carry aMissingValueHintstring, appended (with a leading space) to the compiler's generic “missing value for non-nullable column” error when that column's value is absent for a given row; anullor whitespace-only hint is treated the same as no hint and has no effect. It exists for contributors that derive an external key from other declared model data instead of asking the author for it directly, so the failure explains what to fix instead of just naming the column — the Util extension's ODBC driver/data-source tables use it to point at an unresolvedFileName/SetupFileNamereference (ODB001).
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 not — WixFirewallException 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 member | Type | Description |
|---|---|---|
Id | string | Required. 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. |
InstallCommand | string | Required. 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. |
RollbackCommand | string? | Optional. Undoes InstallCommand if a later step fails. Scheduled immediately before the install action so Windows Installer runs it automatically, in reverse, on failure. |
UninstallCommand | string? | Optional. Runs when the product is removed. |
CustomActionData | string? | Optional. The secret / late-bound data channel — see below. |
InstallCondition | string? | MSI condition gating install (and rollback). Defaults to NOT Installed. |
UninstallCondition | string? | MSI condition gating uninstall. Defaults to REMOVE~="ALL". |
Elevated | bool | Default 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. |
HiddenProperties | IReadOnlyList<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:
| Method | Description |
|---|---|
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):
| Code | Meaning |
|---|---|
EXT001 | An 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. |
EXT002 | One 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));
}
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
| Pattern | Extension | Files |
|---|---|---|
| Simplest end-to-end execution seam (no secrets) | Firewall | src/FalkForge.Extensions.Firewall/FirewallExtension.cs, FirewallTableContributor.cs, FirewallExecutionContributor.cs, FirewallCommandFactory.cs |
| Secret-credential channel (SQL auth password) | SQL | src/FalkForge.Extensions.Sql/SqlExtension.cs, SqlExecutionContributor.cs, SqlCommandFactory.cs |
| Merge into a built-in table + immediate (non-deferred) check, no execution seam | Dependency | src/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.
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.
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 (Detect → Plan
→ Apply, 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 request | Behavior |
|---|---|
UiRequest.Detect | Calls DetectAsync. On failure, emits a Failed event and exits with code 1. |
UiRequest.Plan | Calls PlanAsync, then automatically calls ElevateAsync if elevation is required by the plan. |
UiRequest.Apply | Calls ApplyAsync. On success emits PhaseChanged(Completing) and shuts down. |
UiRequest.Cancel / UiRequest.Shutdown | Sends 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:
| Code | Meaning |
|---|---|
| 0 | Success, or clean cancellation before apply |
| 1 | Phase error (detection, planning, elevation, or apply without rollback) |
| 3 | Rolled 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.
| Step | Responsibility |
|---|---|
DetectStep | Runs PackageDetector against the manifest and the optional update check. Populates ctx.Detection, ctx.RelatedBundles, ctx.AvailableUpdate. |
PlanStep | Runs the Planner, checks architecture compatibility against the host machine, populates ctx.Plan. |
ElevateStep | Calls IElevatedCommandGateway.StartAsync, which launches the companion .exe and performs the HMAC handshake. Sets ctx.ElevationGateway. |
ApplyStep | Executes every PlanAction via PackageExecutor, drives RestartManager, and journals undo entries. |
RollbackStep | Reads 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.
| Property | Type | Owner |
|---|---|---|
| Manifest | InstallerManifest | set at construction |
| Detection | DetectionResult? | DetectStep |
| RelatedBundles | IReadOnlyList<RelatedBundleInfo> | DetectStep |
| AvailableUpdate | UpdateCheckResult? | DetectStep |
| PlanRequest | UiRequest.Plan? | PlanStep |
| Plan | InstallPlan? | PlanStep |
| IsDryRun | bool | PlanStep |
| ElevationGateway | IElevatedCommandGateway? | ElevateStep |
| RebootRequired | bool | ApplyStep |
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.
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
| Method | Returns | Description |
|---|---|---|
Detect(InstallerManifest) | DetectionResult | Detects 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) | InstallState | Compares 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:
| Action | Behavior |
|---|---|
| Install | Uninstalls related upgrade bundles first, then installs packages forward. Respects feature selections and install conditions. |
| Uninstall | Removes packages in reverse order. Ignores feature selections (all packages are candidates). |
| Repair | Repairs packages forward. Respects feature selections. |
| Modify | Installs 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);
}
| Executor | Package Type | Tool | Description |
|---|---|---|---|
| MsiExecutor | MsiPackage | IMsiApi (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. |
| MsuExecutor | MsuPackage | wusa.exe | Installs/uninstalls Windows Update packages. Uninstall requires KbArticle. Recognizes exit codes 0, 3010 (reboot required), and 2359302 (already installed). |
| MspExecutor | MspPackage | msiexec.exe | Applies/removes MSI patches. Uninstall requires TargetProductCode and PatchCode (validated as GUIDs). |
| BundleExecutor | BundlePackage | (EXE path) | Executes nested bundle EXEs with /quiet /norestart flags. Supports cancellation via process tree kill. |
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.
| Method | Description |
|---|---|
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.
| Name | Type | Description | Example Value |
|---|---|---|---|
| VersionNT | Version | Full OS version | 10.0.22631 |
| VersionNTMajor | long | OS major version number | 10 |
| VersionNTMinor | long | OS minor version number | 0 |
| ServicePackLevel | long | Service pack level (always 0 on modern Windows) | 0 |
| WindowsBuildNumber | long | Windows build number | 22631 |
| NativeMachine | string | OS processor architecture | x64 |
| ProcessorArchitecture | string | OS architecture (same as NativeMachine) | x64 |
| ProcessArchitecture | string | Process architecture (may differ under emulation) | x64 |
| Is64BitOperatingSystem | long | 1 if x64 or arm64 OS, 0 otherwise | 1 |
| SystemFolder | string | Windows system directory | C:\Windows\System32 |
| WindowsFolder | string | Windows directory | C:\Windows |
| ProgramFilesFolder | string | Program Files directory | C:\Program Files |
| ProgramFiles64Folder | string | 64-bit Program Files (empty on 32-bit OS) | C:\Program Files |
| CommonFilesFolder | string | Common Files directory | C:\Program Files\Common Files |
| TempFolder | string | Temporary files directory | C:\Users\User\AppData\Local\Temp\ |
| DesktopFolder | string | Desktop directory | C:\Users\User\Desktop |
| AdminToolsFolder | string | Administrative Tools directory | C:\Users\User\...\Administrative Tools |
| LocalAppDataFolder | string | Local application data directory | C:\Users\User\AppData\Local |
| AppDataFolder | string | Roaming application data directory | C:\Users\User\AppData\Roaming |
| StartMenuFolder | string | Start Menu directory | C:\Users\User\...\Start Menu |
| StartupFolder | string | Startup programs directory | C:\Users\User\...\Startup |
| PersonalFolder | string | Documents directory | C:\Users\User\Documents |
| FontsFolder | string | Fonts directory | C:\Windows\Fonts |
| Privileged | long | 1 if the install can perform privileged (per-machine) work: the process is elevated, or an elevation companion is configured and available | 1 |
| TerminalServer | long | 1 if Terminal Server mode is active | 0 |
| RemoteSession | long | 1 if running in a remote desktop session | 0 |
| ComputerName | string | Machine name | DESKTOP-ABC123 |
| LogonUser | string | Current Windows user name | JohnDoe |
| InstalledCulture | string | Current culture name | en-US |
| UserLanguageID | long | Current user language LCID | 1033 |
| SystemLanguageID | long | System UI language LCID | 1033 |
| VersionMsi | Version | Emulated MSI version (always 5.0) | 5.0 |
| Date | string | Current UTC date | 20260217 |
| Time | string | Current UTC time | 143022 |
| RebootPending | long | 1 if a reboot is pending from prior installations | 0 |
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.
| Constructor | Description |
|---|---|
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) |
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:
- UI Process:
InstallerPagecallsEngine.SetProperty()orEngine.SetSecureProperty() - EngineClient: Serializes to
SetPropertyMessage(0x0208) orSetSecurePropertyMessage(0x0209) over named pipe - NamedPipeUiChannel: Validates the property name (regex check, built-in variable allowlist, length cap) and translates the frame into a
UiRequest;PipelineRunnerstores the value inVariableStore. Secrets are wrapped asSecureVariableand tracked separately so they can be redacted from logs. - Planner: Copies user properties to
PlanAction.Properties; secrets use bracket references ([DB_PASSWORD]) resolved at execution time - MsiExecutor: Resolves bracket references from
VariableStore, builds property string, callsIMsiApi.InstallProduct(packagePath, properties)
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 -> '=' | '<>' | '<' | '>' | '<=' | '>=' | '~='
| Operator | Description |
|---|---|
= | Equality (type-aware: version, integer, then string) |
<> | Inequality |
<, >, <=, >= | Ordered comparison |
~= | Case-insensitive string equality |
AND | Logical AND |
OR | Logical OR |
NOT | Logical 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);
}
| Feature | Detail |
|---|---|
| Max retries | 3 attempts with exponential backoff (1s, 2s) |
| Timeout | 5 minutes per attempt (configurable) |
| Hash verification | SHA-256 computed after download, file deleted on mismatch |
| URL validation | Only http/https schemes allowed; path traversal blocked |
| Progress callback | Action<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:
- PerMachine:
%ProgramData%\FalkForge\Cache\{BundleId}\{PackageId}\{FileName} - PerUser:
%LocalAppData%\FalkForge\Cache\{BundleId}\{PackageId}\{FileName}
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
| Type | Description | Required Fields |
|---|---|---|
| PackageInstalled | A package was installed | PackageId |
| PackageUninstalled | A package was uninstalled | PackageId |
| MsiInstalled | An MSI package was installed | PackageId, ProductCode |
| ExeInstalled | An EXE package was installed | PackageId, UninstallCommand |
| PayloadCached | A payload was cached | PackageId, CachePath |
| FileCreated | A file was created | Description (path) |
| FileModified | A file was modified | Description, UndoData |
| RegistryKeyCreated | A registry key was created | Description |
| RegistryValueSet | A registry value was set | Description, UndoData |
| RegistryModified | A registry entry was modified | Description, UndoData |
| ServiceInstalled | A service was installed | Description |
| SegmentBoundary | Rollback segment marker | Description (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:
MsiUninstallOperation-- Uninstalls an MSI by product codeExeRollbackOperation-- Runs the recorded uninstall commandCacheCleanupOperation-- Deletes cached payloads
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.
| Flag | Aliases | Argument |
|---|---|---|
--log | /log, /L | File path or directory (auto-appends engine.log); path traversal (..) is rejected |
--log-level | /lv | LogLevel 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.
| Instrument | Kind | Recording method |
|---|---|---|
falkforge.engine.phase.transitions | Counter | RecordPhaseTransition(EnginePhase, double ms) |
falkforge.engine.phase.duration_ms | Histogram | RecordPhaseTransition(EnginePhase, double ms) |
falkforge.engine.payload.downloads | Counter | RecordPayloadDownload(bool, long, PayloadKind) |
falkforge.engine.payload.size_bytes | Histogram | RecordPayloadDownload(bool, long, PayloadKind) |
falkforge.engine.retry.count | Counter | RecordRetry(RetryOperation) |
falkforge.engine.error.count | Counter | RecordError(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_transitions | Total engine phase transitions this session. |
payload_downloads_success | Successful payload downloads this session. |
payload_downloads_failure | Failed payload downloads this session. |
retries | Total operation retries (download, MSI install, etc.) this session. |
errors | Terminal 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) |
+------------------+---------------------------+
| Field | Size | Description |
|---|---|---|
| WireVersion | 2 bytes | Per-message wire version. Multiple versions of the same MessageType can be registered side-by-side (see Codec Architecture). |
| Type | 2 bytes | MessageType enum value |
| PayloadLength | 4 bytes | Payload length in bytes (excludes header) |
| Payload | variable | Codec-authored body, starting with SequenceId:u32, then type-specific fields |
MaxPayloadSize = 1 * 1024 * 1024).
Collection counts within messages are capped at 10,000 items.
Message Types
| Type ID | Name | Direction | Properties | Description |
|---|---|---|---|---|
| 0x0101 | DetectBegin | Engine → UI | (none) | Detection phase has started |
| 0x0102 | DetectComplete | Engine → UI | State, CurrentVersion, Features[] | Detection results with install state and feature list |
| 0x0103 | PlanBegin | Engine → UI | Action (InstallAction) | Planning phase has started for the given action |
| 0x0104 | PlanComplete | Engine → UI | TotalDiskSpaceRequired, PackageIds[] | Plan is ready with disk space estimate and package list |
| 0x0105 | ApplyBegin | Engine → UI | TotalPackages | Apply phase has started with total package count |
| 0x0106 | ApplyComplete | Engine → UI | ExitCode, ErrorMessage? | Apply phase completed with result |
| 0x0107 | Progress | Engine → UI | InstallProgress(Current, Total, CurrentPackage) | Progress update during apply |
| 0x0108 | Error | Engine → UI | Message, Kind (ErrorKind) | Error notification with categorized error kind |
| 0x0109 | PhaseChanged | Engine → UI | Phase (EnginePhase) | State machine phase transition notification |
| 0x010A | Log | Engine → UI | Text, Level (LogLevel) | Structured log message for UI display |
| 0x010B | ShutdownResponse | Engine → UI | ExitCode | Engine shutdown acknowledgement with final exit code |
| 0x010C | UpdateAvailable | Engine → UI | Version, ReleaseNotes?, DownloadUrl, LocalPath? | An update is available for download |
| 0x010D | UpdateReady | Engine → UI | Version, LocalPath | Update has been downloaded and is ready to apply |
| 0x010E | UpdateDownloadProgress | Engine → UI | BytesReceived, TotalBytes, PercentComplete | Progress report while downloading an available update, produced by UpdateDownloader (see Update Feed) |
| 0x010F | LaunchUpdate | UI → Engine | (none) | Tells the engine to launch a downloaded/ready update; sent by EngineClient.LaunchUpdate() |
| 0x0110 | License | Engine ↔ UI | Action (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 |
| 0x0111 | DetectPackageComplete | Engine → UI | PackageId, State (InstallState), Version? | Per-package detection result, emitted once per package in manifest chain order during Detect |
| 0x0112 | DetectRelatedBundle | Engine → UI | BundleId, Relation (RelatedBundleRelation), InstalledVersion | A related bundle (e.g. an older version eligible for upgrade) was found on the machine during Detect |
| 0x0113 | PlanPackageBegin | Engine → UI | PackageId, DisplayName, PlannedAction | Planning is about to start for a single package during the Plan phase |
| 0x0114 | PlanPackageComplete | Engine → UI | PackageId, DisplayName, PlannedAction | Planning finished for a single package, sent immediately after its PlanPackageBegin |
| 0x0115 | ApplyPackageBegin | Engine → UI | PackageId, DisplayName | A package's installer is about to run during the Apply phase, in execution order |
| 0x0116 | ApplyPackageComplete | Engine → UI | PackageId, DisplayName, Succeeded | A package's installer returned; Succeeded is false for the package whose failure aborts the apply |
| 0x0117 | PackageMsiFeatures | Engine → UI | PackageId, 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 |
| 0x0201 | Cancel | UI → Engine | (none) | User requested cancellation |
| 0x0202 | ShutdownRequest | UI → Engine | (none) | UI requests engine shutdown |
| 0x0203 | SetInstallDirectory | UI → Engine | Directory (string) | User changed install directory |
| 0x0204 | SetFeatureSelection | UI → Engine | FeatureId, IsSelected | User toggled a feature |
| 0x0205 | RequestDetect | UI → Engine | (none) | UI requests detection to begin |
| 0x0206 | RequestPlan | UI → Engine | Action (InstallAction) | UI requests planning for a specific action |
| 0x0207 | RequestApply | UI → Engine | (none) | UI confirms readiness to apply |
| 0x0208 | SetProperty | UI → Engine | PropertyName (string), Value (string) | Sets an MSI property for package execution. Phase-gated: only accepted during Initializing, Detecting, or Planning. |
| 0x0209 | SetSecureProperty | UI → Engine | PropertyName (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. |
| 0x020A | SetPackageFeatureSelection | UI → Engine | PackageId, 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 |
| 0x0301 | ElevateExecute | Engine → Elevated | CommandName, CommandPayload (byte[]) | Execute an elevated command |
| 0x0302 | SessionStart | Engine → Elevated | CorrelationId (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 |
| 0x0401 | ElevateResult | Elevated → Engine | Success, ErrorMessage?, ResultPayload? | Result of an elevated command execution |
| 0x0402 | ElevateProgress | Elevated → Engine | Percent | Progress 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:
- Write-side reveal buffer. The codec borrows the plaintext as a scoped
ReadOnlySpan<byte>viaSensitiveBytes.Borrow(), writes it to theBinaryWriter, and the span goes out of scope at end ofWrite. - Read-side scratch buffer. The codec rents from
ArrayPool<byte>.Shared, reads into the rented buffer, copies into a freshSensitiveBytesviaSensitiveBytes.FromPlaintext, then zeroes the scratch withCryptographicOperations.ZeroMemoryand returns it withclearArray: true. - PostWrite hook.
SetSecurePropertyCodec.PostWritecallsmsg.Dispose(), which zeroes the message'sSensitiveBytesimmediately after framing completes. One-shot messages cannot leak even if the caller forgets ausingblock.
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)
- Server generates a 32-byte cryptographic nonce using
RandomNumberGeneratorand sends it to the client - Client generates its own 32-byte nonce and sends
clientNonce || tag_c, wheretag_c = HMAC-SHA256(secret, LABEL_C2S || serverNonce || clientNonce) - Server validates
tag_cusing constant-time comparison (CryptographicOperations.FixedTimeEquals), then replies withtag_s = HMAC-SHA256(secret, LABEL_S2C || serverNonce || clientNonce) - Client validates
tag_sbefore processing any message — a server that cannot prove knowledge of the secret is refused. Distinct labels (LABEL_C2S≠LABEL_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);
}
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
| Property | Type | Description |
|---|---|---|
| Name | string | Bundle display name |
| Manufacturer | string | Publisher name |
| Version | string | Bundle version |
| BundleId | Guid | Unique bundle identifier |
| UpgradeCode | Guid | Upgrade code for related bundle detection |
| Packages | PackageInfo[] | Package definitions |
| RelatedBundles | RelatedBundleEntry[] | Related bundle entries for upgrade detection |
| Chain | ManifestChainItem[] | Ordered chain of packages and rollback boundaries |
| Variables | ManifestVariable[] | Bundle variables with defaults |
| Features | ManifestFeature[] | Feature definitions with package mappings |
| DependencyProviders | ManifestDependencyProvider[] | Dependency provider registrations |
| DependencyConsumers | ManifestDependencyConsumer[] | Dependency consumer declarations |
| LicenseFile | string? | Path to license file |
| UpdateFeed | ManifestUpdateFeed? | Update feed configuration |
| Scope | InstallScope | PerMachine or PerUser |
PackageInfo
| Property | Type | Description |
|---|---|---|
| Id | string | Unique package identifier |
| Type | PackageType | MsiPackage, ExePackage, MsuPackage, MspPackage, BundlePackage, NetRuntime |
| DisplayName | string | Human-readable package name |
| Version | string? | Package version |
| Vital | bool | If true, failure stops the installation (default: true) |
| SourcePath | string | Path to the package file |
| Sha256Hash | string | SHA-256 hash for integrity verification |
| Properties | Dictionary<string, string> | Package-specific properties (e.g., ProductCode) |
| InstallCondition | string? | Condition expression for conditional installation |
| ExitCodes | IReadOnlyDictionary<int, ExitCodeBehavior>? | Custom exit code mappings |
| DownloadUrl | string? | Remote URL for download |
| ContainerId | string? | Container that holds the payload |
PackageType Enum
| Value | Description |
|---|---|
| MsiPackage | Windows Installer MSI package |
| ExePackage | Executable package |
| NetRuntime | .NET runtime prerequisite |
| MsuPackage | Windows Update standalone package |
| MspPackage | Windows Installer patch |
| BundlePackage | Nested 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: WelcomeDlg → ProgressDlg → ExitDlg
InstallDir Template
src/FalkForge.Compiler.Msi/UI/Templates/InstallDirDialogTemplate.cs
Adds a license agreement and destination folder chooser to the flow.
Dialog flow: WelcomeDlg → LicenseAgreementDlg → InstallDirDlg → ProgressDlg → ExitDlg
FeatureTree Template
src/FalkForge.Compiler.Msi/UI/Templates/FeatureTreeDialogTemplate.cs
Includes a feature selection tree (SelectionTree control) with disk cost information.
Dialog flow: WelcomeDlg → LicenseAgreementDlg → CustomizeDlg → ProgressDlg → ExitDlg
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: WelcomeDlg → LicenseAgreementDlg → SetupTypeDlg (Typical → Progress | Custom → CustomizeDlg | Complete → Progress) → InstallDirDlg → ProgressDlg → ExitDlg
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: WelcomeDlg → InstallScopeDlg → LicenseAgreementDlg → SetupTypeDlg → CustomizeDlg → InstallDirDlg → ProgressDlg → ExitDlg
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.
RmRestart only relaunches processes that called Win32
RegisterApplicationRestart before Setup closed them; an unregistered application is
simply closed and stays closed.
| Control | Type | Bound property / text | Control_Next |
|---|---|---|---|
| Title | Text | Dialog.RestartManager.Title | — (non-focusable) |
| Description | Text | Dialog.RestartManager.Description | — (non-focusable) |
| Text | Text | Dialog.RestartManager.Text | — (non-focusable) |
| List | ListBox | the built-in FileInUseProcess property, populated by the Windows Installer engine at runtime | ShutdownOption |
| ShutdownOption | RadioButtonGroup | FalkForgeRMOption | OK |
| BottomLine | Line | — (the standard footer separator shared by every full-canvas wizard dialog; see DialogFooter.BottomLine()) | — (non-focusable) |
| OK | PushButton | — | Cancel |
| Cancel | PushButton | — | List |
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:
| Order | Value | Text |
|---|---|---|
| 1 | UseRM | Dialog.RestartManager.CloseApps |
| 2 | DontUseRM | Dialog.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:
| Control | Event | Argument | Condition | Ordering |
|---|---|---|---|---|
| OK | RMShutdownAndRestart | 0 | FalkForgeRMOption~="UseRM" | 1 |
| OK | EndDialog | Return | 1 | 2 |
| Cancel | EndDialog | Exit | 1 | 1 |
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.
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:
| Table | One row per… | FalkForge model |
|---|---|---|
Dialog | a dialog window (size, title, focus/default/cancel control) | CustomDialogModel |
Control | one control placed on a dialog (type, position, bound property, tab-order pointer) | CustomDialogControlModel |
ControlEvent | one action a control fires (usually on click), gated by a condition | CustomDialogControlEventModel |
ControlCondition | one show/hide/enable/disable rule that re-evaluates as properties change | CustomDialogControlConditionModel |
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:
| Method | Maps to | Notes |
|---|---|---|
Title(title) | Dialog.Title | Window title; omit for an untitled dialog. |
Size(width, height) | Dialog.Width/Height | Dialog units; defaults to the MSI standard 370×270. |
Centering(h, v) | Dialog.HCentering/VCentering | 0–100 percent; defaults to 50/50 (screen-centered). |
Attributes(int) | Dialog.Attributes | Raw bitmask escape hatch; default 39 = Visible | Modal | Minimize | TrackDiskSpace. |
FirstControl(name) | Dialog.Control_First | Initial focus; unset falls back to the first authored control. |
DefaultControl(name) | Dialog.Control_Default | Control activated by Enter. |
CancelControl(name) | Dialog.Control_Cancel | Control 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):
| Adder | Control type | Data-bound | Notes |
|---|---|---|---|
Text(name, x, y, w, h, text) | Text | No | Static label. |
PushButton(name, x, y, w, h, text) | PushButton | No | Wire navigation/actions with control events. |
Line(name, x, y, width) | Line | No | Horizontal etched separator; height fixed at 0. |
CheckBox(name, x, y, w, h, property, text) | CheckBox | Yes | Toggles the bound property between 1 and empty. |
Edit(name, x, y, w, h, property) | Edit | Yes | Single-line text field. |
PathEdit(name, x, y, w, h, property) | PathEdit | Yes | Editable path field bound to a directory property. |
ScrollableText(name, x, y, w, h, text) | ScrollableText | No | Scrollable read-only multi-line area (license bodies). |
Bitmap(name, x, y, w, h, binaryKey) | Bitmap | No* | Text names an embedded Binary stream, not a property. |
Icon(name, x, y, w, h, binaryKey) | Icon | No* | Text names an embedded Binary stream, not a property. |
GroupBox(name, x, y, w, h, text) | GroupBox | No | Labelled frame around related controls. |
Control(type, name, x, y, w, h) | any CustomControlType | depends | Escape 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. |
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):
| Method | Control.Attributes bit | Effect |
|---|---|---|
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 0x0004 | Sunken 3-D border (used for the license body in demo 65). |
Transparent() | sets 0x00010000 | Transparent background, for text/bitmap drawn over another control. |
NoPrefix() | sets 0x00020000 | Disables & accelerator-prefix processing in the control text. |
RightAligned() | sets 0x0040 | Right-aligns the control text. |
Attributes(int) | replaces the whole bitmask | Escape 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:
| Method | MSI ControlEvent | Argument | When to use |
|---|---|---|---|
NavigateTo(dialogId, condition?) | NewDialog | target dialog Id | Replace the current dialog in place — a normal Next/Back step. |
SpawnDialog(dialogId, condition?) | SpawnDialog | target dialog Id | Open a modal child on top of the current dialog (confirmation prompts). |
EndDialog(exitCode = "Return", condition?) | EndDialog | Return / Exit / Retry / Ignore | Close the dialog chain with an outcome — Return proceeds with the install. |
DoAction(actionName, condition?) | DoAction | custom action name | Run a custom action from a button click. |
SetProperty(property, value, condition?) | [PropertyName] | value to assign | Assign an MSI property directly, no custom action needed. |
Reset(condition?) | Reset | 0 | Reset every control on the dialog to its default state. |
PublishEvent(event, argument, condition?, ordering = 1) | any verb | any | Escape 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:
| Method | ControlCondition Action | Effect when the condition is true |
|---|---|---|
ShowWhen(condition) | Show | Shows the control. |
HideWhen(condition) | Hide | Hides the control. |
EnableWhen(condition) | Enable | Enables the control. |
DisableWhen(condition) | Disable | Disables (greys out) the control. |
DefaultWhen(condition) | Default | Restores the control to its default/initial state. |
When(action, condition) | any CustomConditionAction | Escape 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:
| Code | Severity | Fires when |
|---|---|---|
| DLG010 | Error | A custom dialog's Id is empty or whitespace. |
| DLG011 | Error | A 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). |
| DLG012 | Error | Two custom dialogs in the package share the same Id. |
| DLG013 | Error | A custom dialog has zero controls — it would have no Control_First and could never be shown. |
| DLG014 | Error | A control on a custom dialog has an empty or whitespace Name. |
| DLG015 | Error | A control Name is not a valid MSI identifier. |
| DLG016 | Error | Two controls on the same dialog share a Name. |
| DLG017 | Error | A control's Next(...) tab-order target does not name a control on the same dialog. |
| DLG018 | Error | A data-bound control type (Edit, CheckBox, PathEdit, MaskedEdit, RadioButtonGroup, ComboBox, ListBox, DirectoryCombo, DirectoryList, SelectionTree) has no bound property. |
| DLG019 | Error | A dialog's FirstControl, DefaultControl, or CancelControl names a control that isn't on the dialog. |
| DLG020 | Error | A control event has an empty event verb. |
| DLG021 | Error | A NewDialog/SpawnDialog/DoAction/EndDialog event has an empty argument. |
| DLG022 | Error | A 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
| Code | Severity | Description |
|---|---|---|
| DLG001 | Error | An InsertedDialogStep references a step name that is not registered in the DialogStepRegistry. Fix: register the builder via the owning extension before compiling. |
| DLG002 | Error | DialogCustomizationModel.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. |
| DLG003 | Error | DialogCustomization.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. |
| DLG005 | Warning | A 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.
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
| Code | Constant | Description |
|---|---|---|
| 0 | Success | Operation completed successfully |
| 1 | ValidationFailure | Validation errors in the installer definition |
| 2 | CompilationError | Compilation failed |
| 3 | RuntimeError | Runtime error (file not found, platform error, etc.) |
JSON Configuration Format
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.
| Variable | Type / Effect | Default |
|---|---|---|
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 |
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.
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
- Define string tables in JSON files named
name.culture.json(e.g.,strings.en-US.json,strings.de.json) - Register them with the
LocalizationBuilder, specifying a default culture - The
LocalizedStringResolverresolves!(loc.StringId)references at build time - 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."
}
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:
| Builder | DetectCulture default | Used for |
|---|---|---|
FalkForge.Localization.LocalizationBuilder | false | Compile-time MSI dialog string resolution (the builder documented above) |
FalkForge.Ui.Localization.UiLocalizationBuilder | true | Runtime 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
| Code | Description |
|---|---|
| LOC001 | Duplicate string ID in a culture |
| LOC002 | Default culture not specified or not defined |
| LOC003 | Circular reference or unresolved localization string |
| LOC004 | Invalid 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/:
| Schema | Row type | MSI table |
|---|---|---|
PropertySchema | PropertyRow | Property |
DirectorySchema | DirectoryRow | Directory |
ComponentSchema | ComponentRow | Component |
FileSchema | FileRow | File |
FeatureSchema / FeatureComponentsSchema | FeatureRow / FeatureComponentsRow | Feature / FeatureComponents |
RegistrySchema | RegistryRow | Registry |
ServiceSchema | ServiceRow | ServiceInstall |
ShortcutSchema | ShortcutRow | Shortcut |
UpgradeSchema | UpgradeRow | Upgrade |
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
| Code | Subsystem | Description |
|---|---|---|
| DEC001 | MSI | Cannot open MSI file (null path, file not found, or open failure) |
| DEC003 | MSI | Row shape or type mismatch in a table read (default schema diagnostic) |
| BDC001 | Bundle | Invalid or missing bundle path |
| BDC002 | Bundle | Invalid bundle format / magic mismatch |
| BDC003 | Bundle | Manifest deserialization failure |
| BDC004 | Bundle | TOC read failure |
| WBD001 | Burn | Invalid or missing bundle path |
| WBD002 | Burn | PE header invalid or too small |
| WBD003 | Burn | No .wixburn section in PE |
| WBD004 | Burn | .wixburn magic mismatch / no containers |
| WBD005 | Burn | UX container extraction failed |
| WBD006 | Burn | Manifest file not found in UX cabinet |
| WMM001 | Burn | Manifest 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)
| Code | Description |
|---|---|
| PKG001 | Package name is required |
| PKG002 | Package manufacturer is required |
| PKG003 | Package version is required |
| PKG004 | UpgradeCode is required |
| PKG005 | Package must have at least one file or component |
| PKG006 | Duplicate component ID |
| PKG007 | File source path not found |
| PKG008 | Invalid install scope for component configuration |
| PKG009 | Directory reference not found |
| PKG010 | Invalid license file path |
| PKG011 | Invalid architecture for install scope |
Feature Validation (FEA)
| Code | Description |
|---|---|
| FEA001 | Feature ID is required |
| FEA002 | Feature title is required |
| FEA003 | Duplicate feature ID |
| FEA004 | Feature parent reference not found |
| FEA005 | Feature has no components |
Service Validation (SVC)
| Code | Description |
|---|---|
| SVC001 | Service Name is required |
| SVC002 | Service Executable is required |
| SVC003 | Service with Account=User requires a UserName |
| SVC004 | Service name must not exceed 256 characters |
| SVC005 | Plaintext service password (warning) |
| SVC009 | Service Arguments is empty string (use null for no arguments) |
| SVC010 | Both AccountProperty and UserName specified (AccountProperty takes precedence) |
| SVC011 | Service ComponentCondition must not be empty (use null to omit) |
| SVC012 | Custom account specified without a password (warning) |
Permission Validation (PRM)
| Code | Description |
|---|---|
| PRM001 | Permission LockObject is required |
| PRM002 | Permission must have either SDDL or User specified |
| PRM003 | Invalid permission Table (must be File, Registry, CreateFolder, or ServiceInstall) |
Registry Validation (REG)
| Code | Description |
|---|---|
| REG001 | Registry entry Key is required |
| REG002 | Duplicate 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) |
| REG003 | Conflicting 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) |
| REG007 | Registry value references a sensitive MSI property (warning) |
RemoveRegistry Validation (RRG)
| Code | Description |
|---|---|
| RRG001 | RemoveRegistry Id is required |
| RRG002 | RemoveRegistry Key is required |
| RRG003 | RemoveValue action requires a Name |
Custom Table Validation (CTB)
| Code | Description |
|---|---|
| CTB001 | Table name is required |
| CTB002 | Table must have at least one column |
| CTB003 | Column name is required |
| CTB004 | Table must have a primary key |
| CTB005 | Duplicate column name |
| CTB006 | Invalid column type |
| CTB007 | Row data has wrong number of values |
| CTB008 | Row primary key is required |
| CTB009 | Duplicate table name |
| CTB010 | Reserved table name (conflicts with MSI standard tables) |
Major Upgrade (MUP)
| Code | Description |
|---|---|
| MUP001 | Downgrade error message is required |
| MUP002 | Invalid schedule for RemoveExistingProducts |
| MUP003 | Major upgrade requires an UpgradeCode |
Merge Module Validation (MSM)
| Code | Description |
|---|---|
| MSM001 | Merge module name is required |
| MSM002 | Merge module version is required |
| MSM003 | Merge module must have components |
| MSM004 | Invalid merge module GUID |
Patch Validation (MSP)
| Code | Description |
|---|---|
| MSP001 | Patch requires a baseline MSI |
| MSP002 | Patch requires a target MSI |
| MSP003 | Invalid patch GUID |
| MSP004 | Patch description is required |
Transform Validation (MST)
| Code | Description |
|---|---|
| MST001 | Transform requires a base MSI |
| MST002 | Transform must define at least one modification |
Bundle Validation (BDL)
| Code | Description |
|---|---|
| BDL001 | Bundle name is required |
| BDL002 | Bundle manufacturer is required |
| BDL003 | Invalid version format |
| BDL004 | Bundle must contain at least one package |
| BDL005 | Duplicate package IDs in chain |
| BDL006 | Invalid container or boundary references |
| BDL007 | Custom UI requires a project path |
| BDL008 | BundleId must not be empty GUID |
| BDL009 | UpgradeCode must not be empty GUID |
| BDL010 | Variable name is required |
| BDL011 | Duplicate variable names |
| BDL012 | Variable default value type mismatch |
| BDL013 | Secret variable cannot be persisted |
| BDL014 | Feature ID is required |
| BDL015 | Duplicate feature IDs |
| BDL016 | Feature references unknown package IDs |
| BDL017 | Required feature has no packages |
| BDL018 | Feature title is required |
| BDL019 | Dependency provider key must not be empty |
| BDL020 | Dependency provider has invalid version |
| BDL021 | Duplicate dependency provider key |
| BDL022 | Dependency consumer provider key must not be empty |
| BDL023 | Dependency consumer key must not be empty |
| BDL024 | Update feed URL is not a valid absolute URI |
| BDL025 | Update feed URL must use HTTPS |
| BDL027 | EnableFeatureSelection is only valid for MsiPackage type |
Bundle Payload Integrity (INT)
| Code | Description |
|---|---|
| INT001 | No trusted signature validates the manifest (tampering, untrusted publisher key, or locally-revoked key) |
| INT002 | Signed hash does not match the manifest package hash, or a signed entry has no matching package |
| INT003 | Malformed integrity envelope (parse failure, no signatures, or embedded manifest deserialization failure) |
| INT004 | A manifest package, or a bundle TOC payload, is not covered by the integrity signature (set-coverage violation) |
| INT006 | Bundle TOC payload hash does not match the ECDSA-signed manifest hash (post-signing overlay tamper) |
| INT007 | A signature is required on this path (e.g. update) but the manifest carries none |
| INT008 | Bundle key-epoch is below the highest epoch this machine has accepted (downgrade/replay) — see §23 (Safe Updates) |
| INT009 | A signature is required but the engine's effective trusted set is empty (fail closed) |
| INT010 | The signing quorum for this operation is not satisfied (roles/quorum policy, see §23) |
| INT011 | A 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)
| Code | Description |
|---|---|
| SGN001 | PFX certificate file embeds a private key (Authenticode signing options; warning) |
| SGN002 | Signing 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 |
| SGN003 | Authenticode DigestAlgorithm must be sha256, sha384, or sha512 |
| SGN010 | An asynchronous signature provider (e.g. SignServer) was configured on the synchronous build path — use BuildBundleAsync/CompileAsync |
| SGN011 | ML-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) |
| FALKPQ001 | Build-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 |
| FALKPQ002 | Build-time error: a FalkForgeTrustedKey item has a malformed PqFingerprint (must be the 64-hex SHA-256 of the ML-DSA public key) |
| FALKPQ003 | Build-time error: duplicate trusted-key fingerprint in the baked FalkForgeTrustedKey set |
SBOM Generation (SBM)
| Code | Description |
|---|---|
| SBM001 | Failed to compute SHA-256 hash for SBOM component |
| SBM002 | Failed to write SBOM output file |
| SBM003 | SPDX 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 |
| SBM004 | An 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)
| Code | Description |
|---|---|
| PLN001 | Detection phase failed during plan-only mode |
| PLN002 | Planning phase failed during plan-only mode |
| PLN003 | Failed to serialize plan to JSON |
| PLN004 | Dry-run mode blocked: one or more extensions do not support it |
Bundle Detach/Reattach (BDS)
| Code | Description |
|---|---|
| BDS001 | Bundle file not found, file too small, magic marker not found, or detach failed |
| BDS002 | Signed stub or data file not found, data file corrupted, or reattach validation failed |
| BDS003 | Reattach verification failed (payload offsets, TOC offsets out of bounds) |
Bundle Decompiler (BDC)
| Code | Description |
|---|---|
| BDC001 | Bundle path is null, empty, or file not found |
WiX Bundle Decompiler (WBD)
| Code | Description |
|---|---|
| WBD001 | Bundle file not found or path is null/empty |
| WBD002 | Invalid PE: file too small, bad header, bad signature, or open failure |
| WBD003 | .wixburn PE section not found |
| WBD004 | Invalid .wixburn magic or no containers |
| WBD005 | UX container extraction/read failure |
| WBD006 | Manifest file not found in UX container |
WiX Manifest Mapper (WMM)
| Code | Description |
|---|---|
| WMM001 | Manifest XML has no root element |
Decompiler (DEC)
| Code | Description |
|---|---|
| DEC001 | MSI path is null/empty or file not found |
| DEC003 | Failed to read an MSI table (Component, Directory, Feature, File, Property, Registry, Service, Shortcut, Upgrade) |
Firewall Extension (FWL)
| Code | Description |
|---|---|
| FWL001 | Firewall rule ID is required |
| FWL002 | Firewall rule name is required |
| FWL003 | Firewall rule must specify port or program |
| FWL004 | Duplicate firewall rule ID |
.NET Detection Extension (NET)
| Code | Description |
|---|---|
| NET001 | VariableName is required |
| NET002 | MinimumVersion is required |
| NET003 | Duplicate VariableName |
| NET004 | RuntimeType 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 |
| NET005 | VariableName is not a valid PUBLIC MSI property identifier ([A-Z_][A-Z0-9_.]*, all uppercase — e.g. DOTNET8_FOUND) |
| NET006 | Search model passed to DotNetExtension.AddSearch is null |
| NET007 | Platform is not a recognized value; supported values are X64, X86, Arm64 |
IIS Extension (IIS)
| Code | Description |
|---|---|
| IIS001 | WebSite must have a Description |
| IIS002 | WebSite must have at least one binding |
| IIS003 | Binding must have a valid Port (1-65535) |
| IIS004 | HTTPS binding must have a CertificateRef |
| IIS005 | AppPool must have a Name |
| IIS006 | SpecificUser identity requires UserName |
| IIS007 | Certificate must have an Id |
| IIS008 | Certificate must have a FindValue |
| IIS009 | SpecificUser identity requires Password |
| IIS010 | Reference to undefined app pool |
| IIS011 | Binding references undefined certificate |
SQL Extension (SQL)
| Code | Description |
|---|---|
| SQL001 | Database requires either Server or ConnectionString |
| SQL002 | Script/SqlString requires a DatabaseRef |
| SQL003 | Script requires either SourceFile or SqlContent (not both) |
| SQL004 | Database name is required |
| SQL005 | SqlString requires a Sql statement |
| SQL006 | Duplicate database Id |
| SQL007 | Duplicate script Id |
| SQL008 | Duplicate SqlString Id |
| SQL009 | Script SqlContent exceeds maximum length |
| SQL010 | SqlString Sql exceeds maximum length |
| SQL011 | Database Id is required |
| SQL012 | Script Id is required |
| SQL013 | SqlString Id is required |
XML Config (XCF)
| Code | Description |
|---|---|
| XCF001 | XPath expression must not be empty |
| XCF002 | FilePath must not be empty |
| XCF003 | CreateElement action requires ElementName |
| XCF004 | SetAttribute action requires AttributeName and Value |
| XCF005 | XPath expression exceeds maximum length |
| XCF006 | DeleteAttribute action requires AttributeName |
| XCF007 | SetValue action requires Value |
| XCF008 | BulkSetValue action requires Value |
| XCF009 | Duplicate XmlConfig Id |
File Share (FSH)
| Code | Description |
|---|---|
| FSH001 | FileShare Id is required |
| FSH002 | FileShare Name is required |
| FSH003 | FileShare Directory is required |
Internet Shortcut (ISC)
| Code | Description |
|---|---|
| ISC001 | InternetShortcut Id is required |
| ISC002 | InternetShortcut Name is required |
| ISC003 | InternetShortcut Target URL is required |
| ISC004 | InternetShortcut Directory is required |
Quiet Exec (QEX)
| Code | Description |
|---|---|
| QEX001 | QuietExec Id is required |
| QEX002 | QuietExec CommandLine is required |
| QEX003 | QuietExec CommandLine exceeds maximum length |
| QEX004 | QuietExec RollbackCommandLine exceeds maximum length |
Remove Folder (RFX)
| Code | Description |
|---|---|
| RFX001 | RemoveFolderEx Id is required |
| RFX002 | RemoveFolderEx requires either Directory or Property |
User Management (USR)
| Code | Description |
|---|---|
| USR001 | User Name is required |
| USR002 | Password is required when creating a new user |
Group Management (GRP)
| Code | Description |
|---|---|
| GRP001 | Group Name is required |
Localization (LOC)
| Code | Description |
|---|---|
| LOC001 | Duplicate string ID in a culture |
| LOC002 | Default culture not specified or not defined |
| LOC003 | Circular reference or unresolved localization string |
| LOC004 | Invalid filename format, invalid JSON, null content, or non-string values |
JSON Configuration (JSN)
| Code | Description |
|---|---|
| JSN001 | Invalid JSON syntax |
| JSN002 | Missing required field: product.name |
| JSN003 | Missing required field: product.manufacturer |
| JSN004 | Invalid version format |
| JSN005 | Invalid upgrade code GUID |
| JSN006 | Invalid UI dialog set value |
| JSN007 | Invalid platform value |
| JSN009 | Feature must have an id |
| JSN010 | Configuration error during mapping |
| JSN011 | Firewall rule missing required fields |
| JSN012 | IIS configuration missing required fields |
| JSN013 | SQL configuration missing required fields |
| JSN014 | .NET detection missing required fields |
| JSN015 | signing.provider missing or unknown (valid: none, pem, signserver) |
| JSN016 | Inline secret material in the signing config — keys and credentials must be referenced by file path or environment-variable name, never pasted into the JSON |
| JSN017 | Ambiguous or missing key source for the pem provider (exactly one of keyPath/keyEnv; at most one of pqKeyPath/pqKeyEnv) |
| JSN018 | Invalid 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) |
| JSN019 | Fail-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)
| Code | Description |
|---|---|
| UPD001 | Failed to fetch update feed (HTTP error, timeout, or exceeds max size) |
| UPD002 | Failed to parse update feed JSON, null feed, or invalid current version |
| UPD003 | Update feed bundle ID mismatch |
| UPD004 | Update entry download URL is not a valid HTTPS URI |
| UPD005 | Update 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.
MSI Basics (01–05)
| # | Name | Description | Key Features |
|---|---|---|---|
| 01 | Hello World | Minimum installer — one file, media template, reproducible build | Files MediaTemplate Reproducible RestartManager Minimal UI |
| 02 | Notepad Clone | App with shortcuts, DWord registry, RemoveRegistry, startup shortcut | Shortcuts Registry RemoveRegistry License MajorUpgrade InstallDir UI |
| 03 | Client-Server | Services with arguments, config file protection (NeverOverwrite/Permanent) | Features Services EnvVars LaunchConditions FeatureTree UI |
| 04 | Dev Toolkit | Developer tools with nested features and extensions | NestedFeatures FileAssociations CustomActions Registry Mondo UI |
| 05 | Enterprise Suite | Full enterprise IDE with 15 features and 71 files | 15 Features CustomTables Fonts LaunchConditions Advanced UI |
Bundle Basics (06, 10)
| # | Name | Description | Key Features |
|---|---|---|---|
| 06 | Product Suite | Bundle wrapping multiple MSI packages into a single EXE | BundleBuilder MsiPackage RollbackBoundary Built-in WPF UI |
| 10 | Advanced Bundle | All chain package types: ExePackage, MsuPackage, MspPackage, containers | ExePackage MsuPackage MspPackage ExitCodeMapping RelatedBundles Containers |
UI Demos (11–14)
| # | Name | Description | Key Features |
|---|---|---|---|
| 11 | Custom UI Simple | Custom WPF installer UI with page navigation and localization | InstallerApp.Run InstallerPage<T> Localization AccentColor |
| 12 | Custom UI VS-Style | Visual Studio-style installer with workload selection | Borderless DarkTheme Workloads Localization |
| 13 | Glass UI | Borderless acrylic/glass window with pill shape | CustomWindow Transparency CornerRadius DragMove |
| 14 | Lifecycle Hooks | Bundle lifecycle event hooks with custom configuration page | OnDetect/Plan/Apply SetProperty SetSecureProperty PasswordBridge |
Focused Features (15–28)
| # | Name | Description | Key Features |
|---|---|---|---|
| 15 | Bundle Signing | Detach/sign/reattach workflow with Store, Timestamp, Algorithm | Signing Detach Reattach Timestamp |
| 15-msix | MSIX Basic | Basic MSIX package with manifest, visual assets, and capabilities | MSIX AppxManifest Capabilities VisualAssets |
| 16 | Features | Feature tree with AllowSameVersionUpgrades, Schedule, MigrateFeatures | NestedFeatures MajorUpgrade tuning |
| 17 | Services | Complete service API showcase: arguments, AccountProperty, conditional install, service permissions | ServiceInstall FailureActions ServiceControl Credentials AccountProperty Arguments ComponentCondition Permissions |
| 18 | Environment Variables | System and user-scoped environment variables | Set Append UserScope |
| 19 | File Associations | Register file extension with verbs and icons | FileAssociation Verbs ContentType |
| 20 | Custom Actions | DllFromBinary, ExeFromBinary, Binary, Commit, ContinueOnError | DllFromBinary ExeFromBinary Deferred Rollback Commit |
| 21 | Launch Conditions | Block install unless conditions are met (admin, OS version) | Require IsPrivileged IsWindows10OrLater |
| 22 | INI Files | Write INI file entries during installation | IniFile CreateEntry |
| 23 | Permissions | NTFS permissions via SDDL strings and ForTable | Permission SDDL ForTable |
| 24 | Fonts | Register TrueType fonts with Title override | Font TitleOverride |
| 25 | File Operations | MoveFile, DuplicateFile, RemoveFile, CreateFolder, ComponentCondition | MoveFile DuplicateFile RemoveFile CreateFolder |
| 26 | Custom Tables | Typed custom MSI tables with row data | CustomTable TypedColumns RowData |
| 27 | GAC Assembly | Register assemblies in the Global Assembly Cache | Assembly GAC |
| 28 | Sequence Scheduling | ExecuteSequence and UISequence ordering | ExecuteSequence UISequence Conditions |
Extension Demos (29–34)
| # | Name | Description | Key Features |
|---|---|---|---|
| 29 | Ext: Firewall | Windows Firewall inbound TCP rule configuration | FirewallExtension AddRule TCP Inbound |
| 30 | Ext: IIS | IIS application pool and web site with HTTP binding | IisExtension AppPool WebSite Binding |
| 31 | Ext: SQL | SQL Server database creation and schema scripts | SqlExtension DefineDatabase Script |
| 32 | Ext: .NET | .NET runtime detection via factory pattern with launch conditions | DotNetExtension SearchForRuntime LaunchCondition |
| 33 | Ext: Util | XmlConfig transformation (XPath-based attribute setting) | XmlConfig XPath SetAttribute |
| 34 | Ext: Dependency | Provider/consumer registration with version constraints | DependencyExtension Provides Requires |
Bundle Advanced (35–43)
| # | Name | Description | Key Features |
|---|---|---|---|
| 35 | Bundle Simple | Basic bundle with RelatedBundle and DependencyProvider | RelatedBundle DependencyProvider |
| 36 | Bundle EXE Package | EXE prerequisite with exit code mapping | ExePackage ExitCode |
| 37 | Bundle MSU Package | Windows Update (.msu) hotfix prerequisite | MsuPackage KB Article |
| 38 | Bundle Nested | Nested child bundle inside a parent bundle | BundlePackage Nested |
| 39 | Bundle Remote Payload | Download package from URL at install time | RemotePayload Hash Size |
| 40 | Bundle Variables | Secret, Hidden, and Persisted bundle variables | Secret Hidden Persisted InstallCondition |
| 41 | Bundle Rollback | Rollback boundaries isolating failure domains | RollbackBoundary Isolation |
| 42 | Bundle Update Feed | Automatic update checking from a JSON feed URL | UpdateFeed NotifyOnly |
| 43 | Bundle Layout | Named containers for offline layout scenarios | Container Layout |
Output Types (44–46)
| # | Name | Description | Key Features |
|---|---|---|---|
| 44 | Merge Module | Reusable .msm component package with Dependency | BuildMergeModule Dependency |
| 45 | Patch | Delta .msp patch with Classification and AllowRemoval | BuildPatch Classification AllowRemoval |
| 46 | Transform | .mst transform for MSI property customization | BuildTransform SetProperty |
Advanced Features (47–53)
| # | Name | Description | Key Features |
|---|---|---|---|
| 47 | PowerShell Actions | Inline script, file-based script, and deferred PowerShell custom actions | PowerShell InlineScript Deferred |
| 48 | COM Registration | In-proc server, local server, ProgId, and threading model registration | COM ProgId ThreadingModel |
| 49 | HTTP Extension | URL reservation and SNI SSL certificate binding | HttpExtension UrlReservation SslBinding |
| 50 | Driver Install | INF-based device driver installation with NeverOverwrite on INF files | DriverExtension PlugAndPlay ForceInstall NeverOverwrite |
| 51 | ICE Validation | Post-build ICE validation with rule suppression and JSON reporting | IceValidation Suppress WarningsAsErrors |
| 52 | MSIX Advanced | Advanced MSIX packaging with visual assets and capabilities | MSIX Advanced Capabilities |
| 53 | Delta 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 |
Provability & Supply Chain (54–58)
| # | Name | Description | Key Features |
|---|---|---|---|
| 54 | forge 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 |
| 55 | WinGet 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 |
| 56 | Verify 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 |
| 57 | Reproducible 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 |
| 58 | Project References | References another project's build output via the generated ProjectOutputs class instead of a hardcoded bin/ path |
ProjectOutputs Source Generator ProjectReference |
Signing & Trust (59–63)
| # | Name | Description | Key Features |
|---|---|---|---|
| 59 | Bundle 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 |
| 60 | Trusted-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 |
| 61 | SignServer 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 |
| 62 | Require-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 |
| 63 | Hybrid 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 |
JSON Mode (01–07)
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.
| # | File | Description | Dialog Set |
|---|---|---|---|
| 01 | 01-minimal.json | Minimal UI, single file | Minimal |
| 02 | 02-installdir.json | InstallDir UI, desktop shortcut, registry | InstallDir |
| 03 | 03-featuretree.json | FeatureTree UI, nested features, services | FeatureTree |
| 04 | 04-mondo.json | Mondo UI, environment variables, license, services | Mondo |
| 05 | 05-advanced.json | Advanced UI, nested services, env vars, all features | Advanced |
| 06 | 06-web-server.json | Basic file installer (JSON can't author IIS/firewall — see C# Demo 07) | InstallDir |
| 07 | 07-database-app.json | Basic 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
- Post-build verification — Run after
Installer.Build()to catch table relationship errors, missing references, and invalid property values - CI/CD pipelines — Gate releases on zero ICE errors with
--ice-warnings-as-errors - Debugging — When an MSI fails to install correctly, ICE messages pinpoint the exact table, column, and primary key at fault
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 noIceConfigurationsupplied — treats ICE as opt-in and skips gracefully whendarice.cubis missing. - Everything that goes through an explicit
IceConfiguration— thepackage.Ice(...)builder shown below,forge build, andforge validate --ice— is strict by default: a missingdarice.cubis a hard build/validate failure. PassSkipWhenCubUnavailable(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:
| Method | Description |
|---|---|
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
| Severity | Meaning | Effect on IsValid |
|---|---|---|
Error | The MSI violates a Windows Installer rule | Fails validation |
Failure | The ICE rule itself could not execute | Fails validation |
Warning | Potential issue that may cause runtime problems | Passes (unless WarningsAsErrors) |
Information | Advisory message, no action required | Passes |
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
| Method | Description |
|---|---|
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 |
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
| Field | Required | Description |
|---|---|---|
version | Yes | Semantic version string (parsed via System.Version) |
url | Yes | HTTPS download URL for the updated bundle EXE. Non-HTTPS URLs are rejected (UPD004). |
sha256 | Yes | SHA-256 hash of the download for integrity verification |
size | No | File size in bytes (used for progress display) |
releaseNotes | No | Human-readable release notes for UI display |
published | No | ISO 8601 publication date (reserved for future UI display) |
minVersion | No | Minimum currently-installed version that can upgrade to this entry. If the installed version is below minVersion, the entry is skipped. |
deltaUrl | No | HTTPS 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. |
deltaSha256 | No, but required alongside deltaUrl | SHA-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 |
deltaSize | No | Delta 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:
| Policy | Behavior |
|---|---|
NotifyOnly | Check for updates and send an UpdateAvailable message to the UI. No automatic download. The UI decides what to show the user. |
DownloadAndPrompt | Download the update to cache, then send UpdateReady to the UI for user confirmation. (Currently behaves as NotifyOnly.) |
AutoUpdate | Download and auto-launch the update. (Currently behaves as NotifyOnly.) |
Configuration Flags
| Property | Default | Description |
|---|---|---|
FeedUrl | (required) | HTTPS URL of the JSON feed |
Policy | NotifyOnly | Update behavior policy |
AllowResumeDownload | true | Allow resuming interrupted downloads |
ShowDownloadProgress | true | Send progress messages to the UI during download |
ShowDownloadErrors | false | Send download error details to the UI (off by default to avoid user confusion) |
PromptBeforeAutoUpdate | false | When 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:
- The engine reads the
UpdateFeedconfiguration from the embedded manifest. - It fetches the JSON feed from
FeedUrlover HTTPS. - The
UpdateFeedParserdeserializes the feed (AOT-safe viaUpdateFeedJsonContext). - It verifies the
bundleIdmatches the running bundle (UPD003 on mismatch). - It selects the highest version entry that is greater than the currently installed version, respecting
minVersionconstraints. - If an update is found, an
UpdateAvailablemessage is sent to the UI via the named pipe. - Under
DownloadAndPrompt/AutoUpdatepolicies, theUpdateDownloaderdownloads 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:
- 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 againstdeltaSha256. - 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.
- If the delta download fails, or the move to the final cache path fails,
UpdateDownloaderlogs a warning and falls back to downloading the full bundle fromurl/sha256instead — the update still completes, just without the smaller transfer. - 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
| Code | Description |
|---|---|
UPD002 | Failed to parse update feed JSON or current version string |
UPD003 | Feed bundleId does not match the running bundle |
UPD004 | Update entry download URL is not a valid HTTPS URI |
Hosting the Feed
- Host the JSON file on any HTTPS endpoint (static file hosting, CDN, or API).
- Set
Cache-Controlheaders appropriately — short TTL (e.g., 5 minutes) ensures clients see updates quickly. - The feed must be publicly accessible (or accessible from the target machines) without authentication.
- Update the feed JSON whenever a new version is released. Old entries can remain for
minVersiongating.
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
- WPF + MVVM — Each editor is a paired
View(XAML) andViewModel(C#) loaded viaDataTemplatematching in the shell. - JSON Project Format —
StudioProjectis serialized withSystem.Text.Json. All state is in the JSON; no binary formats. - Undo/Redo —
UndoManagersnapshots the full project JSON on each change, with a 50-state stack. - Live Validation — The validation panel runs after every build and on demand, reporting errors with codes (STU001–STU00x) that link to the responsible editor.
- Async Build — Compilation runs on a background thread via
StudioBuildService.Compile(), keeping the UI responsive.
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
| Type | Value | Description |
|---|---|---|
| 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 Property | Type | Description |
|---|---|---|
projectType | string | Project type: "msi", "bundle", or "msix" |
product | ProductSection | Name, manufacturer, version, upgrade code, architecture, scope, support info |
installDirectory | string? | Target installation directory path |
features | FeatureSection[] | Feature tree with IDs, titles, install levels, required/default flags |
registry | RegistryEntrySection[] | Registry keys and values to create during installation |
services | ServiceSection[] | Windows service definitions (install, start type, dependencies) |
shortcuts | ShortcutSection[] | Start menu and desktop shortcut definitions |
environment | EnvironmentVariableSection[] | Environment variable create/set/append operations |
customActions | CustomActionSection[] | Custom action definitions (DLL, EXE, script, property) |
sqlDatabases | SqlDatabaseSection[] | SQL Server database creation and script execution |
firewallRules | FirewallRuleSection[] | Windows Firewall rule definitions |
xmlConfigs | XmlConfigSection[] | XML file modifications (XPath-based) |
scheduledTasks | ScheduledTaskSection[] | Windows Task Scheduler entries |
perfCounters | PerfCounterSection[] | Performance counter categories and counters |
odbcDrivers | OdbcDriverSection[] | ODBC driver registrations |
odbcDataSources | OdbcDataSourceSection[] | ODBC data source (DSN) definitions |
bundleSettings | BundleSettingsSection? | Bundle-specific settings (only for "bundle" projects) |
bundlePackages | BundlePackageSection[] | Packages included in a bundle chain |
ui | UiSection | Dialog template selection, custom dialog definitions |
build | BuildSection | Output path, compression, signing, ICE validation settings |
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.
| Editor | Tree Node | Description |
|---|---|---|
| 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:
| Platform | Output | Description |
|---|---|---|
| GitHub Actions | .github/workflows/build.yml | YAML workflow with .NET SDK setup, build, and artifact upload steps |
| Azure DevOps | azure-pipelines.yml | YAML pipeline with .NET SDK task, build, and publish artifact steps |
| Jenkins | Jenkinsfile | Declarative pipeline with .NET SDK stage, build, and archive artifacts |
*.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:
- Orphan detection — Files not assigned to any feature are collected in a separate orphaned files list.
- Click-to-navigate — Clicking a node fires a
NavigateRequestedevent that switches to the corresponding editor. - Refresh on demand — The graph rebuilds from the current project state when the Refresh command is invoked.
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:
STU001— Product name is emptySTU002— Manufacturer is emptySTU003+— Version format, missing features, file reference errors, etc.
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
| Menu | Items |
|---|---|
| File | New Project, Open..., Save, Import MSI..., Import WiX Source..., Export to C# Script..., Export CI/CD Pipeline..., Exit |
| Edit | Undo (Ctrl+Z), Redo (Ctrl+Y) |
| Tools | Compare Projects..., MSI Table Inspector, Dependency Graph |
| Build | Build MSI |
21. MSIX Packaging
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.
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
| Format | Use Case | Windows 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.
| Method | Signature | Description |
|---|---|---|
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.
| Method | Return Type | Description |
|---|---|---|
Name(string name) | MsixBuilder | Package identity name (e.g., "Company.AppName"). Must be unique in the Store. |
Publisher(string publisher) | MsixBuilder | Publisher identity in certificate subject format (must start with "CN="). Must match the signing certificate. |
DisplayName(string name) | MsixBuilder | Human-readable package name shown to users. |
PublisherDisplayName(string name) | MsixBuilder | Human-readable publisher name. |
Version(Version version) | MsixBuilder | Package version. Must have 4 parts (Major.Minor.Build.Revision). Default: 1.0.0.0. |
Architecture(ProcessorArchitecture arch) | MsixBuilder | Target processor architecture. Default: X64. |
Description(string description) | MsixBuilder | Package description for the manifest Properties element. |
LogoPath(string path) | MsixBuilder | Path to the package logo image (StoreLogo). |
MinWindowsVersion(string version) | MsixBuilder | Minimum Windows version. Default: "10.0.17763.0" (Windows 10 1809). |
MaxVersionTested(string version) | MsixBuilder | Maximum Windows version tested against. Default: "10.0.26100.0". |
Application(string id, string executable, Action<MsixApplicationBuilder> configure) | MsixBuilder | Adds an application entry point to the package. At least one is required. |
Files(Action<FileSetBuilder> configure) | MsixBuilder | Adds files using the standard FileSetBuilder from Core. |
RegistryEntry(string key, string? valueName, string? value, MsixRegistryValueType type, string root) | MsixBuilder | Adds a registry entry to the package. Default root: "HKCU", default type: String. |
Signing(Action<SigningOptionsBuilder> configure) | MsixBuilder | Configures code signing. MSIX packages must be signed (MSIX008). |
Capability(string capability) | MsixBuilder | Declares a general capability (e.g., "internetClient"). |
RestrictedCapability(string capability) | MsixBuilder | Declares a restricted capability (requires Store approval). |
Dependency(string name, string publisher, Version? minVersion) | MsixBuilder | Declares a package dependency on another MSIX package. |
Sbom(Action<SbomOptions>? configure) | MsixBuilder | Opts in to a CycloneDX 1.6 SBOM sidecar (<package>.msix.cdx.json) listing every packaged file with its SHA-256. |
VfsMapping(VfsMappingMode mode) | MsixBuilder | Sets the VFS mapping mode. Default: Auto. |
VfsOverride(string sourceDir, string packageRelPath) | MsixBuilder | Adds a custom VFS path override for files from a specific source directory. |
UpdateSettings(string appInstallerUri, Action<MsixUpdateSettingsBuilder>? configure) | MsixBuilder | Configures auto-update via .appinstaller file generation. |
Build() | MsixModel | Builds 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.
| Method | Return Type | Description |
|---|---|---|
EntryPoint(string entryPoint) | MsixApplicationBuilder | Sets the entry point class (for UWP/WinUI apps). Optional for desktop apps. |
DisplayName(string displayName) | MsixApplicationBuilder | Display name shown in Start menu and taskbar. Defaults to the application ID. |
Description(string description) | MsixApplicationBuilder | Application description for the visual elements. |
BackgroundColor(string color) | MsixApplicationBuilder | Tile background color. Default: "transparent". |
Square150x150Logo(string path) | MsixApplicationBuilder | Path to the 150x150 medium tile logo. |
Square44x44Logo(string path) | MsixApplicationBuilder | Path to the 44x44 app list logo (taskbar, Start menu). |
Wide310x150Logo(string path) | MsixApplicationBuilder | Path to the 310x150 wide tile logo. |
FileTypeAssociation(string name, params string[] fileTypes) | MsixApplicationBuilder | Registers 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) | MsixApplicationBuilder | As above, with a user-visible name and/or icon for the association. |
Protocol(string name, string? displayName, string? logo) | MsixApplicationBuilder | Registers 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().
| Capability | Category | Description |
|---|---|---|
internetClient | General | Outbound internet access. |
internetClientServer | General | Outbound and inbound internet access. |
privateNetworkClientServer | General | Inbound and outbound access on home/work networks. |
webcam | General | Access to the device camera. |
microphone | General | Access to the device microphone. |
runFullTrust | Restricted | Run as a full-trust desktop application (required for most desktop apps). |
allowElevation | Restricted | Allow the app to request elevation at runtime. |
broadFileSystemAccess | Restricted | Access 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.
| Category | Fluent API | Emitted 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
| Mode | Behavior |
|---|---|
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 Token | VFS Path (X64) | VFS Path (X86) |
|---|---|---|
ProgramFilesFolder | VFS/ProgramFilesX64 | VFS/ProgramFilesX86 |
ProgramFiles64Folder | VFS/ProgramFilesX64 | VFS/ProgramFilesX64 |
ProgramFilesX86Folder | VFS/ProgramFilesX86 | VFS/ProgramFilesX86 |
CommonFilesFolder | VFS/ProgramFilesCommonX64 | VFS/ProgramFilesCommonX86 |
CommonFiles64Folder | VFS/ProgramFilesCommonX64 | VFS/ProgramFilesCommonX64 |
SystemFolder / System64Folder | VFS/SystemX64 | |
WindowsFolder | VFS/Windows | |
CommonAppDataFolder | VFS/CommonAppData | |
AppDataFolder | VFS/AppData | |
LocalAppDataFolder | VFS/LocalAppData | |
FontsFolder | VFS/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
| Property | Type | Default | Description |
|---|---|---|---|
Root | string | "HKCU" | Registry root hive ("HKCU" or "HKLM"). |
Key | string | required | Registry key path (e.g., "Software\MyApp"). |
ValueName | string? | null | Value name. null for the default value. |
Value | string? | null | Value data. |
Type | MsixRegistryValueType | String | Registry value type. |
Supported Value Types
| MsixRegistryValueType | Registry Type |
|---|---|
String | REG_SZ |
DWord | REG_DWORD |
QWord | REG_QWORD |
Binary | REG_BINARY |
ExpandString | REG_EXPAND_SZ |
MultiString | REG_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:
- Validate — Runs
MsixValidator.Validate()to check all required fields and constraints. - Resolve VFS layout —
VfsMapper.Resolve()maps files to their package-relative paths based on the VFS mapping mode. - Generate AppxManifest.xml —
AppxManifestGenerator.Generate()produces the XML manifest with identity, properties, dependencies, capabilities, and applications. - Build Registry.dat —
RegistryHiveBuilder.Build()creates the registry hive file (only if registry entries exist). - Create MSIX package —
AppxPackageWriter.CreatePackage()produces the MSIX file via COM interop withIAppxPackageWriter. - Sign the package — Invokes
signtool.exewith the configured signing options. - Generate .appinstaller —
AppInstallerGenerator.Generate()creates the auto-update manifest (only ifUpdateSettingsis 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
| Component | Location | Purpose |
|---|---|---|
AppxManifestGenerator | Manifest/ | Generates AppxManifest.xml with Windows 10 foundation, UAP, restricted capabilities, and desktop namespaces. |
AppxPackageWriter | Packaging/ | Creates the MSIX package file using IAppxPackageWriter COM interface. |
VfsMapper | Packaging/ | Resolves file-to-VFS-path mappings for Auto and Manual modes. |
ContentTypeMapper | Packaging/ | Maps file extensions to content types for the package content types stream. |
RegistryHiveBuilder | Registry/ | Builds a Registry.dat hive file from MsixRegistryEntry records. |
AppInstallerGenerator | Manifest/ | Generates .appinstaller XML for auto-update configuration. |
AppxInterop | Interop/ | 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.
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.
| Method | Return Type | Description |
|---|---|---|
HoursBetweenUpdateChecks(int hours) | MsixUpdateSettingsBuilder | How often Windows checks for updates. Default: 24 hours. |
ShowPrompt(bool show) | MsixUpdateSettingsBuilder | Show a prompt to the user when an update is available. Default: false. |
UpdateBlocksActivation(bool blocks) | MsixUpdateSettingsBuilder | Block app launch until the update is applied. Default: false. |
AutomaticBackgroundTask(bool automatic) | MsixUpdateSettingsBuilder | Check for updates via a background task. Default: false. |
ForceUpdateFromAnyVersion(bool force) | MsixUpdateSettingsBuilder | Allow 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.
| Method | Return Type | Description |
|---|---|---|
Name(string name) | MsixBundleBuilder | Bundle identity name. |
Version(Version version) | MsixBundleBuilder | Bundle version. Default: 1.0.0.0. |
Package(string filePath, ProcessorArchitecture arch) | MsixBundleBuilder | Adds an architecture-specific MSIX package to the bundle. |
Signing(Action<SigningOptionsBuilder> configure) | MsixBundleBuilder | Configures code signing for the bundle. |
Build() | MsixBundleModel | Builds 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:
-
MsixModelneeds dataPackageModelhas no equivalent for — aPublisherdistinguished name (CN=..., distinct from the free-text MSIManufacturer), one or moreApplications(executable + visual elements per app), and aCapabilitieslist. NeitherJsonConfigLoadernor the script-loadedPackageModelcarries this, so there is no lossless way to derive anMsixModelfrom whatforge buildalready loads. -
Even for
.cs/.csxinput,ScriptLoader.LoadPackageModelevaluates the script viaCSharpScript.EvaluateAsync<PackageModel>— the script's final expression must be aPackageModel. A script whose entry point callsInstallerMsix.BuildMsix()(which returnsint, notPackageModel) can never satisfy that contract, so it can't run throughforge buildeven in principle.
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:
| Code | Rule |
|---|---|
MSIX001 | Package Name is required. |
MSIX002 | Publisher is required. |
MSIX003 | Publisher must start with "CN=" (certificate subject format). |
MSIX004 | Version must have 4 parts (Major.Minor.Build.Revision). |
MSIX005 | At least one Application is required. |
MSIX006 | DisplayName is required. |
MSIX007 | PublisherDisplayName is required. |
MSIX008 | MSIX packages must be signed. Provide SigningOptions. |
MSIX010 | Application Id is required (for each application entry). |
MSIX011 | Application Executable is required (for each application entry). |
MSIX012 | MinWindowsVersion must be a valid version string. |
MSIX013 | File type association name must use lowercase letters, digits, ., - or _. |
MSIX014 | A 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 < > : % " / \ | ? *. |
MSIX015 | Protocol 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
| Plugin | Project | Registers | Notes |
|---|---|---|---|
| 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. |
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:
- Every
Plugin<T>()-registered plugin'sRegisterServicesruns first, in registration order. - Then
RegisterPlugins(PluginRegistry)'s bulk list runs its ownRegisterServicescalls against the same registry — first-registration-wins overall, so aPlugin<T>()call always beats a same-typed service coming from the bulk registry. - The registry is frozen (further
Registercalls throw), then assigned to every page'sInstallerPage.PluginServicesproperty (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;
}
}
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:
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.
| Signature | Costs | Protects | Gives 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
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);
});
.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>"));
}
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:
- The signature carries its own public key, but that is not enough. The engine re-derives the fingerprint from that key and only proceeds if the fingerprint is in the pinned set. A fresh attacker key fails here.
- Payload bytes are checked against the signed hash list. Flipping a byte and rewriting the unsigned table-of-contents to match no longer works — the bytes are bound to the signed list, not the rewritable one.
- Fresh installs of unsigned bundles still run. Installing is a deliberate act on a file the user chose, so an unsigned bundle is allowed. But a bundle that is signed by an untrusted key is rejected — a present-but-wrong signature is an attack signal, not an old bundle.
- Updates are held to a stricter rule (they must be signed and trusted) — covered in Safe Updates below, because an automatic update is the higher-risk path.
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:
| Aspect | Local PEM key | SignServer |
|---|---|---|
| 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.
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);
});
"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:
| Attack | What the attacker tries | What 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 |
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:
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:
| Action | Signatures 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 key | 1 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);
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.
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.
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.
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.
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.
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:
- Signature expiry windows. A per-signature “valid from / valid until” so a retired key's signatures naturally age out on a schedule, even before a revocation reaches a machine.
- Transparency log. A tamper-evident public record of every signature, so a victim could detect being handed a specially-crafted malicious bundle nobody else received.
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
(DetectStep → PlanStep → optional ElevateStep →
ApplyStep, 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).
-
Detect —
PackageDetector/MsiDetectorask Windows (registry, MSI database) what's already installed, andDependencyDetectorchecks whether anything else on the machine depends on a package this bundle would remove (Detection). -
Plan —
Plannerdiffs detected state against the requested action (install/uninstall/repair/update) and produces an orderedInstallPlanofPlanActions (Planning). This is the same planforge plan/plan-diffcan export and diff without ever executing it (Demo 56). - Elevate — skipped entirely for a plan with no privileged actions. When needed, the Engine launches the Elevated companion and the two mutually authenticate before a single command crosses the pipe (24.4).
-
Apply —
PackageExecutordispatches each planned action to the matching executor (MsiExecutorviaIMsiApiP/Invoke,MsuExecutor,MspExecutor,BundleExecutorfor nested bundles,ExeExecutor,NetRuntimeExecutor). Before every destructive step, aJournalEntryis written to theRollbackJournaldescribing how to undo it (Rollback Journal) — this is what lets a mid-install failure unwind cleanly instead of leaving the machine half-changed. -
Complete / Shutdown — on success the plan's outcome is reported back over
the pipe and the journal for a successful run is retired. On failure,
RollbackExecutorwalks the journal in reverse, running the matchingIUndoOperation(MsiUninstallOperation,ExeRollbackOperation,CacheCleanupOperation) for each entry.
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:
| Layer | Question it answers | Mechanism |
|---|---|---|
| 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.