Tutorials

MSI Basics

20 min read ยท Prerequisite: Getting Started ยท Wraps demos 02–05

The Getting Started tutorial built a minimal installer. Real installers need shortcuts, services, registry entries, and features. This tutorial covers the building blocks you'll use in every project.

Shortcuts & Upgrades

Demo 02 (demo/02-notepad-clone) creates a text editor installer with desktop shortcuts, registry entries, and major-upgrade support.

Desktop, Start Menu, and Startup shortcuts

FalkForge provides a fluent Shortcut API. Each call targets a different location.

// Desktop shortcut with icon
package.Shortcut("FalkPad", "falkpad.exe")
    .WithIcon("payload/falkpad.ico")
    .WithDescription("Launch FalkPad text editor")
    .OnDesktop();

// Start menu shortcut under a company subfolder
package.Shortcut("FalkPad", "falkpad.exe")
    .WithIcon("payload/falkpad.ico")
    .OnStartMenu("Falk Software");

// Startup shortcut — launches on Windows login
package.Shortcut("FalkPad Startup", "falkpad.exe")
    .WithArguments("--minimized")
    .OnStartup();

The first argument is the shortcut name visible to the user. The second is the target executable. Chain OnDesktop(), OnStartMenu(), or OnStartup() to place the shortcut. You can also chain multiple locations on a single shortcut call.

How Windows shortcuts work in MSI

MSI shortcuts are not .lnk files created by your code. Windows Installer writes them to the Shortcut table in the MSI database. During installation, the installer creates the actual .lnk files in the right folders. During uninstall, it removes them automatically.

This is a key advantage over script-based installers: Windows tracks every shortcut and guarantees clean removal.

Registry entries

Use Registry to write keys and values during installation.

package.Registry(reg => reg
    .Key(RegistryRoot.LocalMachine, @"Software\FalkSoftware\FalkPad", key =>
    {
        key.Value("Version", "2.1.0");
        key.Value("InstallPath", MsiProperty.InstallDir);
        key.DWord("EditorFlags", 3);
        key.DefaultValue("FalkPad Text Editor");
    }));

Value writes a string. DWord writes a 32-bit integer. DefaultValue sets the key's (Default) entry. You can reference MSI properties like MsiProperty.InstallDir — the installer resolves them at install time.

Removing registry on uninstall

Registry entries created by the installer are removed automatically. To clean up entries created by your application at runtime, use RemoveRegistry.

package.RemoveRegistry(rr => rr
    .Id("RemoveFalkPadRegKey")
    .Root(RegistryRoot.LocalMachine)
    .Key(@"Software\FalkSoftware\FalkPad")
    .RemoveKey());

RemoveKey() deletes the entire key and its values on uninstall. This catches any values your application added after installation.

Major upgrade strategy

When you release version 2.1 to replace version 2.0, Windows needs to know it's the same product. The major upgrade pattern handles this.

package.MajorUpgrade(_ => { });
package.Downgrade(d => d.Block("A newer version is already installed."));

MajorUpgrade tells Windows Installer to uninstall the previous version before installing the new one. Downgrade(...).Block() prevents users from installing an older version over a newer one, showing the provided message instead.

Major upgrade vs patch

Windows Installer supports three update strategies:

  • Major upgrade — uninstalls the old version, installs the new one. Simplest and most common. Use for any version bump.
  • Minor upgrade — updates in-place without a full uninstall. Riskier and rarely needed.
  • Patch (MSP) — ships only the changed files. Smallest download, but complex to create and test.

For most products, major upgrade is the right choice. FalkForge makes it a one-liner.

License agreement

Demo 02 uses the InstallDir dialog set, which includes a license page. Point it at an RTF file.

package.LicenseFile = "payload/license.rtf";
package.UseDialogSet(MsiDialogSet.InstallDir);

The installer shows the license text and requires acceptance before proceeding. The file must be RTF format — that is a Windows Installer requirement.

Services & Launch Conditions

Demo 03 (demo/03-client-server) installs a client application, a Windows service, and documentation as three selectable features.

