Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Installway builds single-file .exe installers for Windows. You point it at a directory of files, it produces a signed, self-contained setup executable that your users can double-click. No MSI runtime, no proprietary scripting language, no admin rights required.

Every installer you ship is one file that carries everything:

  • The file payload (a zip) is appended to the executable as a PE overlay, so there is no size ceiling. It is streamed on at build time and memory-mapped at install time.
  • The signed manifest, the uninstaller, and the payload length are embedded as small RT_RCDATA resources.
  • Your application's icon and a Win32 version-info resource are stamped on, so the setup file looks finished in Explorer.

Why Installway

  • One file, zero setup. Everything the install needs is inside the .exe.
  • Verified, not just copied. The payload is signed with Ed25519 and every file is hash-checked with BLAKE3 before it touches disk.
  • Small updates. Patch installers ship binary diffs between versions instead of full re-downloads.
  • Crash-safe. Installs are transactional, with rollback and power-loss recovery. A failed install never leaves a broken app behind.
  • No admin rights needed. Per-user installs get shortcuts, file associations, and an entry in Windows Apps without an elevation prompt. Machine-wide installs are supported too, with a single UAC prompt.
  • Extensible with native code. Custom install logic is a plain Windows DLL written in C, C++, Rust, or anything with a C ABI. It is readable, debuggable, and scannable by antivirus engines like any other binary.

Security model

Each installer carries overlapping guarantees:

  1. Ed25519 signature over the exact JSON bytes that describe the payload. The public key is compiled into the installer stub at build time, never shipped as a swappable resource.
  2. BLAKE3 hash of the payload zip, recorded in the signed manifest and re-verified before a single byte is extracted.
  3. BLAKE3 hash per file, checked after each write or patch apply.
  4. Version floor via min_installer_version: a stub that is too old refuses the payload.
  5. Patch pinning: a patch installer refuses to run unless the installed version matches its from_version.

Authenticode is not handled in code. You sign the final .exe with signtool as a post-build step, and the builder prints the exact command. See Authenticode signing.

The workspace

CrateTypePurpose
commonlibManifest types, BLAKE3 hashing, file scan, HDiffPatch wrapper, shared helpers.
installer_builderbinThe offline build tool. Generates Ed25519 keypairs and packs a directory (or a from/to pair) into a self-contained installer .exe.
installerbinThe installer stub. Verifies the signature, checks per-file hashes, and either extracts the payload or applies HDiffPatch deltas in place.
uninstallerbinBuilt by the builder and embedded in the installer. It lives outside the app folder and registers the product in Windows Apps.

How these docs are organized

  • Getting started takes you from a clean checkout to a working, verified installer.
  • Building installers covers the build tool in depth: full and patch installers, the config file, and packaging on machines without a Rust toolchain.
  • Customizing the installer covers everything you can declare at pack time: branding, wizard behavior, shortcuts, file associations, registry keys, feature packs, and plugins.
  • Shipping covers Authenticode signing and optional install analytics.
  • Installing and uninstalling describes what the built installer does at runtime: the three install modes, per-user versus machine-wide installs, and uninstall.
  • Reference has the complete tables: every CLI flag, exit code, and the payload format.

Start with the Quickstart.

Quickstart

This page takes you from a clean checkout to a signed, verified installer in four steps. You need a Windows machine with a Rust toolchain. (Packaging machines without Rust are covered in Packaging without the Rust toolchain.)

1. Build the build tool

Everything starts from one tool: installer_builder. Build it once from the workspace root:

cargo build --release -p installer_builder

The binary lands at target\release\installer_builder.exe. It has two subcommands:

CommandWhat it does
keygenGenerate an Ed25519 signing keypair.
packPack a directory into a self-contained installer .exe.

You do not build the installer stub yourself. By default, pack runs cargo build for the stub and the uninstaller on demand, with your public key compiled in.

2. Generate a signing key

Every installer is signed. Generate a keypair once per product:

.\target\release\installer_builder.exe keygen --out .\keys

This writes keys\priv.key and keys\pub.key. Keep priv.key secret and back it up: there is no recovery if you lose it. See Signing keys for how the two keys work together and how to manage them.

3. Pack your first installer

Point pack at the directory that contains your application's files:

.\target\release\installer_builder.exe pack `
    --product    "My App" `
    --product-id myapp `
    --publisher  "My Company" `
    --to-version 1.0 `
    --input      .\build\myapp-1.0 `
    --exe        myapp.exe `
    --priv-key   .\keys\priv.key `
    --pub-key    .\keys\pub.key `
    --out        .\dist\setup-myapp-1.0.exe

pack scans the input directory, hashes and compresses every file, signs the manifest, builds the stub and uninstaller, and assembles the final .exe. It finishes by running the installer's own --verify as a self-check, so a broken build fails here instead of shipping.

Each option is explained in Full installers. Once the command line grows, move the options into a TOML file and pass --config instead. See The config file.

4. Test the result

Verify the embedded payload without installing anything:

.\dist\setup-myapp-1.0.exe --verify

Then double-click the .exe (or run it from a shell) to walk through the wizard. To try a scripted install:

.\dist\setup-myapp-1.0.exe --silent

Next steps

  • Sign the .exe. Before distributing, apply an Authenticode signature with your code-signing certificate. The builder prints the exact signtool command after each pack. See Authenticode signing.
  • Ship updates as patches. A patch installer carries only the delta between two versions. See Patch installers.
  • Customize the installer. Add a license, a header banner, shortcuts, file associations, and more. Start with Branding.

Optional: hdiffz.exe for patch deltas

Patch installers can ship small binary deltas instead of whole files. Delta generation requires hdiffz.exe next to installer_builder.exe:

target\release\installer_builder.exe
target\release\hdiffz.exe            <- drop it here

Without hdiffz.exe, the builder still produces working patch installers. It falls back to shipping each changed file in full and prints a warning. See Patch installers.

Signing keys

Every installer is signed with an Ed25519 private key, and the matching public key is compiled into the installer stub. At runtime, the stub verifies the payload signature against its baked-in key before touching the disk. This page covers generating, using, and protecting that keypair.

Generate a keypair

.\target\release\installer_builder.exe keygen --out .\keys

This writes two hex-encoded files:

keys\priv.key   KEEP SECRET
keys\pub.key
  • priv.key signs the payload at pack time. Anyone holding it can produce installers that your stubs will accept. Guard it like a code-signing key.
  • pub.key is compiled into every installer stub through the INSTALLER_PUB_KEY build-time environment variable. It is not secret.

The two keys are bound together

A stub built with a given pub.key only accepts payloads signed by the matching priv.key. If they do not match, the installer rejects its own payload at runtime.

pack protects you from shipping such a build: after producing the .exe, it runs the installer's own --verify as a self-check and fails the build on a mismatch. The mismatch mostly matters in toolchain-free packaging, where the packager receives a priv.key that must pair with the public key already baked into the prebuilt stub.

Passing keys in CI

Both keys can be passed as hex strings instead of file paths, which is convenient for CI/CD secret stores:

installer_builder.exe pack --config .\pack.toml `
    --priv-key-literal $env:MYAPP_PRIV_KEY
  • --priv-key-literal <hex> replaces --priv-key <file>. The two are mutually exclusive.
  • --pub-key-literal <hex> replaces --pub-key <file> the same way.

The config file accepts the same values as priv_key_literal and pub_key_literal, but a hex private key in a committed file defeats the purpose. Prefer injecting it from your CI secret store on the command line.

If you lose the private key

There is no recovery. Lose priv.key and every installer signed with it must be re-issued from a stub rebuilt with a fresh pub.key. Back the key up offline.

One key per product

Use a distinct keypair per product line. A leak then only affects that one product, and you can rotate the key without re-issuing unrelated installers.

Relationship to Authenticode

The Ed25519 signature protects the payload inside the .exe. It does not make Windows trust the file: SmartScreen and antivirus reputation come from an Authenticode signature, which you apply to the final .exe with signtool as a separate step. See Authenticode signing.

Full installers

A full installer carries every file of one product version. Run it on a clean machine or over any existing install: it writes the complete set.

.\target\release\installer_builder.exe pack `
    --product    "My App" `
    --product-id myapp `
    --publisher  "My Company" `
    --to-version 1.0 `
    --input      .\build\myapp-1.0 `
    --exe        myapp.exe `
    --priv-key   .\keys\priv.key `
    --pub-key    .\keys\pub.key `
    --out        .\dist\setup-myapp-1.0.exe

Required options

Supply each of these on the CLI or in a config file:

OptionDescription
--productDisplay name. Shown in the wizard, in Windows Apps, in the version-info resource, and as the default shortcut label.
--product-idRegistry-safe internal id, distinct from --product. Drives the Uninstall registry key, association ProgIDs, the uninstall data folder, and upgrade detection. Must match ^[A-Za-z][A-Za-z0-9._-]{0,49}$. Keep it stable across versions.
--publisherVendor name. Sets the "Publisher" field in Windows Apps and the uninstall data folder %LOCALAPPDATA%\<publisher>\Uninstall\<product-id>. Must not be empty.
--to-versionVersion string, such as 1.0 or 1.2.3. Also parsed as a.b.c.d for the version-info resource.
--inputDirectory containing the files to install. Scanned recursively.
--priv-keyEd25519 private key that signs the payload. Or pass --priv-key-literal <hex> instead; see Signing keys.
--outOutput installer path. Parent directories are created.

--pub-key is also required, unless you pass a prebuilt stub. See Packaging without the Rust toolchain.

The main executable

--exe names your application's main executable, relative to --input, for example myapp.exe or bin\myapp.exe. It drives:

  • the "Run program now" checkbox on the wizard's final page and the --launch flag,
  • the icon inherited by the setup .exe and the uninstaller,
  • the target of file associations,
  • the %EXE% token in shortcuts and registry entries.

--exe is technically optional. Omit it only if your product has no executable at all: without it there is no launch option, no icon inheritance, and file associations and %EXE% tokens cannot resolve.

What pack does, in order

  1. Loads the signing key and validates arguments. It rejects an empty --publisher, an invalid --product-id, and case-only filename collisions that would clash on NTFS.
  2. Scans --input, hashes every file with BLAKE3, and compresses the set into the payload zip. Already-compressed media formats are stored verbatim; everything else is compressed with zstd at level 19.
  3. Builds the signed manifest and metadata, and signs the exact JSON bytes with Ed25519.
  4. Produces the installer stub and the uninstaller, either through cargo build or from a prebuilt kit, and copies the stub to --out.
  5. Embeds the signed manifest, the icon-stamped uninstaller, and a version-info resource, then appends the payload zip as a PE overlay.
  6. Self-verifies by running the produced installer's own --verify. If the stub rejects the payload, for example because a prebuilt stub's key does not match --priv-key, the build fails here instead of shipping a broken installer.
  7. Prints the signtool command for the Authenticode signing step.

The output is written to a .tmp path and renamed at the end, so a failed build never leaves a half-written setup file at --out.

Inspect the result

.\dist\setup-myapp-1.0.exe --verify

This verifies the embedded payload and prints its kind, versions, and size without installing anything.

Patch installers

A patch installer carries only what changed between two versions: binary deltas for modified files (or full bytes where a delta would be larger), plus the list of files to delete. Unchanged files have no payload entry at all, so a patch is typically a fraction of the size of a full installer.

You enter patch mode by passing both --from-version and --from-dir:

.\target\release\installer_builder.exe pack `
    --product      "My App" `
    --product-id   myapp `
    --publisher    "My Company" `
    --from-version 1.0 --from-dir .\build\myapp-1.0 `
    --to-version   1.1 --input    .\build\myapp-1.1 `
    --exe          myapp.exe `
    --priv-key     .\keys\priv.key `
    --pub-key      .\keys\pub.key `
    --out          .\dist\patch-myapp-1.0-to-1.1.exe

