A Windows installer/uninstaller framework — a modern, declarative replacement for Inno Setup. Describe your installer in TOML; outto packages it into a single self-extracting .exe with rollback, a proper Add/Remove Programs entry, and a real uninstaller.
Status: 0.1.0, early. Windows only. The TOML schema may still shift.
- Declarative manifests. One
outto.tomldescribes files, directories, registry entries, shortcuts, environment variables, services, file associations, COM registrations, font installs, and before/after commands. - Self-extracting installers.
outto buildstages your source tree, packs it into a zstd-compressedbox_formatarchive, and embeds it into an SFX stub — the result is a single signed.exe. - Transactional installs. Every action is recorded; any failure rolls everything back. Successful installs write a manifest to
{install_dir}/.outto/{package_id}/that the bundled uninstaller replays in reverse. - Upgrade-aware. Detects existing installs via the registry. Choose
overwrite,side_by_side, orfail. - Dependency cascade. Packages may declare
depends_on; uninstalling a dependency cascades to its dependents so nothing is left dangling. - Admin or per-user.
privileges = "admin" | "user" | "auto"— outto elevates only when required.
-
Install Rust nightly and add the MSVC target:
rustup toolchain install nightly rustup component add rust-src --toolchain nightly rustup target add x86_64-pc-windows-msvc --toolchain nightly -
Build outto itself (see Building from source below).
-
Write an
outto.tomlnext to your app's staged files:[package] id = "com.example.myapp" name = "My Application" version = "1.0.0" publisher = "Example Corp" default_dir = "#{pf}/My Application" privileges = "admin" [uninstall] display_icon = "#{app}/bin/myapp.exe" remove_app_dir = true [[files]] source = "bin/*" dest = "#{app}/bin" overwrite = "always" [[shortcuts]] name = "My Application" target = "#{app}/bin/myapp.exe" location = "start_menu" [[environment]] name = "PATH" value = "#{app}/bin" scope = "system" action = "append"
-
Stage your files in a source directory, then build the installer:
outto build --config outto.toml --source ./staged --output myapp-setup.exe --compress --compression-level 22Add
--sign <command>to code-sign each produced binary (the uninstaller, the installer, and the SFX wrapper are each signed in turn).
The resulting myapp-setup.exe is self-contained — double-click to run the GUI installer, or call it with /S for a silent install.
The installer and the uninstaller exit with the same codes, on Windows and macOS:
| Code | Meaning |
|---|---|
| 0 | Succeeded. |
| 1 | Failed, and the machine is as it was before. This includes an install that --no-close-apps stopped because running apps hold its files (see below). |
| 2 | Refused: an [[uninstall.refuse_while]] condition holds and nothing changed. Retrying once it passes can succeed. |
| 3 | Failed, and outto could not undo everything it had done. |
| 4 | The command line, configuration or payload is unusable, and nothing changed. |
| 3010 | Succeeded, but the machine must restart, and outto did not restart it (with /NORESTART, or when the restart could not be started). This is Windows Installer's ERROR_SUCCESS_REBOOT_REQUIRED. |
A silent install restarts the machine itself when a restart is needed, unless /NORESTART is passed. Callers that schedule restarts themselves pass /NORESTART and act on 3010.
On Windows, every install and uninstall of a package appends to that package's own log, whatever its switches: %ProgramData%\outto\<package id>.log, or %LOCALAPPDATA%\outto\<package id>.log for a package with privileges = "user". Each run begins with a line giving the time, outto's version, what it does and how, and its command line; every warning and error follows. A log larger than 1 MiB when a run starts is cut back to its newest 512 KiB, from the start of a line.
%ProgramData%\outto belongs to Administrators, and inherits nothing from %ProgramData%: SYSTEM and Administrators have full control, Users can read. outto gives an existing directory of that name the same owner and ACL, writes nothing through one that is a link elsewhere, and deletes a log file that is a link or that Administrators or SYSTEM do not own.
/LOG and /LOG="path" (or --log) write a log of the run as well, as with Inno Setup.
On Windows, when running apps hold a file the installer has to replace, Restart Manager can close them, and relaunch the ones that registered for it once the install is done. The installer asks first, unless it runs with /VERYSILENT or /SUPPRESSMSGBOXES, when it closes them without asking.
With --no-close-apps, the installer never closes them. The install fails, what it had done is undone, the apps keep running, and it exits with 1. Callers that update software while someone may be using it, such as a background updater, pass --no-close-apps and try again later.
Restart Manager can't always name what holds a file, and a package can turn it off with [reboot] restart_manager = false. Either way the file is replaced at the next restart instead, with or without the flag.
The uninstaller accepts --no-close-apps too, but it never closes apps: on Windows it deletes a file in use at the next restart. On macOS, the installer and the uninstaller replace and delete files in use without closing anything, so the flag changes nothing there.
With /VERYSILENT, the installer and the uninstaller append progress to a file given as --progress-file <path>, on Windows and macOS. Each line is one JSON object, flushed as it is written:
type |
Fields |
|---|---|
progress |
phase, current, total |
log |
level (debug, info, warn or error), message |
reboot_required |
none: the reboot policy decided a restart is wanted |
finished |
ok; when false, also error and exit_code. Always the last line. |
Readers should ignore types they don't know. The exit code remains the authoritative result.
TOML values accept #{…} placeholders that are resolved at install time:
| Variable | Value |
|---|---|
#{app} |
Resolved install directory |
#{pf} |
%ProgramFiles% |
#{windir} |
%WINDIR% |
#{sys} |
%WINDIR%\System32 |
#{userappdata} |
Current user's %APPDATA% |
#{commonappdata} |
%PROGRAMDATA% |
#{startmenu}, #{desktop}, #{startup} |
The current user's Start Menu programs, desktop and Startup folders |
#{commonstartmenu}, #{commondesktop}, #{commonstartup} |
All users' (C:\ProgramData\Microsoft\Windows\Start Menu\Programs, C:\Users\Public\Desktop, ...\Programs\Startup) |
#{package.name} |
From [package].name |
#{package.version} |
From [package].version |
A [[run]] command that runs another installer can hand it the current log with #{log_arg:NAME}, an argument of its own in arguments:
[[run]]
phase = "after_install"
command = "#{app}/dependencies/tip-setup.exe"
arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART #{log_arg:text-service}"
wait = trueWhen the installer or uninstaller runs with /LOG, #{log_arg:NAME} becomes the single argument /LOG=<path>, a log next to the current one with .NAME added before the extension: with /LOG=C:\logs\install.log the command above gets /LOG=C:\logs\install.text-service.log. Without /LOG it becomes no argument at all. It is expanded when the command runs, so a before_uninstall or after_uninstall command passes on the uninstaller's log. NAME is letters, digits, -, _ and .; #{log_arg:…} can't be used anywhere else, or inside a longer argument.
By default a [[run]] command's exit code is only logged. required = true makes its failure the operation's failure:
[[run]]
phase = "after_install"
command = "#{app}/register.exe"
arguments = "--all"
wait = true
required = trueIf a required command can't be started, or exits with anything but 0 or a restart request (3010, 1641), the install fails and what outto recorded is rolled back (exit code 1). Rollback doesn't undo what earlier commands did. A failing required before_uninstall command stops the uninstall before anything is removed. required needs wait = true, and after_uninstall commands can't be required, since the package is already gone when they run.
A [[shortcuts]] entry puts a .lnk in the Start Menu, on the desktop or in the Startup folder (location = "start_menu", "desktop" or "startup"), in its subfolder if it has one, which is created if missing and removed on uninstall once empty:
[[shortcuts]]
name = "My Application"
target = "#{app}/bin/myapp.exe"
location = "start_menu"
subfolder = "#{package.name}"
# scope = "current_user"As with Inno Setup, the folder is all users' when the package has privileges = "admin" (#{commonstartmenu}, #{commondesktop}, #{commonstartup}), and the current user's otherwise (#{startmenu}, #{desktop}, #{startup}). scope = "all_users" or scope = "current_user" overrides that for one shortcut; all_users needs an elevated install, so a package with privileges = "user" can't use it. The current user is the account the installer runs as: an install elevated with another administrator's credentials puts a current_user shortcut in that administrator's Start Menu, not in the signed-in user's.
Uninstall removes each shortcut from where it was created. An upgrade removes the ones the installed version created that the new version doesn't create again, such as one that moved to another scope, and the folders created for them once they are empty.
A [[services]] entry registers a Windows service, and with on_install = "start" starts it:
[[services]]
name = "myapp-daemon"
display_name = "MyApp Background Service"
executable = "#{app}/myapp-svc.exe"
start_type = "auto"
on_install = "start"When the service is already registered, as it is when a newer version installs over an older one, outto keeps the registration. A running service is stopped before any file is copied, and outto waits for its process to exit. The registration is then given the entry's executable, display name and start type, and its account when the entry sets one, and a service that was running is started again whatever on_install says. Everything else on the registration stays, such as the recovery actions or a description an administrator set. If the install fails, the rollback puts back the old files and the old registration and starts the old service again. A service that does not stop within 30 seconds fails the install. Uninstall deletes the service, whether the package created it or found it registered.
On macOS, a [[launchd]] job that is already loaded is booted out before any file is copied, and bootstrapped again from the new plist once it is written; a failed install puts the old plist back and bootstraps the old job.
A [[retire]] entry names an install that outto did not make, such as the older product a package takes the place of, for outto to remove once the package has installed:
[[retire]]
name = "Old Service" # for the log
inno_app_id = "8A3F2C1D-0B4E-4F6A-9C7D-1E2F3A4B5C6D" # its Inno Setup AppId, without braces
services = ["old-service"]
registry_keys = ['HKLM\SYSTEM\CurrentControlSet\Services\EventLog\Application\Old Service']
[[retire]]
name = "Old Manager"
inno_app_id = "1C2D3E4F-5A6B-4C7D-8E9F-A0B1C2D3E4F5"
processes = ["OldManager.exe"]
shortcuts = ["#{commonstartmenu}/Old Manager.lnk", "#{commonstartup}/Old Manager.lnk"]Nothing here runs unless the install succeeds, and nothing here is undone. Once the install is in place and registered, outto retires the entries in the order they are listed. For each, it ends every running program of each processes image name, except the installer itself and the programs that started it; stops each service in services, waits for it to stop, and deletes it; removes the directory the Inno Setup install's Add/Remove Programs entry names, with everything in it; deletes the shortcuts, and their folder once empty; deletes the registry_keys with everything under them; and last deletes the Inno Setup install's Add/Remove Programs entries, under HKLM's native and 32-bit views and HKCU. A file still in use, such as the program that started the installer, is moved aside and deleted at the next restart. Each step is logged, and a step that fails is logged and the next one runs; the install still succeeds.
This is separate from legacy_inno_app_id, which names the Inno Setup install a package installs over, and which is uninstalled or adopted before the install starts.
Each [[retire]] entry in outto.macos.toml names one product from some other installer that the package replaces, and lists its paths, installer package receipts and launchd jobs. They are removed only after the install has succeeded. Entries run in file order; within one, paths go first, then receipts with pkgutil --forget, then each job: its plist is deleted, then the job is booted out by label and waited for. Every step is logged as Retire <name>: ...; one that fails is logged and the rest still run, and the install still succeeds. Nothing is recorded, so a rollback or an uninstall brings none of it back. name is required, and an entry with nothing to retire is rejected. A path that resolves to /, a top-level directory, anything through .., or the install directory, inside or around it, is skipped with a warning.
[[retire]]
name = "Divvun Manager"
# scope "daemon": the system domain; "agent": the GUI domain of the user outto runs as
launchd = [{ label = "no.divvun.pahkatd", scope = "daemon", plist = "#{launch_daemons}/no.divvun.pahkatd.plist" }]
paths = ["#{applications}/Divvun Manager.app", "/tmp/pahkat.sock", "/Library/Caches/Pahkat", "/Library/Logs/Pahkat"]
pkg_receipts = ["no.divvun.Manager"]Booting out a job ends every process in it, which can include whatever started this install, so an entry's jobs come last.
outto build --pkg also writes a .pkg beside the .app, for installs by installer -pkg, MDM or another updater. Its identifier and version are the manifest's unless --pkg-identifier / --pkg-version say otherwise; --pkg-sign signs it with a Developer ID Installer identity. The installer .app travels in the package's scripts, which installd extracts into a private directory of its own, and the postinstall runs it as root with --very-silent --suppress-msgboxes --no-restart. Exit 0, or 3010 (which reaches the script as 194), passes; anything else fails the package, so installd writes no receipt. The payload is empty, there only so that installd records a receipt under the package's identifier and version, and holds no bundle installd could relocate.
A .pkg is for one architecture, that of the outto-gui the CLI finds beside it, and its distribution's hostArchitectures names only that one. Build one per architecture with scripts/build-release.sh --target aarch64-apple-darwin --pkg and --target x86_64-apple-darwin --pkg. The inner .app states the oldest macOS its binary loads on as LSMinimumSystemVersion: Rust's defaults give 11.0 for arm64 and 10.12 for x86_64, and MACOSX_DEPLOYMENT_TARGET raises them.
| Section | Purpose |
|---|---|
[package] |
id, name, version, publisher, default_dir, privileges, architecture (x86: x86 and x64 Windows; x64; x64compatible: x64 and Arm64 Windows, as Inno Setup 6.3; arm64; any), legacy_inno_app_id and legacy_inno_mode (uninstall or adopt) |
[[files]] |
Copy files/globs into dest, per-file overwrite policy |
[[dirs]] |
Create directories (optionally preserved on uninstall) |
[[registry]] |
HKLM/HKCU/HKCR keys and values |
[[shortcuts]] |
Start Menu, desktop or Startup shortcuts, for all users or the current user (see above) |
[[environment]] |
System or user environment variables (set / append / prepend) |
[[services]] |
Windows service install/start; a registered service is updated in place (see above) |
[[associations]] |
File-type associations |
[[com]] |
COM server registration with regsvr32; host = "x86" uses SysWOW64's, arch limits it to one architecture |
[[fonts]] |
Font installation |
[[prerequisites]] |
Detect and require prior installs |
[[run]] |
Run commands in before_install / after_install / before_uninstall / after_uninstall phases; #{log_arg:NAME} passes /LOG on; required = true makes a failure fail the operation (see above) |
[[components]] |
Optional/required component groups; each entry may set component = "foo" |
[upgrade] |
policy = "overwrite" | "side_by_side" | "fail"; downgrade = "refuse" with an optional [upgrade.installed_version] registry probe; prune, directories emptied of everything the install did not record, and removed once nothing in them is |
[[retire]] |
Other installs to remove once the install has succeeded: an Inno Setup install by AppId, services, programs, shortcuts and registry keys (see above) |
[uninstall] |
Display icon, whether to remove #{app} on uninstall, extra dirs; [[uninstall.refuse_while]] registry keys (with * segments) that make the uninstaller refuse, exiting 2 |
[reboot] |
never / if_needed / always, Restart Manager integration (see Files held by running apps) |
[logging] |
Install log path |
See outto.toml in the repo root for the manifest used to package outto itself.
outto is a Cargo workspace: a library crate plus outto, outto-gui, outto-sfx, and outto-uninstall binaries. Release builds require nightly + a rebuilt std (the release profile opts into panic = "immediate-abort" for a smaller installer).
./scripts/build-release.ps1
This builds every crate, stages them into target/installer-stage/{bin,libexec}, and invokes the CLI to produce target/release/outto-setup.exe. Pass -Sign <command> to sign each artifact.
For development iteration:
cargo +nightly build --workspace
cargo +nightly test
outto --version, outto-gui --version and outto-uninstall --version print the build's version, which is also logged at start-up and is the ProductVersion of the Windows binaries (their FileVersion stays numeric). It is the crate version unless the build sets OUTTO_BUILD_VERSION, as CI does (0.1.0-dev.20261020T143317Z+build.12); that has to be semver with the crate's major.minor.patch.
Admin-scoped integration tests must be run from an elevated shell:
./scripts/test-admin.ps1
MIT.