Multiple features

You saw features in the Getting Started tutorial. Here's how to define multiple named features so users can pick what to install.

p.Feature("Client", f =>
{
    f.Title = "Client Application";
    f.Description = "Desktop client with user interface";
    f.IsDefault = true;
    f.IsRequired = false;
});

p.Feature("Server", f =>
{
    f.Title = "Server Application";
    f.Description = "Background service for data processing";
    f.IsDefault = true;
    f.IsRequired = false;
});

IsDefault means the feature is selected by default. IsRequired prevents the user from deselecting it. Pair this with MsiDialogSet.FeatureTree to show a feature picker.

p.UseDialogSet(MsiDialogSet.FeatureTree);

Windows service installation

The Service API registers a Windows service. The installer creates, starts, stops, and removes it automatically.

p.Service("AcmeServer", svc =>
{
    svc.DisplayName = "Acme Server";
    svc.Description = "Acme Client-Server Suite background service";
    svc.Executable = "server.exe";
    svc.StartMode = ServiceStartMode.Automatic;
    svc.Account = ServiceAccount.LocalSystem;
    svc.Arguments = "--port=8080 --config=appsettings.json";
});

The first argument is the service name (used by sc.exe and the Services console). StartMode controls when the service starts: Automatic, Manual, or Disabled. Account sets the identity: LocalSystem, LocalService, or NetworkService.

How MSI handles Windows Services lifecycle

Windows Installer has built-in service management via the ServiceInstall and ServiceControl tables. During install, it creates the service and optionally starts it. During uninstall, it stops and deletes it. During upgrade, it stops the old service before file replacement and starts the new one after.

This is safer than scripting sc.exe commands in custom actions. The installer handles error recovery and rollback automatically. If the install fails midway, Windows Installer rolls back the service registration.

Launch conditions

Launch conditions block installation if prerequisites are missing. The installer checks them before copying any files.

p.Require(Condition.IsWindows10OrLater,
    "This application requires Windows 10 or later.");

FalkForge provides typed conditions like Condition.IsWindows10OrLater, Condition.IsPrivileged, and Condition.Is64BitOS. You can also pass a raw property name as a string.

Environment variables

Set or append system environment variables during installation.

p.EnvironmentVariable("ACME_SERVER_PORT", "8080", ev =>
{
    ev.IsSystem = true;
    ev.Action = EnvironmentVariableAction.Set;
});

Set creates or overwrites the variable. Append adds to an existing value (useful for PATH). IsSystem = true writes a machine-level variable; false writes a per-user one. The installer removes the variable on uninstall.

Demo 03 also shows how to use MsiProperty as a value — the install directory resolves at install time:

p.EnvironmentVariable("ACME_INSTALL_DIR", MsiProperty.InstallFolder, ev =>
{
    ev.IsSystem = true;
    ev.Action = EnvironmentVariableAction.Set;
});
Feature tree design

Good feature trees follow a few rules:

  • Keep it shallow. One or two levels deep. Users won't expand a five-level tree.
  • Name features for users, not developers. "Client Application" beats "ClientModule".
  • Make the required feature obvious. Mark the core feature as IsRequired = true so users can't accidentally break the install.
  • Default to useful. Set IsDefault = true for features most users want. Opt-in features like debugging tools can default to off.

File Associations & Custom Actions

Demo 04 (demo/04-dev-toolkit) builds a developer toolkit with nested features, file type registration, PATH modification, and a custom action.

Nested features

Features can contain child features. Define them inside the parent's lambda.

p.Feature("Editor", f =>
{
    f.Title = "Editor";
    f.Description = "Source code editor with syntax highlighting";
    f.IsDefault = true;

    // Nested feature: Editor Plugins
    f.Feature("EditorPlugins", child =>
    {
        child.Title = "Editor Plugins";
        child.Description = "Linter, formatter, and Git integration";
        child.IsDefault = true;
    });
});

In the feature tree UI, "Editor Plugins" appears indented under "Editor". Deselecting the parent deselects all children. Selecting a child auto-selects the parent.

File associations