--from-version and --from-dir are required together. Passing only one is an error. Everything else works exactly as for a full installer, including the config file and toolchain-free mode.

How the payload is chosen, per file

For each file in the new version:

CaseWhat ships
New file (absent from the old directory)The full file.
Unchanged (same BLAKE3 hash as the old file)Nothing. The installer keeps the file already on disk.
ChangedThe builder runs hdiffz to produce a delta. If the delta is smaller than the full file, the delta ships; otherwise the full file does.

Files present only in the old version are recorded in deleted_files and removed at install time.

hdiffz.exe is required for real deltas

Delta generation calls hdiffz.exe, which must sit next to installer_builder.exe. If it is missing, the builder prints:

warning: ...\hdiffz.exe not found - patch payload will ship full files instead of HDiffPatch deltas

The patch installer still works; it just is not smaller than a full one.

Version pinning

A patch installer records its from_version and refuses to run unless the target install's version.json matches. This refusal happens before anything is touched: the existing install keeps working, and in silent mode the process exits with code 10 so a launcher can fall back to the full installer. See Exit codes.

Dev option: force a reinstall from scratch

--force-reinstall (valid on full and patch builds) produces an installer that skips the from-version check, rewrites every file without hash-skipping, and removes orphan files, so the install matches the build exactly. It stays fully transactional. Use it during development; ship normal installers to users.

The config file

pack takes a long command line. Put the options in a TOML file instead and pass --config:

# everything from the file
.\target\release\installer_builder.exe pack --config .\pack.toml

# file as a base, override one value on the CLI
.\target\release\installer_builder.exe pack --config .\pack.toml --to-version 1.1

Keys are flat and snake_case, matching the CLI long names. Unknown keys are rejected, which catches typos. Booleans are true or false.

Merge rules

CLI arguments override the file. Anything absent from both uses the built-in default, or fails with a message naming the missing key if it is required.

  • Scalars and paths. The CLI value wins; otherwise the file value; otherwise the default.
  • The assoc list. A CLI --assoc list replaces the file's list entirely when given. It does not merge.
  • Booleans (force_reinstall, purge_unknown_files, skip_license, skip_path, upgrade_minimal_ui, show_uninstall_complete, reuse_stub). Either source can turn them on.
  • Tables ([[shortcut]], [[registry]], [[plugin]], [[feature]]) and feature_mode are config-file only. There are no CLI equivalents.

Required keys

Via CLI or file: product, product_id, publisher, to_version, input, out, and exactly one of priv_key / priv_key_literal. In toolchain mode, one of pub_key / pub_key_literal is also required. An invalid product_id fails the build; see Full installers.

Key reference

Identity and content

KeyTypeDescription
productstringDisplay name.
product_idstringRegistry-safe id, stable across versions.
publisherstringVendor name.
hintway_tenant_idstringOptional Hintway tenant UUID. Enables the Hintway build in toolchain mode; see Install analytics.
to_versionstringVersion being packaged.
inputpathDirectory of files to install.
exestringMain executable, relative to input.
outpathOutput installer path.

Signing and stub

KeyTypeDescription
priv_keypathEd25519 private key file.
priv_key_literalstringPrivate key as 64 hex chars. Mutually exclusive with priv_key.
pub_keypathPublic key file, compiled into the stub. Ignored in toolchain-free mode.
pub_key_literalstringPublic key as 64 hex chars. Mutually exclusive with pub_key.
installer_stubpathPrebuilt installer.exe. Switches to toolchain-free mode; requires uninstaller.
uninstallerpathPrebuilt uninstall.exe, paired with installer_stub.
reuse_stubboolSkip rebuilding the stub and uninstaller when they already exist (toolchain mode).

Patch mode

KeyTypeDescription
from_versionstringPrevious version. Required together with from_dir.
from_dirpathPrevious version's files, for delta generation.

Wizard and install behavior

KeyTypeDefaultDescription
licensepathbuilt-in placeholderUTF-8 EULA text shown on the License page.
bannerpathflat gray headerPNG painted across the wizard header. See Branding.
assocarray[]File associations, entries of the form ".ext:Description".
default_install_dirstring%LOCALAPPDATA%\Programs\<product>Install path the UI proposes. %VAR% env tokens are expanded.
skip_licenseboolfalseHide the License page.
skip_pathboolfalseHide the Choose-location page.
install_dir_restrictionstringenforceWhether a fresh interactive install may target a non-empty folder: enforce, default_dir_only, or bypass. See Wizard pages and install location.
launch_optionstringcheckedThe "launch now" checkbox on the final page: checked, unchecked, or hidden.
upgrade_minimal_uiboolfalseUpgrades use the compact minimal UI; a first install still gets the wizard.
show_uninstall_completeboolfalseShow a confirmation message box at the end of an interactive uninstall.
min_installer_versionstring1.0.0Minimum installer stub version allowed to run this payload.
purge_unknown_filesboolfalseOn a full install over an existing copy, remove files not in this build. Ignored for patches.
force_reinstallboolfalseDev: rewrite all files, remove orphans, skip the from-version check.
feature_modestringstickyHow an upgrade seeds the active feature set: sticky or override. See Feature packs.

Tables

Declared as arrays of tables. Standard TOML ordering applies: put all flat keys above them, then the table blocks at the end of the file.

TablePurpose
[[shortcut]]Shortcuts to create. See Shortcuts.
[[registry]]Free-form registry entries. See Registry keys.
[[plugin]]Native DLL plugins. See Plugins.
[[feature]]Feature packs mapping path globs to a feature id. See Feature packs.

Complete example

# pack.toml
product    = "My App"
product_id = "myapp"
publisher  = "My Company"
hintway_tenant_id = "your-tenant-id" # optional
to_version = "1.0"
input      = "build/myapp-1.0"
exe        = "myapp.exe"
out        = "dist/setup-myapp-1.0.exe"

priv_key = "keys/priv.key"
pub_key  = "keys/pub.key"

license = "legal/EULA-myapp-en.txt"
banner  = "branding/header-1400x144.png"
assoc   = [".myx:MyApp Document", ".myz:MyApp Archive"]

default_install_dir = "%LOCALAPPDATA%\\Programs\\MyApp"
launch_option       = "checked"

# Patch mode: uncomment to build a patch instead of a full installer.
# from_version = "0.9"
# from_dir     = "build/myapp-0.9"

# Toolchain-free mode: point at prebuilt binaries instead of cargo builds.
# pub_key above is then ignored; the stub carries its own baked-in key.
# installer_stub = "kit/installer.exe"
# uninstaller    = "kit/uninstall.exe"

[[shortcut]]
dir    = "%DESKTOP%"
name   = "%PRODUCT%"
target = "%EXE%"

[[shortcut]]
dir    = "%START_MENU%"
name   = "%PRODUCT%"
target = "%EXE%"

[[registry]]
hive  = "HKCU"
key   = "%APP_KEY%"
name  = "InstallDir"
type  = "sz"
value = "%INSTALL_DIR%"

Packaging without the Rust toolchain

installer_builder pack needs two binaries to assemble an installer: the installer stub (installer.exe) and the uninstaller (uninstall.exe). There are two ways to get them, and choosing between them is the main decision when you set up a packaging pipeline.

Toolchain mode (default)Toolchain-free mode (prebuilt kit)
Stub and uninstallerBuilt on demand by cargo buildSupplied as prebuilt .exe files
Needs Rust and the source treeYes, on the packaging machineNo
Public keyPassed as --pub-key, compiled in per buildAlready baked into the prebuilt stub
How you select itNothing; this is the defaultPass --installer-stub and --uninstaller
Who runs itYou or your CI, where Rust livesAnyone on any Windows machine

Both modes produce equivalent installers as far as the end user is concerned. Icon stamping, version info, the overlay payload, the signature, and Authenticode signing all behave identically. The only difference is where the stub comes from and whether the packaging machine needs Rust.

Toolchain mode (default)

This is what every example in Full installers and Patch installers uses. You pass --pub-key, and pack invokes cargo build to produce a fresh stub with that public key compiled in, plus the uninstaller:

