Tutorials

Custom MSI Dialogs

15 min read ยท Prerequisite: MSI Basics ยท Wraps demo/65-custom-dialog

The stock dialog sets (Minimal, InstallDir, Mondo, …) cover most installers. But sometimes you need a screen the stock templates don't have — a license-key entry field, a survey question, a custom EULA layout. PackageBuilder.AddCustomDialog lets you author a complete MSI dialog from scratch: your own controls, your own navigation, your own validation — no WiX, no XML, no reference to the compiler project.

This tutorial builds the exact dialog in demo/65-custom-dialog one call at a time, explaining every Windows Installer concept — dialog units, tab order, property binding, control events — as it comes up. You don't need any prior MSI UI knowledge.

Start from an empty package

A FalkForge installer is a call to Installer.Build with a callback that configures a PackageBuilder. Strip away the dialog and demo 65 is a completely ordinary single-file installer:

return Installer.Build(args, package =>
{
    package.Name = "Custom Dialog Demo";
    package.Manufacturer = "Demo";
    package.Version = new Version(1, 0, 0);

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

    // the dialog goes here
}, new MsiCompiler());

No UseDialogSet call, no DialogSet at all. Left as-is, this package compiles with no UI — the MSI installs silently. Everything from here on is one package.AddCustomDialog(…) call that adds a single screen.

Open the dialog

package.AddCustomDialog("LicenseKeyDlg", dlg => dlg
    // … configuration and controls go here, chained off dlg
);

AddCustomDialog takes an Id ("LicenseKeyDlg" — must be a valid MSI identifier: starts with a letter or underscore, then only letters, digits, underscores, and periods) and a configuration callback. The callback receives a CustomDialogBuilder; every method on it returns the same builder, so the whole dialog reads as one fluent chain.

Nothing about this call touches FalkForge.Compiler.Msi. CustomDialogBuilder lives in FalkForge.Core and produces a plain CustomDialogModel the compiler translates into MSI Dialog/Control/ControlEvent/ControlCondition rows when the package builds.

Title, size, and sequence

dlg
    .Title("Custom Dialog Demo โ€” License")
    .Size(370, 270)
    .Sequence(1100)                 // Show this dialog as the first install-UI screen.

Title sets the window caption. Size(370, 270) is the MSI standard dialog size — and it's the default even if you don't call Size at all, so this line is here for clarity, not necessity.

Deep dive: dialog units aren't pixels

Every coordinate and size you'll pass to a control — X, Y, Width, Height — is in dialog units (DLU), not pixels. A DLU is defined relative to the dialog's font: roughly one quarter of the average character width horizontally, one eighth of the character height vertically. Windows Installer converts DLU to pixels at render time using the actual font metrics on the machine showing the dialog.

This is why the standard dialog is 370 × 270 and not some pixel figure: it looks right regardless of the user's DPI setting or system font size, because the conversion happens on their machine, not at compile time. When you lay out a control at x: 15, y: 180, think of it as "15/270ths of the way across, 180/270ths of the way down" — not an absolute pixel offset.

.Sequence(1100) is what makes this dialog actually appear. Without it, LicenseKeyDlg would be a fully valid dialog that nothing ever shows — reachable only if some other dialog's NewDialog/SpawnDialog event targeted it. Setting a sequence number places the dialog's Id into the InstallUISequence table, which is what Windows Installer walks to decide the first screen of the install UI. 1100 is the conventional first-dialog slot every stock FalkForge template also uses.

Tab order anchors

dlg
    .FirstControl("AcceptCheck")
    .CancelControl("CancelButton")
    .DefaultControl("InstallButton")

These three calls name controls we haven't added yet — that's fine, FalkForge only checks that the names resolve when the package is validated, after the whole dialog is authored. They set up three independent keyboard anchors:

None of these three by themselves create the Tab-key cycle — that's a separate, per-control chain covered in a moment.

Adding controls, one at a time

Every control adder on CustomDialogBuilder takes the same shape: a unique name, then x, y, width, height in dialog units, then whatever data the control type needs, then an optional configure callback for anything beyond the basics (tab order, events, conditions, appearance bits).

Header text and separator