Register your application as the handler for a file type.

p.FileAssociation(".falk", fa =>
{
    fa.ProgId("FalkTech.FalkProject");
    fa.Description = "Falk Project File";
    fa.Verb(ShellVerb.Open, "\"%1\"");
});

The first argument is the file extension. ProgId is a unique identifier for the association (convention: Company.AppName). Verb defines the action — ShellVerb.Open is the default double-click action, and "%1" is replaced with the file path.

How file associations work in the registry

Windows file associations live in the registry under HKEY_CLASSES_ROOT. The MSI writes several entries:

  1. .falk key — maps the extension to a ProgId.
  2. FalkTech.FalkProject key — contains the description and default icon.
  3. FalkTech.FalkProject\shell\open\command — the command line to execute.

FalkForge writes all of this from the FileAssociation call. On uninstall, the entries are removed and Windows reverts to the previous handler.

PATH modification

Add your tool's directory to the system PATH so users can run it from any command prompt.

p.EnvironmentVariable("PATH", "[INSTALLFOLDER]bin", ev =>
{
    ev.IsSystem = true;
    ev.Action = EnvironmentVariableAction.Append;
    ev.Separator = ";";
});

Append adds your directory to the existing PATH value, separated by ;. The installer removes just your entry on uninstall — it does not overwrite the entire PATH variable.

Custom actions

Custom actions run logic during the install sequence. The simplest form sets an MSI property.

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

The After property controls when the action runs. MSI has a fixed sequence of standard actions (CostInitialize, CostFinalize, InstallValidate, etc.). Your custom action slots in relative to these.

Custom action execution timing

MSI runs actions in two phases:

  • UI sequence — runs with the user's permissions. Collects input, validates conditions.
  • Execute sequence — runs with elevated permissions (if per-machine). Actually modifies the system.

SetProperty actions are safe in both sequences because they only set a value in memory. Actions that modify files or the registry must run in the execute sequence. FalkForge places SetProperty actions in the correct sequence automatically.

The Mondo dialog

Demo 04 uses MsiDialogSet.Mondo — the most full-featured built-in dialog set. It combines directory selection with a feature tree, giving the user maximum control over what gets installed and where.

p.UseDialogSet(MsiDialogSet.Mondo);

Enterprise Feature Trees

Demo 05 (demo/05-enterprise-suite) is a 500-line installer for a large product. Rather than walk through every line, let's focus on the patterns you'll reuse.

Large feature hierarchies

Demo 05 defines eight top-level features. Several have nested children. Here's the "Database Tools" branch:

pkg.Feature("DatabaseTools", db =>
{
    db.Title = "Database Tools";
    db.IsDefault = true;

    db.Feature("SqlExplorer", se => {
        se.Title = "SQL Explorer";
        se.IsDefault = true;
        se.Files(f => f.Add(...).To(dbExplorerDir));
    });

    db.Feature("SchemaDesigner", sd => { /* ... */ });

    // Opt-in: not selected by default
    db.Feature("DbProfiler", dp => {
        dp.Title = "Performance Profiler";
        dp.IsDefault = false;
    });
});

Notice that files are added directly inside the feature lambda using se.Files(...). This scopes those files to the feature — they only install when the user selects "SQL Explorer".

Feature tree design patterns

Demo 05 follows a consistent pattern worth copying:

  • One required root — the IDE feature is IsRequired = true. Every installer needs a core that always installs.
  • Default-on for common features — Web Tools, Database Tools, Cloud Tools, and Documentation all default to selected.
  • Opt-in for heavy or niche features — Mobile SDK and Diagnostics default to off. This keeps the default install size reasonable.
  • Two-level nesting maximum — Top-level categories with one level of children. Deeper trees confuse users.

Font registration

Install a font so it's available system-wide.

pkg.Font("payload/fonts/ApexMono.ttf", f =>
{
    f.Title = "Apex Mono";
});

FalkForge copies the font file to the Windows Fonts folder and registers it. The font is available to all applications immediately after install.

Custom tables

