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_RCDATAresources. - 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:
- 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.
- BLAKE3 hash of the payload zip, recorded in the signed manifest and re-verified before a single byte is extracted.
- BLAKE3 hash per file, checked after each write or patch apply.
- Version floor via
min_installer_version: a stub that is too old refuses the payload. - 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
| Crate | Type | Purpose |
|---|---|---|
common | lib | Manifest types, BLAKE3 hashing, file scan, HDiffPatch wrapper, shared helpers. |
installer_builder | bin | The offline build tool. Generates Ed25519 keypairs and packs a directory (or a from/to pair) into a self-contained installer .exe. |
installer | bin | The installer stub. Verifies the signature, checks per-file hashes, and either extracts the payload or applies HDiffPatch deltas in place. |
uninstaller | bin | Built 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:
| Command | What it does |
|---|---|
keygen | Generate an Ed25519 signing keypair. |
pack | Pack 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 exactsigntoolcommand 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.keysigns the payload at pack time. Anyone holding it can produce installers that your stubs will accept. Guard it like a code-signing key.pub.keyis compiled into every installer stub through theINSTALLER_PUB_KEYbuild-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:
| Option | Description |
|---|---|
--product | Display name. Shown in the wizard, in Windows Apps, in the version-info resource, and as the default shortcut label. |
--product-id | Registry-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. |
--publisher | Vendor name. Sets the "Publisher" field in Windows Apps and the uninstall data folder %LOCALAPPDATA%\<publisher>\Uninstall\<product-id>. Must not be empty. |
--to-version | Version string, such as 1.0 or 1.2.3. Also parsed as a.b.c.d for the version-info resource. |
--input | Directory containing the files to install. Scanned recursively. |
--priv-key | Ed25519 private key that signs the payload. Or pass --priv-key-literal <hex> instead; see Signing keys. |
--out | Output 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
--launchflag, - the icon inherited by the setup
.exeand 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
- 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. - 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. - Builds the signed manifest and metadata, and signs the exact JSON bytes with Ed25519.
- Produces the installer stub and the uninstaller, either through
cargo buildor from a prebuilt kit, and copies the stub to--out. - Embeds the signed manifest, the icon-stamped uninstaller, and a version-info resource, then appends the payload zip as a PE overlay.
- 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. - Prints the
signtoolcommand 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:
| Case | What 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. |
| Changed | The 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
assoclist. A CLI--assoclist 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]]) andfeature_modeare 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
| Key | Type | Description |
|---|---|---|
product | string | Display name. |
product_id | string | Registry-safe id, stable across versions. |
publisher | string | Vendor name. |
hintway_tenant_id | string | Optional Hintway tenant UUID. Enables the Hintway build in toolchain mode; see Install analytics. |
to_version | string | Version being packaged. |
input | path | Directory of files to install. |
exe | string | Main executable, relative to input. |
out | path | Output installer path. |
Signing and stub
| Key | Type | Description |
|---|---|---|
priv_key | path | Ed25519 private key file. |
priv_key_literal | string | Private key as 64 hex chars. Mutually exclusive with priv_key. |
pub_key | path | Public key file, compiled into the stub. Ignored in toolchain-free mode. |
pub_key_literal | string | Public key as 64 hex chars. Mutually exclusive with pub_key. |
installer_stub | path | Prebuilt installer.exe. Switches to toolchain-free mode; requires uninstaller. |
uninstaller | path | Prebuilt uninstall.exe, paired with installer_stub. |
reuse_stub | bool | Skip rebuilding the stub and uninstaller when they already exist (toolchain mode). |
Patch mode
| Key | Type | Description |
|---|---|---|
from_version | string | Previous version. Required together with from_dir. |
from_dir | path | Previous version's files, for delta generation. |
Wizard and install behavior
| Key | Type | Default | Description |
|---|---|---|---|
license | path | built-in placeholder | UTF-8 EULA text shown on the License page. |
banner | path | flat gray header | PNG painted across the wizard header. See Branding. |
assoc | array | [] | File associations, entries of the form ".ext:Description". |
default_install_dir | string | %LOCALAPPDATA%\Programs\<product> | Install path the UI proposes. %VAR% env tokens are expanded. |
skip_license | bool | false | Hide the License page. |
skip_path | bool | false | Hide the Choose-location page. |
install_dir_restriction | string | enforce | Whether a fresh interactive install may target a non-empty folder: enforce, default_dir_only, or bypass. See Wizard pages and install location. |
launch_option | string | checked | The "launch now" checkbox on the final page: checked, unchecked, or hidden. |
upgrade_minimal_ui | bool | false | Upgrades use the compact minimal UI; a first install still gets the wizard. |
show_uninstall_complete | bool | false | Show a confirmation message box at the end of an interactive uninstall. |
min_installer_version | string | 1.0.0 | Minimum installer stub version allowed to run this payload. |
purge_unknown_files | bool | false | On a full install over an existing copy, remove files not in this build. Ignored for patches. |
force_reinstall | bool | false | Dev: rewrite all files, remove orphans, skip the from-version check. |
feature_mode | string | sticky | How 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.
| Table | Purpose |
|---|---|
[[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 uninstaller | Built on demand by cargo build | Supplied as prebuilt .exe files |
| Needs Rust and the source tree | Yes, on the packaging machine | No |
| Public key | Passed as --pub-key, compiled in per build | Already baked into the prebuilt stub |
| How you select it | Nothing; this is the default | Pass --installer-stub and --uninstaller |
| Who runs it | You or your CI, where Rust lives | Anyone 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-stuband--uninstallerpoint at the prebuilt binaries. Passing them switchespackinto toolchain-free mode; it never invokescargo. The builder prints "Toolchain-free mode: using prebuilt binaries (no cargo build)".--pub-key(and thepub_keyconfig key) is ignored. The public key is already compiled intoinstaller.exe, andpackwarns if you pass one.--priv-keymust match the public key baked into the supplied stub.packchecks 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.keyand the kit'sinstaller.exemust come from the samepub.key.- Re-issuing a stub with a new key means re-issuing the kit's
priv.keytoo.
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-stuband--uninstallermust be provided together. Passing one without the other is an error.- In toolchain mode (neither prebuilt binary given),
--pub-keyor--pub-key-literalis required. - Both prebuilt paths must exist, or
packstops 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:

How it behaves:
- Optional. Omit
--bannerand 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
.exeas 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 cachedtarget\release\uninstall.exestays 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
| Option | Effect |
|---|---|
--skip-license | Hide the License page. |
--skip-path | Hide 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:
- An explicit path argument (
--silent "<dir>",--minimal "<dir>", or theINSTALLWAY_PATHenvironment variable). - The folder the product was last installed to. A reinstall or upgrade lands in place, and the Choose page is skipped automatically.
- The build's
--default-install-dir, with%VAR%tokens expanded. %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:
| Value | Effect |
|---|---|
enforce | Block any non-empty destination (default). |
default-dir-only | Allow only the build's --default-install-dir to be non-empty. |
bypass | Allow 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:
| Value | Effect |
|---|---|
checked | Visible and ticked (default). The product launches on Finish unless the user clears it. |
unchecked | Visible but not ticked. The user opts in. |
hidden | No 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.
| Run | UI with --upgrade-minimal-ui set |
|---|---|
| First install (no prior copy) | Full wizard, always. |
| Upgrade or reinstall over an existing copy | Minimal UI. |
--silent / --minimal | Unchanged; 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
| Field | Required | Description |
|---|---|---|
dir | Yes | Directory the .lnk is placed in. Tokens are expanded at install time. |
name | Yes | Shortcut file name without .lnk. Must be a single filename: no \ / : * ? " < > |. |
target | Yes | Shortcut target. A relative path resolves against the chosen install directory; an absolute path (or %EXE%) is used as is. |
args | No | A string appended verbatim as the shortcut's command-line arguments. |
feature | No | A 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:
| Token | Expands 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:
| Flag | Effect |
|---|---|
--ignore-desktop-shortcuts | No .lnk is created in any Desktop location (%DESKTOP%, %COMMON_DESKTOP%, %USER_DESKTOP%). |
--ignore-start-menu-shortcuts | No .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:cgives extension.aand descriptionb: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
type | TOML value | Registry type |
|---|---|---|
sz | string | REG_SZ |
expand_sz | string | REG_EXPAND_SZ (Windows expands %ENV% at read time) |
dword | integer, 0 to 4294967295 | REG_DWORD |
qword | integer, 0 or greater | REG_QWORD |
multi_sz | array of strings | REG_MULTI_SZ |
binary | hex string of even length | REG_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:
| Token | Expands 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
- Build.
[[feature]]tables map path globs to a feature id. Matching files getfeature = "<id>"in the manifest; everything else is base. - Choose. A
ui = trueplugin may show a checkbox page, pre-checked to the current set, viainstallway_pages. The host hands it the catalog inctx.features_json. - Resolve. Just before staging, the host queries each plugin's
installway_featureswith this run's page answers. Each plugin returns an{ enable, disable }delta. The active set is(base + enable) - disable, where the base depends onfeature_mode(see below). Only ids the build declares are kept. - 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.
- Persist. The active set is written to
installer_info.json. The next upgrade reads it to clean up any feature it deactivates and, understicky, 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)
| Field | Description |
|---|---|
id | Feature id: ASCII letters, digits, -, _; unique. Referenced by plugins and shortcut gates. |
paths | One or more path globs, relative to the input root. |
default | true 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_pagesemits amulti_choicelistingall, pre-checked toactive, andinstallway_featuresturns 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_mode | Upgrade base | Effect |
|---|---|---|
sticky (default) | The previously installed set. | Features carry over from install to install. Only a plugin's delta changes them. |
override | This 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 fullexepath;data_dir, the folder holdinginstaller_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--langandINSTALLWAY_LANGoverrides;inputs_jsonand theemit_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
| Field | Description |
|---|---|
name | Unique id: ASCII letters, digits, -, _. Names the in-payload DLL and the log lines. |
dll | Path to the DLL to bundle. |
phase | pre-install (before any file is staged) or post-install (after the install is finalized). |
required | If true (default), a non-zero up fails the install. If false, the failure is logged and the install continues. |
ui | If 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
| Phase | When | A required up failure |
|---|---|---|
pre-install | Before staging and commit. | Aborts cleanly; nothing is committed. |
post-install | After finalize: files in place, product registered. | Fails the install. Files stay; uninstall removes them. |
down | At 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:
- The host calls
installway_pageswith the answers so far inctx->inputs_json(empty on the first call). You build one step, hand it toctx->emit_pages(json), and return 0. - The installer renders that page, validates required fields, collects the
answers, and calls
installway_pagesagain with the updated answers. - Return
{ "step": "done" }when there are no more pages. Theninstallway_upreceives all the answers inctx->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" }
pageis{ id, title, subtitle?, widgets[] }. Theidnamespaces the answers and only needs to be unique per page you show.noticeis an optional banner, useful to surface a validation error when re-asking.backdefaults totrue; setfalseto 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
kind | Control | Value in inputs_json |
|---|---|---|
label | Static text | None. |
text | Text box. password masks input, number accepts digits only, multiline is taller. | The typed string. |
checkbox | Checkbox | "true" or "false". |
single_choice | Radio group or drop-down (style: radio or combo) | The chosen option's value. |
multi_choice | Checkbox group, pick any | The 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 underctx->data_dir, next toinstaller_info.json, for exampledata_dir\myplugin.txt. - In
installway_pages, read that file. If it exists, return{ "step": "done" }. The page is skipped andupreuses 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 matchINSTALLWAY_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 SHA256selects the file digest algorithm./tr <url> /td SHA256adds an RFC 3161 timestamp, so the signature stays valid after the certificate expires./aauto-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.
| Data | Values |
|---|---|
operation | install or update |
mode | silent, minimal, or interactive |
privilege | admin, user, or unknown |
lang | Detected UI language code, such as en or fr |
Events sent:
| Event | When |
|---|---|
app_started | The installer launches. Fired automatically by the SDK on init. |
stage_reached | extract, then finalize, then done, as each phase completes. |
install_error | Any failure. Carries a category and the stage; never a raw message or path. |
app_exit | The 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:
- Already correct. The destination exists and its BLAKE3 hash matches: skip. A re-run is effectively instant.
- 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.
- 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 install | Machine-wide install | |
|---|---|---|
| Elevation | None | One UAC prompt |
| Uninstall data folder | %LOCALAPPDATA%\<publisher>\Uninstall\<product-id> | %ProgramData%\<publisher>\Uninstall\<product-id> |
| Windows Apps entry | HKCU\...\Uninstall, visible to the installing user | HKLM\...\Uninstall, visible to every user |
| File associations | HKCU\Software\Classes | HKLM\Software\Classes |
Shortcut tokens %DESKTOP% / %START_MENU% | Per-user Desktop and Start Menu | All-Users Desktop and Start Menu |
Registry entries with hive = "HKLM" | Logged and skipped | Written |
| Plugins | Run as the user | Run 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 type | Data folder | Apps 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:
- Reads
installer_info.jsonto find the real install directory. - Runs plugin
downfunctions, best-effort and in reverse declaration order. See Plugins. - Walks
installer_manifest.jsonand 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. - 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).
- Removes
version.json,installer_manifest.json, and empty subdirectories. - Deletes the Uninstall registry entry, in
HKCUorHKLMto match the install. - Spawns a second-stage copy of itself from
%TEMP%that deletes the app directory and the data directory (includinguninstall.exeitself), then schedules its own removal at reboot. Nocmd.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
| Option | Required | Description |
|---|---|---|
-o, --out <DIR> | Yes | Output 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
| Option | Required | Description |
|---|---|---|
-p, --product <NAME> | Yes | Display name: Windows Apps, version info, wizard UI, shortcut labels. |
--product-id <ID> | Yes | Registry-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> | Yes | Vendor name: Apps "Publisher" field and the uninstall data folder. Must not be empty. |
--to-version <VER> | Yes | New version. Also parsed as a.b.c.d for the version-info resource. |
--input <DIR> | Yes | Source directory of the new version's files. |
-e, --exe <REL> | No | Main executable, relative to --input. Omit only if the product has no executable; see Full installers. |
-o, --out <FILE> | Yes | Output installer .exe path. |
Signing and stub
| Option | Required | Description |
|---|---|---|
--priv-key <FILE> | One of the two | Ed25519 private key file that signs the payload. |
--priv-key-literal <HEX> | One of the two | The private key as 64 hex chars, for CI pipelines. Mutually exclusive with --priv-key. |
--pub-key <FILE> | Toolchain mode: one of the two | Public key file compiled into the stub. Ignored with --installer-stub. |
--pub-key-literal <HEX> | Toolchain mode: one of the two | The public key as 64 hex chars. Mutually exclusive with --pub-key. |
--installer-stub <FILE> | With --uninstaller | Prebuilt stub. Switches to toolchain-free mode. |
--uninstaller <FILE> | With --installer-stub | Prebuilt uninstaller. |
--reuse-stub | No | Skip rebuilding the stub and uninstaller when they already exist (toolchain mode). |
Patch mode
| Option | Required | Description |
|---|---|---|
--from-version <VER> | With --from-dir | Previous version string. Pins the target install. |
--from-dir <DIR> | With --from-version | Previous version's files, for delta generation. |
Packaging and behavior
| Option | Default | Description |
|---|---|---|
--license <FILE> | Built-in placeholder | UTF-8 EULA shown on the License page. |
--banner <FILE.png> | Flat gray header | PNG painted across the wizard header. Author at 1400 x 144 px; see Branding. |
--assoc ".ext:Description" | None | File 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-license | Off | Hide the License page. |
--skip-path | Off | Hide the Choose-location page. |
--install-dir-restriction <enforce|default-dir-only|bypass> | enforce | Non-empty-folder guard for fresh interactive installs. See Wizard pages and install location. |
--launch-option <checked|unchecked|hidden> | checked | State of the final-page "launch now" checkbox. |
--upgrade-minimal-ui | Off | Upgrades use the compact minimal UI; a first install still gets the wizard. |
--show-uninstall-complete | Off | Show a confirmation message box at the end of an interactive uninstall. |
--min-installer-version <VER> | 1.0.0 | Minimum installer stub version allowed to run this payload. |
--purge-unknown-files | Off | Full installs: remove unknown or leftover files on an upgrade or reinstall. Known files are still hash-skipped. Ignored for patches. |
--force-reinstall | Off | Dev: rewrite all files, remove orphans, skip the from-version check. |
--config <FILE.toml> | None | Read 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.
| Flag | Description |
|---|---|
--silent | Headless install. Progress prints to stdout; see Install modes. |
--minimal | Compact self-update UI; see Install modes. |
--launch | Launch the installed exe after a successful silent or minimal install. |
--verify | Verify 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-shortcuts | Do not create desktop shortcuts, in any mode. See Shortcuts. |
--ignore-start-menu-shortcuts | Do 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
| Variable | Effect |
|---|---|
INSTALLWAY_PATH | Target install directory for --silent and --minimal when no positional path is given. |
INSTALLWAY_LANG | UI 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:
| Flag | Description |
|---|---|
--silent | Skip 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.
| Code | Meaning |
|---|---|
0 | Success. |
10 | Wrong installed version for this patch. The install is untouched; run the full installer instead. |
1 | Any 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
--verifyexits0when the embedded payload verifies,1otherwise.--verify-install "<dir>"exits0when every file in the installed manifest is present and matches its hash,1if anything isMISSINGorCORRUPT.
Uninstaller (uninstall.exe)
| Code | Meaning |
|---|---|
0 | Success. |
1 | Failure. |
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
| Resource | Id | Contents |
|---|---|---|
RT_RCDATA | 2 | SignedPayload JSON: the manifest and metadata, plus the signature. |
RT_RCDATA | 3 | The uninstaller .exe. |
RT_RCDATA | 4 | The payload length, a little-endian u64. |
RT_RCDATA | 5 | The optional header banner PNG. Not signed; see Branding. |
| PE overlay | A 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
| Field | Type | Notes |
|---|---|---|
kind | Full or Patch | |
product | String | Display name. |
product_id | String | Registry-safe id: Uninstall key, ProgIDs, data folder, upgrade detection. |
publisher | String | Uninstall data folder and the Apps "Publisher" field. |
hintway_tenant_id | Option<String> | Hintway tenant UUID configured at pack time. |
from_version | Option<String> | Set for patches; pins the target version. |
to_version | String | |
min_installer_version | String | Minimum stub version allowed to run this payload. Default 1.0.0. |
payload_blake3 | String | BLAKE3 of the zip, re-verified before extraction. |
created_at_unix | i64 | |
manifest | Manifest | The per-file table; see below. |
license_text | Option<String> | EULA shown on the License page. |
associations | Vec<FileAssoc> | File types to register under Software\Classes. |
plugins | Vec<PluginEntry> | Bundled plugins and their phases. |
shortcuts | Vec<ShortcutEntry> | Shortcuts to create. dir, target, and args are token templates. None are created unless declared. |
registry | Vec<RegistryEntry> | Free-form registry entries. Key and value are token templates. |
force_reinstall | bool | Dev: rewrite all, remove orphans, skip the from-version check. |
purge_unknown_files | bool | Full installs: remove unknown or leftover files. Ignored for patches. |
skip_license, skip_path | bool | Trim the wizard. |
install_dir_restriction | Enforce, DefaultDirOnly, or Bypass | Whether a fresh interactive install may target a non-empty folder. Default Enforce. |
default_install_dir | Option<String> | Proposed path; %VAR% tokens are expanded. |
launch_option | Checked, Unchecked, or Hidden | The final-page "launch now" checkbox. |
upgrade_minimal_ui | bool | Upgrades use the minimal UI; a first install always gets the wizard. |
show_uninstall_complete | bool | Show 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.