A launch wrapper: injects environment variables, prefix commands and Vault
secrets into a command from layered TOML profiles, then replaces itself with
the target via exec. Zero extra processes, zero runtime overhead.
Two use cases, one engine:
-
Linux gaming (the primary one): wrap Steam's
%command%, toggle MangoHud/GameMode/gamescope/vkBASALT per game, per group of games, or per Proton version.Steam → riftwrap → [umu-run] → Proton/Wine → game -
Secrets-injecting launcher: run any command with env and Vault secrets from a named profile — see Outside gaming.
riftwrap decides whether to enable tools like MangoHud and with which flags/env — it does not replace their own configs, and it does not manage Proton versions (that's umu/Lutris territory).
Download the latest release for your platform (Linux/macOS, amd64/arm64 are
detected automatically) and put it into PATH:
curl -fLo riftwrap "https://github.com/Austrum-lab/riftwrap/releases/latest/download/riftwrap-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/')"
sudo install -m755 riftwrap /usr/local/bin/
riftwrap --version(any directory in PATH works; /usr/local/bin is the conventional place
for manually installed binaries). The result is a single fully static
binary with no runtime dependencies. On macOS a binary downloaded by a
browser gets the quarantine attribute (curl downloads don't) — if
Gatekeeper complains, run xattr -d com.apple.quarantine riftwrap before
installing. Note that on macOS only the secrets-injecting use case applies —
the gaming toggles wrap Linux tools.
CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X github.com/Austrum-lab/riftwrap/cmd.version=$(git describe --tags 2>/dev/null || echo dev)" \
-o riftwrap .
sudo install -m755 riftwrap /usr/local/bin/CGO_ENABLED=0 keeps the net package off libc, so the binary runs on any
distro. Development build: go build; tests: go test ./....
Cross-compiling needs no extra toolchain:
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o riftwrap .Shell completion (bash; zsh/fish work the same way):
riftwrap completion bash | sudo tee /usr/share/bash-completion/completions/riftwrapReleases are built by CI: pushing a v* tag triggers
.github/workflows/release.yml, which tests, cross-builds all platforms
with the version stamped in, and attaches the binaries to a GitHub Release.
-
Put
riftwrapsomewhere inPATH. -
In Steam: game → Properties → Launch Options:
riftwrap %command%(a
--separator before%command%is accepted but not required) -
Create a profile for the game (AppID is in the store page URL):
riftwrap profile new cyberpunk2077 --appid=1091500 riftwrap profile edit cyberpunk2077
-
Check what will happen without launching anything:
riftwrap resolve --appid 1091500
First run creates ~/.config/riftwrap/ with a commented riftwrap.toml.
Configuration lives in ~/.config/riftwrap/:
riftwrap.toml # main config = base profile (always applied first) + includes
conf.d/*.toml # one file = one profile, name = file name without .toml
riftwrap.toml holds base [env]/[wrap.*] settings and the include globs
(relative to the config dir):
include = ["conf.d/*.toml"]What a profile matches is decided by its [match] section, not by file
location. All matchers present in one profile must match together (AND), and
the profile merges at the level of its strongest matched kind — higher level
wins on key conflict:
1. riftwrap.toml (base, always)
2. community sources (below everything local)
3. group (profiles pulled in by name via match.group)
4. match.regex (regex on the full command line)
5. match.exe (glob on the executable name, case-insensitive)
6. match.deck (Steam Deck detection)
7. match.appid (SteamAppId / SteamGameId / GAMEID env)
8. --profile / RIFTWRAP_PROFILE
A profile with no matchers applies only when referenced — by name via
--profile, or via another profile's group list (that's how shared
"group" presets work: group = ["dx11-heavy"] pulls in
conf.d/dx11-heavy.toml at level 3, transitively and cycle-safe).
An explicitly selected profile expands its group list too, and each
profile applies once, at the strongest level it earned.
--only skips layers 1–7 and applies just the explicit profile — no
group expansion.
[meta].priority orders multiple matches within one level (higher applies
later, i.e. wins). wrap.extra_prefix.commands concatenates across layers
instead of overwriting; set [meta].replace_extra_prefix = true to reset the
accumulated list from that layer on.
The executable is detected by walking the launch argv from the end: the first
argument ending in .exe (case-insensitive) is the Windows binary; if there
is none, the last argument is taken as a native binary.
A broken profile (bad TOML, invalid regex/glob) never blocks a launch: wrap
mode prints a warning to stderr and skips it. doctor and resolve report
the same problems as errors.
Every key is optional; a profile can be a single [env] block.
[match] # all present matchers must match (AND)
appid = 1091500 # Steam AppID
exe = "Cyberpunk2077.exe" # glob on the executable basename
regex = "proton.*experimental" # regex on the full command line
deck = true # true = Steam Deck only, false = off-deck only
group = ["dx11-heavy"] # profiles (by name) to pull in when this matches
# ("groups" is an accepted alias for this key)
[env] # plain env vars, higher layers win per key;
# bare numbers/bools are fine (true -> "1"),
# but text values must be quoted (TOML syntax);
# a leading "~/" expands to the home directory
# (nothing downstream expands tildes — a literal
# "~/x" would become a relative path)
DXVK_HUD = "fps,memory"
PROTON_ENABLE_NVAPI = 1
[secrets] # Vault KV v2 refs "<path>/<field>[@<mount>]"
# (GitLab CI style); resolved right before exec,
# never written to disk
DISCORD_RPC_TOKEN = "riftwrap/discord/token@secret"
[wrap.gamemode]
enabled = true # prefixes gamemoderun
args = [] # optional extra gamemoderun arguments
[wrap.mangohud]
enabled = true # prefixes mangohud
config = "~/.config/MangoHud/custom.conf" # sets MANGOHUD_CONFIGFILE
args = ["--dlsym"]
[wrap.gamescope]
enabled = true # prefixes gamescope -W .. -H .. -r .. <args> --
width = 1920
height = 1080
fps_limit = 60
args = ["--hdr-enabled"] # optional extra gamescope flags
[wrap.vkbasalt]
enabled = true # sets ENABLE_VKBASALT=1
[wrap.obs_vkcapture]
enabled = true # sets OBS_VKCAPTURE=1
[wrap.prime_offload]
enabled = true # sets __NV_PRIME_RENDER_OFFLOAD / DRI_PRIME etc.
[wrap.cpu]
taskset = "0-7" # prefixes taskset -c 0-7 (no privileges needed)
nice = -5 # prefixes nice -n -5; negative values need a
# raised limit, see "About negative nice" below
[wrap.wine_sync]
esync = true # WINEESYNC=1; false sets WINEESYNC=0 + PROTON_NO_ESYNC=1
fsync = true # same for fsync
[wrap.extra_prefix] # escape hatch: arbitrary prefix commands
commands = [["some-tool", "--flag"]]
[meta]
priority = 0 # merge order within one layer
mask_keys = ["MY_TOKEN"] # extra [env] keys to mask in resolve/log outputPrefix command order (outermost first): nice, taskset, gamescope --,
gamemoderun, mangohud, extra_prefix commands, then the original command.
Raising priority (negative nice) is not allowed for regular users by
default: nice prints "Permission denied" and the game still starts, just
at normal priority. To make it apply, raise the allowed ceiling in
/etc/security/limits.conf and re-login:
<your-user> - nice -5
This does not change the default priority of anything — processes still start at nice 0; it only permits going down to -5 when a profile asks for it. (Alternatively, GameMode's daemon renices games via polkit without any limit changes.)
Three layers that compose well in practice — global defaults, Steam-wide tweaks, and per-game settings:
# riftwrap.toml — applies to every wrapped launch
include = ["conf.d/*.toml"]
[wrap.mangohud]
enabled = true# conf.d/steam-any.toml — every Steam launch contains steam-launch-wrapper
# in its command line, so this regex hits all Steam games (level 4)
[match]
regex = "steam-launch-wrapper"
[env]
PROTON_USE_NTSYNC = 1
PROTON_ENABLE_WAYLAND = 1
VKD3D_CONFIG = "descriptor_heap"
PULSE_LATENCY_MSEC = 50# conf.d/helldivers2.toml — one game (level 7, overrides the layers above
# on key conflicts)
[match]
appid = 553850
[env]
LOW_LATENCY_LAYER = 1
LOW_LATENCY_LAYER_REFLEX = 1Bare numbers and booleans are fine in [env]; text values must be quoted
(TOML syntax). Proton-only variables in the Steam-wide layer are harmless for
native Linux games — they just ignore them.
riftwrap [--profile=<name>] [--only] [--] %command% wrap mode (used by Steam)
riftwrap resolve [--appid=<id>] [--exe=<name>] [--cmd="<line>"]
show matched layers, final env and command
riftwrap profile list list the base config and included profiles
riftwrap profile new <name> [--appid=<id>] [--exe=<glob>] [--regex=<re>] [--deck] [--group=<a,b>]
matcher flags combine (AND)
riftwrap profile edit <name> open in $EDITOR, validate on save
riftwrap profile show <name> print normalized TOML
riftwrap doctor check tools in PATH, validate all profiles
(TOML + regex/glob), ping Vault if configured
riftwrap log [-n N] show recent launches from the pre-launch log
riftwrap source add <git-url> [--name=<n>] [--path=<dir>] [--allow-unsafe]
riftwrap source update [<name>] fetch, show diff, confirm, re-pin
riftwrap source remove <name>
riftwrap source list
riftwrap completion bash|zsh|fish shell completion
resolve --cmd accepts shell-style quoting, so paths with spaces work:
riftwrap resolve --cmd '"/data/Steam/steamapps/common/My Game/game.exe" -skiplauncher'[secrets] values are KV v2 references in GitLab CI style —
<path>/<field>[@<mount>], e.g. riftwrap/discord/token@secret. The mount
defaults to secret and the KV v2 data/ API segment is inserted
automatically. References are resolved lazily right before exec and never
cached to disk; each distinct Vault path is fetched once per launch, so
several fields off one path cost a single request.
References may contain $VAR / ${VAR}, expanded from riftwrap's own
environment first, then from the merged [env] of local profile layers —
never from community profiles, which must not be able to steer where a
secret is read from. An undefined variable aborts the launch:
[secrets]
DB_PASSWORD = "${VAULT_BASE}/myapp/db/password@my-kv"Auth, from the environment:
VAULT_ADDR+VAULT_TOKEN, orVAULT_ADDR+VAULT_ROLE_ID+VAULT_SECRET_ID(AppRole; mount defaults toapprole, override withVAULT_APPROLE_MOUNT). An explicit token wins.
If Vault is unreachable or a path/field is missing, the launch is aborted —
the game never starts half-configured. Everything that came from [secrets]
is always shown as *** in resolve output and in the log; use
[meta].mask_keys to mask ordinary [env] keys too.
riftwrap only ever reads from Vault (one AppRole login per launch, then one
GET per reference) — grant its token or AppRole read capability on the
referenced paths and nothing else. With AppRole, each launch issues a fresh
token that is never revoked (the wrapper execs away), so give the role a
short token_ttl if you launch often.
riftwrap source add https://github.com/someone/gaming-profiles
riftwrap source update # shows a diff, asks for confirmationA source repository ships its profiles as riftwrap.conf.d/*.toml (another
directory can be chosen with --path when adding the source). The source
name defaults to the repository path from the URL
(someone/gaming-profiles above), and profile names get a <source>/
prefix: someone/gaming-profiles/dx11-heavy. Reference them from a local
profile's group list — --profile only looks at local profiles.
Community sources are sandboxed by design:
- synced to
~/.local/share/riftwrap/community/, physically separate from local profiles; - pinned to a commit —
source updateshows the diff and re-pins only after confirmation; - merged below all local profiles, so anything local overrides them;
- cannot define
wrap.extra_prefix(arbitrary commands) unless the source was added with--allow-unsafe; - never feed
${VAR}expansion in secret references.
See docs/community-sources.md for a full walkthrough: naming, referencing, private repos, update workflow.
Every launch appends one JSON line to
~/.local/share/riftwrap/log/riftwrap.log before exec: timestamp, matched
layers, applied env (secrets masked), final argv. The log self-rotates (last
50 launches or 14 days). riftwrap log pretty-prints it. Exit codes are not
tracked — the wrapper is gone once the game starts.
Profile resolution is not tied to Steam: the exe/runtime/group layers work
off argv alone, so riftwrap can wrap launches from Lutris/Heroic as well —
only the appid/ layer needs Steam's env vars.
An explicit-only profile (no matchers) plus --only turns riftwrap into a
lightweight vault agent/envconsul-style wrapper for any command:
# ~/.config/riftwrap/conf.d/db-prod.toml
[env]
PGHOST = "prod-db.internal"
[secrets]
PGPASSWORD = "team/db/password@secret"riftwrap --profile db-prod --only -- psql -U app mydb--only skips the whole gaming pipeline (the riftwrap.toml base and every
matched profile); the secret is fetched right before exec,
never touches disk, and shows up as *** in resolve and the launch log.
MIT — see LICENSE.