.\target\release\installer_builder.exe pack `
    --product   "My App" --product-id myapp --publisher "My Company" --to-version 1.0 `
    --input     .\build\myapp-1.0 --exe myapp.exe `
    --priv-key  .\keys\priv.key `
    --pub-key   .\keys\pub.key `
    --out       .\dist\setup-myapp-1.0.exe

Under the hood, pack runs from the workspace root:

# installer stub, with your public key threaded in as a build-time env var
cargo build -p installer --release      # INSTALLER_PUB_KEY=<pub.key>
# uninstaller
cargo build -p uninstaller --release

When pack.toml contains hintway_tenant_id, pack also enables the hintway feature on both builds. See Install analytics.

This mode requires a working Rust toolchain and the Installway source tree on the machine running pack.

Speed up repeat builds

Add --reuse-stub to skip rebuilding the stub and uninstaller when target\release\installer.exe and uninstall.exe already exist:

.\target\release\installer_builder.exe pack --config .\pack.toml --reuse-stub

Use it in a loop where the key has not changed; it turns a cargo rebuild into a copy. Drop the flag whenever you change the public key, so the stub is rebuilt with the new key.

Toolchain-free mode (prebuilt kit)

To let someone package versions without installing Rust (a release engineer, a build server, a CI job with no Rust step), build the binaries once yourself and hand over a kit. The packager then runs pack pointing at those prebuilt binaries.

Step 1: build the kit, once, with the toolchain

Bake your public key into the stub and build all three binaries:

$env:INSTALLER_PUB_KEY = (Get-Content .\keys\pub.key).Trim()
cargo build --release -p installer -p uninstaller -p installer_builder

Collect a kit folder containing:

kit\
    installer_builder.exe   the packer
    installer.exe           the stub, with YOUR pub.key compiled in
    uninstall.exe           the uninstaller
    priv.key                the signing key (KEEP SECRET)
    hdiffz.exe              optional, for patch deltas

priv.key in the kit is sensitive: whoever holds it can sign installers that your stubs will accept. Hand the kit only to trusted packagers, over a secure channel.

Step 2: pack, anytime, on any Windows machine

.\installer_builder.exe pack `
    --product   "My App" --product-id myapp --publisher "My Company" --to-version 1.0 `
    --input     .\files --exe myapp.exe `
    --installer-stub .\installer.exe `
    --uninstaller    .\uninstall.exe `
    --priv-key       .\priv.key `
    --out            .\setup-myapp-1.0.exe

What differs from toolchain mode:

  • --installer-stub and --uninstaller point at the prebuilt binaries. Passing them switches pack into toolchain-free mode; it never invokes cargo. The builder prints "Toolchain-free mode: using prebuilt binaries (no cargo build)".
  • --pub-key (and the pub_key config key) is ignored. The public key is already compiled into installer.exe, and pack warns if you pass one.
  • --priv-key must match the public key baked into the supplied stub. pack checks this; see below.

Patch builds work identically: add --from-version and --from-dir.

The one trap: the key must match the stub

This is the single failure mode unique to toolchain-free mode. The prebuilt installer.exe has a public key compiled in, from when you built the kit. --priv-key signs the payload, and the stub verifies the signature against its baked-in key at install time. If the private key does not pair with the stub's key, or the stub was built without INSTALLER_PUB_KEY at all, the installer rejects its own payload:

installer was built without INSTALLER_PUB_KEY - refusing to install

pack catches this at build time. After producing the .exe, it runs the installer's own --verify as a self-check, so a keyless or mismatched stub fails the build:

self-verify failed (...\setup.exe --verify exited 1). The produced installer
rejects its own payload ...

Fixing it

A common mistake is grabbing an installer.exe from a plain cargo build, which has no key, and pointing the kit at it. Rebuild the stub and the uninstaller with your key, then refresh the kit:

$env:INSTALLER_PUB_KEY = (Get-Content .\keys\pub.key).Trim()
cargo build --release -p installer -p uninstaller
# copy target\release\installer.exe and uninstall.exe into the kit folder

Two rules of thumb:

  • priv.key and the kit's installer.exe must come from the same pub.key.
  • Re-issuing a stub with a new key means re-issuing the kit's priv.key too.

You can spot-check any built installer yourself. --verify prints text and sets the exit code (0 means ok), with no dialog:

.\setup-myapp-1.0.exe --verify

Paired-argument rules

  • --installer-stub and --uninstaller must be provided together. Passing one without the other is an error.
  • In toolchain mode (neither prebuilt binary given), --pub-key or --pub-key-literal is required.
  • Both prebuilt paths must exist, or pack stops before doing any work.

Branding

Four packaging options make the setup .exe look finished and legally complete: the license text, a custom header banner, the icon (inherited from your application automatically), and the version-info resource. None are required. All are driven from pack, on the CLI or in the config file.

License text

Pass --license <path> and the UTF-8 text in that file becomes the EULA shown on the installer's License page. Without it, the page shows a built-in placeholder text.

installer_builder.exe pack `
    --product "My App" --product-id myapp --publisher "My Company" --to-version 1.0 `
    --input .\build\myapp --exe myapp.exe `
    --license .\legal\EULA-myapp-en.txt `
    --priv-key .\keys\priv.key --pub-key .\keys\pub.key `
    --out .\dist\setup-myapp-1.0.exe

The text rides inside the signed payload, so tampering with it invalidates the Ed25519 signature. --verify reports License: custom (<bytes>) or License: built-in placeholder.

To hide the License page entirely, see Wizard pages and install location.

Header banner

By default, the wizard's header is a flat light-gray card with the product title and a sub-line. Pass --banner <path.png> to paint your own image across that whole strip instead.

