# TigerSetup — Design
This document is the authoritative description of what TigerSetup is, what it
must do, and the architecture that constrains every implementation decision.
`README.md` is the practical entry point for building an installer; how
TigerSetup is proven is owned by `TigerSetup-Validation.md`.
---
## 1. Positioning
TigerSetup is:
> **A small, modern, native Windows setup builder for ordinary desktop
> applications.**
Its value is not a longer installer feature list. It is **removing duplicated
project-specific packaging glue and making installation state explicit and
verifiable**.
The useful combination is:
- a declarative installer definition;
- metadata extraction from MSBuild projects or built executables;
- consistency validation between source metadata, binaries, installer metadata
and distribution metadata;
- SQLite-backed installation ownership and a transaction journal;
- upgrade, rollback, uninstall, verify and repair semantics;
- WinGet-ready package metadata from the same source of truth.
### Non-goals
TigerSetup does not try to compete with:
- MSI / Windows Installer;
- WiX / Burn;
- InstallShield;
- Advanced Installer;
- enterprise deployment frameworks;
- arbitrary installer scripting languages;
- every obscure Windows installation scenario.
**If TigerSetup does not understand an operation, it does not perform it.**
---
## 2. What TigerSetup replaces
TigerSetup replaces per-project packaging glue — PowerShell around Inno Setup,
each project re-implementing metadata extraction, version checks, PATH
handling, scope and elevation logic, dependency detection and release
integration in its own way — with one engine and a declarative package.
**TigerMarkView is the reference application**: a per-user or machine-wide
desktop application with optional PATH integration, a
`Microsoft.DotNet.DesktopRuntime.10` and a `Microsoft.EdgeWebView2Runtime`
dependency, unattended use by release automation and package managers, and
upgrade and uninstall behaviour that package managers rely on. Its package is
`packages/TigerMarkView/`, and its replacement validation is TigerSetup's
acceptance standard (`TigerSetup-Validation.md` §4).
Real applications define scope. The behaviour of the Tiger installers
TigerSetup replaces is a **requirement source, not an architecture to
translate**:
> **A requirement repeated across real applications is evidence for a TigerSetup
> primitive. A one-off installer trick is not.**
---
## 3. Developer workflow
```text
project / binary
↓
TigerSetup.toml
↓
tiger-setup build
↓
validated Setup.exe
↓
WinGet-ready package metadata
```
The builder command line, in the same command-app grammar as the generated
installer (§6.2):
```text
tiger-setup build TigerSetup.toml [--output
] [--engine ]
[--property ]... [--offline] [--fast]
tiger-setup metadata TigerSetup.toml [--property ]... [--json]
tiger-setup inspect Setup.exe [--json] [--output-zip ] [--output-meta ]
[--output-meta-json ]
tiger-setup verify Setup.exe
tiger-setup winget prepare TigerSetup.toml --installer Setup.exe --output
tiger-setup winget finalize --url --installer Setup.exe
```
Naming conventions: `TigerSetup.toml`, `tiger-setup.exe`, and
`--Setup.exe` unless `--output` names the file.
---
## 4. Declarative package definition
TOML is the package-definition format. The sections are `[package]`,
`[metadata]` (§9), `[install]`, `[installer]`, `[[files]]`, `[[options]]`,
`[[shortcuts]]`, `[[path]]`, `[[environment]]`, `[[registry]]`,
`[[file_associations]]`, `[[url_protocols]]`, `[[app_paths]]`,
`[[context_menu]]`, `[[firewall]]`, `[[actions]]` (§5.14), `[launch]` (§11.7),
`[registration]`, `[legacy]` (§5.12), `[[dependencies]]` (§7) and `[winget]` (§8.2);
`README.md` shows every key, and the builder's manifest module is the
schema.
```toml
[package]
id = "ItTiger.TigerMarkView"
name = "TigerMarkView"
version = "0.8.0"
publisher = "IT Tiger"
license = "MIT"
icon = "assets/TMV.ico" # the product's branding icon
[install]
architecture = "x64"
scopes = ["user", "machine"] # first entry is the default scope
existing_scope = "preserve" # what a rerun does when the product is installed in the other scope (§5.13)
[installer]
icon = "branding" # the generated Setup.exe's own icon (§11.6)
[[files]]
source = "publish/**"
exclude = ["*.pdb"]
```
`[installer].icon` selects the icon the generated `Setup.exe` carries in its
own Windows resources, separately from the product's branding icon; the
resolution rules are §11.6. `[install].existing_scope` is the cross-scope
policy of §5.13. Both are optional and default to the least-surprising
behaviour — the branding icon (or TigerSetup's when there is none), and
`preserve`. Every declaration is typed: a file set is a glob, a shortcut names
its install-relative target, a registry value its kind, an option its default
and its label per language. A value the builder can derive — the version, the
description, the copyright — is derived once through `[metadata]` and
validated, never typed twice.
**Options and the one predicate.** An option is boolean, or a *choice* of
exactly one declared value; both are labelled per language and both are
remembered by the installation (§5.3). Every optional resource is gated by
the same predicate, `when = { option, equals }`, comparing the option's
canonical value text (`true`/`false`, or the choice value) — and by nothing
else: no expressions, no negation, no combination, no machine-state
conditions. A resource wanted under two values is declared twice. This is
deliberately the whole conditional language, because every richer one
observed in real installers grew into scripting. A *component* is therefore
not a concept of its own: it is an option that gates `[[files]]`, and its
files come and go with the option through the ordinary reconciliation,
conservative removal included. The older `option = ""` spelling on
shortcuts and PATH entries remains valid and means `equals = true`.
The foundational distinction:
> **Manifest = intent. Database = reality.**
The manifest expresses desired installation state. The installed database
records actual installation state and ownership.
`TigerSetup.toml` is the **developer-facing source format** and is not shipped
inside the generated installer. `tiger-setup build` validates it, resolves
metadata sources, dependency identities and the file set, and emits **runtime
metadata** in the compact form the installer engine reads on the target machine
(§10.4). Both express the same intent: one is written by a developer, the other
is read by the engine.
---
## 5. Core architecture
### 5.1 Manifest is intent; database is reality
A per-installation SQLite database records what the installation actually owns.
Uninstall and upgrade plan from that database, **never** by inverting the
current manifest.
Concretely: if 0.8 installed `foo.dll` and 0.9 replaces it with `bar.dll`, the
0.9 package needs no hardcoded historical knowledge. The database already knows
TigerSetup owns `foo.dll`, so the upgrade plans `remove owned foo.dll` /
`install bar.dll`.
### 5.2 State location
One database per installed product/installation — not one global TigerSetup
database:
```text
Machine scope: %ProgramData%\TigerSetup\\state.db
User scope: %LOCALAPPDATA%\TigerSetup\\state.db
```
Per-product state isolates corruption, avoids turning TigerSetup into a
system-wide package manager, avoids requiring a background service, and keeps
ownership boundaries simple. The product ID must be stable across versions.
### 5.3 Installation state vs transaction journal
Two concepts that must stay separate.
**Installation state** — the currently committed state of the installed product:
product identity, installed version, scope, installation ID, install root,
registration key, the recorded option values (each as its canonical text, so
a boolean and a choice are one column and reports give each back with its
type), the licence text a person explicitly accepted (as the SHA-256 of its
exact bytes, or nothing), the owned resources — files with their hashes,
directories, registry keys and values, PATH entries, shortcuts, environment
variables (with the value that was there before, for the restore), firewall
rules (as written) — and the uninstall-phase custom actions the installation
keeps for its own uninstall, each with its definition and the identity of
the packaged program the state directory holds for it (§5.14). Option values
follow one precedence everywhere — an
explicit value for this run, else the last committed value, else the manifest
default — and are committed with the transaction, so a failed, cancelled or
rolled-back run leaves the recorded values exactly as they were; there is no
separate wizard preference store.
**Transaction journal** — the current install/upgrade/uninstall/repair attempt
and its rollback information: transaction ID and kind, the versions it moves
between, the package identity and metadata hash it was planned from, what the
commit will record on the installation (registration key, accepted licence),
and one row per operation with its sequence, kind, target, state, the
previous state it needs to undo (existence, hash, backup path, previous
registry data), what it wrote, and its result.
The tables are typed, one per concept, rather than generic JSON blobs:
```text
installation transaction operation installation_option transaction_option
file directory registry_key registry_value path_entry shortcut
environment_variable firewall_rule action
dependency_event what the dependency phase observed — history, never ownership
action_run every custom action execution — evidence, never ownership
```
The schema version lives in `PRAGMA user_version`, with forward-only
migrations in place: a mutating run migrates the database it opens; a
read-only reader (`inspect`, `verify`, the wizard working out its flow)
reads every schema back to the oldest one it understands, so an installation
made by an earlier engine is described, not refused, until a mutating run
migrates it. The current schema is 6: version 4 (0.6.0) added the
environment-variable and firewall tables and made option values text,
version 5 (0.7.0) added the `action` and `action_run` tables and nothing
else, so a reader of a 0.6.0 database sees an installation with no actions
and the first mutating run adds the two tables without touching a row;
version 6 (0.7.1) added a registry value's pre-installation state to
`registry_value` (`pre_existed`, `previous_kind`, `previous_data`), so a
reader of an older database sees values that did not pre-exist — exactly
what the engine that wrote them knew — and takes them away by deletion, as
it always did; version 7 (0.8.0) added the fingerprint of an owned file as
installed (`file.modified`, `operation.applied_modified`, §5.7), so a reader
of an older database has no fingerprint to trust and hashes every file, as
that engine did; version 8 (0.9.0) added the journal batch an operation
transitions with (`operation.batch`, §5.4), so an operation an older engine
journaled has none and is walked on its own, as that engine walked it.
### 5.4 Crash consistency
SQLite cannot make the Windows filesystem, registry, services and process state
transactional. TigerSetup therefore uses a **persistent transaction journal
written in many short durable commits** — not one giant SQLite transaction held
open for the whole installation.
```text
start logical transaction
↓
record operation and durable undo state
↓
mutate Windows
↓
mark operation applied
↓
repeat
↓
commit installed state
↓
mark transaction committed
```
The hard invariant:
> **Durable undo state must be written before the Windows mutation**, so a crash
> between the two is recoverable.
A naive `planned → mutate → completed` state machine is ambiguous if the process
dies after the mutation but before `completed`. The operation states are
therefore:
```text
planned → applying → applied
```
where `applying` is written together with the operation's undo record, and on
restart resource inspection reconciles the one ambiguous state, `applying`.
(A journal an older engine left may still carry `prepared` — undo durable,
mutation not started — and it is read as such.)
**What the transaction must guarantee.** The objective is transactional
consistency: an installation reaches **success** or **full rollback**. A third
condition is legitimate but never terminal — **recoverable failure**, where the
attempt stopped part-way and durable state still describes enough to finish
converging. Durable state must always allow TigerSetup to reach success or
rollback eventually; an unknown or hybrid installation is exactly the outcome
this architecture exists to prevent.
```text
absent ──── install ────→ installed
↑ │
└──────── rollback ────────┘
version A ── upgrade ────→ version B
↑ │
└──────── rollback ────────┘ never a mixture of A and B
```
First installation is the simple case: `absent → installed`, or rollback to
`absent`. **Upgrade is the case that matters**: `A → B`, or rollback to a valid,
complete, working `A`. A failed or interrupted upgrade must never be accepted as
a hybrid A/B installation — the files of B with the registration of A is a
failure, not a partial success.
Dependencies sit outside the product transaction. From its perspective they are
external, shared prerequisites that succeed or fail independently; if TigerSetup
caused a shared dependency to be installed and the product transaction later
rolls back, that dependency normally remains installed (§7.1).
**The journal model.**
- An operation moves `planned → applying → applied`; a transaction
`running → committed`, or `rolling_back → rolled_back`, with
`rollback_failed` as the state that needs a later run. There is no separate
`committing` state: the commit is one SQL transaction that rewrites the
installation and ownership rows from the journal and marks the transaction
committed, so there is nothing between "not committed" and "committed" to
reconcile.
- Transitions are journaled under a rollback journal (`journal_mode = DELETE`)
with `synchronous = FULL` and an exclusive lock for the run, and **the unit
of a durable transition is the commit group, not the batch or the file**.
The invariant constrains what must be durable *before* a mutation and
*after* it, not how many operations share a commit, so the journal records
recovery state rather than a narrative of every file. The forward walk
takes up to **eight consecutive journal batches** as one commit group:
the operations of the group move to `applying` together, each with its
undo record, in one durable commit; the mutations are performed in
sequence order with nothing written to the journal between them; and the
group moves to `applied` together, each operation with the inventory of
what it wrote — hash, size, last-write time — in one more commit. A crash
anywhere inside a group leaves it `applying`, and recovery reconciles the
*group* by inspecting each of its targets; it never needs to know which
file's syscall was the last to succeed. The group bound is the bound on
that work: at most eight of the builder's batches, so at most 2,048 files
or 256 MiB of them, plus the files larger than a batch. The per-file row
is kept throughout — it is the undo record the rollback reads and the
inventory the ownership row is made from at the commit — but it carries
no transition of its own inside a group.
- **The batch is the plan's unit, and the file batches are the builder's.**
The metadata carries them (`file_batches`, §10.4): consecutive runs of
the file list, in payload order, closed before the file that would take a
batch past **256 files or 32 MiB** of uncompressed bytes, whichever comes
first, so that a file larger than 32 MiB is a batch of its own. The engine
reads the boundaries and never reproduces the rule; an uninstall removes
files by the batches the uninstaller's own metadata carries, and an owned
file the package no longer knows goes in a batch of the residue after
them. Every other typed resource — a directory, a registry key or value,
a PATH entry, an environment variable, a shortcut, a firewall rule, a
stored action program — shares a batch with its neighbours of the same
kind. A resource whose exact previous state cannot be reconstructed after
its mutation — a registry value's prior type and data, the whole previous
`Path` text — is as safe there as a file is, because the walk takes every
undo record of a group before it mutates any of it. The rollback walks
batch by batch, in reverse.
- **A custom action is a commit group of its own**, because its `action_run`
row must be durable before its process exists and its effects are not
TigerSetup's to reason about; so is an operation an injected fault names,
so that a fault's boundary is exactly that operation's — everything
before it durably applied, nothing after it started. The guiding
invariant is unchanged on every path: durable recovery information before
the mutation; completion acknowledgements batched only where recovery can
reconcile the actual state safely. A thousand files therefore cost two
commits per group rather than three per file, and the transaction's time
is the mutation, not the journal.
- For a file whose target already holds a file: record the previous hash and
where the previous file will be kept; write `.tigersetup-new`,
`FlushFileBuffers`, and replace the target in one `ReplaceFileW`, which
moves the previous file into the transaction's staging area as the undo and
puts the new one in its place — the target is at every instant the old file
or the new one, and no bytes are copied. A removal moves the file into the
staging area the same way; the staging area is deleted after the commit, in
one directory removal, and a rollback moves the files back. A backup on
another volume, where a rename cannot reach, falls back to a flushed copy.
Recovery classifies an `applying` operation by inspecting the target:
absent, equal to the payload, equal to the previous content, or different —
and re-applies or completes accordingly, logging what it found
(`file_missing`, `file_content_mismatch`, `operation_reapplied`).
- **The hash a file is owned by comes from the package.** The payload index
records every entry's SHA-256 (§10.5), the engine checks the bytes it wrote
against it before the rename, and the ownership row records that hash with
the file's size and last-write time once it is in place. A file whose size
and last-write time are still what the row records is the file TigerSetup
wrote and has that hash without being read; a plan hashes only a file whose
fingerprint has changed, and a walk does the same when it records the undo.
An uninstall of a thousand files therefore plans in milliseconds, and an
upgrade decides what to keep by comparing the installation's hashes with
the package's index without decoding the stream. `verify` still hashes
every owned file: it is the explicit request for a thorough check.
- **Direction is decided once per recovery**: forward when the running engine
carries the same package identity, version and metadata hash and the
transaction is still `running`, so every payload byte is at hand; rollback
otherwise, and always for a transaction already rolling back. Rollback
inspects before it acts and is idempotent.
- Recovery begins with a sweep of `*.tigersetup-new` temporaries and backups no
journal row references, so a crash between a filesystem action and its
journal row leaves nothing behind.
**The flush before the rename is not optional.** A file renamed into place
without `FlushFileBuffers` can survive a power cut at its full length with
different content — Windows' lazy writer has not yet written the data — and
only the recorded hash would detect it. Write-through on the rename covers the
directory entry; the flush covers the data; both are needed, and the recorded
hash is the check that catches what neither covered.
### 5.5 Typed operations, not scripting
Every system mutation is a typed, known, journaled, reversible operation. Each
resource kind — file, directory, registry key, registry value, PATH entry,
environment variable, shortcut, firewall rule — has an install, a remove and
a keep operation, and the Add/Remove Programs registration is registry values
like any other:
```text
InstallFile CreateDirectory CreateRegistryKey SetRegistryValue AddPathEntry CreateShortcut
RemoveFile RemoveDirectory RemoveRegistryKey RemoveRegistryValue RemovePathEntry RemoveShortcut
KeepFile KeepDirectory KeepRegistryKey KeepRegistryValue KeepPathEntry KeepShortcut
SetEnvironmentVariable RestoreEnvironmentVariable KeepEnvironmentVariable
CreateFirewallRule RemoveFirewallRule KeepFirewallRule
RunAction StoreAction
```
**The typed Windows integrations are registry values.** A file association,
a URL protocol, an `App Paths` entry and a classic context-menu verb are each
compiled by the engine into the registry keys and values Windows documents
for them — a ProgID class with its `DefaultIcon` and `shell\open\command`,
an `OpenWithProgids` entry per extension, a capability registration under
the publisher's key and `RegisteredApplications`, a scheme class carrying
`URL Protocol`, an `App Paths\` key, a verb under `Classes\*\shell`,
`Directory\shell` or `Directory\Background\shell` — and then planned,
journaled, rolled back, verified, repaired and removed as ordinary registry
values with value-level ownership. There is no second registry engine, only
the knowledge of which values each integration is; the shell is told once,
after the walk, that associations changed. A key chain is created downward
from the deepest key Windows itself owns (`Software`, `Software\Classes`,
`App Paths`, the Add/Remove Programs root), so TigerSetup never owns a key of
Windows's own. Two rules keep the integrations from taking anything over: an
association registers the product as a *handler* — `OpenWithProgids` and the
capability, never the extension's default and never a `UserChoice` — and a
URL scheme's own class key is written only where nothing else owns it; a
scheme another application registered is left exactly as it is and reported
(`url_protocol_scheme_in_use_preserved`), while the handler ProgID and the
capability are still registered.
A `Keep` operation is how an upgrade or a repair records that an owned
resource is carried over unchanged: it is journaled `applied` at once, so
the transaction's ownership rows are complete without walking the disk.
TigerSetup resists arbitrary script execution unless a concrete requirement
proves typed mechanisms insufficient. Where a real application needs work
no typed resource can express, a **custom action** (§5.14) is the one
sanctioned form: a declared, packaged, verified and recorded program with a
bounded envelope — journaled as `RunAction`, with `StoreAction` keeping the
programs an uninstall will need — and with no pretence that TigerSetup can
undo what it did.
### 5.6 Ownership is conservative
- **Files** — record installed hashes. A file owned by TigerSetup but modified
after installation is **preserved on uninstall and reported**: the uninstall
outcome lists it under the stable code `file_modified_preserved`, and the
directory that contains it is not removed. `verify --json`, which only
observes, reports the same file as `file_modified`: an observation code
names what was found (`_missing`, `_modified`, and for
a container that still holds something, `_not_empty`), and a
mutating run that left something alone adds the action (`_preserved`), so
a reader always knows whether anything was done.
- **PATH** — if an equivalent entry existed before installation, TigerSetup does
not claim ownership of it. The database records scope, exact/normalised entry,
whether it pre-existed, and whether TigerSetup added it.
- **Resources whose location Windows may move** — a stored path outside the
roots the scope resolves *now* is not automatically evidence that the
database was tampered with. A registry hive cannot move, so a key in the
other hive stops the run — a stored registry key or value is confined to
the scope's hive, because a product value may live at an explicit location
outside `Software` (below), while a PATH, environment or registration row
is confined to the scope's own environment key and Add/Remove Programs
root; the machine-scope database is writable by administrators alone
(§5.11), so a row there names nothing its writer could not already reach.
A shortcut folder can: OneDrive's Known Folder Move
relocates the desktop and policy can redirect the Start Menu, so a link
recorded before such a move is left untouched, reported as
`shortcut_outside_scope_preserved`, and the rest of the uninstall proceeds.
Both answers refuse to act outside the scope, which is the point; refusing
the whole run would leave the product impossible to uninstall, and that is a
worse outcome than a resource left behind.
- **Directories** — do not recursively delete unknown content merely because
TigerSetup created the directory.
- **Registry** — prefer ownership at value level; deleting a whole key must be
conservative when unrelated values may exist. A `[[registry]]` value lives
under the scope's `Software` root by default, or — `root = "HKLM"` or
`root = "HKCU"` — at an explicit location in the scope's hive, such as
`HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled`; the
hive must be the one the package's only scope writes, and the builder
refuses a dual-scope package that declares one. Either way the ownership
row records what TigerSetup wrote *and* what the value held before, and
the value follows the environment-variable model below: taking it away
(uninstall, or the option turned off) restores the previous data where
there was some and deletes the value where there was none, only while the
value still holds what TigerSetup wrote; a value the user or another
program changed since is preserved and reported
(`registry_value_modified_preserved`) by an upgrade, a reinstall and an
uninstall alike, and only a repair rewrites it; a value already holding
the wanted data is kept with itself as the data to restore, so it is never
claimed. The Add/Remove Programs registration is the one exception: it is
TigerSetup's own bookkeeping, and the desired values always win. Keys are
created down from the deepest key Windows owns — `Software`, its
`Classes` and `App Paths`, the Add/Remove Programs root, or the hive's
top-level key for an explicit location — and only the keys TigerSetup
created are owned and removed, when empty.
- **Environment variables** — the ownership row records what TigerSetup wrote
*and* what the variable held before. Removal (uninstall, or the option
turned off) restores the previous value where there was one and deletes
the variable where there was not — but only while the variable still holds
what TigerSetup wrote; a value the user or another program changed since is
preserved and reported (`environment_variable_modified_preserved`), and a
variable already holding the wanted value is kept with itself as the value
to restore, so it is never claimed. Only a repair, which is asked for,
rewrites a changed value. The environment is broadcast once per run.
- **Firewall rules** — rules are machine-wide, identified by a name Windows
does not keep unique, and need an administrator. TigerSetup files its rule
under a `Grouping` naming the product, which is how it tells its own rule
from a stranger's of the same name: a same-named rule without the grouping
is never claimed, rewritten or removed (`firewall_rule_name_in_use_preserved`),
and a same-named rule of TigerSetup's that reads differently belongs to
another installation of the product — the other scope's — and is preserved
the same way. A rule the user changed (disabled, retargeted) is reported by
`verify`, kept by an upgrade or uninstall (`firewall_rule_modified_preserved`)
and rewritten only by a repair. A run without an administrator — a per-user
install by a standard user — creates and removes no rule and reports
`firewall_rule_skipped_unelevated` for each declared one; owned rules stay
owned for a later elevated run. Rules are written through the Windows
Firewall API (`INetFwPolicy2`), never through a command-line tool.
- **Shortcuts, extended** — a Startup link runs at sign-in; a Send To link
exists per user only, so a machine-scope run reports
`shortcut_location_unavailable` rather than inventing a shared one; a URL
shortcut is an Internet shortcut file (`.url`) removed only while it still
opens the recorded URL; the working directory and the AppUserModelID are
written into the link and compared on verify. A link's `folder` goes with
the last link in it: removing a link — on an uninstall, a change of options,
the undo of a create, or a resumed removal that finds the link already gone
— removes the folders it leaves empty. It never removes a shortcut folder of
the scope (Startup included, although it lies inside Programs), a junction,
or a folder that still holds anything, so a folder several products share
stays until the last of them leaves. The rule is emptiness, not recorded
creation: the folder is created with the link and not journaled, the way
conventional installers treat a Start Menu group, and a failure to remove it
is left in place rather than failing the run. The completion page's launch
offer is not derived from any shortcut: a package declares it as `[launch]`
(§11.7), and one that declares none offers nothing.
### 5.7 File backup strategy
Backups for replace/delete operations live in a transaction staging directory
rather than in SQLite blobs. The database stores metadata: original path,
installed hash, previous hash, backup path, ownership information. The undo
record — the previous hash and the backup path — is durable before the
destructive mutation; the mutation itself *moves* the previous file to the
backup path rather than copying it, so the bytes are exactly where the record
says they are and nothing was copied to get them there (§5.4). A rollback moves
them back. Obsolete backup data is cleaned up after commit, in one removal of
the staging directory — which for an uninstall is where the deletion of every
removed file actually happens.
### 5.8 Uninstall model
The uninstaller does not need the original installer manifest.
```text
Setup.exe → install → state.db → uninstall.exe + state.db
```
Uninstall queries actual installed state and removes what TigerSetup owns.
**A committed uninstall leaves nothing of TigerSetup's own behind** — no
database, no uninstaller, no logs, no temporary files — whichever executable
ran it. The state directory goes, and so does the `TigerSetup` directory above
it once no other product's state is left in it. The uninstall's own log is
written in a staging directory of its own, never in the state directory it is
about to remove, and is deleted with that directory when the uninstall
commits; an uninstall that fails keeps it, where the outcome says it is. A log
written to a path the caller chose with `--log` is the caller's and stays.
The uninstaller copy is the complicated case, because it must remove the
directory it lives in. Add/Remove Programs runs `uninstall.exe`, which copies
itself into a staging directory — `%TEMP%\TigerSetup` unelevated, a fresh
protected directory under `%SystemRoot%\Temp` elevated (§5.11) — and runs the
uninstall from the copy. The copy moves the running `uninstall.exe` aside into
its own staging directory, removes the state directory, and leaves a detached
helper that deletes both executables once every process running them has
exited, then the staging directories they leave empty. Should the move fail,
the rest of the state directory is still removed, and the helper deletes the
executable where it is and the directories it leaves empty. An installer that
uninstalls runs from somewhere else entirely, so nothing in the directory is
held open and it is simply removed. The loader removes the extraction
directory it ran the engine from, and the `%TEMP%\TigerSetup` it was in when
nothing else is left there (§10.2).
A rolled-back install is different: it never became an installation, and the
database that recorded the attempt stays for diagnosis, as does the log of a
failed run.
**The uninstaller lives in the state directory**, beside the database:
`%ProgramData%\TigerSetup\\uninstall.exe` for machine scope and
the `%LOCALAPPDATA%` twin for user scope, with the Add/Remove Programs
`UninstallString` and `QuietUninstallString` pointing there. The install root
is what an upgrade rewrites, so an uninstaller living there would be replaced
in the middle of the transaction that might need it; in the state directory
the engine that can read the current journal is always the one that wrote it,
an upgrade replaces the uninstaller as bootstrap rather than as an owned
resource, and the uninstaller survives a user deleting the install root by
hand. The uninstaller is the engine block of the installer, copied durably
before any transaction is opened.
### 5.9 Reconciliation model
```text
desired state from manifest
+ owned state from database
+ actual Windows state
→ plan
→ journal
→ apply
```
The same model supports install, upgrade, uninstall, verify and eventually
repair. Repair is not a second installer engine; it is reconciliation using the
same resource model.
### 5.10 Running applications and files in use
TigerSetup follows normal Windows installer conventions here rather than
inventing a TigerSetup-specific cooperative shutdown and restart protocol.
**Windows Restart Manager** is the mechanism for detecting which processes hold
files that are about to be replaced, asking them to shut down, and restarting
them afterwards; an application that wants to come back cleanly registers with
`RegisterApplicationRestart`.
The division of responsibility:
- the **application** saves and restores its own user and session state;
- **TigerSetup** coordinates shutdown and quiescence before it mutates
anything, and restarts what it stopped after a successful upgrade or after a
rollback, as appropriate.
Two properties are the design's rather than the implementation's:
- **What decides whether the run may go on is who still holds the files**, not
what the shutdown call returned. Windows reports success once it has stopped
what it could, and an application with no message loop is asked and simply
never answers. **That question is asked of the machine, not of the Restart
Manager's own list**: `RmGetList` answers with the applications the session
was told about when the resources were registered and keeps naming one that
has already exited, so a run that waited for *that* list to empty would wait
for something that never happens and refuse every upgrade over a running
application, however promptly it closed. Whether a holder is still there is a
question about its process, asked by the identity the Restart Manager itself
uses — the process id together with the moment it started, so a reused id is
not mistaken for it.
- **A graceful shutdown is a request, and a request takes time to honour.** An
application asked to close has a window to answer, work to save and a process
to end, so the holders are re-listed over a bounded grace period before
the run concludes that anyone refused. Re-listing immediately reports an
application that is doing exactly what it was asked to do as one that would
not, and fails an upgrade against a co-operating application.
**How long the grace period is depends on what Windows can ask of the
holder.** A windowed application is closed by messaging its windows, which is
the request that works, so it is given real time to save and exit; a holder
the Restart Manager found no window for is asked in a way it may never
answer, and waiting the same time for it only delays the refusal. The
Restart Manager's own classification of each holder is what decides which,
and it is recorded beside the holder in the log and in the in-use message,
because "still running after the grace period" otherwise reads identically
for an application that refused and one that was never asked.
Quiescence is what makes replacing a file in use *pleasant*, and the journal is
what makes the transaction *safe*. A holder still there at the end is
`package_in_use` with nothing mutated, which is one of the two acceptable ends.
Forcing a running application to die is never the other one.
**Only a file something holds is put to the Restart Manager.** Before the
session is opened, every file the plan replaces or removes is probed with an
open for `DELETE` and write access that grants every sharing mode, and a file
that refuses neither has no holder, so it is not registered. Held means in
use the way Windows means it, and the two kinds of holder refuse different
halves of that open: a data file an application keeps open without delete
sharing refuses the delete, which is what would make the replacement or the
removal fail; the image of a running program — its executable and the DLLs
it has loaded — is mapped with delete sharing, so Windows lets it be renamed
from under the process and refuses only the write. A probe for delete access
alone therefore calls a running application's own files free, and an upgrade
would replace them under the live process without the Restart Manager ever
being asked — which is the case the Restart Manager exists for. The probe
asks for no data and writes none, which matters: the Restart Manager opens
every file it is given to find its holders, and on files an installation has
just written that open is what a real-time scanner reads each of them for —
seconds per thousand files, spent to learn that nobody holds them. A file
whose write is refused for a reason that is not a holder — a read-only
attribute, an access control list — is probed for delete access alone.
**Package-declared quiescence.** The Restart Manager closes an application by
messaging its windows, so a process with no window to message — a tray helper,
a service-like background process, a detached worker — is listed as a holder
and never closed, and every upgrade of such a product would end
`package_in_use`. A `[[quiescence]]` entry is the package's own answer, and it
is a structured lifecycle rather than a pre-install action moved earlier:
```toml
[[quiescence]]
name = "viewer"
run_on = ["upgrade", "reinstall", "repair", "uninstall"] # the default
not_running_codes = [3]
[quiescence.stop] # the custom action's envelope (§5.14)
kind = "exe"
command = "%INSTALLROOT%\TigerMarkView.exe"
arguments = ["--quit"]
timeout_seconds = 30
[quiescence.resume] # optional
kind = "exe"
command = "%INSTALLROOT%\TigerMarkView.exe"
arguments = ["--background"]
```
- **When.** The stop program runs before the Restart Manager is asked and
before the transaction opens, on the operations the entry names — by
default upgrade, reinstall, repair and uninstall, the operations that find
an installation whose application may be running; `install` may be named
for a package whose stop program does not need the product's files. An
installing run uses the package's entries; an uninstall uses the entries
the installation recorded when it was installed, with their packaged
programs kept in the state directory beside the uninstall actions, because
the installer that brought the product is usually gone by then.
- **What the stop reports.** Its `success_codes` (`[0]` by default) mean the
application was running and is now stopped; `not_running_codes` mean
nothing was running; any other exit code, a timeout or a launch failure is
a failed quiescence, which `on_failure` decides — `fail` (the default) ends
the run with `quiescence_failed` before anything is mutated, `continue`
records the failure (`quiescence_failed_continued`) and lets the Restart
Manager have its turn. The program runs with the custom action's envelope:
`exe`, `powershell` or `cmd`, a command on the target or a packaged file,
arguments passed separately, hidden, captured, bounded by a job object, told
about the run through `TIGERSETUP_*` (with `TIGERSETUP_PHASE=quiesce`).
- **Only what was stopped is resumed.** TigerSetup records which entries
reported the application running; an application that was not running
stays not running. The resume program is started **detached** — not waited
for, not bounded, not captured, with the run's token, shown as it starts —
and its start is recorded; it is the one program TigerSetup starts and does
not wait for, because its purpose is to outlive the installer.
- **Every failure past the stop resumes.** Whether the Restart Manager then
finds another holder that will not close, a dependency cannot be acquired,
or the transaction rolls back, the application is started again before the
failure is reported: a refusal never leaves it stopped for nothing. After a
successful install, upgrade, reinstall or repair it is resumed as designed.
- **Uninstall stops and never resumes;** a rolled-back uninstall resumes,
because the product is still there. A run that leaves its transaction open
— a rollback that itself failed — does not resume (`quiescence_not_resumed`):
the product's files are in no state to run from, and the recovery that
settles them is the run that should decide.
- **Nothing is claimed about side effects.** What the stop program did to the
machine is the package author's, exactly as a custom action's is; TigerSetup
records that it ran, what it reported, and what it started again — in the
log, in `action_run` rows (phases `quiesce` and `resume`) and in the outcome
document's `actions` and findings (`quiescence_stopped`,
`quiescence_not_running`, `quiescence_resumed`, `quiescence_resume_failed`).
A crash while the application is stopped is not a normal failure path: the
next run's recovery settles the product, and the application is started by
whoever starts it next.
**What this asks of the application.** Respond promptly to the normal Windows
close or session-ending request; a service stops cleanly through the Service
Control Manager rather than being signalled directly. Release file handles as
soon as shutdown begins — a lock held past that point is what turns a
cooperating close into `package_in_use`. Save whatever state the next launch
needs before exiting, since nothing asks again. Do not run a watchdog or
background helper that relaunches the application while Restart Manager is
closing it for servicing; that reads as a refusal, not a race TigerSetup will
retry. An application that wants to come back automatically once servicing is
done registers with `RegisterApplicationRestart` before the close request
arrives, so Restart Manager knows to relaunch it.
The exact Restart Manager API flow is otherwise an implementation decision.
### 5.11 Security
Security is part of the installation-state architecture, especially for machine
scope:
- the state database and backup files need strict ACLs;
- an unelevated user must not be able to tamper with data that an elevated
uninstall later trusts;
- stored paths must be validated before privileged operations;
- resource roots must be constrained;
- arbitrary scripts are avoided; operations are typed and validated, and
the one exception — a custom action (§5.14) — runs only bytes the
package carries and hashes, or a program the package names, never
anything acquired at run time; a stored program is verified against its
recorded hash before an elevated uninstall runs it.
Otherwise a privileged uninstall becomes a confused-deputy mechanism.
**State directories are TigerSetup's own, and checked on every mutating
run** before anything is written into them — the default log, the dependency
downloads and the database included:
- the `TigerSetup` directory under the scope's known folder and the product's
directory below it are each created one level at a time and must be real
directories: a junction, a symbolic link or a file in either place is
refused (`state_directory_invalid`), before and after protection, so
nothing is written wherever it would lead;
- in machine scope both get the same owner (Administrators) and protected
access control list — full control for SYSTEM and Administrators, read and
execute for everyone else — and a drifted list is rewritten. With the
shared directory protected, no standard user can create a product's
directory before its first install. A product directory somebody else
created anyway is taken over only while it is empty; one that already holds
anything is not this installation's state, and is refused and left as it
is;
- the installation a database records must be this package's, in this scope,
with an absolute install root (`state_mismatch`); an upgrade, a repair and
an uninstall plan from nothing else;
- every stored resource is confined to its scope's roots before a plan is made
from it (`scope.rs`).
**Temporary and handed-over files are written without indirection.** A log
the engine names is always a new file in a directory of its own; a log a
caller names with `--log`, and the result file an elevated child hands back to
its unelevated parent — which that parent creates, under a name nobody
predicts — is written only when the name is not a link, the file has no
second name, and, in an elevated process, the file opened is the one the path
names rather than one a junction above it leads to (`log_path_unsupported`).
An elevated run stages what it executes in a fresh protected directory under
`%SystemRoot%\Temp`, never in the invoking user's `%TEMP%`.
**Test redirections are not in a release build.** The process-level tests
run the engine against an isolated machine by redirecting known folders, the
registry roots and the firewall store through `TIGERSETUP_TEST_*` variables.
Those exist only in a test build (the engine's `test-seams` feature, which only
the test targets enable); a release engine contains none of them, and the
self-installer build refuses one that does. Fault injection is different by
design: it is a command-line argument that affects only the run that names it,
compiled into every build so the bytes the recovery rows validate are the
bytes that ship (`TigerSetup-Validation.md` §3). An elevated process also
takes known folders from Windows alone, never from the environment it was
given.
### 5.12 Migrating a legacy installation
A product moving to TigerSetup from another installer technology declares its
legacy footprint in the package definition: the legacy installer type and the
legacy registration key it wrote. The model is generic — Inno Setup is the
first type, and NSIS or another installer is added as another type, never as
brand-specific architecture.
```toml
[legacy]
installer_type = "inno"
registration_key = "{E718860E-EDE4-4ACC-8235-BCF1DD40FC25}_is1"
```
Migration is **uninstall-first**. When the declared legacy registration is
present, the installer runs the quiet uninstall command that registration
records, once, outside the product transaction, waits for it to finish,
verifies that the registration is gone, and only then installs into a fresh
TigerSetup-owned installation. The old uninstaller is the proven tool for
removing what it installed; adopting a foreign footprint in place would be
engine work whose only beneficiary is the migration. A failure after the legacy
uninstall is a clean `absent`, never a hybrid.
**The legacy uninstall has finished when every process it started has ended.**
The uninstaller runs with the engine's own token, hidden, in a job object of its
own that allows no breakaway, and the migration waits — bounded, ten minutes —
until that job is empty. Neither the exit of the program the registration names
nor the disappearance of the registration marks the end: Inno Setup's
`unins000.exe` hands the uninstall to a copy of itself in `%TEMP%`, which removes
the registration first (it was written last) and the install root last, after
`unins000.exe` has already exited. Only once the job is empty are the results
read: the started program's exit code must be 0 and the registration must be
gone. An uninstaller that is still running at the bound stops the run with
`legacy_uninstall_failed`, naming the processes still running, and is left
running rather than ended halfway. Whatever the finished uninstaller left —
a file it could not delete, a directory a user added to — stays foreign: the
install finds that directory and never claims it, and the outcome says so
(`legacy.location_remains`, from the registration's `InstallLocation`, beside
`legacy.processes`, how many processes the wait covered).
The job holds what the uninstaller starts the ordinary way. A process it
starts elsewhere — elevated through `runas`, through a service, the Task
Scheduler or COM — is outside it and outside the wait; the supported path meets
none, because a machine-scope uninstaller runs from the elevated engine and a
per-user one for a per-user installation, so neither asks to elevate. A process
an uninstaller leaves running on purpose holds the wait to its bound. After
that bound the registration may already be gone while the old uninstaller is
still at work, so a run started again at once would find nothing to migrate:
the failure says the uninstaller was left running, and the remedy is to let it
finish before installing.
TigerSetup **prefers a new registration identity** derived from the package
id. A package may explicitly preserve its legacy registration key name
(`[registration] key_name`) when an external consumer requires it; TigerMarkView
does not, and takes the new identity. The WinGet `PackageIdentifier` is not
part of the registration identity and stays unchanged across the transition.
WinGet permits that transition. Nothing in the manifest schema, the
`winget-pkgs` validation pipeline or its published policy gates a change of
`InstallerType` between versions of one package, and the client's only
technology gate is a compatibility set in which `inno`, `nullsoft`, `exe` and
`burn` are interchangeable, so an installed Inno version upgrades to an `exe`
version. WinGet correlates the installed legacy version with the package through
the community index, which aggregates the ProductCodes of every retained
version, and through the display name and publisher — so the legacy version's
manifest **stays in `winget-pkgs`**, the new manifest declares only the
registration TigerSetup actually writes, and `UpgradeBehavior` stays `install`,
because the migration lives in `Setup.exe` and must run identically whether
WinGet or a person starts the installer.
### 5.13 Cross-scope installation policy
A product can be installed per user and per machine at the same time, and each
installation is its own: its own state database (§5.2), its own install root,
its own Add/Remove Programs registration. So before a run touches anything it
decides **which installation it is about**, and it never silently creates a
second one beside an existing one. The decision is the engine's, one place both
clients reach, so the command line and the wizard answer it identically.
The rules, over the installations the machine actually holds:
- **One existing installation is sticky.** A run that names no scope continues
with that installation, whichever scope it is in — an ordinary rerun of
`Setup.exe` upgrades or repairs what is there rather than treating the
package's default scope as a fresh-install opportunity. A machine install
plus an ordinary rerun upgrades the machine installation; likewise for a
per-user one.
- **An explicit scope stays explicit.** A run that names a scope which holds
nothing, while the *other* scope holds the product, is a **scope conflict**,
not a silent redirection: automation gets a structured `scope_conflict`
result naming what exists, and nothing is installed. It is never rewritten to
the existing scope.
- **Two existing installations are never chosen between.** With both scopes
installed, a run that names no scope is a **scope ambiguity**: the interactive
wizard shows the two and asks which, and automation gets a structured
`scope_ambiguous` result. Each installation stays independently owned until
one is named.
- **No implicit migration.** Changing an installation's scope is materially
different from an upgrade — it can mean uninstall/reinstall, and different
ownership, state and elevation — so nothing here turns user→machine or
machine→user into an automatic migration. Scope migration, if it is ever
added, is a separate explicit capability.
`first install` — no installation of the product anywhere — uses the package's
configured default scope (the first `[install].scopes` entry), offers only the
scopes the package allows, and lets the interactive wizard choose among them.
The consumer states three independent things: the **default scope** for a first
install (`scopes` order), the **allowed scopes** (`scopes`), and the
**cross-scope policy** — `[install].existing_scope`:
```text
preserve # the default: continue with the existing installation;
# refuse an explicit request for the other scope
allow-parallel # an explicit request for the other scope creates a second,
# independent installation
error # refuse every run whose scope, named or defaulted, is not the
# one the product is installed in
```
Under `allow-parallel` the explicit second-scope request is honoured and the
two installations coexist; a later scope-less run of a two-installation machine
is then ambiguous as above. Reading (`verify`, `inspect`) and removing or
repairing an empty scope are never conflicts — they report what that scope
holds, which may be nothing; only an install can create a second installation,
so only an install is refused by policy.
### 5.14 Custom lifecycle actions
TigerSetup strongly prefers a typed resource wherever it understands the
operation — a file, a registry value, a shortcut, a firewall rule — because
a typed resource is owned, journaled, rolled back, verified, repaired and
removed by the engine. Real applications also need product-specific work at
install or uninstall time that no typed resource can express: building a
cache, registering with a service the product ships, migrating a settings
store, cleaning up what the product generated while it ran. A **custom
action** is the deliberate, first-class capability for that work, and it is
not a scripting escape hatch: it is a program the package declares, TigerSetup
packages and verifies, starts under a controlled envelope at a defined point
of the run, and records. The line between the two is the whole design:
```text
typed resource → TigerSetup knows what changed: ownership, rollback,
repair, verification
custom action → TigerSetup knows what it started, when, with what
result; what the program changed on the machine is the
package author's responsibility
```
> **TigerSetup can roll back the resources it owns and understands. It
> cannot guarantee rollback of arbitrary side effects produced by a custom
> action.**
**Phases and operations.** An action runs at one of four phases, and on the
lifecycle operations it names:
```text
pre-install dependencies satisfied; the first operations of an installing
transaction, before any product resource is mutated
post-install every product resource applied and every removal done; the
last operations before the commit
pre-uninstall the first operations of an uninstall, before any owned
resource is removed
post-uninstall every owned resource removed, the install root included; the
last operations before the uninstall commit
```
`run_on` names the operations — `install`, `upgrade`, `reinstall`, `repair` for
an install phase, `uninstall` for an uninstall phase; a phase never runs on an
operation of the other kind, and the builder refuses the combination rather
than ignoring it. The default is the phase's operations without `repair`:
repair is opt-in, because an arbitrary program is not necessarily idempotent,
and a repair that runs every action merely because it exists would repeat
work the package author never meant to repeat. An upgrade runs the *new*
package's install-phase actions; it never runs the previous installation's
uninstall actions, which belong to an uninstall alone. A same-version rerun
with nothing to reconcile (`already_installed`) opens no transaction and runs
nothing.
**One predicate.** An action carries the same `when = { option, equals }`
predicate as every optional resource (§4), evaluated against the same
effective option set — explicit value, else the last committed value, else
the manifest default. There is no action-specific condition language and no
action-specific option state; an uninstall action's predicate is evaluated
when the uninstall runs, against the options the installation recorded.
**Kinds and programs.** An action is `exe` (a native executable, run
directly), `powershell` (a script run by Windows PowerShell:
`powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File`) or
`cmd` (a batch script run by `cmd.exe /d /s /c`). The interpreters are
invoked explicitly and non-interactively, with no profile and no dependence
on the user's execution policy — the package is the trust boundary, and a
script it carries is run as the package's. The program is either a
**command** on the target machine — a template such as
`%INSTALLROOT%\tools\cache-builder.exe`, expanded with `%INSTALLROOT%`,
`%VERSION%` and the known folders, the same placeholders every other template
in the manifest uses — or a **packaged** file (`source`), which the builder
reads, hashes and carries inside the installer under
`.tigersetup/actions/` (§10.4), beside the embedded dependency
installers and with the same integrity: `verify` checks its bytes against
the recorded SHA-256, `inspect` shows the identity, and the engine verifies
the bytes again after extracting them and refuses to run bytes that do not
match (`action_program_mismatch`). A pre-install action cannot assume the
product's files exist yet, and a post-uninstall action cannot use the install
root at all — it is gone by then — so the builder refuses a post-uninstall
command or working directory under `%INSTALLROOT%`; the packaged form is what
both phases use. Nothing is ever downloaded and run.
**The envelope.** Arguments are declared as a list, expanded as templates and
passed as separate arguments, never joined by a shell; the working directory
defaults to the program's own directory; the process is started hidden, with
no standard input and both output streams captured; and it is told about the
run through its environment — `TIGERSETUP_INSTALL_ROOT`, `TIGERSETUP_VERSION`,
`TIGERSETUP_PRODUCT_ID`, `TIGERSETUP_SCOPE`, `TIGERSETUP_OPERATION`,
`TIGERSETUP_PHASE`, `TIGERSETUP_ACTION`, `TIGERSETUP_QUIET`. The action and
everything it starts live in a job object: `timeout_seconds` (300 by default)
ends the whole tree, and so does the action's own exit, so nothing an action
started outlives it — an action is bounded by definition, and a program that
must keep running belongs to the product, not to its installer. The exit code
is judged by the declaration: `success_codes` (`[0]` by default) is success,
`reboot_codes` is success with a reboot pending — carried by the same
`reboot_required` and exit code 3010 as a dependency's — and anything else,
a timeout, or a program that cannot be started is a failure. `on_failure`
decides what a failure means: `fail` (the default) fails the transaction,
which rolls back what TigerSetup owns; `continue` records the failure as a
finding (`action_failed_continued`), reports it in the outcome, and goes on.
`continue` never turns a failure into a silent success.
**Execution context.** An action runs with the token of the run it is part
of: a machine-scope install or uninstall runs it elevated, a per-user one
runs it as the user, and TigerSetup never elevates an individual action
behind the caller's back. There is no per-action elevation or impersonation.
A quiet run and an interactive run start an action identically —
non-interactive, hidden — and TigerSetup manufactures no prompt on the
program's behalf; whether the program itself can run unattended is the
package author's responsibility, which is why `TIGERSETUP_QUIET` is passed.
Because an action may run arbitrary code, and in an elevated installer, the
package must be trusted as a whole: actions are not sandboxed, and everything
that makes the package inspectable (§10.5) is what makes them auditable.
**In the transaction.** An action is a journaled operation (`run_action`)
with the same `planned → prepared → applying → applied` states as every
resource, placed by its phase — pre-actions first, post-actions last, with
the uninstall actions an installing transaction records for the installation
(below) just before the post-install ones. What differs is the undo: a run
has none. When a failing action rolls the transaction back, the typed
resources are put back and the action's own record stays exactly as it is —
`action_run` says it ran and failed, the outcome names it as the cause with
its exit code and output, and the rollback records `action_not_reverted` for
every action that ran, so nothing reads as if the program's effects were
undone.
**Evidence.** Every execution writes an `action_run` row (§5.3) — the
action's name, phase, operation, kind, resolved program and policy —
`started` **before** the process exists and finished with its status
(`completed`, `failed`, `timed_out`, `launch_failed`), exit code and reboot
flag after it exits. The log carries the launch (`action_started`, with the
command line and the envelope), the captured output line by line
(`action_output`, bounded) and the verdict; the outcome document carries
every action with its status, exit code, duration, policy, the tail of both
streams and the packaged program's hash. Failures name the action by its
stable name. Arguments are logged as they were passed, so an author who
must pass a secret should pass it through a file the action reads rather
than on the command line.
**Crash and recovery.** A crash while an action runs leaves its `action_run`
row `started`, which is how the next run tells it from an action that never
ran. The existing recovery (§5.4) decides the direction: a **forward**
recovery — the same package running again — marks the row `interrupted`,
reports `action_interrupted`, and runs the action again, then completes the
transaction; a **rollback** recovery marks it `interrupted`, never runs it,
and records `action_not_reverted`. An interrupted action has unknown external
side effects, and TigerSetup claims nothing about them; what it guarantees is
that its own state converges and that the evidence is preserved. This is the
one rule package authors must design for: **an action must be idempotent,
safe to retry, bounded, non-interactive, and able to detect work it already
did**, because TigerSetup will run it again after a crash, and may run it on
every operation it names.
**Uninstall actions belong to the installation.** The installer that brought
a product is usually gone by the time it is uninstalled, and the uninstaller
copy in the state directory carries no payload. The committed installation
therefore keeps what its own uninstall will need: the definitions of its
pre-uninstall and post-uninstall actions in the `action` ownership table
(§5.3), and the bytes of every packaged program under
`\actions\\`. An installing transaction
records them with `store_action` operations, content-addressed so that a new
version's program never overwrites an old one; the commit switches the
`action` table with the rest of the ownership tables, so **a successful
upgrade makes the new definitions and programs current, and a failed or
rolled-back upgrade leaves the previous ones exactly as they were**; a
program nothing owns any more is swept after the commit, and a stored
program's directory a rollback finds it created is removed. Uninstall plans
its actions from the database, never from the metadata of whichever
executable runs the uninstall, and verifies each stored program against the
recorded hash before running it; `verify` reports a stored program that is
missing or modified (`action_program_missing`, `action_program_modified`) and
`repair` restores it from the package. An install-phase program is not kept:
it is extracted into the transaction's staging area for the run and removed
with it.
**Inspectable.** A package that runs arbitrary programs is obvious wherever
the package is described: `tiger-setup inspect` lists every action with its
name, phase, operations, kind, program, packaged identity and hash,
arguments, timeout, exit codes, policy and predicate; `Setup.exe inspect`
lists the same under `package.actions`, and for an installed product the
stored uninstall actions under `owned.actions`.
**Deliberately not here.** No compensating or rollback action per action, no
per-action elevation, no network acquisition of action programs, no
action-specific condition language, and no way for an action to keep a
process running after it ends. Each is added only on a concrete requirement,
and "do not become MSI by accident" (§16) is the standing objection.
---
## 6. Execution model
### 6.1 One engine, two clients
```text
Inputs
|
+-----------+-----------+
| |
unattended CLI interactive UI
| |
+-----------+-----------+
|
desired state
|
plan engine
|
transaction engine
```
There must never be separate interactive and silent implementations that can
drift apart, and the UI must never become a second home for installation logic.
### 6.2 Automation-first, interactive-capable
> **Installation is an automatable state transition. Interaction is optional.**
Every install, upgrade and uninstall path must have deterministic unattended
semantics:
- deterministic defaults and no required prompts;
- stable command-line behaviour and stable exit codes;
- unattended install, upgrade and uninstall;
- machine-readable validation/status;
- useful logging;
- dependency handling without desktop interaction;
- deterministic recovery/failure semantics;
- repeatable behaviour in TigerWinLab.
The unattended contract — a public contract the WinGet manifest, the lab
specifications and the release gate all encode:
```text
Product-Setup.exe the root operation, interactive
Product-Setup.exe install [--quiet] [--scope user|machine] [--install-root ]
[--option ]... [--no-dependency-install]
[--lang ] [--log ] [--json]
Product-Setup.exe uninstall [--quiet] [--scope user|machine] [--lang ] [--log ] [--json]
Product-Setup.exe repair [--quiet] [--scope user|machine] [--lang ] [--log ] [--json]
Product-Setup.exe verify [--scope user|machine] [--json]
Product-Setup.exe inspect [--scope user|machine] [--json]
Product-Setup.exe install --quiet --fault [@]:[:] (fault injection, every build)
```
`verify` and `inspect` take no `--log` because they change nothing at all: a
default log would live in the state directory and so would create it for a
package the machine does not have. A read-only command writes only to its
standard output.
The grammar is the command-app grammar shared with the other Tiger tools:
`app [options]` — commands express
operations, positional arguments identify their subjects, and options modify
behaviour. An option takes its value as a separate argument (`--log `,
never `--log=` in documentation or specifications). Without `--quiet`
an install, uninstall or repair is interactive; running `Setup.exe` without
arguments is the root operation — `install` for an installer, `uninstall`
for the uninstaller copy in the state directory (§5.8) — so a double-click
and a package manager both do the expected thing. `--option` sets a declared
installer option such as the PATH entry or the desktop shortcut; an option
not named keeps the value the installation recorded, or the declared default
on a first install. `--scope` names the installation to act on; omitted, the
run follows the installation the machine holds (§5.13). Exit codes, aligned
with WinGet's return-code types; machine-readable output carries the same
identifiers as `code` strings:
| Exit | Meaning |
|---|---|
| `0` | success |
| `1` | failed and rolled back |
| `2` | invalid arguments or package, including a `scope_conflict` or `scope_ambiguous` cross-scope refusal (§5.13) |
| `3` | dependency missing or unacquirable |
| `4` | elevation required or refused |
| `5` | cancelled |
| `6` | package in use — an application would not close |
| `7` | an earlier transaction needs recovery and could not be completed |
| `8` | unsupported platform |
| `3010` | success, reboot required |
**UI displayed is not the same as interaction required.** Double-clicking
`Setup.exe` for an ordinary application such as TigerMarkView may show progress
and offer optional choices, but installation has sensible deterministic defaults
and requires no human decision.
A future application may genuinely need installation parameters. TigerSetup
should be able to model such parameters independently of the UI and obtain
values from CLI arguments, configuration files, the environment, existing
installation state, or optional interactive input.
### 6.3 Machine-readable output is language-independent
Human-readable output may be localized. Machine-readable output must not be.
```json
{
"code": "dependency_missing",
"dependency": "Microsoft.DotNet.DesktopRuntime.10"
}
```
Stable identifiers, never localized text — so AI agents, TigerWinLab, CI,
release automation, support tooling and package-manager integration never parse
translated strings to determine an outcome.
---
## 7. Dependencies
### 7.1 Dependencies are requirements, not owned resources
```text
Resource
owned by this Installation
lifecycle managed by TigerSetup
removed during uninstall when safe
Dependency
requirement that must be satisfied
may already exist
may be installed by TigerSetup
normally remains externally/shared owned
normally NOT removed with the application
```
Installing WebView2 because TigerMarkView requires it must **not** mean that
uninstalling TigerMarkView removes WebView2 — another application may now depend
on it. TigerSetup may record that a dependency was installed during a
transaction, but:
> **Installed by TigerSetup does not imply owned by TigerSetup.**
The same holds when a product transaction fails: rolling back an installation or
an upgrade does not uninstall a shared dependency TigerSetup caused to be
installed along the way (§5.4).
### 7.2 Dependency model
The dependency engine separates at least these concerns:
```text
Identity What dependency/capability is required?
Detection Is the requirement already satisfied?
Acquisition Where can an installer that satisfies it be obtained?
Installation How is it installed unattended?
Verification Is the requirement satisfied after installation?
```
The separation matters because a package identifier and an application
capability are not always the same thing. TigerMarkView conceptually requires a
compatible .NET Windows Desktop Runtime 10; `Microsoft.DotNet.DesktopRuntime.10`
is the package identity that acquires it, while what proves it present is a
version directory under the shared runtime root. WebView2 has the same shape,
with a registry value as the proof.
**TigerSetup stays generic.** The engine carries no catalogue, library or
policy for any named product — nothing in it knows what .NET or WebView2 is.
A dependency is declared in the package with a **typed detector** and an
**acquisition source**, and every product-specific fact lives in that
declaration:
```toml
[[dependencies]]
id = "Microsoft.DotNet.DesktopRuntime.10" # the requirement: a WinGet identity
minimum = "10.0" # same major, not lower
detect = { kind = "directory-version",
path = "%PROGRAMFILES%\\dotnet\\shared\\Microsoft.WindowsDesktop.App",
pattern = "10.*" }
[[dependencies]]
id = "Microsoft.EdgeWebView2Runtime"
detect = { kind = "registry-version",
keys = ["HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
"HKLM\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
"HKCU\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"],
value = "pv" }
```
The detector kinds are `directory-version` (the child directories of a path
are versions), `registry-version` (a value read as a version from the first
key that has it), `file-version` (a file's Windows version resource) and
`registration` (an Add/Remove Programs entry matched by display name). A
version satisfies the requirement when it has the minimum's major component
and is not lower; no minimum means any version. Acquisition defaults to the
WinGet catalog entry for `id` (§7.9); a dependency with no catalog entry
declares `acquire = { url, sha256 }` and `install = { arguments,
success_codes, reboot_codes }` instead, and is never refreshed.
### 7.3 Scope: two real prerequisites, no framework
The dependency model is proven on the two prerequisites a real application
needs — `Microsoft.DotNet.DesktopRuntime.10` and
`Microsoft.EdgeWebView2Runtime` — and its scope stays deliberately narrow: a
correct generic model, exercised on real prerequisites, rather than a
universal prerequisite framework, and never by special-casing either of them.
### 7.4 Custom developer-defined dependencies
The dependency model must be broader than WinGet's package model — a developer
must be able to define a dependency with no WinGet entry, describing enough for
TigerSetup to `detect → acquire → verify installer bytes → install unattended →
verify result`.
```text
TigerSetup dependency identity
|
+-- WinGet mapping (when available)
|
+-- Chocolatey mapping (optional, when useful)
|
`-- custom / no external mapping
```
Typed detection mechanisms may include registry values, file/version presence,
executable/version checks and built-in dependency-specific detectors. Arbitrary
scripting is resisted unless typed mechanisms are demonstrably insufficient.
### 7.5 Offline behaviour
> **TigerSetup itself must never require Internet access merely to execute.**
Network access is only an optional acquisition mechanism for missing application
dependencies.
```text
Fresh supported Windows
.NET Desktop Runtime 10 present
WebView2 Runtime present
NO Internet connection
↓
TigerMarkView-Setup.exe
↓
installation succeeds
```
This is a hard requirement, not an aspiration.
### 7.6 Offline failure behaviour
If a required dependency is missing and acquisition needs Internet access that
is unavailable, installation fails cleanly:
```text
dependency missing → network unavailable → dependency cannot be acquired
↓
application installation does not commit
↓
clear deterministic failure
```
TigerSetup must not leave a partially committed application installation because
dependency acquisition failed. The installer reports which dependency is
unsatisfied, why it could not be acquired, whether any system changes were made,
and whether rollback or cleanup was performed.
### 7.7 Remote and embedded acquisition
The same dependency requirement can be satisfied by remote acquisition **or**
an embedded payload, enabling two package styles:
```text
Small online-capable installer Larger fully-offline installer
Setup.exe + app payload Setup.exe + app payload
→ download dependency if needed + .NET Desktop Runtime installer
+ WebView2 Runtime installer
```
`acquire = { file = "" }` embeds the
installer: the builder carries its exact bytes as a payload entry under the
reserved `.tigersetup/dependencies/` prefix, which no product file may use,
and records the entry name, size and SHA-256 in the dependency's acquisition
metadata. Everything else is the model of §7.2 unchanged: detection first,
so a satisfied requirement extracts nothing; extraction to the state
directory only for a missing one, refusing bytes whose hash differs
(`dependency_unacquirable`, `hash_mismatch`); the declared unattended
switches, success and reboot codes, elevation and re-detection exactly as
for a download; the extracted file removed with the phase. `tiger-setup
verify` checks every embedded installer against its recorded hash and size
without running anything, and `inspect --json` reports the dependency's
source as `embedded` with what it carries. A dependency may carry the one
predicate of §4, so an optional component's prerequisite is a requirement
only while the component is selected.
### 7.8 Acquisition policy
Resolving and acquiring missing dependencies is a TigerSetup strength, not an
exception. When a required dependency is missing and the machine is online,
the installer **acquires and installs it by default**, unattended and
interactive alike. `--no-dependency-install` opts out; a run that opted out
fails with `dependency_missing` before any product change.
A dependency whose installer needs elevation — a machine-wide runtime required
by a user-scope installation run by a standard user — never becomes an
unexpected prompt: an unattended run fails deterministically with
`dependency_requires_elevation` before any product change, and an interactive
run asks for elevation for the dependency alone.
### 7.9 The requirement is pinned, the artifact is refreshed
A dependency declaration pins the **requirement** — the WinGet package
identity and the version requirement (same major, at least the declared
minimum) — and that is what is durable. The URL, version, hash, unattended
switches and return codes the builder resolves from the WinGet catalog at
build time are embedded as an **acquisition hint**: refreshable acquisition
metadata, a cache, never runtime truth. The engine uses the hint while it is
fresh, and refreshes from the catalog at install time when the hint is older
than its lifetime, has no URL (an offline build), or fails — a missing
artifact, a download that cannot be completed, bytes that do not match. A
refresh selects the current package that satisfies the requirement and
verifies the download against the refreshed hash. TigerSetup never maintains
a dependency package library of its own, never installs an unverified
download — there is no unhashed fallback of any kind — and never needs
`winget.exe` on the target (§8.1). Offline, a missing dependency with no
usable hint is a clean `dependency_unacquirable` before any product change.
The catalog the engine refreshes from is the WinGet community source as
Microsoft serves it, which needs no client: a pre-indexed SQLite database
(`cdn.winget.microsoft.com/cache/source2.msix`), a per-package compressed
version list naming every manifest with its SHA-256, and the merged manifest
itself with `InstallerUrl`, `InstallerSha256`, the silent switches and the
return codes — three HTTPS reads over WinHTTP. Every acquisition
source is generic: the same chain serves any package identifier, and a
dependency declared with a fixed URL and hash bypasses it.
**The chain is authenticated the way `winget.exe` authenticates it**, from a
key the content servers cannot forge down to the executed bytes
(`tigersetup-catalog`, `source.rs` and `winget.rs`):
```text
source2.msix WinVerifyTrust (Authenticode, the AppX subject package) and a
signer chaining to a Microsoft application root; identity
Microsoft.Winget.Source by Microsoft; every file read checked
against the signed block map; no duplicate entry names
versionData SHA-256 equal to the hash the signed index lists for the package
manifest SHA-256 equal to the hash the version list lists for it
installer SHA-256 equal to InstallerSha256 before it is executed
```
An index older than the one the builder resolved against (less two days for
clocks) is refused as a replay (`catalog_index_stale`), when its version reads
as a publication time — a convention of the publisher rather than a contract,
so an index whose version does not is judged by the other checks alone. The
signature check uses only inbox Windows APIs, needs no revocation download,
and so works offline-cached and behind a firewall like the rest of the chain.
What the chain cannot catch is a bad entry in the catalog itself; that is the
catalog's own review, as it is for `winget.exe`. The refusals have stable
codes: `catalog_signature_invalid`, `catalog_identity_mismatch`,
`catalog_index_stale`, `catalog_integrity_failure`.
Staleness is decided by **age or a missing artifact, not by a hash mismatch**:
a versioned artifact keeps serving the bytes its hash describes long after a
newer one exists, so a mismatch never fires. The hint lifetime is per
dependency (`max_age_days`, two weeks by default). Refreshing is on by
default: the requirement, the detector and the hash check are what is
proven, and validation proves the behaviour, not one artifact. Detection
always comes first, so a machine whose dependencies are present never
touches the network.
---
## 8. Distribution
### 8.1 WinGet-aligned, WinGet-independent at runtime
WinGet support is not bolted on later: a TigerSetup-built installer is
WinGet-ready by default, and the same metadata drives one identity throughout.
```text
TigerSetup package identity
↓
Add/Remove Programs identity
↓
WinGet package identity
```
This prevents drift between installer metadata, ARP metadata, release metadata
and WinGet manifests.
TigerSetup aligns with WinGet package identifiers, community package metadata,
dependency identities, scope/architecture concepts, silent-installer
conventions, stable exit behaviour and manifest generation. For public
dependencies the WinGet package identifier is normally the preferred external
identity where a good mapping exists.
```text
TigerSetup.toml
↓
tiger-setup build
↓
resolve dependency metadata from a WinGet-compatible catalog ← build time
↓
capture the information the generated installer needs
↓
Setup.exe
↓
detect / download / verify / install dependency itself ← run time
```
The target machine must never need `winget.exe` — nor TigerSetup, Rust, .NET or
anything else — to execute the setup engine.
> **WinGet is a catalog/integration model, not a runtime prerequisite for
> TigerSetup installers.**
### 8.2 WinGet manifest generation
The same dependency declarations feed both TigerSetup's standalone installer
behaviour and generated WinGet dependency metadata where a clean mapping exists:
```text
TigerSetup.toml
|
+--------------+---------------+
| |
TigerSetup Setup.exe WinGet manifests
| |
detect/install/verify PackageDependencies
```
If WinGet installs the prerequisites first, TigerSetup verifies they are
satisfied and continues. If the user downloads `Setup.exe` directly, TigerSetup
handles the same prerequisites itself. Both paths stay correct.
The final WinGet installer manifest needs the immutable public installer URL and
SHA-256, so generation is a two-stage workflow:
```text
tiger-setup winget prepare
→ manifests with unresolved / expected publication values
tiger-setup winget finalize --url https://.../TigerMarkView-0.9.0-Setup.exe
→ hash computed from the published exact bytes, final URL filled,
consistency validated, submission-ready manifests
```
> **Build once, validate exact bytes, publish those exact bytes. Never rebuild
> just for WinGet.**
The installer manifest states what WinGet needs and nothing it would only
restate. It has one `exe` installer entry per declared scope, each with the
same URL and hash. Each entry carries the silent switches
(`install --quiet --scope `), `--log` and, unless the package pins the
scope's root, `--install-root`. Each entry also carries `ProductCode`, the
registration's key name, through which WinGet correlates the installation with
the package. The machine-scope entry adds `ElevationRequirement: elevatesSelf`:
started unelevated, that run raises its own UAC prompt (§11.6), so WinGet must
not elevate on its behalf, and a user-scope run never elevates. An
`AppsAndFeaturesEntries` block is written only where the registration says
something the package does not: a display name other than the package name, or
a display version other than the package version. With such an entry,
`ProductCode` appears in it too. An entry that repeats the package's name,
publisher, version and installer type adds nothing WinGet uses, and the
community repository asks for it to be removed. `winget validate` accepting a
set is necessary but not sufficient. A submission also needs a public, stable,
version-specific installer URL, reachable from the publisher's site.
The locale manifest takes its URLs from `[winget]` exactly as declared; one
that names the release — a licence or release notes at a version tag — writes
`{version}` where the version goes, and the generator puts the package version
there, so the manifest set and the installer can never disagree about it. A
`PrivacyUrl` is written only when declared, never derived from another URL.
Automatic submission to `winget-pkgs` remains a separate release/policy
decision.
### 8.3 Chocolatey compatibility
> **WinGet-aligned by design; Chocolatey-compatible by convention.**
TigerSetup does not distort its core model to make both ecosystems equally
native. WinGet shapes dependency identities, metadata alignment, installer
automation conventions and release output. Chocolatey compatibility comes from
producing a conventional, predictable Windows installer with reliable silent
install and uninstall, stable exit codes, deterministic scope behaviour,
logging, upgrade support, correct ARP registration, predictable reboot/failure
behaviour, and dependency metadata that can be mapped when appropriate.
A Chocolatey package should be a thin wrapper around a TigerSetup-generated
installer, never a second installer implementation. Both of these must remain
possible, and the installer must be correct either way:
- Chocolatey manages mapped dependencies before invoking `Setup.exe`; or
- the Chocolatey package stays thin and `Setup.exe` handles its own
dependencies.
### 8.4 TigerSetup's own distribution
A TigerSetup release is the exact bytes of the self-hosted installer (below),
`TigerSetup--Setup.exe`, which installs `tiger-setup.exe`,
`tigersetup-setup.exe`, `tigersetup-loader.exe` and the installed help. The
release workflow builds it once from the release commit on the release-quality
path, checks it against the engine and loader of that build, and publishes it
as a GitHub Release asset at a stable, version-specific public URL, with
its WinGet manifest set, generated by the same §8.2 workflow TigerSetup offers
every consumer, on those same bytes, the licence and privacy statement that
apply to that release (`LICENSE.txt` and `PRIVACY.md`, the commit's exact
bytes, frozen with it), and a record of every file's SHA-256 and the commit
they were built from (`RELEASING.md`). A consuming project pins a release by
version and hash.
The Add/Remove Programs links name the publisher's site
(`https://www.ittiger.net/`), and omit an optional URL rather than name a
destination the package has not chosen. The WinGet locale manifest names
TigerSetup's own first-party pages besides the publisher's site: the
repository (`PackageUrl`), its issues (`PublisherSupportUrl`), the release's
notes (`ReleaseNotesUrl`), and the licence and privacy statement frozen with
that release — its `LICENSE.txt` and `PRIVACY.md` assets (`LicenseUrl`,
`PrivacyUrl`), never a page that changes with a later version — each written
with the `{version}` placeholder of §8.2's `[winget]` URLs so the version is
never typed twice. Its description says what installing does to the
machine: per-user by default, the optional PATH entry, administrator approval
for all users, and the catalog access a WinGet dependency means.
The installer installs `LICENSE.txt` and `THIRD-PARTY-NOTICES.md` beside the
binaries. The engine carries the notices too, so every generated `Setup.exe`
— which contains TigerSetup's loader and engine, and so their third-party
material and TigerSetup's own licence — prints them with `Setup.exe notices`,
as the builder does with `tiger-setup notices`.
TigerSetup also **installs itself with itself**. `packages/tigersetup/`
(`ItTiger.TigerSetup`) is a normal TigerSetup package whose payload is the three
release binaries and the installed help, built by TigerSetup's own current
release engine on the
release-quality path — so the `Setup.exe` that installs TigerSetup is produced
by exactly the mechanism it produces for every other product, its product
version read from the packaged `tiger-setup.exe`'s VERSIONINFO (§9.2) rather
than typed. Every product coding session ends by rebuilding and verifying this
self-installer (`AGENTS.md`, *Version and release discipline*), which keeps the
whole build-and-package path honest against the product it most has to work
for.
A command-line tool that installs silently is easy to install and then lose, so
the package gives TigerSetup a visible Windows presence. A **TigerSetup** folder
in the Start Menu holds three shortcuts:
- **TigerSetup Shell** runs `tiger-setup shell`. That opens `%ComSpec%` (the
system `cmd.exe` when the variable is unset) with the installation's own
directory first on that shell's `PATH`, prints the brief landing help
(`tiger-setup --help-brief`: the version and folder, that `tiger-setup` is
on `PATH` in that window, the first command, where the complete help and the
guide are — coloured where the console shows colour) and stays open. It
starts in the user's profile rather than in the install directory.
`tiger-setup` in that window is the installation the shortcut belongs to,
whether or not the installer's PATH option was chosen and whatever other
`tiger-setup.exe` the persistent `PATH` names (`cmd.exe` looks in the
current directory first, which is why the shell does not start in a
directory of the user's choosing unless it was asked from one). The persistent `PATH` is never
written: the installer owns that choice, and opening a shortcut does not
change it. `tiger-setup shell` lets Ctrl+C and Ctrl+Break reach the shell
and waits for it to end.
- **TigerSetup Help** opens the installed getting-started guide as PDF, which
Windows opens directly. The guide is `docs/TigerSetup-Help.md`, written for
a person who has just installed TigerSetup;
`packages/tigersetup/Build-Package.ps1` renders the PDF from that Markdown
on every build with `tiger-mark` — the registered TigerMarkView tool, or on
the release runner one built from a pinned TigerMarkView commit
(`RELEASING.md`) — so the PDF is never maintained on its own.
- **TigerSetup Help (Markdown)** opens that Markdown source. A shortcut can
only open an installed file, and Windows has no default app for `.md`, so on
a clean machine Windows first asks which app to use; that is why the
Markdown is the second form rather than the primary help.
Only TigerSetup Shell carries TigerSetup's icon. The help shortcuts declare no
icon: a shortcut whose icon is its own document shows that document's
file-type icon, so each help form looks like what it opens.
`tiger-setup --help` stays the complete command reference and says nothing
about where TigerSetup is installed; there is no `help` command, as
` --help` already answers it.
A shortcut name is a link file name, so it may hold anything Windows allows in
a file name, parentheses included. That is looser than a package name, which
also names the install folder and the installer file.
---
## 9. Application metadata integration
Easy .NET integration is one of TigerSetup's strongest practical advantages: it
removes the per-project scripting that parses project metadata and feeds it to
an installer script. `[metadata].source` selects the provider — `static`
(everything typed in `[package]`, the default), `msbuild` or `exe` — and
`[metadata].executable` names a built binary that is validated against
whichever source is in use.
### 9.1 Evaluate MSBuild; do not parse XML
Values may come from `Version.props`, `Directory.Build.props`, imported
`.props`/`.targets`, conditions, SDK defaults or command-line properties.
TigerSetup uses **evaluated MSBuild properties**, not naive `.csproj` parsing:
`dotnet msbuild -getProperty:…` evaluates the project without
running a target, and `--property Name=Value` on the command line (or
`[metadata].properties`) passes a global property to that evaluation.
```toml
[metadata]
source = "msbuild"
project = "../TigerMarkView/TigerMarkView.csproj"
executable = "publish/TigerMarkView.exe" # validated against the project
```
The properties read are `Version`, `Product`, `Company`, `Description`,
`Copyright`, `FileVersion`, `InformationalVersion` and `AssemblyName`.
`dotnet` must be on `PATH` when the manifest names a project.
### 9.2 Metadata from compiled executables
```toml
[metadata]
source = "exe"
executable = "publish/MyApp.exe"
```
The Windows `VERSIONINFO` fields — ProductName, ProductVersion, FileVersion,
CompanyName, FileDescription, LegalCopyright — supply the version, description
and copyright, and validate the product name and publisher. This keeps
TigerSetup useful for C++ applications, Rust applications, third-party
binaries and anything without MSBuild metadata, and enables very small package
definitions: `[package]` with an id, a name and a publisher, `[metadata]`
naming the executable, and `[[files]]`.
### 9.3 Mixing and overriding sources
Metadata is composable: project/application metadata comes from the provider
while installer/distribution-specific values stay explicit in `[package]`,
and a value typed in `[package]` wins over the provider's.
```toml
[package]
id = "ItTiger.TigerMarkView"
name = "TigerMarkView"
publisher = "IT Tiger"
license = "MIT"
[metadata]
source = "msbuild"
project = "../TigerMarkView/TigerMarkView.csproj"
```
The source must be selectable and visible rather than relying on excessive
magic.
### 9.4 Provenance and validation
TigerSetup does not merely extract metadata; it shows where every value came
from. The providers are `static` (typed in `[package]`), `msbuild-project`
and `pe-version-info`, and `tiger-setup metadata` lists every candidate in
precedence order — `=` marks the value the build uses, `~` a value a
higher-precedence one displaced — with its source, the property or field it
was read from, and the file:
```text
version = 0.8.2
msbuild-project · Version of source\src\TigerMarkView\TigerMarkView.csproj
description = A local Markdown viewer, reviewer and PDF exporter.
msbuild-project · Description of source\src\TigerMarkView\TigerMarkView.csproj
validated binary_product_version_matches · ProductVersion 0.8.2 of publish\TigerMarkView.exe matches the package version (Version)
validated binary_company_name_matches_publisher · CompanyName IT Tiger of publish\TigerMarkView.exe matches package.publisher
```
`--json` gives the same as one document with stable identifiers (`source`,
`origin`, `file`, `effective`; each validation's `check`). Where the manifest
names an executable, the built binary is validated against the declared
source: version, product name and company name must agree. If the project
says `0.9.0` but the executable is still `0.8.0`, TigerSetup refuses to build
the installer — catching stale binaries before they become release artifacts.
Two different questions, two different answers, kept apart on purpose. The
generated installer's **Windows VERSIONINFO** (its `ProductName`,
`FileDescription`, `ProductVersion`, `CompanyName`, `LegalCopyright`,
`OriginalFilename`) answers *what product is this an installer for* — derived
from the manifest's product/publisher/copyright metadata, so a TigerMarkView
installer reads as TigerMarkView in Explorer, and TigerSetup does not put its
own name there. **Engine provenance** — *which TigerSetup engine built and runs
this installer* — stays in the embedded runtime metadata's `Engine` message:
the TigerSetup version, the SHA-256 of the release engine executable
(`engine_sha256`), and the SHA-256 of this installer's engine block after the
identity rewrite (`engine_block_sha256`). `tiger-setup inspect` reports both —
a `windows` object with the shell identity the file presents and an `icon`
array describing its executable icon, alongside the engine identity — and
`verify` checks the engine block against the recorded `engine_block_sha256`.
Engine identity is never leaked into `ProductName`/`FileDescription` to carry
provenance, and product identity is never written into the `Engine` message.
### 9.5 Version semantics
.NET distinguishes `Version`, `AssemblyVersion`, `FileVersion` and
`InformationalVersion`; Windows PE metadata distinguishes file and product
versions. TigerSetup normalizes rather than copying whichever value it finds
first:
```text
package_version = 0.8.0
file_version = 0.8.0.0
informational_version = 0.8.0+abc123
```
For a .NET project the evaluated MSBuild `Version` is the authority for the
installer/package version; EXE metadata is validation input.
---
## 10. Platform baseline and the generated installer
### 10.1 Supported Windows baseline
```text
Windows 10 1809 x64+
Windows 11 x64
Windows Server 2019 x64+
```
Windows Server 2016 is intentionally outside the support baseline.
Windows 10 1809 is the **API baseline**: every Windows API the engine and the
UI use must exist in 1809, checked by review. Validation runs on Windows
Server 2019 (build 17763, the 1809 code base) and on Windows 10 22H2
(`TigerSetup-Validation.md` §5); TigerSetup does not claim direct testing on a
Windows 10 1809 client.
TigerSetup distinguishes **TigerSetup platform support** (can the generated
installer engine run on this OS?) from **application/package support** (does the
packaged application and its dependencies support this OS?). A generated
installer may therefore run on a platform that a particular application does not
support.
### 10.2 Self-contained installer
> **`Setup.exe` must bring everything TigerSetup itself requires to execute.**
A generated installer must start and run on a plain supported Windows
installation without requiring TigerSetup, Rust, .NET, PowerShell 7, WinGet,
Chocolatey, Windows App SDK runtime, a separately installed UI framework, or any
development tooling.
Application dependencies are a separate concern: TigerMarkView may require .NET
Desktop Runtime and WebView2, but the TigerSetup installer engine must not.
### 10.3 One file
A generated installer is **a single Windows executable**. `Setup.exe` is the
whole deliverable: no side-by-side data files, no extraction step the user
performs, no second stub to ship or version. Multi-file or split-media packages
are out of scope for v1.
### 10.4 Installer composition
The executable is a small native **loader** followed by the compressed
**engine** and the package:
```text
+-----------------------------------------------------------------+
| loader (a small PE: bootstraps the engine) |
+-----------------------------------------------------------------+
| compressed engine (the engine PE, one zstd frame) |
+-----------------------------------------------------------------+
| payload (every file, one solid zstd stream) |
+-----------------------------------------------------------------+
| compressed metadata (Protocol Buffers, one zstd frame) |
+-----------------------------------------------------------------+
| fixed footer (the map, 320 bytes) |
+-----------------------------------------------------------------+
```
- **Loader** — the executable Windows runs: a deliberately boring native C
Win32 program of a few tens of kilobytes, with no runtime beyond the
Windows API — no Rust, no protobuf, no SQLite, no engine library — and
only the Zstandard *decoder* linked in. Its whole job is to get the
engine running against the file it came from: it locates and validates
the footer, decompresses the engine block into a fresh temporary file,
checks the decompressed length and SHA-256 (through Windows CNG) against
the footer *before* anything is executed, starts the engine with the
original command line verbatim plus `--package
--package-identity -`, waits, propagates the
engine's exit code, and removes the temporary — and the `%TEMP%\TigerSetup`
it made it in, once nothing else is left there. A running executable cannot
be written, but its name can be given to another file; the engine opens the
package once, refuses a file whose identity is not the one the loader read
its engine from (`package_changed`), and reads the metadata and the payload
through that one handle, each block and entry against its hash. It carries
no metadata, payload, state or transaction logic and makes no decision
about the run — the engine parses the command line and the engine asks
for elevation, so a machine-scope run is an elevated `Setup.exe` (the
loader again) that extracts its own engine under a system-owned root
nobody else can write.
Unelevated, the engine runs from `%TEMP%\TigerSetup\-\`; elevated,
from a directory under `%SystemRoot%\Temp` created with an access control
list granting SYSTEM and Administrators alone, atomically at creation.
The temporary keeps the package's own file name, so Task Manager, the
Restart Manager and a crash dialog name the installer that is running.
The loader loads system DLLs from `System32` only, so the download folder
it runs from is never a DLL search path. The *release loader executable*
(`tigersetup-loader.exe`) is one file for every package; the loader
*block* is that file after the builder gave its copy the product's own
Windows VERSIONINFO and icon (§11.6), which is what Explorer shows for
`Product-1.2.3-Setup.exe`.
- **Engine** — the common TigerSetup installer engine, compressed with
Zstandard. The *release engine executable* (`tigersetup-setup.exe`) is
identical bytes for every package built by one TigerSetup version, and its
SHA-256 is what the embedded metadata records as the engine identity (§9)
and what a lab compares against the engine beside the builder. The engine
the loader runs is that executable **after the builder gave its copy the
product's identity and icons too** — so the running process, and not only
the file on disk, is the product's — and its SHA-256 is recorded twice, in
the metadata (`engine_block_sha256`) and in the footer, where the loader
reads it. The engine reads the metadata and the payload from the original
`Setup.exe`, and everything it relaunches — an elevated run, the temporary
uninstaller copy — is that package, never its own temporary file.
- **Payload** — **one solid Zstandard stream** holding every packaged file:
the product's files under their install-relative names, the installers of
embedded dependencies (§7.7) under `.tigersetup/dependencies/` and the
packaged programs of custom actions and quiescence entries (§5.14, §5.10)
under `.tigersetup/actions/`, directories no product file may occupy.
Every file is in the stream; there is no per-file compression, no
compressibility probe, no signature or extension classifier and no raw
region for files that look compressed already. Directories are metadata
and state, never bytes in the stream.
- **Metadata** — the runtime form of `TigerSetup.toml` (§4), encoded with
**Protocol Buffers** and compressed whole as **one Zstandard frame** under
the same profile as the payload: the package identity, resources,
dependencies, localized strings and everything the engine plans from, plus
the **payload index** — for every entry of the stream, its name, its
offset and length in the uncompressed stream, its CRC-32 and its SHA-256 —
and the **file batches** — consecutive runs of the file list, each with
its first file, its file count and its bytes, closed by the builder before
the file that would take a batch past 256 files or 32 MiB (§5.4). Both are
data, not rules: the engine reads the stream in index order and journals
the files by the batches it is given, and never reproduces the builder's
ordering or its batching. The metadata follows the payload in the file
because it carries the payload's index, which is only known once the
payload has been written; that order lets the builder write the whole file
in one pass. Protocol Buffers stays the logical model; the compression is
of the serialized bytes as one block, with no separate string table or
filename interning, and a reader decodes it within the length the footer
declares.
- **Footer** — a fixed 320-byte trailer carrying the format identification
and version (format 3), the absolute offsets and lengths of the engine,
payload and metadata blocks, the uncompressed lengths of all three, the
SHA-256 of the compressed engine block and of the engine executable it
decompresses to, of the payload block, of the compressed metadata block
and of the metadata it decompresses to, and its own CRC-32. It is the
only thing the loader and the engine have to find by position — at the
end of the file, or immediately before the PE security directory when a
downstream signature has been appended — and everything else it
addresses directly. Everything the loader needs is in the footer alone,
so it decodes no metadata; everything a reader needs to reach the
metadata safely is there too, so a damaged block can neither exhaust the
decoder nor be accepted short. The hash of the decompressed metadata is
the identity of its content, the same for a release-quality and a
`--fast` build of one package.
#### How the payload is encoded
The engine block, the payload and the metadata block use one codec and, by
default, one profile: **Zstandard level 19, a 128 MiB window (`windowLog`
27), long-distance matching, single-threaded** — the `zstd-19-w27` setting
of the compression spike (`benchmark/compression-spike/report.md`); a block
smaller than the window is encoded with a window no wider than itself. The encoding is deterministic:
the same files in the same order produce the same bytes, in separate processes
and on separate days, which the spike verified for exactly these settings.
Single-threaded is a choice, not an omission: Zstandard's worker threads
split the input into jobs whose shared history is at most a fraction of the
window, so on this setting they buy a 1.3–2.5× build (VLC: 48 s → 22 s on
four workers) for a payload up to 0.9 % larger and three to five times the
builder's memory (`benchmark/tuning-2026-09-20.md`). The build is paid once;
the bytes are downloaded and decoded on every installation.
Two build modes make the build-time trade explicit:
```text
tiger-setup build TigerSetup.toml release quality (zstd-19-w27)
tiger-setup build TigerSetup.toml --fast the iteration loop (zstd-3)
```
- **Release** is the default and is what a published installer is built with.
An installer is downloaded and installed far more often than it is built, so
build CPU is the cheap side.
- **`--fast`** is for the developer and AI build-test loop: a fast level with
the default window. The installer it produces is functionally identical and
installs the same bytes; it is simply larger.
**Stream order.** The reserved `.tigersetup/` entries come first — the
dependency installers the engine needs before its transaction and the action
programs it needs at the transaction's start — then the product files sorted by
extension and then by path, compared as bytes. The spike measured
extension-then-path as a consistent gain over plain path order with no
classifier: files of one kind share bytes, and putting them side by side keeps
those bytes inside the window. A static type family ahead of the extension,
and same-named files side by side across directories, were measured on the
whole corpus afterwards (`benchmark/tuning-2026-09-20.md`) and gain nothing
the 128 MiB window does not already reach. The metadata's file list is
written in that order, and the engine's install walk follows it, so an
installation decodes the stream exactly once, sequentially.
**Why Zstandard, when LZMA2 is smaller.** The spike is unambiguous about ratio:
at the same window LZMA2 produces payloads about 8.5 % smaller, and on the
benchmark applications it reaches parity with Inno Setup and NSIS where
Zstandard leaves 9–18 % of the gap. It is equally unambiguous about decoding:
Zstandard decodes at 910–950 MiB/s single-threaded, LZMA2 at 113–126 MiB/s.
The product priority decides between them, and it is explicit:
> **TigerSetup optimizes for the shortest reliable installation transaction.**
TigerSetup is transactional and transaction-aware: the package is downloaded
*before* the transaction, and decompression and machine mutation happen
*inside* it — inside the window in which a crash, a power cut or a cancel has
to be recovered from, and during which the application is stopped. Bytes
saved on the download shorten nothing that has to be recoverable; decode time
lengthens exactly that window. A codec that is 7.6× faster to decode and
within a tenth of the size is the right one for a transaction-optimized
installer, and the choice is not reopened to chase a ratio. The spike's
measurements stand as recorded; its engineering judgment favoured LZMA2 on the
ratio criterion it was given, and this section is where the product's own
criterion overrides it. On the benchmark's real applications the format
change alone took 26–48 % off every 0.7.1 installer and left them 6 % below
to 14 % above the smaller of the Inno Setup and NSIS builds
(`benchmark/README.md`).
**Random access is deliberately not bought back.** Reading one entry means
decoding the stream from its start up to that entry, so a repair or a
single-file extraction pays the skip; the spike priced bounded blocks at
+3.5 % (128 MiB blocks) to +7.5 % (32 MiB) for this codec, and fast sequential
decode is exactly why Zstandard was chosen. An engine that reads in index order
never pays it.
**A stronger encoder is deliberately not used.** Level 22 (`btultra2` at its
widest) closes 0.9 % of the gap to LZMA2 for 60 % more build time; the spike
measured it and it does not pay for itself.
### 10.5 Inspectable by design
The format is deliberately easy to inspect, decompose and verify **without
executing the installer**. A reviewer, a build pipeline, an AI agent or a
support engineer can read the footer, decode the metadata, list the payload
index and compare them against what the package claims — with ordinary
tools, and with `tiger-setup inspect` for the stream itself.
`tiger-setup inspect` is the decomposition in one command. Besides its
report — which lists the file batches beside the files — `--output-payload`
writes the payload block out exactly as the footer addresses it, the same
byte range `verify` hashes, never re-packed or re-encoded, so the SHA-256 of
the exported file is the hash the footer records; `--output-meta` writes the
metadata decompressed, byte for byte, whose SHA-256 is the metadata hash the
footer and every transaction row record; `--output-zip` reconstructs the
payload as an ordinary ZIP archive of stored entries, one per payload entry in
stream order, which any archive tool opens; `--output-engine` writes the engine
executable the loader runs, decompressed, whose SHA-256 is the engine block
hash the metadata and the footer record; and `--output-meta-json` writes the
metadata decoded to JSON, every field of the message tree under its proto
name with enumerations as stable names. Nothing is exported from an installer
that fails verification, and no existing file is overwritten: an export is
evidence about the file, and evidence that could be mistaken for a good payload
is not produced.
There is no proprietary obfuscation, no container encryption, and no format
trick whose purpose is to make the contents hard to read.
The integrity model:
```text
CRC-32 of the footer
SHA-256 of the compressed engine block, and of the engine executable it
decompresses to — checked by the loader before the engine is executed
SHA-256 of the compressed metadata block, and of the Protocol Buffers
metadata it decompresses to, decoded within the length the footer
declares
SHA-256 of the payload block
per-entry CRC-32 and SHA-256 in the payload index — the CRC checked as an
entry is read, the SHA-256 checked before a written file is renamed into
place, so an index that mis-slices the stream can never install the
wrong bytes
```
The per-entry SHA-256 is also what an installed file is *owned by*: an
upgrade or a repair compares the hash the installation recorded with the hash
the package's index carries and decides without decoding the stream or
reading the disk (§5.9). The two kinds of entry the engine *executes* — an
embedded dependency installer and a packaged program — are recorded a second
time beside their declaration; `verify` checks both, and the engine checks the
bytes again before running them. There is no chain-of-custody machinery.
> **TigerSetup is an installer builder, not a supply-chain security framework.**
**No earlier format is read.** An installer of format 1 — the engine as the
executable stub and a ZIP payload — or of format 2 — the loader/engine split
with an uncompressed metadata block, never published — carries its own
engine and stays self-contained on every machine it was built for; the
builder, the engine and the lab inspect format 3 only, and an older file is
refused with `format_unsupported` rather than half-read.
### 10.6 Code signing is outside the core design
TigerSetup does not own a signing workflow, a signing policy, or key handling.
A downstream project may sign a generated installer if it wants to; the core
design neither requires it nor provides it.
---
## 11. Interactive UI
### 11.1 Philosophy
The UI must be clean, modern, native-feeling, fast, small, easy to understand,
DPI-aware, accessible, and visually consistent with modern Windows conventions.
The goal is **not** a visually elaborate installer.
> **Small, neat and fast — not beautiful bloatware.**
The UI is a presentation layer over the same installation engine used by
unattended installation (§6.1). It contains no separate installation
implementation and no installer-specific business logic.
**Options pages.** A boolean option is a check box; a choice option is a
heading with one radio button per value. Every row starts from the value the
engine itself would resolve — explicit, else recorded, else default — and the
options take as many pages as their rows need, nine rows to a page, an option
never straddling two; several pages are numbered in the header ("… (1 of 2)").
Pages rather than a scrolling list keep the proven static layout, its DPI
scaling and its keyboard order, at the cost of a Next per page for a package
with many options; a choice option is limited to eight values so that it
always fits one page. The Ready page summarises the selection as the person
made it: each selected check box, and each choice with the value chosen.
**The licence page asks once per licence text.** Three facts are kept apart:
the package carries licence text, a person explicitly accepted a particular
text, and the run may proceed unattended. The page is shown when the package
carries text and the installation (§5.3) records no acceptance of exactly
that text — a first install, an installation nobody accepted a licence for,
or a text that differs by any byte, an edited copyright year included — and
the run cannot continue until the person accepts. The acceptance is passed
to the engine with the run and committed with the installation it was
accepted for: a cancelled, failed or rolled-back run records nothing, and an
upgrade that rolls back keeps the previous acceptance. An interactive upgrade
or reinstall under the accepted text skips the page; uninstall and repair
never show it. A `--quiet` run is the third fact and never the second: it
neither shows nor waits for the page, proceeds whatever the text, and
records no acceptance — an unattended upgrade to a changed text leaves the
earlier acceptance as it was, for the next interactive run to ask about.
### 11.2 No Windows App SDK runtime requirement
A modern appearance must not cost a large framework/runtime requirement. The UI
direction is:
> **A clean, modern, Fluent-aligned native Windows UI, without an external
> Windows App SDK runtime requirement.**
The implementation technology is **plain Win32 common controls** (comctl32 v6
under the visual-styles manifest, GDI text, a hand-kept device-independent
layout rescaled on `WM_DPICHANGED`). It was chosen over the Win32 Aero wizard
and over a Rust UI toolkit against the real constraints — executable size
(about 0.4 MB over the engine), start-up, Windows 10 1809 / Server 2019
compatibility, no runtime requirement, DPI, accessibility, localization (every
string the wizard shows is the installer's own, in the installer's language),
visual quality and maintainability from Rust — and it is the settled
technology: a visual change varies the layout within Win32, not the
technology.
Identity in the wizard is the **application's**: the product name in the
title and headers, the product icon from the metadata. TigerSetup's own
branding is secondary and is exactly the word `TigerSetup` — never translated,
never "Powered by".
### 11.3 Engine and UI platform support are separate
```text
TigerSetup engine
|
+-- unattended/headless CLI
|
`-- optional native UI
```
The engine's platform support is the primary compatibility contract. This
matters most on Windows Server: unattended Server installation must never depend
on UI technology. Interactive UI support may be narrower than engine support
if a technical limitation forces it; the same experience on desktop and
Server is the target, and the Server 2019 interactive row proves it
(`TigerSetup-Validation.md` §5.2).
### 11.4 DPI awareness is a hard requirement
The UI must be properly DPI-aware, not merely acceptable at 100% scaling.
Validation covers 100%, 125%, 150% and 200% (see `TigerSetup-Validation.md`),
checking for clipped text, overlapping controls, incorrectly scaled icons,
layout breakage, unreadable text, badly sized windows, incorrect scaling
behaviour, keyboard usability and accessibility regressions. Moving between
monitor DPI contexts may also need validation, depending on the chosen UI
technology.
### 11.5 Light and dark are both the product
The wizard follows the person's Windows theme. That is not decoration: an
installer that opens a white window in front of somebody who set Windows to
dark is the first thing they see of the product, and it looks like a program
that was not finished.
Windows does not do this for a plain Win32 application. `GetSysColor` keeps
answering with the light palette whatever the app-theme setting says, because
the classic system colours belong to high contrast. Following the theme is
therefore the wizard's own work, and it has three parts:
- **the palette** the window paints its bands, rules and text with;
- **the title bar**, asked of the Desktop Window Manager, so a dark page does
not sit under a white caption;
- **the common controls**, moved onto their dark visual style, because a
control draws its own border, tick and frame and only its style can change
those. The one exception is a radio button's label, which the dark style
draws in its own dim colour: the wizard paints that label itself, in the
palette's text colour, and leaves the button its behaviour and its glyph.
Two settings decide it, in this order. **High contrast wins**: a person using
it has told Windows exactly which colours they can see, so the palette is the
system's own and nothing overrides it. Otherwise the app-theme preference
decides, and its absence means light. A theme changed while the wizard is open
is picked up and repainted.
Both themes are acceptance requirements on Windows 11, alongside the scale
dimension (`TigerSetup-Validation.md` §8).
### 11.6 Icons
The wizard's icons are the ones a Windows installer is expected to show. Where
Windows owns the meaning, the system's own icon is used and not replaced: the
elevation shield is the shield Windows draws everywhere else, and a folder is
the shell's folder. The shield sits on the control that actually raises the
prompt — the wizard's **Next** button, set through `BCM_SETSHIELD` when the
selected scope needs an administrator and cleared when it does not — following
the Windows convention that the affordance marks the action, not the choice
that leads to it. Selecting "install for all users" does not itself elevate;
pressing Next while it is selected does, so the shield belongs on Next.
Because it is the themed button's own state rather than a drawn glyph, it
survives hover, focus, repaint, a theme change, a DPI change and page
navigation without the wizard painting anything.
What the shield promises is what pressing Next does. The wizard relaunches
this same executable elevated for the chosen scope, on a worker thread so the
window keeps pumping messages for the whole life of the prompt; the elevated
child is started **shown** — a process's first window follows the show state
it was started with, so a child started hidden would put up an invisible
wizard and wait forever for a click — and continues the wizard from its next
page on. Once the prompt has been answered and the child is running, the
parent steps aside for it, exactly as it does for a plain relaunch between two
installations, and reports the child's outcome and exit code as its own when
the child finishes. A refused prompt reaches no child: the parent stays where
it was, visible and usable, and says the prompt was declined. Both halves of
that — the prompt and the elevated run — are only proven together, on one
wizard, from the unelevated request to the outcome that comes back to it
(`TigerSetup-Validation.md` §5.2, the elevation rows).
Where the meaning is the product's, the glyph comes from **Fluent UI System
Icons**, which is the Tiger family's icon language. Only concept glyphs are
used, and only where they say something the words beside them do not: the
outcome mark on the completion page, a checkmark or an exclamation in a
circle.
They are embedded as their **outline path data** and drawn, not shipped as
bitmaps, which is what makes one glyph right at every scale the wizard runs at,
lets it take the theme's foreground colour instead of colours baked into an
image, and adds nothing measurable to the installer. Provenance and the
licence notice are in `THIRD-PARTY-NOTICES.md`; the notice travels with the
redistributed material, and adding or replacing a glyph means re-reading the
upstream licence and comparing it with what is recorded there.
#### The generated installer's own icon
The icon Explorer, the taskbar and the wizard's title bar show for a
`Setup.exe` is the **product's**, not TigerSetup's — a TigerMarkView installer
looks like TigerMarkView. That icon is a resource the builder writes into the
engine copy it composes, alongside the product's version resource (§10.4), and
it is resolved from the manifest, never guessed from a payload executable:
```text
[installer].icon = "" an explicit .ico, relative to the manifest → that icon
= "tigersetup" always the built-in TigerSetup icon
= "branding" the product's [package].icon; a validation error if there is none
omitted [package].icon when one is declared, otherwise the TigerSetup icon
```
`"branding"` without a `[package].icon` fails the build deterministically
rather than falling back silently, so a package that means to carry its own
mark cannot ship TigerSetup's by accident. The engine copy keeps a second
icon group as well — TigerSetup's own — which the wizard draws as the small
secondary brand mark beside the product's; the resource-id contract
(`RT_GROUP_ICON 1` the product's, `RT_GROUP_ICON 2` TigerSetup's, `RT_VERSION 1`
the product's identity) is in the builder's `resource` module. Multi-size,
theme-following icon quality is preserved: every image of the `.ico` is
carried, so Windows picks the right size at every DPI.
TigerSetup's own application icon — the one the built-in default and the brand
mark come from — remains provisional artwork.
### 11.7 Launch after install
A package may declare the program the completion page offers to start:
```toml
[launch]
executable = "MyApp.exe" # install-relative; an .exe the package installs
arguments = ["--welcome"] # separate arguments, expanded like an action's
working_directory = "." # install-relative; default: the executable's own directory
checked = true # the check box's initial state; default true
```
It is **not a custom action**, and the difference is the whole design. An
action (§5.14) is part of a transaction: journaled, hidden, bounded, run with
the run's own token, and able to fail the run. The launch is a post-success
piece of interactive UX: it happens after the transaction has committed, is
shown, is neither waited for nor bounded, runs as the person rather than as
the run, and can never change what the run reports — the outcome, its code and
the exit code are the transaction's whatever happens to the program. So it has
no journal row, no `run_on`, no timeout, no exit code and no predicate; the
builder refuses an executable the package does not install and a working
directory it does not create, so the offer cannot promise a file that the
installation lacks.
**When it is offered.** On the completion page of an interactive `install` —
a first install, an upgrade or a reinstall, the flows that show that page for
a run that installs — and only once the run ended `installed`. A failed,
rolled-back, refused or cancelled run shows no box; a repair and an uninstall
never offer it, because neither is a moment a person expects the product to
start. Pressing Finish with the box checked starts the program, and closing
the window on the completion page is Finish, as Windows convention has it.
The box starts as `checked` declares.
**Never silent.** The offer lives on the completion page, and a `--quiet` run
has none: an unattended run never starts the program, whatever the manifest
says, because the caller that asked for no window did not ask for one to
appear. There is no switch that makes a quiet run launch.
**Never elevated.** The program is always started as the person signed in to
the desktop, with their unelevated token, whatever token the wizard holds —
the decision is the wizard's own token, not the scope of the run:
```text
the wizard is not elevated → CreateProcessW with its own token, in a fresh
environment of that user, so a PATH the run just
changed is seen
the wizard is elevated → the desktop's shell starts it: ShellWindows,
(a UAC child, or started registered to run as the interactive user, hands
elevated) out the desktop's Shell.Application, and
IShellDispatch2::ShellExecute runs in Explorer,
with Explorer's token and environment
no non-elevated context → nothing is started: no shell is running, or the
shell's own token is elevated (User Account
Control off, the built-in Administrator), or it
cannot be read
```
Asking the shell rather than borrowing its token is what makes the elevated
case hold for every way a wizard gets elevated: an interactive logon's token
grants the Administrators group `TOKEN_QUERY` and nothing more, so an
administrator who approved the prompt over a standard user's shoulder could
read the shell's token but not duplicate it — while `ShellWindows` reaches the
desktop's Explorer from any account. The query is still used: the shell's
token is read before it is asked, and an elevated shell means there is no
unelevated context on this desktop. **Nothing ever falls back to the wizard's
own elevated token.** Arguments expand `%INSTALLROOT%`, `%VERSION%` and the
known folders in the wizard, exactly as an action's do (§5.14): a per-user
known folder therefore names the wizard's account, which is the person's own
except under over-the-shoulder elevation, so a package that must name the
person's folder should let the program find it.
**In front.** The person pressed Finish, so the program should be the window
they see next, and Windows will not arrange that on its own for a window that
appears after its creator has closed. The wizard is the foreground window when
Finish is pressed and passes that right on explicitly: the program is created
suspended and `AllowSetForegroundWindow` names it before its first instruction
runs (through the shell, Explorer is named before it is asked, and the program
once it appears). The completion page then stays up, still in the foreground,
until the program's main window — visible, top-level, unowned, not a tool
window — appears, and puts it in the foreground with `SetForegroundWindow`. It
waits at most 15 seconds, and less for a program that shows no window: once
the program has reached its message loop and still shows nothing two seconds
later, or has exited, the page closes — a tray application has no window to
bring forward, and the outcome says `foreground: false` rather than claiming
otherwise.
**Failure.** A program that cannot be started (`launch_failed`) or a desktop
with no unelevated context (`launch_unavailable`) is reported to the person in
the wizard's own dialog, as a program that did not start — "MyApp is
installed, but it could not be started: …" — and never as a failed
installation: the run still ends `installed` with exit code `0`. A declined
offer is recorded as declined.
**Evidence.** The outcome document of an interactive run carries `launch` —
`status` (`started`, `declined`, `failed`, `unavailable`), `code`, the resolved
program, arguments and working directory, `method` (`own_token` or `shell`),
`pid` and `foreground` — and the run's log carries the same as one line,
`launch_started`, `launch_declined`, `launch_failed` or `launch_unavailable`,
appended after the transaction's own lines. An elevated child hands its
document, launch included, back to the unelevated wizard that asked (§11.6).
`inspect` lists the declaration under `package.launch`.
---
## 12. Localization
Localization is first-class, not a later framework. The installer's own text
— outcome messages and everything the wizard shows — is available in:
```text
en-US
pl-PL
```
Strings live in the engine's text catalog (`i18n`), keyed by stable
identifiers, separate from UI and engine logic: a catalog entry per language,
`en-US` complete and every other language falling back to it key by key, so a
missing or incomplete translation never makes the installer unusable. Product
strings — names, descriptions, option labels — come from the package
metadata, where a custom option carries a label per language with `en-US`
required. Adding a language is adding a catalog and a language mapping; no
installer logic changes. Machine-readable output never goes through the
catalog (§6.3). `TigerSetup` is a name, not a word: it is never translated.
### 12.1 Language selection
```text
--lang (explicit installer language selection)
↓
Windows UI language
↓
en-US fallback
```
Polish is a deliberate layout stress case, because string lengths often
differ materially from English, and both languages are acceptance
requirements (`TigerSetup-Validation.md` §8).
---
## 13. Technology choices
**Rust** is the implementation language: a native Windows executable, no .NET
runtime dependency on the target, a good fit for a small self-contained
CLI/builder and for low-level Windows integration, and a natural fit with the
Tiger tool philosophy.
**SQLite through `rusqlite` with the bundled feature**, so SQLite compiles into
the executable and the self-contained deployment model is preserved. The
bundled build is configured in `.cargo/config.toml` for what TigerSetup
actually uses: the full-text search, R*Tree and DBSTAT modules, extension
loading, column metadata, STAT4 and `soundex()` the crate opts into by default
are declined, and SQLite's recommended options for an embedded database are
set — no double-quoted string literals, no memory statistics, no deprecated
interfaces, no progress callback, no shared cache — about 360 KB of code in
every executable that nothing reaches. SQLite stays thread-safe: `rusqlite`
refuses a single-threaded build, and one process holds two connections (the
state database and the WinGet index), each opened without its own mutex.
Durability behavior is the crate's default. The state database's schema is
at version 7; a reader tolerates every schema back to 2.
**Protocol Buffers and Zstandard** are the installer-format technologies
(§10.4). Both are read by the generated installer — Zstandard by the loader
and by the engine — so both must compile into the executable and neither may
pull in a runtime prerequisite on the target machine. Zstandard is libzstd
1.5.7 through the `zstd` crate, BSD-3-Clause, built without its legacy formats
and dictionary trainer (`THIRD-PARTY-NOTICES.md`); there is deliberately one
compression technology, and the loader needs only its decoder. Which Rust
crate provides Protocol Buffers, and how the `.proto` schema is compiled
during the build, are implementation choices (§17).
**Durability settings** are a rollback journal (`journal_mode = DELETE`, no
WAL sidecars), `synchronous = FULL`, `foreign_keys = ON`, an exclusive lock
for the duration of a mutating run, and the schema version in
`PRAGMA user_version` with forward-only migrations; `inspect` and `verify`
open the database read-only and report `database_busy` while a run holds it.
The recovery rows exercise it under process kill, reboot and power-off
(`TigerSetup-Validation.md` §3).
**Implementation structure.** A Cargo workspace of six crates whose
dependency directions the compiler enforces: `tigersetup-format` (footer,
metadata, payload, compose, inspect, verify, and the package-identity
derivations both sides must agree on; no Windows API), `tigersetup-catalog`
(the WinGet catalog client — the pre-indexed source, version data, merged
manifests — which both sides read, the builder at build time and the engine
when it refreshes an acquisition hint), `tigersetup-engine` (state, journal,
planner, transaction executor, recovery, resources, quiescence, reports,
fault injection; depends on the format and catalog crates),
`tigersetup-loader` (the C Win32 loader every generated `Setup.exe` begins
with, compiled and linked by the package's build script from `src/loader.c`
and libzstd's decoder — taken from the sources the `zstd-sys` crate carries,
so the loader decodes with the same libzstd the engine links — into the
profile directory beside the other executables; it reads the footer by its
own code, links nothing of Rust or of the engine, and its own tests run it
against synthetic packages), `tigersetup-setup` (the engine executable: the
command-line client and the interactive client, which reach the engine only
through its public API), and `tigersetup-build` (the builder library and
`tiger-setup.exe`; depends on the format and catalog crates and never on the
engine, so nothing that installs can leak into the tool that packages). One
format implementation serves builder and engine, and the loader's footer
reader is checked against it by every process-level test; one
package-identity implementation serves both sides. Static CRT,
`opt-level = "z"` with the two hot crates — the SHA-256 implementation and
the Zstandard decoder — at full optimization, fat LTO, one codegen unit,
`panic = "abort"`, symbols stripped: a profile audited against opt-level s,
2 and 3 and thin LTO by the engine's compressed bytes and its install,
uninstall and verify times, where z is the smallest compressed and, with
those two crates at 3, as fast as the whole engine at 3
(`benchmark/README.md`). The engine executable is about 2.6 MB raw and
1.19 MB as the compressed block every installer carries, and links the
Zstandard decoder alone (the uninstaller copy's metadata block is a stored
frame); the loader is 76,288 bytes, most of it the Zstandard decoder (28 KB
of code) and the resources (20 KB), with a few KB of the C runtime the
compiler needs (`benchmark/README.md` has the audited breakdown). Both import
only inbox DLLs.
Fault injection (`--fault [@]:[:][:skip_flush]`)
is compiled into every build and affects only the invoking run, so the bytes
the interrupted rows validate are the bytes that ship.
---
## 14. Product scope
### 14.1 What TigerSetup does
The product is bounded by what replacing a real desktop application's
installer needs. TigerSetup provides:
- declarative package definition;
- a self-contained, natively generated `Setup.exe`;
- per-user and per-machine scope, with UAC elevation for machine scope, and
the cross-scope policy of §5.13;
- file installation with conservative ownership;
- Start Menu, Desktop, Startup and Send To shortcuts, with working
directory and AppUserModelID, and URL shortcuts;
- PATH integration and environment variables;
- registry values under the scope's `Software` root or at an explicit
location in the scope's hive, with their pre-installation state restored;
- file associations, URL protocols, `App Paths` and classic context-menu
verbs, registered as handlers rather than as defaults;
- Windows Firewall rules for installed programs;
- custom lifecycle actions — a packaged or installed program, PowerShell or
batch script run at pre-install, post-install, pre-uninstall or
post-uninstall on the operations it names, under a bounded, recorded
envelope, with no claim of rollback for what the program changed (§5.14);
- boolean and choice options with the one `when` predicate, and optional
components as option-gated files;
- launch after install: a completion-page offer to start the application after
an interactive install or upgrade, as the signed-in user and never elevated,
never from a quiet run (§11.7);
- Add/Remove Programs registration;
- uninstall, reinstall, upgrade and repair;
- SQLite-backed installation state;
- a persistent transaction journal with rollback and recovery;
- silent mode and logging;
- automated machine-readable verification;
- dependency detection, acquisition, installation and verification through
typed detectors and the WinGet catalog, or from an installer embedded in
`Setup.exe`;
- offline installation when dependencies are already satisfied;
- migration from an Inno Setup installation;
- a small native DPI-aware, theme-following interactive UI localized to
`en-US` and `pl-PL`;
- a single-file `Setup.exe` in the documented composition — engine, Protocol
Buffers metadata, ZIP payload, footer — inspectable without executing it
(§10.3–10.5);
- WinGet manifest generation;
- TigerWinLab end-to-end validation.
### 14.2 Not in scope today
Deliberately outside the product; each is added only on a concrete
requirement:
- Windows services;
- ARM64;
- automatic update system;
- binary patching;
- enterprise-style repair policies;
- drivers;
- modern shell extensions — COM shell-extension registration,
`DllRegisterServer`, sparse AppX/MSIX packages, the Windows 11 top-level
context menu — as distinct from the classic registry-declared verbs
TigerSetup does register; a separate design topic;
- arbitrary script execution beyond the declared custom actions of §5.14 —
no inline scripts, no compensating or rollback actions, no per-action
elevation, no action-specific condition language;
- elaborate or highly customised installer UI beyond the small native wizard;
- multi-file or split-media installer packages (§10.3);
- code signing, which is outside the core design entirely (§10.6);
- a downgrade guard — a downgrade is mechanically an upgrade with an older
target and is tested as one;
- installation-state history retention;
- custom detector and installer types beyond the built-ins, and dependency
version ranges beyond "same major, at least the declared minimum";
- a redistribution licensing policy for downloaded prerequisites;
- a Chocolatey package skeleton, and `winget validate` inside the builder;
- Authenticode interaction with the footer — the locate rule is implemented
but has not been checked against a signed file;
- installation parameters beyond declared boolean and choice options — free
text, paths other than the install root, numbers;
- optional application-data purge on uninstall, AutoPlay handlers, Windows
Terminal fragments, ACL or security-descriptor declarations, registry
values in a hive the installation's scope does not write (an `HKLM` value
from a per-user install, or one gated on the scope of a dual-scope
package), and registry keys other than `HKLM` and `HKCU` — `HKU`, `HKCR`
as a root of its own, remote registries, loaded hives;
- deeper accessibility automation beyond the UI Automation ids the wizard's
controls carry, and a deliberately narrower Server UI;
- a visual-regression strategy beyond the lab's per-page captures, which are
reviewed by eye.
---
## 15. Vocabulary
Use these terms consistently:
- **Package** — desired installation definition.
- **Installation** — currently committed installed state.
- **Transaction** — one install / upgrade / uninstall / repair attempt.
- **Operation** — one reversible system mutation.
- **Resource** — typed system entity managed by TigerSetup.
- **Manifest** — desired state / intent.
- **State database** — actual committed ownership/state.
- **Journal** — in-progress transaction and rollback information.
---
## 16. Guiding principles
1. **Small and native.** TigerSetup is a focused Windows tool, not a deployment
platform. Installer size, startup speed and implementation simplicity matter;
a modern appearance never justifies framework bloat.
2. **Declarative before programmable.** Typed operations beat arbitrary setup
scripts.
3. **Manifest is intent; database is reality.** Never reconstruct uninstall
state from the current package definition.
4. **Every mutation is journaled and reversible.** Durability must precede
destructive changes, and an attempt ends in success or full rollback. A
recoverable in-progress state is legitimate; an unknown or hybrid
installation is not.
5. **Ownership is conservative.** Do not remove resources TigerSetup cannot
prove it owns.
6. **Dependencies are requirements, not owned resources.** TigerSetup may
satisfy a shared dependency without claiming lifecycle ownership of it.
7. **One source of packaging truth.** Installer, ARP, release and WinGet
metadata must not drift.
8. **Understand the application when useful.** MSBuild and PE metadata
integration should eliminate glue, not create framework lock-in.
9. **Build once; distribute exact bytes.** WinGet preparation must respect
exact-artifact release discipline.
10. **Self-contained and inspectable on the target.** Generated installers run
on a plain supported Windows installation with no TigerSetup, runtime or UI
prerequisites, and the single file can be decomposed and verified without
being executed.
11. **Automation-first, interactive-capable.** Unattended install, upgrade and
uninstall are the primary execution paths; UI is a client of the same
engine.
12. **Machine-verifiable by design.** Install, upgrade, uninstall, dependency
and recovery results must be objectively inspectable by automated tools.
13. **Offline-capable.** Network access is optional dependency acquisition, not
an installer-engine requirement.
14. **Localizable by design.** Strings stay separate from engine and UI logic;
`en-US` and `pl-PL` are first-class tested languages.
15. **DPI-aware by design.** The interactive UI must be tested and usable across
common Windows DPI scaling levels.
16. **WinGet-aligned, WinGet-independent at runtime; Chocolatey-compatible by
convention.** Use WinGet identities and conventions where useful; generated
installers must never require WinGet, and must stay easy to wrap in
Chocolatey.
17. **Real applications define scope.** Generalize recurring requirements from
the applications TigerSetup packages; do not invent installer-framework
features without evidence.
18. **Respect project ownership boundaries.** TigerSetup may specify missing
TigerWinLab capabilities but must not implement them inside this project.
19. **Do not become MSI by accident.** If the design starts reproducing Windows
Installer's complexity, reconsider scope.
20. **Replacement is the standard.** TigerSetup is acceptable only while a
TigerSetup-generated installer passes real TigerMarkView replacement
validation (`TigerSetup-Validation.md` §4).
---
## 17. Open questions
Deliberately undecided, to be resolved on concrete requirements and
implementation evidence rather than speculative framework design.
**Transaction and state**
- installation-state history retention policy;
- a downgrade policy (today a downgrade is mechanically an upgrade to an
older version, with no guard);
- repair semantics beyond reconciliation.
**Packaging and build**
- how a downstream project's Authenticode signature interacts with the footer
in practice (the locate rule reads the footer before the PE security
directory when one is present; unverified against a signed file, §10.6);
- whether an encoder stronger than the ZIP container's DEFLATE ever becomes
worth its cost (§10.4); a different container is not on the table.
**Dependencies and distribution**
- licensing/redistribution policy for downloaded or embedded prerequisites;
- whether dependency payloads may be embedded in `Setup.exe` (the acquisition
model allows a fixed source; an embedded one is not implemented);
- whether and how Chocolatey package mappings are represented, and whether
TigerSetup later generates a Chocolatey package skeleton.
**Execution surface**
- how optional interactive installation parameters beyond declared on/off
options are modeled.
**UI and localization**
- the exact accessibility target and automation approach beyond the UI
Automation ids the wizard's controls carry;
- whether interactive UI support on Server differs deliberately from desktop
Windows.