dlg
    .Text("Title", 15, 15, 340, 15, "Please review and accept the license",
        b => b.Attributes(0x00030003)) // Visible | Enabled | Transparent | NoPrefix
    .Line("HeaderLine", 0, 40, 370)

Text is a static, non-interactive label — no property, nothing to bind. It sits at (15, 15), is 340 DLU wide (leaving a 15-unit margin on each side of the 370-wide dialog) and 15 tall. The configure callback here calls .Attributes(0x00030003) directly with a raw bitmask instead of using the named helpers (Transparent(), NoPrefix()) — both produce the identical Control row; the raw form is shown here because that's what the demo does, and it's worth knowing both work.

Line draws a horizontal etched separator. It only takes a name, position, and width — no height parameter, because a line's height is always 0 in the MSI Control table.

The license body

dlg
    .ScrollableText("LicenseBody", 15, 50, 340, 120,
        "This is a demonstration end-user license agreement.\n\n" +
        "You may use this sample installer for evaluation purposes.",
        b => b.Sunken())

ScrollableText is a read-only, multi-line, scrollable text area — the standard control for a license body. It carries its text directly (not a bound property: nothing writes back into it). b.Sunken() sets the appearance bit that draws the classic sunken 3-D border MSI license boxes use, so it reads visually as a distinct, contained region rather than plain text floating on the dialog.

The accept check box

dlg
    .CheckBox("AcceptCheck", 15, 180, 340, 12,
        property: "ACCEPTEULA",
        text: "I &accept the terms in the License Agreement",
        configure: b => b.Next("KeyEdit"))

This is the first data-bound control: property: "ACCEPTEULA" means the check box reads and writes the MSI property ACCEPTEULA — ticked means "1", unticked means empty. Nothing else in the dialog has to poll the check box; anything that needs to know whether the license was accepted just reads that property, which is exactly what the Install button's condition will do shortly.

b.Next("KeyEdit") is the first link in the tab-order chain: this is what makes FirstControl("AcceptCheck") from earlier actually lead somewhere. Control_Next is a linked list, not a Z-order — a control you never point to or set as FirstControl is simply unreachable by Tab.

The license key field

dlg
    .Text("KeyPrompt", 15, 200, 120, 12, "License &key:")
    .Edit("KeyEdit", 135, 198, 220, 16,
        property: "LICENSEKEY",
        configure: b => b.Next("InstallButton"))

A plain Text label ("License &key:") sits next to an Edit field bound to LICENSEKEY. The two share a row visually (y: 200 and y: 198, close enough to align) but only the Edit is in the tab chain — static text is never a tab stop. Next("InstallButton") continues the chain: AcceptCheck → KeyEdit → InstallButton so far.

The buttons

dlg
    .PushButton("InstallButton", 210, 240, 66, 17, "&Install", b => b
        .Next("CancelButton")
        .EndDialog("Return")                       // proceed with the installation
        .DisableWhen("ACCEPTEULA <> \"1\""))       // gated on the accept check box
    .PushButton("CancelButton", 289, 240, 66, 17, "Cancel", b => b
        .Next("AcceptCheck")
        .EndDialog("Exit")));

CancelButton.Next("AcceptCheck") closes the tab loop: AcceptCheck → KeyEdit → InstallButton → CancelButton → AcceptCheck. Tab cycles forever through all four interactive controls; Shift+Tab walks it backwards. This is also why FirstControl("AcceptCheck") and CancelControl("CancelButton") from earlier line up with real controls now that the whole dialog is authored.

Events: what a click actually does

Each button chains two more calls after Next — one event, one condition. Take them separately.

.EndDialog("Return") on the Install button and .EndDialog("Exit") on the Cancel button are both ControlEvent rows with the EndDialog verb. EndDialog closes the entire dialog chain — there's nothing to navigate back to. The argument is the outcome: Return tells Windows Installer the UI sequence finished successfully and it should proceed to install; Exit tells it the user backed out, and the install does not proceed. (The other two standard arguments, Retry and Ignore, matter for dialogs shown during error handling, not here.)