installer_builder.exe pack `
    --product "My App" --product-id myapp --publisher "My Company" --to-version 1.0 `
    --input .\build\myapp --exe myapp.exe `
    --banner .\branding\header-1400x144.png `
    --priv-key .\keys\priv.key --pub-key .\keys\pub.key `
    --out .\dist\setup-myapp-1.0.exe

A ready-made sample lives in the repo at docs/src/images/banner-sample.png:

Sample header banner

How it behaves:

  • Optional. Omit --banner and the header stays the default gray card.
  • PNG only. The file must start with the PNG signature or the build fails. Transparency is supported.
  • Packaged in the .exe as a dedicated resource. There is no external file, and it works the same in toolchain-free mode.
  • Crisp at every DPI. The image is stretched to the header at runtime with high-quality scaling. Author it at twice the logical header size, 1400 x 144 px (the header is 700 x 72 logical px), for a sharp result at 100%, 125%, 150%, and 200% display scale.
  • Keep the left edge light. The product title and sub-line are drawn on top of the banner in dark text, anchored to the left. Use a light or low-contrast left third so the title stays readable; busier art belongs on the right, as in the sample above.

The banner is pure branding, so unlike the license it rides as its own raw resource rather than inside the signed manifest. Sign the final .exe with Authenticode to seal the whole file; see Authenticode signing.

Previewing a banner

You can iterate on a banner without packing a full installer. The debug build of the stub has a preview window that reads any PNG from an environment variable:

cargo build -p installer
$env:INSTALLWAY_PREVIEW_BANNER = ".\branding\header-1400x144.png"
.\target\debug\installer.exe --preview license

Icon inheritance

At pack time, the builder reads the icon resources from <input>\<exe> (your application) and stamps them into both the setup .exe and the embedded uninstall.exe. Explorer then shows your application's own icon on the installer and uninstaller files, and on the Windows Apps entry.

  • No flag is needed. It happens automatically when <input>\<exe> has icon resources.
  • If the source exe has no icon resources, the build prints a notice and falls back to the default icon.
  • The uninstaller is stamped on a staging copy in %TEMP%, so the cached target\release\uninstall.exe stays untouched between pack runs.

Version info

pack stamps a Win32 VS_VERSIONINFO resource built from --product, --publisher, and --to-version. Explorer's Details tab then shows FileVersion, ProductVersion, ProductName, CompanyName, FileDescription (<product> Setup), OriginalFilename, and copyright. A complete version resource makes the binary look finished and helps build SmartScreen reputation.

--to-version is parsed as a.b.c.d with missing parts set to zero, so 1.2 becomes 1.2.0.0.

Wizard pages and install location

The interactive installer is a four-step wizard: License, Choose location, Progress, Done. This page covers everything that shapes that flow at build time: which pages appear, where the install lands, what the destination-folder guard allows, and what happens on the final page.

All options below work on the CLI and as config file keys.

Skip pages

OptionEffect
--skip-licenseHide the License page.
--skip-pathHide the Choose-location page; install straight to the default location. When the license is still shown, its button reads Install.

With both flags, the wizard goes straight to Progress on launch:

installer_builder.exe pack `
    --product "My App" --product-id myapp --publisher "My Company" --to-version 1.0 `
    --input .\build\myapp --exe myapp.exe `
    --skip-license --skip-path `
    --default-install-dir "%LOCALAPPDATA%\Programs\MyApp" `
    --priv-key .\keys\priv.key --pub-key .\keys\pub.key `
    --out .\dist\setup-myapp-1.0.exe

The proposed install location

--default-install-dir <DIR> sets the path the Choose page proposes. It may contain %VAR% environment tokens, for example %LOCALAPPDATA%\Programs\MyApp or C:\Games\MyApp.

At runtime, the installer picks the proposed location in this order:

  1. An explicit path argument (--silent "<dir>", --minimal "<dir>", or the INSTALLWAY_PATH environment variable).
  2. The folder the product was last installed to. A reinstall or upgrade lands in place, and the Choose page is skipped automatically.
  3. The build's --default-install-dir, with %VAR% tokens expanded.
  4. %LOCALAPPDATA%\Programs\<product>.

A reinstall or upgrade always skips the Choose page, for full and patch installers alike, regardless of --skip-path. A patch must land in the existing folder because it patches the files on disk, and a full reinstall there avoids an accidental second copy elsewhere. The build-time --skip-path only affects first installs. Detection is keyed by publisher plus product, so it works across versions.

The non-empty-folder guard

By default, a fresh interactive install refuses a destination folder that is not empty, which protects users from extracting into C:\Users\name\Documents by accident. --install-dir-restriction tunes this:

ValueEffect
enforceBlock any non-empty destination (default).
default-dir-onlyAllow only the build's --default-install-dir to be non-empty.
bypassAllow any folder.

Use default-dir-only when replacing a legacy InstallShield or MSI install that lives in its own fixed directory. Pair it with --purge-unknown-files and a plugin that validates the old install before install and tears it down at uninstall.

The "launch now" checkbox

The Done page shows a "launch the product now" checkbox. --launch-option controls it:

ValueEffect
checkedVisible and ticked (default). The product launches on Finish unless the user clears it.
uncheckedVisible but not ticked. The user opts in.
hiddenNo checkbox. The installer never offers to launch the product.

This only affects the interactive wizard's Done page. Silent and minimal installs decide launching with the runtime --launch flag instead.

Minimal UI for upgrades

--upgrade-minimal-ui makes an upgrade use the compact minimal UI, the small "Applying update" window, instead of the full wizard. It is off by default.

RunUI with --upgrade-minimal-ui set
First install (no prior copy)Full wizard, always.
Upgrade or reinstall over an existing copyMinimal UI.
--silent / --minimalUnchanged; the flag has no effect.

It works on full and patch installers alike. The choice is read from the payload of the installer being run, so it applies to the next installer that carries the flag, never retroactively to the copy already on disk.

An upgrade shown in the minimal UI installs into the existing folder and launches the app afterward only if --launch is passed. There is no Done page and no launch checkbox.

Shortcuts

Declare the .lnk shortcuts you want in the config file. The installer creates them, the uninstaller removes them, and an upgrade reconciles the set, just like file associations and registry entries.

Nothing is created unless you declare a [[shortcut]]. There is no automatic desktop shortcut.

Declaring shortcuts

Shortcuts are config-file only ([[shortcut]] tables), not CLI flags:

[[shortcut]]
dir     = "%DESKTOP%"   # folder the .lnk goes in
name    = "My App"      # file name without ".lnk"; also the label
target  = "%EXE%"       # what it points at (relative paths resolve to the install dir)
args    = ""            # optional command-line arguments
feature = ""            # optional feature-pack id gating this shortcut
FieldRequiredDescription
dirYesDirectory the .lnk is placed in. Tokens are expanded at install time.
nameYesShortcut file name without .lnk. Must be a single filename: no \ / : * ? " < > |.
targetYesShortcut target. A relative path resolves against the chosen install directory; an absolute path (or %EXE%) is used as is.
argsNoA string appended verbatim as the shortcut's command-line arguments.
featureNoA feature pack id. When set, the shortcut is created only if that feature is active in the install. Empty means always created.

The shortcut's working directory is set to the install directory. An empty dir, name, or target, or an illegal character in name, fails the build with a message naming the entry. A non-empty feature that no [[feature]] declares also fails the build.

Tokens

dir, target, and args are templates expanded at install time, so they can reference the chosen install directory:

TokenExpands to
%DESKTOP%Desktop folder. All-Users for a machine-wide install, otherwise per-user.
%START_MENU%Start Menu Programs folder. All-Users for a machine-wide install, otherwise per-user.
%COMMON_DESKTOP%The All-Users (public) Desktop, always. Needs admin.
%COMMON_START_MENU%The All-Users Start Menu Programs folder, always. Needs admin.
%USER_DESKTOP%The per-user Desktop, always.
%USER_START_MENU%The per-user Start Menu Programs folder, always.
%INSTALL_DIR%The chosen install directory.
%EXE%Full path to the installed main exe.
%VERSION%The to-version.
%PRODUCT%The display name.
%PRODUCT_ID%The registry-safe id.
%PUBLISHER%The publisher (sanitized).

Use %DESKTOP% and %START_MENU% to follow the install scope automatically (see Per-user and machine-wide installs), or the %COMMON_*% / %USER_*% variants to force a specific location. After these, any remaining %VAR% is expanded as an environment variable, such as %APPDATA%, so you can place a shortcut anywhere the user can write. A shortcut whose dir resolves to a location the system cannot provide is logged and skipped, not fatal.

Examples

# Desktop shortcut to the main exe.
[[shortcut]]
dir = "%DESKTOP%"
name = "%PRODUCT%"
target = "%EXE%"

# Start Menu shortcut that launches with a flag.
[[shortcut]]
dir = "%START_MENU%"
name = "%PRODUCT%"
target = "%EXE%"
args = "--from-start-menu"

# A shortcut to a helper tool, dropped inside the install folder itself.
[[shortcut]]
dir = "%INSTALL_DIR%"
name = "Config Editor"
target = "bin/config-editor.exe"

# Group under a Start Menu subfolder.
[[shortcut]]
dir = "%START_MENU%\\My Company"
name = "%PRODUCT%"
target = "%EXE%"

Gating on a feature pack

Set feature to a declared feature pack id to make a shortcut conditional: it is created only when that feature ends up active for the install. Features resolve after plugins run, so the decision reflects the final active set, the same set that filters which files land on disk.

[[feature]]
id = "pro"
paths = ["pro/**"]

# This shortcut appears only when "pro" ends up active.
[[shortcut]]
dir = "%START_MENU%"
name = "%PRODUCT% Pro"
target = "%EXE%"
feature = "pro"

Suppressing shortcuts at install time

Whoever runs the installer can suppress a shortcut kind regardless of what the config declares:

FlagEffect
--ignore-desktop-shortcutsNo .lnk is created in any Desktop location (%DESKTOP%, %COMMON_DESKTOP%, %USER_DESKTOP%).
--ignore-start-menu-shortcutsNo .lnk is created in any Start Menu location (%START_MENU%, %COMMON_START_MENU%, %USER_START_MENU%).

They apply to every install mode: wizard, minimal, and silent. Shortcuts pointing elsewhere, such as %INSTALL_DIR% or a %VAR% path, are unaffected. On an upgrade run with one of these flags, a matching shortcut a previous install created is removed as part of the normal reconciliation.

Uninstall and upgrade

Each created shortcut's resolved .lnk path is recorded in installer_info.json. On uninstall, every recorded .lnk is removed. A locked file is retried, then queued for deletion at reboot.

On any reinstall over an existing copy, shortcuts the previous version created but the new config no longer declares are deleted first (reconciled by resolved .lnk path), then the current set is recreated. Renaming, moving, or dropping a shortcut never leaves an orphan behind.

The reconciliation is crash-resilient: installer_info.json is written last, so an interrupted install self-heals on the next run.

File associations

Register file types so double-clicking a document opens your app. Pass --assoc ".ext:Description", repeatable, or the assoc array in the config file. Associations are written under Software\Classes, and the shell open verb points at the installed main executable with "%1". Associations require --exe to be set.

installer_builder.exe pack `
    --product "MyApp" --product-id MyApp --publisher "My Company" --to-version 1.0 `
    --input .\build\myapp --exe myapp.exe `
    --assoc ".myx:MyApp Document" `
    --assoc ".myz:MyApp Archive" `
    --priv-key .\keys\priv.key --pub-key .\keys\pub.key `
    --out .\dist\setup-myapp-1.0.exe

In the config file:

assoc = [".myx:MyApp Document", ".myz:MyApp Archive"]

Format

Each entry is .ext:Description.

  • The extension is normalized to a single leading dot. An empty extension is rejected.
  • Only the first : splits the extension from the description, so the description may itself contain colons: .a:b:c gives extension .a and description b:c.

Keys written

Per association, with ProgID <product-id>.<ext>:

Software\Classes\.myx                          (default) = MyApp.myx
Software\Classes\MyApp.myx                     (default) = MyApp Document
Software\Classes\MyApp.myx\DefaultIcon         (default) = "<exe>",0
Software\Classes\MyApp.myx\shell\open\command  (default) = "<exe>" "%1"

A per-user install writes these under HKCU; a machine-wide install writes them under HKLM, so the association is visible to every user. See Per-user and machine-wide installs. The uninstaller cleans whichever hive was used.

After registration, the installer fires SHChangeNotify(SHCNE_ASSOCCHANGED) so Explorer refreshes immediately.

Clean removal

The chosen associations are recorded in installer_info.json. The uninstaller removes exactly those ProgID trees, and clears each .ext default only if it still points at our ProgID. It never stomps an association the user later re-pointed at another app.

Changing associations between versions

When you install over an existing copy of the product, the installer reconciles associations. Any extension the previous install registered but the new payload no longer declares is unregistered, then the current set is registered. Dropping .myz in a new version therefore removes its handler instead of leaving an orphan. Extensions present in both versions are simply refreshed. This applies to full and patch installers and to every UI mode.

The reconciliation is failure-resilient. Associations are only touched after the new version's files are committed, so an install that fails earlier never strips the previous version's associations. And installer_info.json, the record of what was registered, is rewritten last, after the registry changes. An interrupted install (crash or power loss) leaves the old record intact, and the next run recomputes and heals the association state.

Registry keys

Beyond file associations, you can declare arbitrary registry entries in the config file. The installer writes them, the uninstaller removes them, and an upgrade reconciles the set.

HKCU entries need no admin rights. HKLM entries are written only when the install is machine-wide; on a per-user install an HKLM entry is logged and skipped. See Per-user and machine-wide installs. For per-user class registrations (HKCR), write under HKCU\Software\Classes.

Declaring entries

Registry entries are config-file only ([[registry]] tables), not CLI flags:

[[registry]]
hive  = "HKCU"
key   = "Software\\Acme\\App"   # subkey under the hive
name  = "InstallDir"            # value name; omit or "" for (Default)
type  = "sz"                    # see types below
value = "%INSTALL_DIR%"

Types

typeTOML valueRegistry type
szstringREG_SZ
expand_szstringREG_EXPAND_SZ (Windows expands %ENV% at read time)
dwordinteger, 0 to 4294967295REG_DWORD
qwordinteger, 0 or greaterREG_QWORD
multi_szarray of stringsREG_MULTI_SZ
binaryhex string of even lengthREG_BINARY

A type/value mismatch, an unknown type, an unsupported hive (only HKCU and HKLM are allowed), an empty key, or a key starting with \ all fail the build with a message naming the entry.

Tokens

Keys and string values are templates, expanded at install time, so they can include the chosen install directory:

TokenExpands to
%APP_KEY%Software\<publisher>\<product-id>, with the publisher sanitized.
%INSTALL_DIR%The chosen install directory.
%EXE%Full path to the installed main exe.
%VERSION%The to-version.
%PRODUCT%The display name.
%PRODUCT_ID%The registry-safe id.
%PUBLISHER%The publisher (sanitized).

Use %APP_KEY% for your app's own root so the path follows product-id automatically:

[[registry]]
hive = "HKCU"
key = "%APP_KEY%"
name = "InstallDir"
type = "sz"
value = "%INSTALL_DIR%"

[[registry]]
hive = "HKCU"
key = "%APP_KEY%"
name = "Version"
type = "sz"
value = "%VERSION%"

[[registry]]
hive = "HKCU"
key = "%APP_KEY%\\Settings"
name = "FirstRun"
type = "dword"
value = 1

Example: a custom URL protocol

Register myapp:// so links open the app, a common need beyond file associations:

[[registry]]
hive = "HKCU"
key = "Software\\Classes\\myapp"
name = ""
type = "sz"
value = "URL:MyApp Protocol"

[[registry]]
hive = "HKCU"
key = "Software\\Classes\\myapp"
name = "URL Protocol"
type = "sz"
value = ""

[[registry]]
hive = "HKCU"
key = "Software\\Classes\\myapp\\shell\\open\\command"
name = ""
type = "sz"
value = "\"%EXE%\" \"%1\""

Uninstall and upgrade

Written entries are recorded in installer_info.json. On uninstall, each is removed with an anti-stomp check: a value is deleted only if it still equals what the installer wrote, so a value the user later changed is left alone. Keys are then pruned only if empty, walking up the parents the installer created. A shared key such as ...\Run keeps its other values and is never deleted.

On upgrade, entries the previous version declared but the new one drops are removed (matched by hive, key, and name), and the rest are rewritten. Like associations, this is crash-resilient: installer_info.json is the last thing written, so an interrupted install self-heals on the next run.

A note on antivirus

Registry writes are normal installer behavior, far less alarming to AV engines than running scripts. One mild flag to be aware of: writing under Software\Microsoft\Windows\CurrentVersion\Run (autostart) is a persistence indicator. It is common for legitimate apps; just know that scanners watch it.

Feature packs

Ship one installer that can lay down different subsets of files. Files are tagged with a feature id at build time, in the signed manifest. At install time, a plugin decides which features are active, and the installer stages only those plus the always-installed base. Unselected files are never written; they are not installed and then deleted.

There is no built-in feature UI. A plugin drives the selection, and can contribute a checkbox page that the installer renders, so you stay in control.

How it fits together

  1. Build. [[feature]] tables map path globs to a feature id. Matching files get feature = "<id>" in the manifest; everything else is base.
  2. Choose. A ui = true plugin may show a checkbox page, pre-checked to the current set, via installway_pages. The host hands it the catalog in ctx.features_json.
  3. Resolve. Just before staging, the host queries each plugin's installway_features with this run's page answers. Each plugin returns an { enable, disable } delta. The active set is (base + enable) - disable, where the base depends on feature_mode (see below). Only ids the build declares are kept.
  4. Filter. The manifest is reduced to base plus active features and used for the whole install: staging, verification, disk-space check, and the on-disk manifest the uninstaller reads.
  5. Persist. The active set is written to installer_info.json. The next upgrade reads it to clean up any feature it deactivates and, under sticky, to seed its base.

A feature with default = false (the default) that no plugin enables is never installed.

Declaring features

Config-file only, as [[feature]] tables:

[[feature]]
id      = "Maps"
paths   = ["data/maps"]        # a bare folder covers its whole subtree
default = true                 # installed by default on a fresh install

[[feature]]
id    = "HiResTextures"
paths = ["textures/4k/**", "extra/*.pak"]   # default = false (opt-in)
FieldDescription
idFeature id: ASCII letters, digits, -, _; unique. Referenced by plugins and shortcut gates.
pathsOne or more path globs, relative to the input root.
defaulttrue means enabled by default on a fresh install. Omitted means false (opt-in). A plugin can still override it at runtime.

Glob syntax (paths use /): a plain name matches that file or the whole folder under it (data/maps behaves like data/maps/**); * matches within one path segment; ** matches across segments; ? matches one character. A file may belong to at most one feature: an overlap fails the build, and a feature matching no file fails too, as a typo guard.

The single payload zip still carries every file, under one signature and one BLAKE3 hash. The installer just extracts the active subset, so the .exe size covers all features regardless of what a given run installs.

Activating features from a plugin

A plugin exports installway_features. The host queries it just before staging, passing this run's page answers, and the plugin emits a delta over the usual descriptor callback:

{ "enable": ["Maps"], "disable": ["HiResTextures"] }

The active set is (base + enable) - disable. A plugin that does not export the function, or emits nothing, contributes nothing, and the base then stands. Multiple plugins are unioned.

To decide at runtime, the plugin reads ctx.features_json, which carries { "all": [...], "active": [...] } (the declared features and the current base), and any signal you like:

  • A checkbox page (ui = true): installway_pages emits a multi_choice listing all, pre-checked to active, and installway_features turns the checked set into the delta. The page shows on every interactive install; silent and compact installs fall back to the page defaults, which is the base set.
  • A machine probe, a license check, an environment variable, and so on.

The host persists the resolved set itself, in installer_info.json and the filtered installer_manifest.json, so there is no side file. The worked example is sdk/examples/feature_pack, a checkbox picker. If the selection is static, skip the plugin entirely and just set default = true.

Declare the plugin like any other; ui = true enables its page:

[[plugin]]
name     = "feature-pack"
dll      = "plugins/feature_pack.dll"
phase    = "pre-install"
required = false
ui       = true

Upgrades: sticky vs override

The top-level feature_mode config key decides how an upgrade seeds the base set. That is the only thing it affects; a fresh install always seeds from the build defaults.

feature_modeUpgrade baseEffect
sticky (default)The previously installed set.Features carry over from install to install. Only a plugin's delta changes them.
overrideThis build's default = true features.The running build wins. An upgrade resets to the new build's defaults, and a feature a prior install added is dropped unless this build defaults it on or a plugin re-enables it.
feature_mode = "override"   # omit for the default, "sticky"

Either way, the plugin has the final say through its { enable, disable } delta, and the previously installed set is always used to clean up the files of a feature the upgrade drops. Use override when the build should dictate the feature set. Keep sticky when a user's prior selection should persist.

Under sticky, omitting a feature from a plugin's enable list is not enough to drop it. The plugin must disable it explicitly.

Adding and removing features across versions

  • Adding a feature later works on both full and patch payloads. A patch ships full bytes for a newly activated feature's files, since there is no previous version on disk to delta against; the installer handles this automatically.
  • Deactivating a feature on a copy that had it installed removes its files. They are scheduled into the same transactional delete pass as a patch's removals (backed up, rollback-safe), and emptied folders are pruned. A feature that was never installed is simply not staged.

Plugins

Run your own install and uninstall logic without touching the installer's source. A plugin is a native Windows DLL, written in C, C++, Rust, or any language with a C ABI, bundled into the signed installer payload and run at a chosen phase.

Plugins are a migration pair: up runs at install, down runs at uninstall. Write down to reverse up when that is possible; otherwise make it a no-op. The SDK and ready-to-edit examples live in sdk/.

The contract

A plugin exports these C functions (see sdk/installway_plugin.h):

uint32_t installway_abi_version(void);                  // return INSTALLWAY_ABI_VERSION
int32_t  installway_up(const InstallwayContext*);       // at install   (0 = ok)
int32_t  installway_down(const InstallwayContext*);     // at uninstall (0 = ok)
int32_t  installway_pages(const InstallwayContext*);    // optional: custom pages
int32_t  installway_features(const InstallwayContext*); // optional: feature packs

The host passes a context with:

  • install_dir, product, product_id, version, and the full exe path;
  • data_dir, the folder holding installer_info.json. Write persistent plugin state here;
  • log(level, message), a callback that writes to the install or uninstall log;
  • lang, the host's resolved UI language code such as "en" or "fr", honoring its --lang and INSTALLWAY_LANG overrides;
  • inputs_json and the emit_pages(json) callback, which serve the custom pages feature. A plugin without pages ignores them;
  • features_json, the feature pack catalog ({ "all": [...], "active": [...] }, or empty when the build declares none).

The host-to-plugin channel uses no temp files. The context is streamed to the child process on stdin, and page descriptors come back over a dedicated pipe the host owns.

Localizing a plugin. There is no shared string table across the ABI. A plugin ships its own strings and selects them by ctx->lang, falling back to English for codes it does not translate. See the uninstall_msi example, which localizes its page title and subtitle this way.

Declaring plugins

Config-file only, as [[plugin]] tables:

[[plugin]]
name  = "uninstall-old-msi"
dll   = "plugins/uninstall_old_msi.dll"   # path to the built DLL
phase = "pre-install"                     # pre-install | post-install
required = true                           # default true
ui    = false                             # default false; see custom pages
FieldDescription
nameUnique id: ASCII letters, digits, -, _. Names the in-payload DLL and the log lines.
dllPath to the DLL to bundle.
phasepre-install (before any file is staged) or post-install (after the install is finalized).
requiredIf true (default), a non-zero up fails the install. If false, the failure is logged and the install continues.
uiIf true, the plugin contributes custom wizard pages. Default false.

Plugins of the same phase run in declared order. At uninstall, down runs in reverse order.

Phases and failure

PhaseWhenA required up failure
pre-installBefore staging and commit.Aborts cleanly; nothing is committed.
post-installAfter finalize: files in place, product registered.Fails the install. Files stay; uninstall removes them.
downAt uninstall, before files are removed.Always best-effort: logged, never blocks the uninstall.

Machine-wide installs run plugins elevated; mind per-user state. For a machine-wide install, the host runs in an elevated subprocess under the admin account, and your up and down code runs there too. ctx->data_dir correctly points at the machine-wide folder (%ProgramData%\...) in that case, so prefer it for any state you persist. But Windows per-user APIs (%APPDATA%, %USERPROFILE%, HKEY_CURRENT_USER, the user's Desktop and Start Menu) resolve to the elevated admin's profile, not the user who launched the installer. Do not write there expecting the end user to see it. Use ctx->data_dir, install_dir, or explicit machine locations (HKLM, All-Users folders) instead. See Per-user and machine-wide installs.

Custom wizard pages

A plugin marked ui = true can add its own pages to the installer wizard, for example a country picker whose answer drives a region-specific install. The plugin never draws UI. It returns a descriptor, and the installer renders the page with its own native controls, so the plugin stays crash-isolated in its child process.

installway_pages is a step function. The host calls it once per page:

  1. The host calls installway_pages with the answers so far in ctx->inputs_json (empty on the first call). You build one step, hand it to ctx->emit_pages(json), and return 0.
  2. The installer renders that page, validates required fields, collects the answers, and calls installway_pages again with the updated answers.
  3. Return { "step": "done" } when there are no more pages. Then installway_up receives all the answers in ctx->inputs_json, a JSON object keyed "<page_id>.<widget_id>".

Because each call sees the answers so far, a page can depend on an earlier one: branch, compute options from a prior answer, validate and re-ask, show a confirmation summary, or end early. The plugin stays stateless; the host carries the state.

The step format

{ "step": "page",
  "page": { "id": "region", "title": "...", "widgets": [ ... ] },
  "notice": "",
  "back": true }
{ "step": "done" }
  • page is { id, title, subtitle?, widgets[] }. The id namespaces the answers and only needs to be unique per page you show.
  • notice is an optional banner, useful to surface a validation error when re-asking.
  • back defaults to true; set false to disable the Back button on this page.

A dependent-page loop in pseudo-code:

// read ctx->inputs_json, then emit one step
if (!has("region.country"))                          emit(country_page);
else if (country == "DOM" && !has("dom.territory"))  emit(territory_page);
else                                                 emit("{ \"step\": \"done\" }");

Widget palette

kindControlValue in inputs_json
labelStatic textNone.
textText box. password masks input, number accepts digits only, multiline is taller.The typed string.
checkboxCheckbox"true" or "false".
single_choiceRadio group or drop-down (style: radio or combo)The chosen option's value.
multi_choiceCheckbox group, pick anyThe checked values joined by ,.

text, single_choice, and multi_choice accept required (a single_choice is required by default). default pre-selects a value; for multi_choice it takes a list of values. Titles, labels, and option text are rendered verbatim, so localize them in the plugin.

Silent installs

--silent and the compact upgrade UI have no form to fill, so the host drives the step loop itself, answering each page from its widget defaults (a single_choice with no default uses its first option) until done. A required field with no usable default, or a gate that keeps re-asking, fails the silent install with a message telling the user to run the interactive installer.

Remembering choices across upgrades

The host does not save your answers. To skip pages an earlier install already answered, persist the choice yourself and check for it in the step function:

  • In up, write the choice to a file under ctx->data_dir, next to installer_info.json, for example data_dir\myplugin.txt.
  • In installway_pages, read that file. If it exists, return { "step": "done" }. The page is skipped and up reuses the saved value.
// installway_pages, first thing:
if (file_exists(data_dir, "myplugin.txt"))  emit("{ \"step\": \"done\" }");
else                                        emit(first_page);

This works the same silently: a first silent install fills the widget defaults and saves them; later silent upgrades read the file and skip. data_dir lives in %LOCALAPPDATA%\<publisher>\Uninstall\<product_id> (per-user) or %ProgramData%\<publisher>\Uninstall\<product_id> (machine-wide). Always read it from ctx->data_dir rather than hard-coding it. The uninstaller deletes the folder, so your state is cleaned up automatically; your down runs first, before the folder is removed, if it needs to read the state.

Uninstall (down) gets no page answers in inputs_json. Persisting in data_dir as above is how a plugin carries an install-time choice to uninstall. Avoid storing secrets there in plaintext.

See sdk/examples/country_picker for a complete page-contributing plugin. It remembers the country in data_dir and skips the page on upgrade.

Selecting feature packs

A plugin can also export installway_features to choose which feature packs get installed. The host queries it just before staging, passing the page answers in inputs_json and the catalog in features_json, and the plugin emits { "enable": [...], "disable": [...] } over the same emit_pages channel. The host stages only the base plus the active features. A ui = true plugin can pair this with a checkbox page.

Example: replacing an MSI or InstallShield install

A common use is removing a previous-technology install before laying down the new one:

[[plugin]]
name  = "uninstall-old-msi"
dll   = "plugins/uninstall_old_msi.dll"
phase = "pre-install"

[[plugin]]
name  = "uninstall-old-installshield"
dll   = "plugins/uninstall_old_is.dll"
phase = "pre-install"

Ready-to-edit Rust sources are in sdk/examples/: uninstall_msi, uninstall_installshield, the country_picker page example, the feature_pack picker, and a minimal template. Build them with cargo build --release. C and C++ authors use installway_plugin.h; see sdk/README.md.

Toolchain-free packaging

Plugins are just bundled binaries, so they work in toolchain-free packaging. Nothing is compiled on the packaging machine: build the DLLs once, anywhere, then reference them from pack.toml.

Guardrails

  • Signed and hash-checked. The DLL rides inside the Ed25519-signed payload, and its BLAKE3 hash is re-verified before it is loaded. A tampered DLL is refused.
  • Crash-isolated. Each plugin runs in a child process (the installer or uninstaller re-launched as a hidden host), so a crashing or hanging plugin cannot take down or stall the install. It is killed past a timeout.
  • ABI-checked. The host refuses a plugin whose installway_abi_version() does not match INSTALLWAY_ABI_VERSION.

A note on antivirus

A bundled, signed DLL is far less alarming than spawning PowerShell, but loading a DLL and spawning msiexec is still watched by EDR. Sign the final .exe with Authenticode to build reputation, and keep plugins to genuine install needs.

Authenticode signing

Installway signs the payload inside the .exe with Ed25519 (see the security model), but it does not apply an Authenticode signature to the .exe itself. That is a separate, standard post-build step using your code-signing certificate, and it is what stops SmartScreen and antivirus engines from flagging your installer as coming from an unknown publisher.

After pack finishes, it prints the exact command:

Next step (Authenticode): signtool sign /fd SHA256 /tr http://timestamp.digicert.com setup-myapp-1.0.exe

Run it with your certificate:

signtool sign /fd SHA256 `
    /tr http://timestamp.digicert.com /td SHA256 `
    /a `
    .\dist\setup-myapp-1.0.exe
  • /fd SHA256 selects the file digest algorithm.
  • /tr <url> /td SHA256 adds an RFC 3161 timestamp, so the signature stays valid after the certificate expires.
  • /a auto-selects the best certificate from your store. Use /f cert.pfx /p <password> for a file-based certificate instead.

Why signing comes last

The payload zip is appended as a PE overlay before signing. signtool appends its certificate table after the overlay, and the installer locates the overlay from the PE section table rather than the end of the file, so the trailing certificate is harmless and the order is safe:

pack  >  embed resources  >  stamp icon + version  >  append payload overlay  >  signtool
                                                                                 you, here

Never modify the .exe after signing: no further pack, resource edits, or overlay appends. Any change invalidates the Authenticode signature.

Verifying

signtool verify /pa /v .\dist\setup-myapp-1.0.exe

This is independent of Installway's own --verify, which checks the embedded payload signature rather than the Authenticode signature.

Install analytics

Installway can optionally report install telemetry through Hintway. The feature is compiled in only when the hintway Cargo feature is enabled; binaries built without it contain zero Hintway code.

What is tracked

All events are GDPR-safe: no install path, no username, no OS version, no machine identifier, and no persistent file on disk.

DataValues
operationinstall or update
modesilent, minimal, or interactive
privilegeadmin, user, or unknown
langDetected UI language code, such as en or fr

Events sent:

EventWhen
app_startedThe installer launches. Fired automatically by the SDK on init.
stage_reachedextract, then finalize, then done, as each phase completes.
install_errorAny failure. Carries a category and the stage; never a raw message or path.
app_exitThe installer exits. Fired automatically by the SDK on shutdown.

The duration of each phase, and the total install time, is derived server-side from the timestamps of consecutive events. There is nothing extra to track.

Error categories (the value of install_error.category): version_mismatch, permission_denied, elevation_cancelled, signature_failed, disk_full, unknown.

Configure analytics for a project

Set the tenant UUID in the project's pack.toml:

hintway_tenant_id = "your-tenant-id"

The tenant UUID is stored in the signed installer payload, then copied to installer_info.json for the uninstaller. It is configuration, not a private key; the payload signature prevents it from being changed without detection.

Hintway support still has to be compiled into the installer and uninstaller. In the default toolchain mode, pack detects hintway_tenant_id and enables the hintway Cargo feature automatically for both binaries:

.\target\release\installer_builder.exe pack --config .\pack.toml

When using --reuse-stub, the existing installer.exe and uninstall.exe must already have been built with Hintway support. Omit --reuse-stub once after adding hintway_tenant_id so the correct variants are rebuilt.

Build a reusable Hintway kit

For toolchain-free packaging, build a generic Hintway-enabled kit once. No tenant is baked into these binaries:

$env:INSTALLER_PUB_KEY = (Get-Content .\keys\pub.key).Trim()

cargo build --release -p installer -p uninstaller `
    --features installer/hintway,uninstaller/hintway

