Tutorials

EXE Bundles

20 min read ยท Prerequisite: MSI Basics ยท Wraps demos 06, 10, 35–43, 53

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:

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:

  1. Detect — checks each package to see if it is already installed, at what version, and whether it needs action.
  2. Plan — builds an install plan based on detection results: install, uninstall, repair, or skip each package.
  3. 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
TypeUse whenDetection
MsiPackageInstalling Windows Installer productsAutomatic via ProductCode
ExePackageThird-party prerequisites (runtimes, redistributables)You provide detection conditions
MsuPackageWindows Update hotfixesKB article lookup
MspPackageMSI patchesAutomatic via PatchCode
BundlePackageNesting another FalkForge/WiX bundleVia 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:

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:

  1. The engine marks the current boundary group as failed.
  2. All packages within that boundary are rolled back in reverse order using the RollbackJournal.
  3. Packages in earlier boundary groups that succeeded are left intact.
  4. The engine transitions to the Failed state 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:

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 BaseSha256Hash that identifies the old payload to diff against, and a ReconstructedSha256Hash for 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 against ReconstructedSha256Hash.

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

FeatureDemoKey API
Simple bundle35BundleBuilder, Chain, MsiPackage
Multi-product suite06MsiPackage (multiple), UseBuiltInUI
EXE package36ExePackage, ExitCode
MSU package37MsuPackage, KbArticle
Nested bundle38BundlePackage
Variables & conditions40Variable, InstallCondition
Rollback boundaries41RollbackBoundary, DefineRollbackBoundary
Remote payloads39RemotePayload
Update feeds42UpdateFeed, UpdatePolicy
Layout / containers43Container, DefineContainer
Advanced (all features)10RelatedBundle, MspPackage, typed refs
Extraction / self-extractionforge extract, --extract, --extract-list
Delta updates53DeltaFrom, DeltaBundleCompiler