EXE Bundles
An MSI installs one product. A bundle installs several — multiple MSIs, EXEs, patches, and prerequisites — in a single self-extracting EXE. FalkForge's bundle engine is NativeAOT compiled, so it runs without a .NET runtime.
1. Your First Bundle
Open demo/35-bundle-simple/Program.cs. The entire bundle definition fits in one screen.
Entry point
return Installer.BuildBundle(args, outputPath =>
{
var bundle = new BundleBuilder()
.Name("Simple Bundle")
.Manufacturer("Demo")
.Version("1.0.0")
.BundleId(new Guid("..."))
.UpgradeCode(new Guid("..."))
.Scope(InstallScope.PerMachine)
.UseBuiltInUI(themeColor: "#0078D4")
.Chain(chain => chain
.MsiPackage("MyApp.msi", p => p
.Id("MyApp")
.DisplayName("My Application")
.Vital(true)))
.Build();
return new BundleCompiler().Compile(bundle, outputPath);
});
Installer.BuildBundle is the bundle counterpart to Installer.Build. Where an MSI uses PackageBuilder and MsiCompiler, a bundle uses BundleBuilder and BundleCompiler.
Key points:
- BundleId — uniquely identifies this specific build of the bundle.
- UpgradeCode — ties all versions together. Never change it after shipping.
- Chain — defines the ordered list of packages to install.
- Vital(true) — if this package fails, the entire bundle fails.
The demo also shows .RelatedBundle() and .DependencyProvider() for upgrade detection and dependency tracking. We cover those in section 9.
Deep dive: Bundle format
The compiled bundle is a self-extracting EXE with this layout:
[PE stub] [Magic: "FALKBUNDLE"] [Manifest] [Compressed payloads] [TOC] [Footer]
The PE stub is the NativeAOT-compiled engine. It reads the footer to locate the table of contents, which lists every embedded payload and its offset. The manifest is a JSON document describing the bundle metadata, chain, variables, and update feed. Payloads are individually gzip-compressed.
Because the engine is NativeAOT, the bundle runs on a clean machine with no .NET runtime installed — essential for bootstrappers that install .NET as a prerequisite.
2. Multi-Product Suite
Open demo/06-product-suite/suite-bundle/Program.cs. This bundle installs two MSI products — an application and a background service — with rollback boundaries between them.
Chaining multiple MSIs
.Chain(chain => chain
.RollbackBoundary("Prerequisites")
.MsiPackage(appMsiPath, p => p
.Id("AcmeApp")
.DisplayName("Acme Application")
.Vital(true))
.RollbackBoundary("Services")
.MsiPackage(serviceMsiPath, p => p
.Id("AcmeService")
.DisplayName("Acme Background Service")
.Vital(true)))
Order matters. The engine installs packages top-to-bottom and uninstalls bottom-to-top. Place prerequisites before the packages that need them.
The UseBuiltInUI call accepts an optional license file and theme color:
.UseBuiltInUI(licenseFile, themeColor: "#2563EB")
Deep dive: How the chain executes
The bundle engine runs three phases for every operation:
- Detect — checks each package to see if it is already installed, at what version, and whether it needs action.
- Plan — builds an install plan based on detection results: install, uninstall, repair, or skip each package.
- Apply — executes the plan. For MSI packages this calls
IMsiApi.InstallProduct(P/Invoke, not msiexec.exe). For EXE packages it launches the process and maps exit codes.
If any vital package fails during Apply, the engine rolls back — but only within the current rollback boundary. Packages in earlier boundaries that succeeded are left in place.
3. Package Types
Bundles support four package types beyond MSI. Each has a dedicated builder method on the chain.
EXE packages (demo 36)
.ExePackage("vcredist_x64.exe", p => p
.Id("VCRedist")
.DisplayName("Visual C++ Redistributable")
.Vital(true)
.ExitCode(0, ExitCodeBehavior.Success)
.ExitCode(3010, ExitCodeBehavior.ScheduleReboot)
.ExitCode(1638, ExitCodeBehavior.Success))
Unlike MSI packages, EXE packages have no standard exit codes. You map each code to a behavior: Success, Failure, ScheduleReboot, or RebootRequired.
MSU packages (demo 37)
.MsuPackage("windows-hotfix-kb123456.msu", p => p
.Id("KB123456")
.DisplayName("Windows Hotfix KB123456"))
MSU files are Windows Update packages. Use them to install required hotfixes before your application.
Nested bundles (demo 38)
.BundlePackage("ChildSetup.exe", p => p
.Id("ChildBundle")
.DisplayName("Child Application Bundle")
.Vital(true))
A bundle can contain other bundles. The parent engine coordinates detection and installation with the child engine via the bundle protocol.
Deep dive: When to use each package type
| Type | Use when | Detection |
|---|---|---|
| MsiPackage | Installing Windows Installer products | Automatic via ProductCode |
| ExePackage | Third-party prerequisites (runtimes, redistributables) | You provide detection conditions |
| MsuPackage | Windows Update hotfixes | KB article lookup |
| MspPackage | MSI patches | Automatic via PatchCode |
| BundlePackage | Nesting another FalkForge/WiX bundle | Via child bundle's UpgradeCode |
4. Variables & Conditions
Open demo/40-bundle-variables/Program.cs. Bundle variables store values that conditions can reference at install time.
Defining variables
.Variable("InstallOptionalTools", v => v
.Numeric()
.Default("0"))
.Variable("InstallPath", v => v
.String()
.Default(@"C:\Program Files\Demo")
.Persisted())
Three variable types: String(), Numeric(), and Version(). Modifiers control behavior:
- Persisted — survives repair and modify sessions.
- Hidden — excluded from install logs.
- Secret — excluded from logs and persisted state. Implies Hidden.
Conditional package installation
.MsiPackage("OptionalTools.msi", p => p
.Id("OptionalTools")
.DisplayName("Optional Developer Tools")
.Vital(false)
.InstallCondition("InstallOptionalTools = 1"))
The InstallCondition is evaluated during the Plan phase. If it evaluates to false, the package is skipped. The custom UI can set variable values via SetProperty before planning begins.
Deep dive: Condition syntax and operators
Bundle conditions use the same expression language as MSI conditions:
- Comparison:
=,<>,<,>,<=,>= - Logic:
AND,OR,NOT - Grouping: parentheses
The engine evaluates conditions using a recursive-descent parser (ConditionEvaluator). Variables are resolved from the VariableStore, which holds 30+ built-in variables alongside your custom ones.
Common built-in variables: VersionNT (OS version), NativeMachine (processor architecture), PrivilegedUser (running elevated).
5. Rollback Boundaries
Open demo/41-bundle-rollback/Program.cs. Rollback boundaries divide the chain into transaction groups.
.Chain(chain => chain
.RollbackBoundary("Prerequisites")
.MsiPackage("Runtime.msi", p => p
.Id("Runtime")
.DisplayName("Runtime Prerequisites")
.Vital(true))
.RollbackBoundary("Application")
.MsiPackage("MyApp.msi", p => p
.Id("MyApp")
.DisplayName("My Application")
.Vital(true)))
Each boundary starts a new transaction group. If MyApp.msi fails, only the packages after the "Application" boundary roll back. The runtime prerequisite stays installed.
Deep dive: Rollback behavior when a package fails
When a vital package fails:
- The engine marks the current boundary group as failed.
- All packages within that boundary are rolled back in reverse order using the
RollbackJournal. - Packages in earlier boundary groups that succeeded are left intact.
- The engine transitions to the
Failedstate and reports the error to the UI.
Without rollback boundaries, a failure anywhere rolls back the entire chain. Use boundaries to protect expensive or slow prerequisite installations.
For typed references (useful in advanced scenarios), use DefineRollbackBoundary on the builder and pass the reference to RollbackBoundary in the chain. Demo 10 shows this pattern.
6. Remote Payloads
Open demo/39-bundle-remote-payload/Program.cs. Instead of embedding a package in the EXE, you can download it at install time.
.MsiPackage("MyApp.msi", p => p
.Id("MyApp")
.DisplayName("My Application")
.Vital(true)
.RemotePayload(
"https://releases.example.com/myapp/1.0.0/MyApp.msi",
"e3b0c44298fc1c14...7852b855", // SHA256 hash
10485760)) // Size in bytes
RemotePayload takes three arguments: the download URL, the expected SHA256 hash, and the file size. The engine downloads the file, verifies the hash, and caches it before installation.
Deep dive: Caching and resume behavior
Downloaded payloads are stored in the package cache (PackageCache). The cache uses a three-layer path traversal defense: allowlist regex, Path.GetFileName sanitization, and Path.GetFullPath containment check.
If a download is interrupted, the engine resumes from where it left off using HTTP range requests. If the cached file already exists and its SHA256 matches, the download is skipped entirely.
Remote payloads reduce the bootstrapper size dramatically. A 5 MB EXE can orchestrate the installation of hundreds of megabytes of software downloaded on demand.
7. Update Feeds
Open demo/42-bundle-update-feed/Program.cs. A single method call adds update checking.
.UpdateFeed("https://updates.example.com/myapp/feed.json")
The engine checks this URL during detection. If a newer version is available, the behavior depends on the UpdatePolicy:
- NotifyOnly — tells the UI an update exists. The user decides.
- DownloadAndPrompt — downloads the update, then asks the user to apply it.
- AutoUpdate — downloads and applies automatically.
Set the policy explicitly with UpdateFeed(url, UpdatePolicy.DownloadAndPrompt). The default is NotifyOnly.
Deep dive: How the engine checks for updates
The engine fetches the feed URL and parses it using UpdateFeedParser (AOT-safe via UpdateFeedJsonContext). The feed is a JSON array of UpdateFeedEntry objects, each with a version, download URL, and SHA256 hash.
During the Detect phase, the engine compares the installed bundle version against feed entries. If a newer entry exists, it sends an UpdateAvailable message to the UI via the named pipe. The UI can then trigger a download, which results in an UpdateReady message when complete.
Validation codes UPD002 and UPD003 catch malformed feed URLs and missing required fields.
8. Layout Mode
Open demo/43-bundle-layout/Program.cs. Containers group payloads for offline distribution.
.Chain(chain => chain
.MsiPackage("Core.msi", p => p
.Id("Core")
.DisplayName("Core Components")
.Vital(true)
.Container("CoreContainer"))
.MsiPackage("Extras.msi", p => p
.Id("Extras")
.DisplayName("Extra Components")
.Vital(false)
.Container("ExtrasContainer")))
.Container("CoreContainer")
.Container("ExtrasContainer")
Each Container becomes a separate compressed archive inside the bundle. When the bundle runs in layout mode (/layout <directory>), it extracts all containers to a folder. This folder can then be copied to an air-gapped machine and installed offline.
Deep dive: When to use layout mode
Layout mode is essential in three scenarios:
- Air-gapped environments — servers with no internet access.
- Network share deployment — extract once, install from a shared folder across many machines.
- Build verification — extract and inspect every payload without running the installer.
The LayoutManager handles extraction and produces a LayoutJsonContext manifest that lists every file in the layout directory. The engine can install from a layout directory by reading this manifest instead of extracting from the EXE.
9. Advanced Bundles
Open demo/10-advanced-bundle/bundle/Program.cs. This demo combines every bundle feature into a single deployment suite.
Related bundles
.RelatedBundle(new Guid("C3D4E5F6-..."), rb => rb
.Relation(RelatedBundleRelation.Upgrade))
.RelatedBundle(new Guid("D4E5F6A7-..."), rb => rb
.Relation(RelatedBundleRelation.Detect))
Related bundles declare relationships with other installed bundles. The engine detects them during the Detect phase and factors them into the plan.
Typed references
var prereqs = bundleBuilder.DefineContainer("Prerequisites", c => c
.DownloadUrl("https://cdn.northwind.example.com/prereqs/"));
var prereqBoundary = bundleBuilder.DefineRollbackBoundary("PrereqBoundary");
DefineContainer and DefineRollbackBoundary return typed references (ContainerRef, RollbackBoundaryRef). Pass them to .Container() and .RollbackBoundary() in the chain. The compiler validates that every reference is used.
Mixed package types
The advanced bundle chains an EXE prerequisite, an MSU hotfix, an MSI application, and an MSP patch — all with install conditions, exit code mappings, and container assignments. This pattern is typical for enterprise deployment suites.
Deep dive: Related bundle detection
Three relation types control how the engine handles detected bundles:
- Upgrade — the engine uninstalls the related bundle after installing the new one. Use this for major version upgrades that use a different UpgradeCode.
- Detect — the engine notes the related bundle's presence but takes no action. Useful for checking if a companion product is installed.
- Addon / Patch — declares this bundle as an extension or patch for the related bundle.
Detection happens by scanning the Windows registry for installed bundle UpgradeCodes. The engine's DependencyDetector also checks ref-counting entries to prevent uninstalling bundles that other bundles depend on.
10. Extracting Bundles
Sometimes you need to inspect or extract the packages inside a bundle without running the installer. FalkForge supports this from both the CLI and the bundle itself.
CLI extraction
# List packages in a bundle
forge extract suite.exe --list
# Extract all packages to a directory
forge extract suite.exe -o D:\Temp\output
# Extract a specific package by ID
forge extract suite.exe -o D:\Temp\output --package ServerMsi
The forge extract command also works on standalone MSI files:
forge extract myapp.msi -o D:\Temp\extracted
Bundle self-extraction
FalkForge bundles can extract themselves without the forge CLI tool. Pass --extract or --extract-list directly to the bundle EXE:
myapp.exe --extract-list
myapp.exe --extract D:\Temp\output
myapp.exe --extract D:\Temp\output --package ServerMsi
Self-extraction bypasses the installer UI entirely โ no elevation, no detection, just extraction. This is useful for IT teams inspecting packages before deployment or for automated build verification pipelines.
Deep dive: When to use extraction
Common scenarios for bundle extraction:
- Pre-deployment review โ IT teams extract and scan packages before approving them for deployment.
- Build verification โ CI pipelines extract the bundle and verify that the expected MSIs and payloads are present.
- Troubleshooting โ extract individual MSIs from a bundle to test them in isolation.
- Air-gapped transfer โ extract on a connected machine, copy the files to a disconnected one.
Unlike layout mode (section 8), extraction produces raw MSI/EXE files without the layout manifest. Use layout mode for planned offline installation; use extraction for inspection and ad-hoc access.
11. Delta Updates
When your users update from v1 to v2, they don't need to re-download the entire bundle. FalkForge can generate a delta bundle that contains only the binary differences between versions. A 50 MB bundle update might shrink to 2 MB.
Generating a delta bundle
Build v2 with a reference to the v1 bundle. Use DeltaBundleCompiler instead of BundleCompiler:
var v2Bundle = new BundleBuilder()
.Name("MyApp")
.Version("2.0.0")
.DeltaFrom(v1Path)
.Chain(chain => chain
.MsiPackage("MyApp.msi", p => p
.Id("MyApp")
.DisplayName("My Application")
.Vital(true)))
.Build();
var result = new DeltaBundleCompiler().Compile(v2Bundle, outputPath, v1Path);
FalkForge extracts the v1 payloads, computes binary diffs using Octodiff, and embeds only the changed bytes. If a payload is new or the delta is larger than the full file, the full payload is included instead.
Update feed with delta URL
Add the delta bundle URL to your JSON update feed alongside the full bundle:
{
"version": "2.0.0",
"url": "https://releases.example.com/myapp-2.0.0.exe",
"deltaUrl": "https://releases.example.com/myapp-2.0.0-delta.exe",
"deltaSha256": "A1B2C3...",
"deltaSize": 2048000
}
The engine downloads the delta first. If anything fails — corrupted delta, missing cached payload, hash mismatch — it falls back to the full bundle URL automatically.
Deep dive: How delta updates work internally
Delta bundles use the same [PE stub][FALKBUNDLE][Manifest][Payloads][TOC][Footer] format as regular bundles. The difference is in the TOC entries:
- Each TOC entry has a delta flag byte. Old engines see flag=0 (full payload) and work normally.
- Delta entries (flag=1) include a
BaseSha256Hashthat identifies the old payload to diff against, and aReconstructedSha256Hashfor the expected output after applying the diff. - The engine finds the old payload in its local package cache, applies the Octodiff binary diff via
DeltaApplicator.Apply, and verifies the result againstReconstructedSha256Hash.
Octodiff uses an rsync-style rolling checksum algorithm. It finds matching blocks between old and new files, then encodes the differences. This works well for MSI files where small code changes produce localized binary differences.
Recap
| Feature | Demo | Key API |
|---|---|---|
| Simple bundle | 35 | BundleBuilder, Chain, MsiPackage |
| Multi-product suite | 06 | MsiPackage (multiple), UseBuiltInUI |
| EXE package | 36 | ExePackage, ExitCode |
| MSU package | 37 | MsuPackage, KbArticle |
| Nested bundle | 38 | BundlePackage |
| Variables & conditions | 40 | Variable, InstallCondition |
| Rollback boundaries | 41 | RollbackBoundary, DefineRollbackBoundary |
| Remote payloads | 39 | RemotePayload |
| Update feeds | 42 | UpdateFeed, UpdatePolicy |
| Layout / containers | 43 | Container, DefineContainer |
| Advanced (all features) | 10 | RelatedBundle, MspPackage, typed refs |
| Extraction / self-extraction | — | forge extract, --extract, --extract-list |
| Delta updates | 53 | DeltaFrom, DeltaBundleCompiler |