Distribute those installer.exe and uninstall.exe files with installer_builder.exe. Each project selects its own tenant through hintway_tenant_id when it packs an installer. A kit built without the feature ignores this field and contains no Hintway code.

Identity and privacy

Each installer run generates a fresh random UUID as its identity. Nothing is written to disk, and there is no cross-run or cross-machine linking.

The only personal data that reaches Hintway's servers is the client IP address, which is inherent to any HTTP request. Document this in your product's privacy policy.

Disabling analytics

Omit hintway_tenant_id from pack.toml. A Hintway-enabled binary then sends no telemetry. To produce binaries containing no Hintway code at all, also build without the hintway Cargo feature; the hintway_analytics crate is optional and is not linked in that variant.

Install modes

A built installer runs in three modes. The mode is chosen by command-line flags; the same .exe serves all three. The full flag list is in Installer runtime flags.

Interactive

Double-click the .exe. The wizard walks License, Choose location, Progress, Done. The Done page offers a "Run program now" checkbox (its default state is a build-time option), and Finish launches the product when it is ticked.

The UI uses Segoe UI, Common Controls v6 visual styles, and is DPI-aware (PerMonitorV2). To hide pages or change the flow, see Wizard pages and install location.

There is no elevation prompt by default. If the user picks a folder that requires administrator rights, such as C:\Program Files, a UAC prompt appears automatically and the install becomes machine-wide. See Per-user and machine-wide installs.