Contrast this with the navigation verbs you'd use in a multi-dialog flow: NavigateTo(dialogId) emits NewDialog, which closes the current dialog and opens another one in its place (a Next/Back step); SpawnDialog(dialogId) emits SpawnDialog, which opens a second dialog on top of the current one — used for things like an "Are you sure you want to cancel?" prompt, where the parent dialog is still there underneath once the child closes. This demo only has one dialog, so it only ever needs EndDialog.

Deep dive: why events need a condition at all

Every ControlEvent row has a condition column. If you don't pass one — and neither button here does — FalkForge compiles the canonical always-true condition "1", so the event fires unconditionally on click. You'd supply a real condition on an event (not a ControlCondition — that's a separate table, covered next) when the same button needs to do different things depending on state, for example firing a DoAction custom action only when a certain property is set, and an EndDialog otherwise. This demo doesn't need that: both buttons always do exactly one thing when clicked, so both events use the default "1".

Conditions: gating the Install button

.DisableWhen("ACCEPTEULA <> \"1\"") is a completely different mechanism from the event above it. It's a ControlCondition row: not something that fires once on a click, but a rule Windows Installer re-evaluates continuously as properties change. As long as ACCEPTEULA is not "1" — the check box unticked — the Install button stays disabled (greyed out, unclickable). The instant the user ticks AcceptCheck, ACCEPTEULA becomes "1", the condition goes false, and the button re-enables itself with no custom action, no page refresh, nothing else involved.

This is the same pattern the stock FalkForge templates use for their own license pages (LicenseAccepted = "1" gating Next) — DisableWhen is just the fluent form of it, available on any control you author yourself. The companion verbs are ShowWhen/HideWhen (visibility) and EnableWhen (the inverse of DisableWhen) and DefaultWhen (restores the control's initial state); all five compile to the same ControlCondition table with a different Action value.

Build it

dotnet run --project demo/65-custom-dialog

This compiles the installer definition and produces a .msi file. Before it writes a single MSI row, FalkForge validates the whole authored dialog: every Id and Name must be a well-formed MSI identifier and unique in its scope, Control_Next/FirstControl/DefaultControl/CancelControl must all resolve to real controls, and every data-bound control (like AcceptCheck and KeyEdit here) must actually have a property. Get any of that wrong and the build fails loud with a specific DLG01x/DLG02x diagnostic instead of silently producing a broken dialog — see the Validation table in the reference manual for the full list.

What it looks like at install

Run the compiled MSI and LicenseKeyDlg is the very first thing you see — that's the .Sequence(1100) from earlier. The screen shows the header text and separator, the sunken scrollable license body, the "I accept the terms…" check box, a "License key:" label next to an edit field, and two buttons in the bottom-right corner. Install starts disabled (greyed out) because ACCEPTEULA is empty. Tab cycles focus through check box → key field → Install → Cancel → back to check box; Esc always triggers Cancel; Enter triggers whichever of Install/Cancel currently has focus behavior wired as default (InstallButton, once it's enabled).

Tick the check box: ACCEPTEULA flips to "1", the DisableWhen condition re-evaluates, and Install lights up — no page reload, because there was never a page to reload. Click Install and the dialog fires EndDialog("Return"): the UI sequence ends successfully and the file copy proceeds. Click Cancel instead (at any point, check box ticked or not) and EndDialog("Exit") ends the sequence without installing anything.

Recap

Building a custom dialog is always the same shape:

  1. AddCustomDialog(id, dlg => …) — opens the builder for one dialog.
  2. Configure the dialogTitle, Size, Sequence (to make it appear), FirstControl/DefaultControl/CancelControl (keyboard anchors).
  3. Add controls — each one named, positioned in dialog units (not pixels), bound to a property if it carries data.
  4. Chain tab order — every interactive control's Next(…) points at the next one, closing the loop back to the first.
  5. Wire events — what happens on click: navigate, spawn a child, run an action, or end the dialog.
  6. Wire conditions — what the control's state should be as a function of live property values, re-evaluated continuously.

The full source is in demo/65-custom-dialog/Program.cs. For the complete authoring surface — every control adder, every event verb, every condition verb, and the full DLG010DLG022 validation reference — see Authoring a Custom Dialog in the manual.