Store arbitrary metadata inside the MSI database. Custom actions or external tools can read it later.

pkg.CustomTable(ct =>
{
    ct.Name("ApexComponents");
    ct.Column("ComponentId", CustomTableColumnType.String,
        c => c.PrimaryKey().Width(72));
    ct.Column("Category", CustomTableColumnType.String,
        c => c.Width(64));
    ct.Column("Priority", CustomTableColumnType.Int32);

    ct.Row(r => r
        .Set("ComponentId", "apex.core.dll")
        .Set("Category", "IDE")
        .Set("Priority", 1));
});

Define columns with types and constraints, then add rows. The table becomes part of the MSI database and survives installation — you can query it with forge inspect.

When to use custom tables

Custom tables are useful for:

  • Metadata for custom actions — store configuration that your DLL custom action reads at install time.
  • Audit and tracking — embed component manifests, version mappings, or dependency metadata.
  • Extension data — FalkForge extensions (Firewall, IIS, SQL) use custom tables internally to store their configuration.

Avoid using custom tables for data that changes at runtime. The MSI database is read-only after installation. For runtime configuration, use registry entries or config files instead.

The Advanced dialog

Demo 05 uses MsiDialogSet.Advanced — the most complete built-in dialog. It adds Typical, Custom, and Complete install types on top of directory and feature selection. Users who want a quick install pick Typical; power users pick Custom to fine-tune features.

pkg.UseDialogSet(MsiDialogSet.Advanced);

Recap

Concept Demo Key API
Shortcuts (Desktop, Start Menu, Startup) 02 Shortcut().OnDesktop() / OnStartMenu() / OnStartup()
Registry entries 02 Registry(), RemoveRegistry()
Major upgrade 02 MajorUpgrade(), Downgrade().Block()
License agreement 02 LicenseFile, UseDialogSet(InstallDir)
Multiple features 03 Feature(), UseDialogSet(FeatureTree)
Windows services 03 Service()
Launch conditions 03 Require()
Environment variables 03 EnvironmentVariable()
Nested features 04 f.Feature() inside parent
File associations 04 FileAssociation()
PATH modification 04 EnvironmentVariable("PATH", ..., Append)
Custom actions 04 CustomAction()
Feature hierarchy 05 Feature() with nested Feature()
Font registration 05 Font()
Custom tables 05 CustomTable()
Advanced dialog 05 UseDialogSet(Advanced)

Inspecting Your Installer

Want to see what files are inside an MSI without installing it? Use the extract command:

forge extract myapp.msi -o D:\Temp\extracted

This extracts all files to disk, mirroring the directory structure as it would be installed. For bundles containing multiple MSIs:

# List packages in a bundle
forge extract suite.exe --list

# Extract all packages
forge extract suite.exe -o D:\Temp\output

# Extract specific packages
forge extract suite.exe -o D:\Temp\output --package ServerMsi
Bundle self-extraction

FalkForge bundles can extract themselves without the forge CLI tool:

myapp.exe --extract-list
myapp.exe --extract D:\Temp\output
myapp.exe --extract D:\Temp\output --package ServerMsi

This bypasses the installer UI entirely โ€” no elevation, no detection, just extraction. Useful for IT teams inspecting packages before deployment.

WinGet Distribution

Want to distribute your installer via winget install? FalkForge can generate WinGet manifests automatically:

package.WinGet(w => w
    .PackageIdentifier("Contoso.MyApp")
    .License("MIT")
    .ShortDescription("A tool for doing things")
    .InstallerUrl("https://releases.contoso.com/myapp/1.0.0/MyApp.msi"));

This generates three YAML manifest files alongside your MSI โ€” the format required by the winget-pkgs repository. Version, publisher, product code, and SHA-256 are auto-populated from your package definition.

Generating manifests from existing MSIs

Don't have the source? Use the CLI to generate manifests from any MSI file:

forge winget myapp.msi --id Contoso.MyApp --license MIT --desc "A tool" -o ./manifests

This reads the MSI metadata (name, version, manufacturer, product code) and produces the three-file manifest set ready for submission.

What's next