Minimal (app-triggered self-update)

A compact windowed UI for updates an app launches for itself: no license page, no folder picker, no Install button. It starts the moment it opens and shows progress:

.\setup-myapp-1.1.exe --minimal "C:\path\to\install"
.\setup-myapp-1.1.exe --minimal "C:\path\to\install" --launch

It closes itself shortly after reaching 100%. On error, it stays open with the message.

You can also make regular upgrades use this UI without passing a flag, via the build-time --upgrade-minimal-ui option.

Silent

.\setup-myapp-1.0.exe --silent
.\setup-myapp-1.0.exe --silent "C:\path\to\install" --launch

Progress prints to stdout, and --launch runs the installed exe afterward. Branch on the exit code: notably, 10 means the installed version does not match this patch.

Because the installer is a Windows GUI-subsystem executable, PowerShell does not wait for it automatically. Start it as a process and wait explicitly when the next step depends on the completed installation, for example in an Azure Pipeline:

$p = Start-Process .\setup-dev.exe -ArgumentList "--silent", "$(Pipeline.Workspace)\INSTALL" -PassThru
$p.WaitForExit()

Silent mode never shows a UAC prompt, since a prompt would defeat "silent". Installing to a machine location such as Program Files therefore fails with a permission error unless you run the silent installer from an already-elevated context, such as an admin shell or a deployment tool. A per-user location needs no elevation.

If a plugin contributes wizard pages, a silent install answers them from their declared defaults. A required field with no usable default fails the install; see Plugins.

What every mode does per file

For each file in the manifest:

  1. Already correct. The destination exists and its BLAKE3 hash matches: skip. A re-run is effectively instant.
  2. Patchable. Patch installer, the destination exists, and the manifest has patch info: apply the HDiffPatch delta, verify the BLAKE3 hash, and rename atomically. Falls back to a full extract on any failure.
  3. Full. Read the file from the payload, verify the BLAKE3 hash, and rename atomically.

Files listed in deleted_files are removed afterward. version.json and installer_manifest.json are written to the install root as the canonical record, and as the state any later patch needs.

Transactional and crash-safe

Installs are two-phase. Every changed file is staged and hash-verified before anything in the live install is touched, then committed via backup and rename with a retry window of about five seconds for files locked by antivirus, Explorer, or the indexer. A failure rolls back to the exact pre-install state. An interrupted commit self-heals from the journal on the next launch. Disk space is pre-checked, and a named mutex per install directory prevents two installers from racing on the same folder.

Inspect and verify without installing

.\setup-myapp-1.0.exe --verify                          # check the embedded payload
.\setup-myapp-1.0.exe --verify-install "C:\path\to\app" # re-hash an installed copy

--verify-install reports OK, MISSING, or CORRUPT per file, and exits 0 when clean or 1 when anything is wrong. It is handy for scripted health checks.

Per-user and machine-wide installs

The same installer serves both scopes, and the choice is made at install time by the destination folder. A per-user location (the default, %LOCALAPPDATA%\Programs\<product>) needs no admin rights and is visible only to the installing user. A shared location such as Program Files makes the install machine-wide: it elevates once and registers the product for every user on the machine.

How elevation works

The installer runs without elevation by default (asInvoker manifest). When the chosen install folder requires administrator rights, the wizard and the minimal UI show a UAC prompt automatically. The main window stays visible while a hidden elevated subprocess performs the file operations.

Silent mode is the exception: it never shows a UAC prompt, since a prompt would defeat "silent". To install silently to a machine location, run the installer from an already-elevated context. See Install modes.

What changes with the scope

Per-user installMachine-wide install
ElevationNoneOne UAC prompt
Uninstall data folder%LOCALAPPDATA%\<publisher>\Uninstall\<product-id>%ProgramData%\<publisher>\Uninstall\<product-id>
Windows Apps entryHKCU\...\Uninstall, visible to the installing userHKLM\...\Uninstall, visible to every user
File associationsHKCU\Software\ClassesHKLM\Software\Classes
Shortcut tokens %DESKTOP% / %START_MENU%Per-user Desktop and Start MenuAll-Users Desktop and Start Menu
Registry entries with hive = "HKLM"Logged and skippedWritten
PluginsRun as the userRun in the elevated subprocess, under the admin account

The scope is recorded in installer_info.json (requires_admin), and the uninstaller mirrors it: uninstalling a machine-wide install elevates and cleans HKLM and %ProgramData%; uninstalling a per-user install does not.

Notes for plugin authors

For a machine-wide install, your up and down code runs under the elevated admin account, not the user who launched the installer. Per-user APIs (%APPDATA%, HKEY_CURRENT_USER, the user's Desktop) resolve to the admin's profile there. Persist state under ctx->data_dir instead; it always points at the right scope. See Plugins.

Uninstall

uninstall.exe and its metadata live outside the application folder, so a manual delete of the app directory never orphans the Windows Apps entry.

The location depends on the install scope:

Install typeData folderApps entry
Per-user (default)%LOCALAPPDATA%\<publisher>\Uninstall\<product-id>\HKCU\...\Uninstall\
Machine-wide%ProgramData%\<publisher>\Uninstall\<product-id>\HKLM\...\Uninstall\
<data-dir>\
    uninstall.exe
    installer_info.json        (the real install_dir, associations, shortcuts, ...)
    installer_manifest.json

The product appears in Settings > Apps > Installed apps (and in classic Add/Remove Programs). Machine-wide installs are listed for every user; per-user installs only for the installing user.

What uninstall does

Uninstalling runs uninstall.exe, which:

  1. Reads installer_info.json to find the real install directory.
  2. Runs plugin down functions, best-effort and in reverse declaration order. See Plugins.
  3. Walks installer_manifest.json and removes every tracked file. If the install was machine-wide and the current user is not elevated, a UAC prompt is shown first and the file operations run in a hidden elevated subprocess.
  4. Removes the shortcuts it created, the file associations that still point at our ProgID, and the registry entries it wrote (anti-stomp; empty created keys are pruned).
  5. Removes version.json, installer_manifest.json, and empty subdirectories.
  6. Deletes the Uninstall registry entry, in HKCU or HKLM to match the install.
  7. Spawns a second-stage copy of itself from %TEMP% that deletes the app directory and the data directory (including uninstall.exe itself), then schedules its own removal at reboot. No cmd.exe, no console flash.

If the app folder was already deleted by hand, the file steps do nothing and the registry entry and data directory are still cleaned.

Silent uninstall

uninstall.exe --silent

Skips the confirmation dialog. This is what the registry QuietUninstallString invokes.

Completion message

By default, an interactive uninstall ends without a confirmation dialog. To show an "uninstall complete" message box at the end, build the installer with --show-uninstall-complete (config key show_uninstall_complete).

Language

The uninstaller picks its UI language the same way the installer does: --lang <code>, then the INSTALLWAY_LANG environment variable, then the OS locale, with English as the fallback. See Installer runtime flags.

Builder CLI

Complete reference for installer_builder. For the flags of the built installer itself, see Installer runtime flags.

installer_builder <COMMAND>

Commands:
  keygen   Generate an Ed25519 signing keypair
  pack     Build an installer .exe with an embedded payload

keygen

OptionRequiredDescription
-o, --out <DIR>YesOutput directory for priv.key and pub.key (hex-encoded).

See Signing keys.

pack

Every value may come from the CLI or from a --config TOML file; the CLI wins. Required fields are checked after merging. See The config file for the file format and merge rules.

Identity and content

OptionRequiredDescription
-p, --product <NAME>YesDisplay name: Windows Apps, version info, wizard UI, shortcut labels.
--product-id <ID>YesRegistry-safe internal id: Uninstall key, ProgIDs, data folder, upgrade detection. Must match ^[A-Za-z][A-Za-z0-9._-]{0,49}$; keep it stable across versions.
--publisher <NAME>YesVendor name: Apps "Publisher" field and the uninstall data folder. Must not be empty.
--to-version <VER>YesNew version. Also parsed as a.b.c.d for the version-info resource.
--input <DIR>YesSource directory of the new version's files.
-e, --exe <REL>NoMain executable, relative to --input. Omit only if the product has no executable; see Full installers.
-o, --out <FILE>YesOutput installer .exe path.

Signing and stub

OptionRequiredDescription
--priv-key <FILE>One of the twoEd25519 private key file that signs the payload.
--priv-key-literal <HEX>One of the twoThe private key as 64 hex chars, for CI pipelines. Mutually exclusive with --priv-key.
--pub-key <FILE>Toolchain mode: one of the twoPublic key file compiled into the stub. Ignored with --installer-stub.
--pub-key-literal <HEX>Toolchain mode: one of the twoThe public key as 64 hex chars. Mutually exclusive with --pub-key.
--installer-stub <FILE>With --uninstallerPrebuilt stub. Switches to toolchain-free mode.
--uninstaller <FILE>With --installer-stubPrebuilt uninstaller.
--reuse-stubNoSkip rebuilding the stub and uninstaller when they already exist (toolchain mode).

Patch mode

OptionRequiredDescription
--from-version <VER>With --from-dirPrevious version string. Pins the target install.
--from-dir <DIR>With --from-versionPrevious version's files, for delta generation.

Packaging and behavior

OptionDefaultDescription
--license <FILE>Built-in placeholderUTF-8 EULA shown on the License page.
--banner <FILE.png>Flat gray headerPNG painted across the wizard header. Author at 1400 x 144 px; see Branding.
--assoc ".ext:Description"NoneFile association. Repeatable; a CLI list replaces the config file's list.
--default-install-dir <DIR>%LOCALAPPDATA%\Programs\<product>Proposed install path. %VAR% tokens are expanded.
--skip-licenseOffHide the License page.
--skip-pathOffHide the Choose-location page.
--install-dir-restriction <enforce|default-dir-only|bypass>enforceNon-empty-folder guard for fresh interactive installs. See Wizard pages and install location.
--launch-option <checked|unchecked|hidden>checkedState of the final-page "launch now" checkbox.
--upgrade-minimal-uiOffUpgrades use the compact minimal UI; a first install still gets the wizard.
--show-uninstall-completeOffShow a confirmation message box at the end of an interactive uninstall.
--min-installer-version <VER>1.0.0Minimum installer stub version allowed to run this payload.
--purge-unknown-filesOffFull installs: remove unknown or leftover files on an upgrade or reinstall. Known files are still hash-skipped. Ignored for patches.
--force-reinstallOffDev: rewrite all files, remove orphans, skip the from-version check.
--config <FILE.toml>NoneRead any of the above from a TOML file.

Shortcuts, registry entries, plugins, feature packs, and feature_mode are config-file only. See The config file.

Installer runtime flags

Complete reference for the flags of a built installer (setup-*.exe) and of the uninstaller (uninstall.exe). For the build tool, see Builder CLI.

Installer (setup-*.exe)

setup-myapp.exe [<install-dir>] [flags]

The optional positional <install-dir> sets the target directory for --silent and --minimal runs.

FlagDescription
--silentHeadless install. Progress prints to stdout; see Install modes.
--minimalCompact self-update UI; see Install modes.
--launchLaunch the installed exe after a successful silent or minimal install.
--verifyVerify the embedded payload and signature, print a summary, and exit without installing.
--verify-install "<dir>"Re-hash an installed copy against its recorded manifest. Reports OK, MISSING, or CORRUPT per file.
--lang <code>Force the UI language, for example fr. See below.
--ignore-desktop-shortcutsDo not create desktop shortcuts, in any mode. See Shortcuts.
--ignore-start-menu-shortcutsDo not create Start Menu shortcuts, in any mode.

The installer also accepts internal, hidden flags (--elevated-worker, --run-plugin) that it passes to its own subprocesses for elevation and plugin isolation. They are not meant to be called manually.

Environment variables

VariableEffect
INSTALLWAY_PATHTarget install directory for --silent and --minimal when no positional path is given.
INSTALLWAY_LANGUI language code, used when --lang is absent.

Target directory resolution

For --silent and --minimal, the target directory is resolved in this order: the positional <install-dir> argument, then INSTALLWAY_PATH, then the folder the product was last installed to, then the build's default install directory, then %LOCALAPPDATA%\Programs\<product>.

Language selection

The UI language is resolved in this order: --lang, then INSTALLWAY_LANG, then the OS display language, then English. Supported languages: English (en), French (fr), Italian (it). An unsupported code falls back to English. The resolved code is passed to plugins as ctx->lang.

Uninstaller (uninstall.exe)

Normally invoked from Windows Apps. It supports:

FlagDescription
--silentSkip the confirmation dialog. This is what QuietUninstallString invokes.
--lang <code>Force the UI language, same resolution as the installer.

Like the installer, it has internal hidden flags for its elevation worker, plugin host, and second-stage cleanup. They are not meant to be called manually.

Exit codes

See Exit codes.

Exit codes

Installer (setup-*.exe)

These are the exit codes a launcher or deployment script branches on. They apply to every mode; in --silent, --verify, and --verify-install runs the error text also prints to the console instead of a dialog.

CodeMeaning
0Success.
10Wrong installed version for this patch. The install is untouched; run the full installer instead.
1Any other failure: bad signature, installer stub older than min_installer_version, payload or file hash mismatch, disk full, permission error, cancellation, and so on.

Handling code 10

A patch run against a version it was not built for is a pre-flight refusal: nothing on disk is touched, and the existing install keeps working. A launcher can branch on 10 to fetch and run the full installer automatically:

.\patch-myapp-1.0-to-1.1.exe --silent "C:\path\to\app"
switch ($LASTEXITCODE) {
    0  { "updated" }
    10 { "version mismatch: running full installer"; .\setup-myapp-1.1.exe --silent "C:\path\to\app" }
    default { "install failed ($LASTEXITCODE)"; exit 1 }
}

Verification flags

  • --verify exits 0 when the embedded payload verifies, 1 otherwise.
  • --verify-install "<dir>" exits 0 when every file in the installed manifest is present and matches its hash, 1 if anything is MISSING or CORRUPT.

Uninstaller (uninstall.exe)

CodeMeaning
0Success.
1Failure.

Manifest and payload format

These are the common crate types that describe what an installer carries. They are serialized to JSON, signed, and embedded as resources. Field documentation lives in common/src/model/.

What is embedded in the installer .exe

ResourceIdContents
RT_RCDATA2SignedPayload JSON: the manifest and metadata, plus the signature.
RT_RCDATA3The uninstaller .exe.
RT_RCDATA4The payload length, a little-endian u64.
RT_RCDATA5The optional header banner PNG. Not signed; see Branding.
PE overlayA magic marker followed by the payload zip, appended after all resource passes.

SignedPayload

#![allow(unused)]
fn main() {
struct SignedPayload {
    payload_json: String,   // exact UTF-8 bytes the signature was computed over
    signature_hex: String,  // Ed25519 signature of payload_json
}
}

The verifier checks the signature against the raw payload_json bytes, then parses InstallerPayload from them. Signing the exact bytes avoids any serializer-determinism trap.

InstallerPayload

FieldTypeNotes
kindFull or Patch
productStringDisplay name.
product_idStringRegistry-safe id: Uninstall key, ProgIDs, data folder, upgrade detection.
publisherStringUninstall data folder and the Apps "Publisher" field.
hintway_tenant_idOption<String>Hintway tenant UUID configured at pack time.
from_versionOption<String>Set for patches; pins the target version.
to_versionString
min_installer_versionStringMinimum stub version allowed to run this payload. Default 1.0.0.
payload_blake3StringBLAKE3 of the zip, re-verified before extraction.
created_at_unixi64
manifestManifestThe per-file table; see below.
license_textOption<String>EULA shown on the License page.
associationsVec<FileAssoc>File types to register under Software\Classes.
pluginsVec<PluginEntry>Bundled plugins and their phases.
shortcutsVec<ShortcutEntry>Shortcuts to create. dir, target, and args are token templates. None are created unless declared.
registryVec<RegistryEntry>Free-form registry entries. Key and value are token templates.
force_reinstallboolDev: rewrite all, remove orphans, skip the from-version check.
purge_unknown_filesboolFull installs: remove unknown or leftover files. Ignored for patches.
skip_license, skip_pathboolTrim the wizard.
install_dir_restrictionEnforce, DefaultDirOnly, or BypassWhether a fresh interactive install may target a non-empty folder. Default Enforce.
default_install_dirOption<String>Proposed path; %VAR% tokens are expanded.
launch_optionChecked, Unchecked, or HiddenThe final-page "launch now" checkbox.
upgrade_minimal_uiboolUpgrades use the minimal UI; a first install always gets the wizard.
show_uninstall_completeboolShow the "uninstall complete" message box. Off by default.

Manifest, FileEntry, and PatchInfo

#![allow(unused)]
fn main() {
struct Manifest {
    version: String,
    exe: Option<String>,               // main exe, relative to the install root
    files: HashMap<String, FileEntry>, // keyed by relative path
    deleted_files: Vec<String>,        // removed at install time (patches)
    full_size: u64,
    total_patch_size: u64,
    features: Vec<String>,             // declared feature-pack ids
    default_features: Vec<String>,     // subset enabled by default on a fresh install
    feature_mode: FeatureMode,         // upgrade base: "sticky" (default) or "override"
}

struct FileEntry {
    hash: String,            // BLAKE3, checked after each write or patch
    size: u64,
    patch: Option<PatchInfo>,
    feature: Option<String>, // feature pack this file belongs to; None = base
}

struct PatchInfo {
    file: String,   // in-zip path: patches/<blake3(rel)>.patch
    size: u64,
}
}

Payload zip layout. Full files live under full/<rel>; binary patches under patches/<blake3(rel)>.patch. The installer reads PatchInfo.file verbatim as the in-zip path, so the name in the manifest and the actual zip entry name are produced by one function in the builder; they always match. Unchanged files in a patch have no zip entry, only their recorded hash.

InstallInfo

Persisted to <data-dir>\installer_info.json by the installer and read by the uninstaller. It holds product, product_id, publisher, hintway_tenant_id, version, install_dir, installed_at_unix, registry_key (equal to product_id), exe, the associations, the resolved shortcuts, the resolved registry entries to remove, requires_admin (which drives the HKLM and %ProgramData% versus HKCU and %LOCALAPPDATA% choice), and features, the active feature packs. The next upgrade reads features to clean up dropped features and, under feature_mode = "sticky", to seed its base. See Feature packs.

In the payload, registry and shortcuts hold token templates. In installer_info.json they hold the resolved entries actually written, with absolute paths, so the uninstaller matches and removes exactly those.

Records written before the product/id split have no product_id; readers fall back to registry_key and a sanitized product.

Backward compatibility

New fields use #[serde(default)], so installers can read JSON written by older versions; missing fields take sensible defaults. The round-trip is covered by tests in the model modules.