commit 9e2431e58aeac03008ee35e1a7c972fd52ec4fb2 Author: 44r0n7 <44r0n7+gitea@pm.me> Date: Sun May 31 22:13:20 2026 -0400 Initial release: gamewrap v1.0.0 Steam-first Linux game launcher wrapper for MangoHud and GameMode. Manages launch behavior via TOML config with named profiles, per-game bindings, and full diagnostics. All v1 criteria validated. Co-Authored-By: claude-flow diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a606462 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/gamewrap diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ca94032 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,2 @@ +> Codex configuration is maintained in Obsidian Mind: +> ~/my-vault/work/projects/gamewrap.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..83c0b16 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +# gamewrap + +> Context for Claude/Codex is maintained in Obsidian Mind: +> ~/my-vault/work/projects/gamewrap.md +> Run `/om-project-switch` from the vault to load full context. + +## Operational Rules + +- Always read `PROJECT_MAP.md` first before making structural changes +- Update `PROJECT_MAP.md` after structural changes +- Run `cargo fmt` and `cargo test` before finishing any task +- Use `cargo install --path . --force` to install for dev testing diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..956b5fd --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,830 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" +dependencies = [ + "clap", + "clap_lex", + "is_executable", + "shlex", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "gamewrap" +version = "0.3.0" +dependencies = [ + "clap", + "clap_complete", + "directories", + "owo-colors", + "serde", + "tempfile", + "thiserror", + "toml", + "which", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_executable" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "owo-colors" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2386b4ebe91c2f7f51082d4cefa145d030e33a1842a96b12e4885cc3c01f7a55" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "which" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +dependencies = [ + "libc", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..82fb8fe --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "gamewrap" +version = "1.0.0" +edition = "2024" +description = "Steam-first game launcher wrapper with friendly config for MangoHud and GameMode." +license = "MIT" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +clap_complete = { version = "4.5", features = ["unstable-dynamic"] } +directories = "6.0" +owo-colors = "1" +serde = { version = "1.0", features = ["derive"] } +thiserror = "2.0" +toml = "0.8" +which = "8.0" + +[dev-dependencies] +tempfile = "3.20" diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md new file mode 100644 index 0000000..e91654a --- /dev/null +++ b/PROJECT_MAP.md @@ -0,0 +1,240 @@ +# PROJECT_MAP.md +# Auto-generated project index. Update this file whenever you add, remove, or significantly change a file or feature. +# Last updated: 2026-03-21 - reworked into a more operational map focused on edit paths, persistence contracts, and update triggers + +--- + +## πŸ—ΊοΈ Project Overview +`gamewrap` is a Rust CLI for Linux/Steam users who want short Steam launch options while still applying MangoHud, GameMode, profile-based behavior, diagnostics, and shareable setup files. It uses `clap` for the CLI, TOML for persistence/share formats, and XDG config/state paths for local data. + +--- + +## πŸ—οΈ Architecture Summary +- Steam launch path: parse implicit Steam launch -> inspect real executable -> resolve defaults/profile/binding -> build env/wrapper plan -> exec target +- Management path: parse subcommand -> load config/state as needed -> mutate or render -> save TOML back to XDG paths +- Shell completion path: completion hook runs before normal CLI parsing and asks live config/state for dynamic candidates + +Representative flow: + +`gamewrap %command% -> src/lib.rs -> src/cli.rs -> src/detect.rs -> src/profile.rs -> src/launch.rs -> src/env.rs -> exec` + +--- + +## πŸ“ File & Folder Map + +| Path | Role | Notes | +|------|------|-------| +| `Cargo.toml` | Crate manifest | Dependency and package metadata; `clap_complete` uses dynamic completion support. | +| `Cargo.lock` | Lockfile | Pinned dependency graph. | +| `README.md` | User docs | Install, command examples, sharing, completion, Steam usage. | +| `PROJECT_MAP.md` | Persistent project index | This file. Keep concise and tactical. | +| `docs/roadmap.md` | Deferred planning | Future integrations, packaging direction, product scope. | +| `src/main.rs` | Binary entry point | Converts `AppError` into stderr + notifier + exit code. | +| `src/lib.rs` | Library entry point | Runs completion hook first, then normal CLI parse/dispatch. | +| `src/cli.rs` | Command surface and dispatch | Owns top-level commands, parsing, command execution, and many user-facing error paths. | +| `src/color.rs` | Terminal color helpers | Enables ANSI styling only when `NO_COLOR` is unset and stdout is a TTY. | +| `src/completion.rs` | Completion engine | Generates/install shell scripts and dynamic candidates for settings, profiles, and observed games. | +| `src/config/mod.rs` | Config/state I/O | XDG path discovery, TOML load/save, CLI rendering of config/profile views. | +| `src/config/schema.rs` | Persistent structs | `Settings`, `ResolvedSettings`, `ProfileConfig`, `ConfigFile`, `ObservedGame`, `StateFile`. | +| `src/config/keys.rs` | Setting mutation helpers | Friendly key parsing and value application/reset logic. | +| `src/profile.rs` | Resolution + validation | Resolves inherited profiles and validates parent chains/binding targets. | +| `src/bindings.rs` | Binding operations | Matching logic, set/remove binding, resolve profile for executable/observed game. | +| `src/detect.rs` | Executable detection | Pulls real game executable out of Steam/Proton wrapper shapes and records observed launches. | +| `src/launch.rs` | Launch planning/execution | Dependency checks, dry-run rendering, final command execution. | +| `src/env.rs` | Env shaping | Steam-context detection, host-library injection decisions, env var construction. | +| `src/doctor.rs` | Doctor rendering | Formats preflight results. | +| `src/status.rs` | Status rendering | Formats dependency/config/state summary. | +| `src/help.rs` | Long-form help text | Shared topic help and top-level examples. | +| `src/error.rs` | Error model | Exit-code-bearing error categories and constructors. | +| `src/notify.rs` | GUI failure notifier | `zenity` / `kdialog` / `xmessage` / `notify-send` selection and popup logic. | +| `src/share.rs` | Share/import formats | Resolved full-config and single-profile portable TOML formats. | +| `tests/cli_matrix.rs` | Integration CLI coverage | End-to-end command validation in isolated XDG temp envs. | + +Skip `target/` and other generated artifacts when extending this map. + +--- + +## 🧭 Hot Paths + +### Add or change a CLI command +- Edit: `src/cli.rs` +- Usually also update: `src/help.rs`, `README.md`, `tests/cli_matrix.rs` +- If command affects config/state formats: `src/config/schema.rs`, `src/share.rs`, this map + +### Change config keys or setting behavior +- Edit: `src/config/keys.rs`, `src/config/schema.rs` +- Usually also update: `src/config/mod.rs`, `src/profile.rs`, `src/help.rs`, completion candidates in `src/completion.rs`, tests + +### Change profile behavior +- Edit: `src/profile.rs` +- Usually also update: `src/cli.rs`, `src/config/mod.rs`, `src/share.rs`, tests + +### Change binding or game matching behavior +- Edit: `src/bindings.rs`, `src/detect.rs` +- Usually also update: `src/cli.rs`, `src/completion.rs`, tests + +### Change launch/runtime behavior +- Edit: `src/launch.rs`, `src/env.rs` +- Usually also update: `src/doctor.rs`, `src/status.rs`, help/docs, tests + +### Change shell completion behavior +- Edit: `src/completion.rs` +- Usually also update: `src/cli.rs`, `src/help.rs`, `README.md`, tests + +### Change import/export/share behavior +- Edit: `src/share.rs` +- Usually also update: `src/cli.rs`, `README.md`, `src/help.rs`, tests + +--- + +## πŸ”‘ Key Concepts & Domain Terms +- **Defaults**: global baseline settings from config +- **Profile**: reusable settings bundle; may inherit from another named profile +- **Binding**: matcher string mapping a known executable/path to a profile +- **Observed game**: recorded launch entry with executable, path, last launched profile, optional note/display name, launch count, and last launch timestamp +- **Resolved settings**: effective values after applying defaults + inheritance + explicit overrides +- **Steam context**: environment where implicit launch mode and GUI failure notifications are enabled +- **Share format**: portable resolved export file, distinct from the sparse internal config layout + +--- + +## πŸ”— Persistence Contracts + +### Internal local files +- Config path: `~/.config/gamewrap/config.toml` +- State path: `~/.local/state/gamewrap/state.toml` +- Config file is sparse/raw: + - stores only explicit overrides + - profiles may be empty and may inherit from parents + - bindings are stored separately + +### Portable share files +- Full config export suffix: `.gamewrap.toml` +- Profile export suffix: `.gamewrap-profile.toml` +- Export commands write resolved values, not sparse internal overrides +- Profile import creates a standalone explicit profile with no inheritance +- Per-profile env overrides are stored as an optional `env-vars` map on profile settings and merge through inheritance; child keys override parent keys +- Pre/post launch hook commands are stored as optional string settings; pre-launch runs before exec, while post-launch is persisted/displayed but deferred until wrapped launching exists +- Config import accepts: + - current resolved share format + - legacy raw config format as fallback + +--- + +## πŸ–₯️ User-Facing Command Surface + +### Top-level commands +- `help` +- `status` +- `doctor` +- `run` +- `dry-run` +- `last` +- `config` +- `profile` +- `game` +- `notify` +- `completion` + +### `config` +- `show` +- `edit` +- `set ` +- `reset ` +- `export [name-or-path]` +- `import ` + +### `profile` +- `list` +- `tree` +- `create ` +- `duplicate ` +- `show ` +- `export [name-or-path]` +- `import ` +- `env set ` +- `env unset ` +- `env list ` +- `env clear ` +- `set ` +- `reset ` +- `inherit ` +- `clear-inherit ` +- `delete ` + +### `game` +- `list [matcher]` +- `show ` +- `bind ` +- `unbind ` +- `rename ` +- `note ` +- `clear-note ` +- `forget ` + +### `notify` +- `test` + +### `completion` +- `` prints the raw script +- `install ` installs shell integration +- `path ` shows where the script/startup wiring lives + +--- + +## πŸ§ͺ Test Coverage Map +- Unit tests live inline in several `src/*` modules +- Integration coverage lives in `tests/cli_matrix.rs` + +If you change: +- command surface -> update `tests/cli_matrix.rs` +- share/import/export formats -> update `tests/cli_matrix.rs` and `src/share.rs` tests +- completion install/candidates -> update `tests/cli_matrix.rs` and `src/completion.rs` tests +- profile inheritance logic -> update `src/profile.rs` tests and related CLI matrix cases + +--- + +## 🧩 Feature Areas & Ownership Map +- **CLI / UX**: `src/cli.rs`, `src/help.rs`, `README.md` +- **Persistence**: `src/config/*`, `src/share.rs` +- **Profiles / inheritance**: `src/profile.rs` +- **Game observation / matching / bindings**: `src/detect.rs`, `src/bindings.rs` +- **Launch execution**: `src/launch.rs`, `src/env.rs` +- **Diagnostics**: `src/doctor.rs`, `src/status.rs` +- **Failure popup behavior**: `src/main.rs`, `src/notify.rs` +- **Completion UX**: `src/completion.rs` + +--- + +## ⚠️ Known Issues / Tech Debt +- `README.md` has a Quick Start numbering/order inconsistency and could use a cleanup pass. +- Completion still includes generic clap-level suggestions like `--help` in some contexts; dynamic candidates are layered on top, not full custom shell UX. +- Elvish completion install is not as automated as bash/zsh/fish/PowerShell. +- Project still has no Git repo/public release metadata/package workflow despite the roadmap discussing future distribution. + +--- + +## πŸ”„ Update Triggers +Update this file when: +- a top-level command or subcommand is added/removed/renamed +- a persisted file path or format changes +- a new top-level source module is added +- a new major feature area appears +- the launch/completion/share architecture changes materially + +Do not update this file for: +- tiny wording tweaks +- purely internal refactors that do not change ownership or edit paths +- dependency version bumps unless they change how the project is operated + +--- + +## πŸ“ Change Log (agent session) +- [2026-03-21] Initial map created for the current `gamewrap` Rust CLI structure and feature set. +- [2026-03-21] Reworked the map into a more tactical index centered on hot paths, persistence contracts, command surface, and update triggers. +- [2026-05-31] Added TTY/NO_COLOR-aware ANSI output helpers for doctor and status rendering. +- [2026-05-31] Added game forget, config edit, profile list/tree improvements, and notify test commands. +- [2026-05-31] Added gamescope settings, launch wrapping, dependency diagnostics, completion candidates, and docs. +- [2026-05-31] Added observed-game display names, launch timestamps/counts, `last`, `game rename`, and `fps-cap`. +- [2026-05-31] Added per-profile env overrides, vkBasalt support, and Proton esync/fsync compatibility controls. +- [2026-05-31] Added pre-launch shell hooks plus persisted/dry-run/doctor visibility for deferred post-launch hooks. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5f8865 --- /dev/null +++ b/README.md @@ -0,0 +1,312 @@ +# gamewrap + +`gamewrap` is a small Linux launcher for Steam games that keeps Steam launch options short while managing MangoHud, GameMode, gamescope, launch hooks, and per-game history from readable local config. + +The main idea is simple: +- keep Steam launch options short +- move behavior into a readable config file +- make common settings easy to understand and change +- provide diagnostics when a launch setup is broken + +For a normal Steam setup, launch options can stay as short as: + +```text +gamewrap %command% +``` + +`gamewrap` stores its configuration outside Steam, supports reusable profiles, can bind specific executables to profiles, and tries to explain technical behavior in plain language instead of expecting users to remember environment variable names. + +## Features + +- Short Steam launch options +- Friendly setting names like `overlay` and `performance` +- Persistent config outside the Steam UI +- Named profiles for reusable setups +- Profile inheritance for layered setups +- Game-specific profile binding through `game bind` +- Quick access to the last played game through `gamewrap last` +- Play time and launch count tracking per game +- Game notes for remembering per-title quirks +- Filtered game listing and fuzzy game lookup +- Observed game cleanup through `game forget` +- Config export/import for backup and sharing +- Direct config editing through `config edit` +- Profile export/import for sharing one setup at a time +- Profile tree view for inheritance checks +- Per-profile environment variable overrides +- gamescope Wayland compositor integration +- FPS cap through MangoHud +- Pre-launch hooks +- vkBasalt and Proton esync/fsync/large-address-aware controls +- Graphical notification self-test +- Shell completion generation +- `doctor`, `status`, and `dry-run` commands for troubleshooting +- explicit `run` mode for command-name collisions +- Clear help text with both plain-language explanations and technical details + +## Install + +Recommended low-maintenance install: + +```bash +cargo install --path . --force +``` + +This installs the binary to `~/.cargo/bin/gamewrap`, which is already on `PATH` for most users who have Rust installed. + +```bash +# After installing, ensure ~/.cargo/bin is on your PATH. +# Most Rust setups do this automatically. If gamewrap isn't found +# in Steam launch options, add to ~/.bash_profile or ~/.zprofile: +# export PATH="$HOME/.cargo/bin:$PATH" +``` + +After that, Steam launch options can stay simple: + +```text +gamewrap %command% +``` + +User-local install: + +```bash +cargo install --path . +``` + +That installs the binary to `~/.cargo/bin/gamewrap`. +If Steam cannot find commands from `~/.cargo/bin` in your desktop session, use the full path in Steam launch options: + +```text +$HOME/.cargo/bin/gamewrap %command% +``` + +## Quick Start + +1. Put this in Steam launch options: + +```text +gamewrap %command% +``` + +If Steam cannot find `gamewrap` by name, use: + +```text +$HOME/.cargo/bin/gamewrap %command% +``` + +2. Turn MangoHud on by default: + +```bash +gamewrap config set overlay on +``` + +3. Turn GameMode on by default: + +```bash +gamewrap config set performance on +``` + +4. Check your setup before launching a real game: + +```bash +gamewrap doctor +``` + +5. Launch a game explicitly from the terminal when needed: + +```bash +gamewrap run /path/to/game/executable +``` + +Add `--` only when the command or its arguments would otherwise look like gamewrap options. + +6. Inspect what `gamewrap` would do without actually launching: + +```bash +gamewrap dry-run /path/to/game/executable +``` + +7. Bind a known game executable to a profile: + +```bash +gamewrap profile create benchmark +gamewrap game bind "Game.exe" benchmark +``` + +8. Layer one profile on top of another when you want shared defaults: + +```bash +gamewrap profile create base +gamewrap profile set base overlay on +gamewrap profile create benchmark +gamewrap profile inherit benchmark base +gamewrap profile set benchmark verbose on +``` + +9. Export your config for backup or sharing: + +```bash +gamewrap config export shared +``` + +10. Export one profile for sharing: + +```bash +gamewrap profile export benchmark benchmark +``` + +11. Import a shared profile: + +```bash +gamewrap profile import benchmark +``` + +12. Install shell completions: + +```bash +gamewrap completion install zsh +``` + +You can still print the raw script with `gamewrap completion zsh`, but `install` is the user-friendly path. +The installed completion script asks `gamewrap` for live data, so new profiles and observed games show up automatically without reinstalling. + +If the real command you want to launch has the same name as a `gamewrap` subcommand, force launch mode with: + +```bash +gamewrap run -- /path/to/game/executable +``` + +## Common Commands + +```bash +gamewrap --help +gamewrap help settings +gamewrap help doctor +gamewrap game list +gamewrap game list "elden" +gamewrap game show "Game.exe" +gamewrap game forget "Game.exe" +gamewrap status +gamewrap doctor +gamewrap notify test +gamewrap doctor /path/to/game/executable +gamewrap run /path/to/game/executable +gamewrap dry-run /path/to/game/executable +gamewrap completion zsh +gamewrap completion install zsh +gamewrap completion path zsh +gamewrap config show +gamewrap config edit +gamewrap config export shared +gamewrap config import shared +gamewrap last +gamewrap profile list +gamewrap profile tree +gamewrap profile create benchmark +gamewrap profile duplicate benchmark benchmark-copy +gamewrap profile inherit benchmark base +gamewrap profile clear-inherit benchmark +gamewrap profile export benchmark benchmark +gamewrap profile import benchmark +gamewrap profile set benchmark overlay on +gamewrap profile reset benchmark overlay +gamewrap profile env set benchmark DXVK_ASYNC 1 +gamewrap profile env list benchmark +gamewrap profile env unset benchmark DXVK_ASYNC +gamewrap game bind "eldenring.exe" benchmark +gamewrap game unbind "eldenring.exe" +gamewrap game rename "eldenring.exe" "Elden Ring" +gamewrap game note "eldenring.exe" needs game-libs gamemode +gamewrap game clear-note "eldenring.exe" +``` + +## Friendly Settings + +- `overlay`: turns MangoHud on or off +- `performance`: turns GameMode on or off +- `steam-host-libs`: prefers host libraries inside Steam runtime environments and sets `STEAM_RUNTIME_PREFER_HOST_LIBRARIES=1` +- `game-libs`: controls whether `gamewrap` injects auto-detected host library directories into `LD_LIBRARY_PATH` +- `verbose`: shows more detail in diagnostic commands +- `gamescope`: wraps the game in the gamescope Wayland compositor +- `gamescope-width`, `gamescope-height`, `gamescope-fps`: pass `-W`, `-H`, and `-r` values to gamescope when `gamescope` is on +- `fps-cap`: caps frame rate through MangoHud when `overlay` is on, for example `gamewrap config set fps-cap 60` +- `vkbasalt`: enables vkBasalt post-processing with `ENABLE_VKBASALT=1` +- `esync`: forces Proton esync on or off with `PROTON_NO_ESYNC`; leave unset to use Steam/Proton defaults +- `fsync`: forces Proton fsync on or off with `PROTON_NO_FSYNC`; leave unset to use Steam/Proton defaults +- `large-address-aware` / `laa`: sets `PROTON_LARGE_ADDRESS_AWARE=1` for older 32-bit Proton games that need more than 2 GB of address space +- `pre-launch`: runs a shell command through `sh -c` immediately before the game launches +- `post-launch`: runs a shell command after the game exits; when set, gamewrap spawns the game and waits for it to finish before running this hook +- `env-vars`: per-profile environment overrides managed with `gamewrap profile env set/list/unset/clear` + +## How It Works + +When `gamewrap` launches a game, it resolves your defaults, any inherited profile chain, and any matching game binding, prepares the needed environment variables, and then prefixes the game command with `gamescope`, `mangohud`, and/or `gamemoderun` when those features are enabled. + +If something important is missing, `gamewrap` is designed to fail clearly instead of silently skipping the requested behavior. The `doctor` and `status` commands help you verify that before launching through Steam. + +For terminal usage, `gamewrap` distinguishes between management commands and explicit launches: +- use commands like `gamewrap game list`, `gamewrap profile list`, or `gamewrap game bind ...` for management +- `gamewrap game list ` filters observed games by executable or path substring +- `gamewrap last` shows the most recently observed launch +- `gamewrap game rename ` gives an observed game a friendlier display name +- `gamewrap game forget ` removes an observed game from local state +- `gamewrap config edit` opens the config file in `$VISUAL`, `$EDITOR`, or `nano` +- use `gamewrap config export` and `gamewrap config import ...` for full-config backup and sharing +- use `gamewrap profile export` and `gamewrap profile import ...` for sharing one profile +- use `gamewrap profile tree` to inspect profile inheritance +- use `gamewrap profile env set ` for per-profile environment overrides +- use `gamewrap notify test` to verify graphical failure notifications +- use `gamewrap run ` when you want to explicitly launch from the terminal +- use `gamewrap dry-run ` when you want to inspect the resolved launch without running it +- add `--` only when the command or its arguments would otherwise look like gamewrap options + +## Shell Completion + +Recommended: + +```bash +gamewrap completion install zsh +``` + +Then open a new shell. + +Other useful commands: + +```bash +gamewrap completion zsh +gamewrap completion path zsh +``` + +The installed completion script is live rather than static. That means: +- new profiles appear in completion results automatically +- newly observed games appear automatically +- you generally only need to reinstall if your shell startup setup changes + +## Sharing Files + +Suggested file names: + +```text +shared.gamewrap.toml +benchmark.gamewrap-profile.toml +``` + +You do not need to type the full extension yourself. These commands are equivalent: + +```bash +gamewrap config export shared +gamewrap config import shared +gamewrap profile export benchmark benchmark +gamewrap profile import benchmark +``` + +`gamewrap` automatically adds `.gamewrap.toml` or `.gamewrap-profile.toml` when needed. + +`gamewrap config export` writes resolved settings, not just the sparse internal overrides. That makes the exported file more predictable when you import it on another machine with different defaults. + +`gamewrap profile export` also writes resolved settings. Imported profiles are brought in as standalone profiles with explicit values, so they keep the same behavior even if the importing machine uses different defaults. + +## Files + +- Config: `~/.config/gamewrap/config.toml` +- State: `~/.local/state/gamewrap/state.toml` diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..33049ab --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,245 @@ +# gamewrap Roadmap + +## Purpose + +`gamewrap` is a Steam-first Linux game launcher wrapper that keeps Steam launch options short while moving launch behavior into a readable, persistent config. + +The project is designed to make features like MangoHud and GameMode easier to use without requiring users to remember long launch commands, environment variables, or per-game flag combinations. + +Core product goals: +- keep Steam launch options simple +- prefer human-friendly configuration over flag-heavy one-off commands +- fail clearly when launch requirements are not met +- keep runtime overhead low +- support both quick default usage and deeper profile-based customization + +## Current Direction + +The current preferred Steam usage is: + +```text +gamewrap %command% +``` + +The preferred low-maintenance install approach is: +1. install with `cargo install` +2. ensure `~/.cargo/bin` is on PATH in the desktop session + +This lets Steam resolve `gamewrap` by name without committing to long-term package maintenance yet. + +## Current Feature Set + +Implemented features: +- Steam-first launcher flow with `gamewrap %command%` +- Default launch mode with MangoHud and GameMode +- Friendly setting names for all launch options +- Named profiles with full inheritance chains and cycle detection +- Per-profile environment variable overrides (`profile env set/unset/list/clear`) +- Manual executable-to-profile bindings +- Observed game history with launch count, timestamps, and display names +- Play time tracking per game (when post-launch hook is configured) +- `game rename` for human-readable game names +- `game forget` to remove entries from observed history +- `gamewrap last` to see the most recently played game +- gamescope Wayland compositor integration +- vkBasalt post-processing integration +- FPS cap via MangoHud +- Proton esync/fsync/large-address-aware controls +- Pre-launch and post-launch shell hooks +- `status` command +- `doctor` preflight checks with color-coded output +- `dry-run` launch plan preview +- `config edit` to open config in `$EDITOR` +- Config and profile export/import for sharing +- `profile tree` for visualizing inheritance chains +- `notify test` for graphical notification verification +- ANSI color output (respects NO_COLOR, CLICOLOR, CLICOLOR_FORCE) +- Shell completion with live candidates (profiles, games, settings) +- Clear runtime dependency checks and actionable error messages + +Friendly settings supported: +- `overlay`, `performance`, `steam-host-libs`, `game-libs`, `verbose` +- `gamescope`, `gamescope-width`, `gamescope-height`, `gamescope-fps` +- `fps-cap`, `vkbasalt` +- `esync`, `fsync`, `large-address-aware` +- `pre-launch`, `post-launch` +- `env-vars` (via `profile env` subcommands) + +## v1 Done Criteria + +The project can be considered functionally complete for v1 when all of the following are true: + +- [x] Steam launch works reliably with the chosen install method +- [x] MangoHud enable/disable works correctly +- [x] GameMode enable/disable works correctly +- [x] `doctor`, `status`, and `dry-run` are trustworthy for normal troubleshooting +- [x] launch failures are visible and understandable +- [x] README and CLI help are clear enough for a new user +- [x] no major known bugs remain in the core launch path +- [x] native Linux games validated in real testing +- [x] Proton / Windows games validated in real testing +- [x] one intentional failure case tested + +**v1 complete β€” 2026-05-31.** + +## Validation Still Desired + +Real-world validation to finish before calling v1 done: +- test at least one native Linux game +- test at least one Proton / Windows game +- test one intentional failure case +- confirm Steam launch behavior with the preferred install method +- confirm notification behavior for internal launcher failures +- confirm that profile binding works on an actual game executable + +## Packaging and Distribution Plan + +Long-term packaging direction: +- distro-native packages first +- `cargo install` remains supported but is not the ideal end-user distribution path + +Planned packaging targets: +- Arch Linux / AUR +- Debian / Ubuntu +- Fedora / RPM + +Packaging goals: +- install `gamewrap` to a system-visible path such as `/usr/bin/gamewrap` +- allow Steam launch options to use `gamewrap %command%` +- avoid requiring absolute paths for normal packaged installs + +Packaging status: +- deferred for now in favor of low maintenance +- still an explicit project direction, not abandoned + +Metadata that is still undecided and should be filled in later: +- public repository location +- homepage URL +- issue tracker URL +- release hosting location +- whether the canonical forge will be GitHub, Gitea, or both + +## Deferred Feature Expansion + +Features that were deferred from the initial scope and are now implemented: +- βœ“ `gamescope` integration +- βœ“ `vkBasalt` integration +- βœ“ Custom environment presets (per-profile env var overrides) +- βœ“ Proton and Wine compatibility toggles (esync, fsync, large-address-aware) + +Features still deferred: +- Benchmark and recording profile presets (named templates that bundle several settings) +- Additional launcher helpers beyond the current set + +## Recommended Expansion Order + +Items 1–3 are now implemented. Remaining if development continues: + +4. Benchmark / recording profile presets β€” named templates that bundle several settings into a shareable starting point +5. Graphics API detection (advisory only β€” see separate section) +6. Additional launcher helpers only if they clearly improve the main use case + +## Future Proton and Compatibility Controls + +One planned expansion area is support for common Steam/Proton launch adjustments that users often copy from compatibility guides, community comments, or ProtonDB reports. + +Examples of future controls that may be worth supporting: +- DXVK-related toggles +- VKD3D-related toggles +- esync/fsync toggles +- Wine debug environment controls +- fullscreen and compositor-related tweaks +- launch-time environment overrides for specific games +- Proton-specific compatibility presets that bundle several known-good settings + +This should not become a free-form dump of obscure environment variables by default. The intended direction is: +- expose the most common useful adjustments with friendly names +- keep raw environment overrides available only as an advanced escape hatch if ever added +- prefer reusable presets and profiles over forcing users to remember low-level variables +- use `doctor` and help text to explain what a toggle actually changes + +Potential sources of future ideas: +- recurring compatibility patterns seen in ProtonDB reports +- common launch command snippets shared in Linux gaming communities +- settings that repeatedly solve real issues in actual testing + +Guardrails for this work: +- do not blindly mirror every ProtonDB workaround +- avoid adding toggles that are highly game-specific unless they can be grouped into a sensible preset model +- keep the main UX readable and non-intimidating +- require a clear explanation in help text for every added compatibility control + +## Graphics API Detection (Maybe) + +A potential future addition: detect a game's graphics API at launch time and use that to surface +warnings or skip inapplicable settings. Not on the roadmap yet, but worth revisiting. + +Specific cases where it would help: + +- **vkBasalt safety** β€” vkBasalt only works with Vulkan. For OpenGL or WineD3D games, `ENABLE_VKBASALT=1` + silently does nothing. Detection could skip the env var or warn in `doctor`. +- **Gamescope compatibility** β€” Gamescope is Vulkan-only. If `gamescope = on` is set but the game uses + OpenGL, it will fail or fall back unexpectedly. A preflight warning would catch this. +- **DXVK vs VKD3D-Proton env hints** β€” Per-profile overrides like `DXVK_ASYNC 1` only matter for D3D9/10/11 + games (DXVK). D3D12 games use VKD3D-Proton with a different set of vars. Detection could surface + a mismatch warning when running `doctor`. +- **MangoHud invocation method** β€” MangoHud works as both a command prefix and an env var injection. The + env var path works better for some Vulkan games. Detection could pick the right method. + +How detection would likely work in practice: +- Check for `d3d9.dll`/`d3d11.dll`/`dxgi.dll` in the game directory β†’ DXVK (D3D9/10/11 via Vulkan) +- Check for `d3d12.dll` β†’ VKD3D-Proton (D3D12 via Vulkan) +- Check for `.dxvk-cache` / `.d3d12.cache` files β†’ reliable secondary signals +- Native Linux ELFs: inspect imported libs with `ldd` or `file` β€” harder, lower priority + +Guardrails if this is ever added: +- Detection should be advisory only β€” never silently override user config +- Emit warnings in `doctor` and `dry-run`, not at actual launch time +- Degrade gracefully when detection is inconclusive (most common case) +- Do not make profile selection automatic based on detection results + +The "avoid" note in Product Boundaries refers to _automatic profile selection_ based on detection, +not to using detection for diagnostics and warnings. Those are meaningfully different. + +## Product Boundaries + +Things `gamewrap` should continue to optimize for: +- simple Steam usage +- low system impact +- minimal always-on behavior +- understandable configuration +- useful diagnostics + +Things to avoid unless clearly justified: +- automatic profile selection based on graphics API or runtime detection (advisory warnings are fine) +- heavy background services +- large dependency trees just to support optional extras +- turning the project into a general desktop/session manager + +## Installation Strategy for Now + +Short-term recommended install strategy: +- `cargo install --path . --force` β€” installs to `~/.cargo/bin/gamewrap` +- Ensure `~/.cargo/bin` is on PATH in your desktop session so Steam can find it by name + +Reason: +- low maintenance +- easy updates +- Steam can resolve `gamewrap` by name if `~/.cargo/bin` is on session PATH +- no distro package maintenance burden yet + +## Dependency Maintenance (post-1.0) + +Checked 2026-05-31. Most crates are on current patch versions via `cargo update`. Two deferred upgrades: + +- **toml 0.8 β†’ 1.x**: top-level `from_str`/`to_string_pretty` likely unchanged but the 1.x release has low-level Deserializer/Serializer API changes. Verify against 1.x docs before upgrading. Low urgency β€” 0.8 is still maintained. +- **owo-colors 1.x β†’ 4.x**: major version jump. Our usage (`.green()`, `.bold()`, `.dimmed()`, `.cyan()`, `OwoColorize`) is fundamental and unlikely to have changed, but read the changelog before upgrading. Low urgency β€” 1.x works fine. +- **clap_complete `unstable-dynamic` feature**: may have been renamed to `unstable-command` in 4.6.x. Run `cargo update && cargo test` β€” if it fails, rename the feature flag in `Cargo.toml`. Low risk, one-line fix if needed. + +## Notes for Future Work + +Before starting packaging or deferred features, re-check: +- whether the install story should remain PATH-based for personal use +- whether the project is going public +- whether package maintenance is worth the overhead +- whether v1 behavior is stable enough to freeze the main CLI/config model diff --git a/src/bindings.rs b/src/bindings.rs new file mode 100644 index 0000000..cf71207 --- /dev/null +++ b/src/bindings.rs @@ -0,0 +1,108 @@ +use crate::config::{Binding, ConfigFile, ObservedGame}; +use crate::detect::ExecutableInfo; +use crate::error::{AppError, config_error}; + +pub fn resolve_profile<'a>(config: &'a ConfigFile, executable: &ExecutableInfo) -> Option<&'a str> { + config + .bindings + .iter() + .find(|binding| matches(binding, executable)) + .map(|binding| binding.profile.as_str()) +} + +pub fn set_binding( + config: &mut ConfigFile, + matcher: String, + profile: String, +) -> Result<(), AppError> { + if !config.profiles.contains_key(&profile) { + return Err(config_error( + format!("Profile `{profile}` does not exist."), + "Create it first with `gamewrap profile create `.", + )); + } + + if let Some(binding) = config + .bindings + .iter_mut() + .find(|binding| binding.matcher == matcher) + { + binding.profile = profile; + return Ok(()); + } + + config.bindings.push(Binding { matcher, profile }); + Ok(()) +} + +pub fn resolve_profile_for_observed<'a>( + config: &'a ConfigFile, + game: &ObservedGame, +) -> Option<&'a str> { + config + .bindings + .iter() + .find(|binding| matches_observed_game(binding, game)) + .map(|binding| binding.profile.as_str()) +} + +pub fn remove_binding(config: &mut ConfigFile, matcher: &str) -> Result<(), AppError> { + let index = config + .bindings + .iter() + .position(|binding| binding.matcher == matcher) + .ok_or_else(|| { + config_error( + format!("No binding exists for `{matcher}`."), + "Run `gamewrap game list` to see observed games.", + ) + })?; + + config.bindings.remove(index); + Ok(()) +} + +fn matches(binding: &Binding, executable: &ExecutableInfo) -> bool { + matcher_matches( + &binding.matcher, + &executable.basename, + &executable.command_path, + ) +} + +fn matches_observed_game(binding: &Binding, game: &ObservedGame) -> bool { + matcher_matches(&binding.matcher, &game.executable, &game.command_path) +} + +fn matcher_matches(matcher: &str, basename: &str, command_path: &str) -> bool { + let matcher = matcher.to_ascii_lowercase(); + basename.to_ascii_lowercase() == matcher || command_path.to_ascii_lowercase().contains(&matcher) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::config::{ConfigFile, ProfileConfig, Settings}; + use crate::detect::ExecutableInfo; + + #[test] + fn binding_prefers_basename_match() { + let config = ConfigFile { + defaults: Settings::default(), + profiles: BTreeMap::from([("benchmark".to_string(), ProfileConfig::default())]), + bindings: vec![Binding { + matcher: "eldenring.exe".to_string(), + profile: "benchmark".to_string(), + }], + }; + + let executable = ExecutableInfo { + basename: "eldenring.exe".to_string(), + command_path: "/games/eldenring.exe".to_string(), + }; + + assert_eq!(resolve_profile(&config, &executable), Some("benchmark")); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..3207d35 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,1355 @@ +use std::ffi::OsString; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +use clap::{Args, CommandFactory, Parser, Subcommand, ValueHint, error::ErrorKind}; +use clap_complete::Shell; +use clap_complete::engine::{ArgValueCandidates, ArgValueCompleter, PathCompleter}; + +use crate::bindings; +use crate::color; +use crate::completion; +use crate::config::keys::{SettingKey, reset_value, set_value}; +use crate::config::{self, AppPaths, ConfigFile, ProfileConfig, StateFile}; +use crate::detect; +use crate::doctor; +use crate::env; +use crate::error::{ + AppError, config_error, game_not_found_error, internal_error, io_to_internal, + profile_not_found_error, usage_error, +}; +use crate::help::{ + EXPLICIT_LAUNCH_HINT, HELP_TOPICS_HINT, TOP_LEVEL_AFTER_HELP, UNKNOWN_COMMAND_HINT, topic_text, +}; +use crate::launch; +use crate::profile; +use crate::share; +use crate::status; + +fn styles() -> clap::builder::Styles { + use clap::builder::styling::{AnsiColor, Effects, Styles}; + Styles::styled() + .header(AnsiColor::Green.on_default() | Effects::BOLD) + .usage(AnsiColor::Green.on_default() | Effects::BOLD) + .literal(AnsiColor::Cyan.on_default() | Effects::BOLD) + .placeholder(AnsiColor::Cyan.on_default()) + .error(AnsiColor::Red.on_default() | Effects::BOLD) + .valid(AnsiColor::Cyan.on_default() | Effects::BOLD) + .invalid(AnsiColor::Yellow.on_default() | Effects::BOLD) +} + +const KNOWN_COMMANDS: &[&str] = &[ + "help", + "status", + "doctor", + "run", + "launch", + "dry-run", + "last", + "config", + "profile", + "game", + "notify", + "completion", + "--help", + "-h", + "--version", + "-V", +]; + +#[derive(Debug)] +pub enum ParsedCommand { + Launch { command: Vec }, + Manage(ManageCli), +} + +pub fn parse(args: impl IntoIterator) -> Result { + let args = args.into_iter().collect::>(); + if args.len() <= 1 { + return Err(usage_error( + "No command was provided.", + "Steam and terminal usage are different.\nIn Steam launch options, use `gamewrap %command%`.\nIn a normal terminal, use `gamewrap run /path/to/game/executable` or `gamewrap dry-run /path/to/game/executable`.\nExamples:\n Steam launch options: gamewrap %command%\n Terminal help: gamewrap --help\n Terminal launch: gamewrap run /path/to/game/executable", + )); + } + + let first = args[1].to_string_lossy(); + if first == "--help" || first == "-h" { + let mut command = command(); + command.print_long_help().ok(); + println!(); + std::process::exit(0); + } + + if first == "--version" || first == "-V" { + println!("{}", env!("CARGO_PKG_VERSION")); + std::process::exit(0); + } + + if KNOWN_COMMANDS.contains(&first.as_ref()) { + if first == "help" && args.len() == 2 { + let mut command = command(); + command.print_long_help().ok(); + println!(); + std::process::exit(0); + } + let parse_args = args.clone(); + let cli = match ManageCli::try_parse_from(parse_args) { + Ok(cli) => cli, + Err(error) => match error.kind() { + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => { + error.print().ok(); + std::process::exit(0); + } + _ => return Err(usage_error(error.to_string(), UNKNOWN_COMMAND_HINT)), + }, + }; + return Ok(ParsedCommand::Manage(cli)); + } + + if env::is_steam_context() { + return Ok(ParsedCommand::Launch { + command: args[1..].to_vec(), + }); + } + + Err(usage_error( + format!("`{first}` is not a known gamewrap command."), + &format!("{UNKNOWN_COMMAND_HINT}\n{EXPLICIT_LAUNCH_HINT}"), + )) +} + +pub fn command() -> clap::Command { + ManageCli::command() + .styles(styles()) + .after_help(TOP_LEVEL_AFTER_HELP) +} + +pub fn execute(command: ParsedCommand) -> Result<(), AppError> { + let paths = AppPaths::discover()?; + match command { + ParsedCommand::Launch { command } => { + let mut config = config::load(&paths)?; + let mut state = config::load_state(&paths)?; + launch_command(&paths, &mut config, &mut state, &command, false) + } + ParsedCommand::Manage(cli) => execute_management(&paths, cli), + } +} + +#[derive(Debug, Parser)] +#[command( + name = "gamewrap", + version, + about = "Friendly Steam-first launcher wrapper for MangoHud and GameMode.", + disable_help_subcommand = true +)] +#[command(after_help = TOP_LEVEL_AFTER_HELP)] +pub struct ManageCli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand)] +enum Commands { + #[command( + about = "Show help for a topic (settings, profiles, bindings, doctor, completion, troubleshooting)" + )] + Help { topic: Option }, + #[command(about = "Show installed dependencies and resolved default settings")] + Status, + #[command(about = "Run preflight checks and show what a launch would do")] + Doctor { + #[arg( + trailing_var_arg = true, + value_hint = ValueHint::AnyPath, + add = ArgValueCompleter::new(PathCompleter::any()) + )] + command: Vec, + }, + #[command( + alias = "launch", + override_usage = "gamewrap run [--] ...", + about = "Launch a game (wraps the game command with MangoHud, GameMode, etc.)" + )] + Run(LaunchArgs), + #[command( + override_usage = "gamewrap dry-run [--] ...", + about = "Show the resolved launch plan without running anything" + )] + DryRun(LaunchArgs), + #[command(about = "Show the most recently launched game and its launch stats")] + Last, + #[command(about = "Show, edit, set, export, or import the global config")] + Config(ConfigCommands), + #[command(about = "Create, show, set, inherit, export, or import profiles")] + Profile(ProfileCommands), + #[command(about = "List, show, bind, rename, forget, and annotate observed games")] + Game(GameCommands), + #[command(about = "Send a graphical test notification to verify your notifier works")] + Notify(NotifyCommands), + #[command(about = "Generate or install shell completions (bash, zsh, fish)")] + Completion(CompletionCommands), +} + +#[derive(Debug, Args)] +struct LaunchArgs { + #[arg( + required = true, + trailing_var_arg = true, + value_hint = ValueHint::AnyPath, + add = ArgValueCompleter::new(PathCompleter::any()) + )] + command: Vec, +} + +#[derive(Debug, Args)] +struct ConfigCommands { + #[command(subcommand)] + action: ConfigAction, +} + +#[derive(Debug, Subcommand)] +enum ConfigAction { + Show, + Edit, + Set { + #[arg(add = ArgValueCandidates::new(completion::setting_name_candidates))] + setting: String, + #[arg(add = ArgValueCandidates::new(completion::setting_value_candidates))] + value: String, + }, + Reset { + #[arg(add = ArgValueCandidates::new(completion::setting_name_candidates))] + setting: String, + }, + Export { + #[arg(value_hint = ValueHint::FilePath, add = ArgValueCompleter::new(PathCompleter::any()))] + path: Option, + }, + Import { + #[arg(value_hint = ValueHint::FilePath, add = ArgValueCompleter::new(PathCompleter::file()))] + path: PathBuf, + }, +} + +#[derive(Debug, Args)] +struct ProfileCommands { + #[command(subcommand)] + action: ProfileAction, +} + +#[derive(Debug, Subcommand)] +enum ProfileAction { + List, + Tree, + Env(ProfileEnvCommands), + Create { + name: String, + }, + Duplicate { + #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] + source: String, + destination: String, + }, + Show { + #[arg(add = ArgValueCandidates::new(completion::profile_show_candidates))] + name: String, + }, + Export { + #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] + name: String, + #[arg(value_hint = ValueHint::FilePath, add = ArgValueCompleter::new(PathCompleter::any()))] + path: Option, + }, + Import { + #[arg(value_hint = ValueHint::FilePath, add = ArgValueCompleter::new(PathCompleter::file()))] + path: PathBuf, + }, + Set { + #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] + name: String, + #[arg(add = ArgValueCandidates::new(completion::setting_name_candidates))] + setting: String, + #[arg(add = ArgValueCandidates::new(completion::setting_value_candidates))] + value: String, + }, + Reset { + #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] + name: String, + #[arg(add = ArgValueCandidates::new(completion::setting_name_candidates))] + setting: String, + }, + Inherit { + #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] + name: String, + #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] + parent: String, + }, + ClearInherit { + #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] + name: String, + }, + Delete { + #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] + name: String, + }, +} + +#[derive(Debug, Args)] +struct ProfileEnvCommands { + #[command(subcommand)] + action: ProfileEnvAction, +} + +#[derive(Debug, Subcommand)] +enum ProfileEnvAction { + Set { + #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] + name: String, + key: String, + value: String, + }, + Unset { + #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] + name: String, + key: String, + }, + List { + #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] + name: String, + }, + Clear { + #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] + name: String, + }, +} + +#[derive(Debug, Args)] +struct NoteArgs { + #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] + matcher: String, + #[arg(required = true, trailing_var_arg = true)] + note: Vec, +} + +#[derive(Debug, Subcommand)] +enum GameAction { + List { + #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] + matcher: Option, + #[arg(short, long, help = "Show full paths without abbreviation")] + full: bool, + }, + Show { + #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] + matcher: String, + }, + Bind { + #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] + matcher: String, + #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] + profile: String, + }, + Unbind { + #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] + matcher: String, + }, + Note(NoteArgs), + Rename { + #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] + matcher: String, + name: String, + }, + ClearNote { + #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] + matcher: String, + }, + Forget { + #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] + matcher: String, + }, +} + +#[derive(Debug, Args)] +struct GameCommands { + #[command(subcommand)] + action: GameAction, +} + +#[derive(Debug, Args)] +struct CompletionCommands { + #[command(subcommand)] + action: Option, + shell: Option, +} + +#[derive(Debug, Args)] +struct NotifyCommands { + #[command(subcommand)] + action: NotifyAction, +} + +#[derive(Debug, Subcommand)] +enum NotifyAction { + Test, +} + +#[derive(Debug, Subcommand)] +enum CompletionAction { + Install { shell: Shell }, + Path { shell: Shell }, +} + +fn execute_management(paths: &AppPaths, cli: ManageCli) -> Result<(), AppError> { + match cli.command { + Commands::Help { topic } => { + if let Some(topic) = topic { + let text = topic_text(&topic).ok_or_else(|| { + usage_error( + format!("`{topic}` is not a known help topic."), + HELP_TOPICS_HINT, + ) + })?; + println!("{text}"); + } else { + let mut command = command(); + command.print_long_help().ok(); + println!(); + } + Ok(()) + } + Commands::Status => { + let config = config::load(paths)?; + let state = config::load_state(paths)?; + print!("{}", status::render(paths, &config, &state)); + Ok(()) + } + Commands::Doctor { command } => execute_doctor(paths, &command), + Commands::Run(args) => { + let mut config = config::load(paths)?; + let mut state = config::load_state(paths)?; + launch_command(paths, &mut config, &mut state, &args.command, false) + } + Commands::DryRun(args) => { + let mut config = config::load(paths)?; + let mut state = config::load_state(paths)?; + launch_command(paths, &mut config, &mut state, &args.command, true) + } + Commands::Last => execute_last(paths), + Commands::Config(command) => execute_config(paths, command), + Commands::Profile(command) => execute_profile(paths, command), + Commands::Game(command) => execute_game(paths, command), + Commands::Notify(command) => execute_notify(command), + Commands::Completion(command) => execute_completion(paths, command), + } +} + +fn execute_last(paths: &AppPaths) -> Result<(), AppError> { + let config = config::load(paths)?; + let state = config::load_state(paths)?; + let game = state + .games + .iter() + .filter(|game| game.last_launched_at.is_some()) + .max_by_key(|game| game.last_launched_at.as_deref()) + .or_else(|| state.games.last()); + + let Some(game) = game else { + println!("No games observed yet."); + return Ok(()); + }; + + let resolved_profile = + bindings::resolve_profile_for_observed(&config, game).unwrap_or("default"); + println!( + "{} {}", + color::bold("Last played:"), + display_game_name(game) + ); + println!( + "{} {}", + color::bold("Profile: "), + color::accent(resolved_profile) + ); + println!( + "{} {}", + color::bold("Path: "), + color::dim(&game.command_path) + ); + println!("{} {}", color::bold("Launches: "), game.launch_count); + println!( + "{} {}", + color::bold("Last launch:"), + game.last_launched_at.as_deref().unwrap_or("unknown") + ); + if let Some(total_secs) = game.total_play_seconds { + println!( + "{} {}", + color::bold("Total playtime:"), + color::ok(&format_duration(total_secs)) + ); + } + if let Some(note) = &game.note { + println!("{} {}", color::bold("Note:"), note); + } + + Ok(()) +} + +fn execute_notify(command: NotifyCommands) -> Result<(), AppError> { + match command.action { + NotifyAction::Test => { + if let Some(name) = crate::notify::notify_test() { + println!("Sent test notification via {name}."); + } else { + println!("No notifier available. Install zenity, kdialog, or notify-send."); + } + } + } + Ok(()) +} + +fn execute_completion(paths: &AppPaths, command: CompletionCommands) -> Result<(), AppError> { + match (command.action, command.shell) { + (Some(CompletionAction::Install { shell }), None) => { + let outcome = completion::install(shell, paths)?; + println!("{}", outcome.message); + Ok(()) + } + (Some(CompletionAction::Path { shell }), None) => { + print!("{}", completion::path(shell, paths)?); + Ok(()) + } + (None, Some(shell)) => { + print!("{}", completion::render(shell)?); + Ok(()) + } + (None, None) => Err(usage_error( + "No shell was provided for completion.", + "Use `gamewrap completion ` to print a script, `gamewrap completion install ` to install it, or `gamewrap completion path ` to see where it lives.", + )), + (Some(_), Some(_)) => Err(usage_error( + "Choose either a completion action or a shell, not both.", + "Examples:\n gamewrap completion zsh\n gamewrap completion install zsh\n gamewrap completion path zsh", + )), + } +} + +fn execute_doctor(paths: &AppPaths, command: &[OsString]) -> Result<(), AppError> { + let config = config::load(paths)?; + let executable = (!command.is_empty()).then(|| detect::inspect_command(command)); + let resolved = match executable.as_ref() { + Some(executable) => profile::resolve(&config, executable)?, + None => { + let mut settings = crate::config::ResolvedSettings::default(); + settings.apply(&config.defaults); + crate::profile::ResolvedProfile { + profile_name: "default".to_string(), + settings, + } + } + }; + + let report = launch::preflight( + resolved.settings.clone(), + if command.is_empty() { + None + } else { + Some(command) + }, + true, + ); + print!( + "{}", + doctor::render( + resolved.settings, + &report, + if command.is_empty() { + None + } else { + Some(command) + } + ) + ); + Ok(()) +} + +fn execute_config(paths: &AppPaths, command: ConfigCommands) -> Result<(), AppError> { + let mut config = config::load(paths)?; + match command.action { + ConfigAction::Show => { + print!("{}", config::render_config(&config)); + } + ConfigAction::Edit => { + let editor = std::env::var("VISUAL") + .or_else(|_| std::env::var("EDITOR")) + .unwrap_or_else(|_| "nano".to_string()); + let status = Command::new(&editor) + .arg(&paths.config_file) + .status() + .map_err(|_| { + usage_error( + format!("Could not launch editor `{editor}`."), + "Set $EDITOR or $VISUAL to your preferred editor.", + ) + })?; + if !status.success() { + eprintln!("Warning: editor `{editor}` exited without saving successfully."); + } + } + ConfigAction::Set { setting, value } => { + let key = SettingKey::parse(&setting)?; + set_value(&mut config.defaults, key, &value)?; + config::save(paths, &config)?; + println!("Updated default setting `{setting}` to `{value}`."); + } + ConfigAction::Reset { setting } => { + let key = SettingKey::parse(&setting)?; + reset_value(&mut config.defaults, key); + config::save(paths, &config)?; + println!("Reset default setting `{setting}`."); + } + ConfigAction::Export { path } => { + let exported = share::export_config(&config)?; + let content = toml::to_string_pretty(&exported) + .map_err(|error| internal_error(format!("failed to serialize config: {error}")))?; + if let Some(path) = path { + let path = share::with_default_config_suffix(&path); + fs::write(&path, content).map_err(|error| { + io_to_internal("Failed writing export file", Some(&path), error) + })?; + println!( + "{}", + color::ok(&format!("Exported config to `{}`.", path.display())) + ); + } else { + print!("{content}"); + } + } + ConfigAction::Import { path } => { + let path = resolve_import_path(path, share::CONFIG_EXPORT_SUFFIX); + let content = fs::read_to_string(&path).map_err(|error| { + io_to_internal("Failed reading import file", Some(&path), error) + })?; + let imported = share::parse_imported_config(&content).map_err(|error| { + config_error( + format!("Import file {} is invalid: {error}", path.display()), + "Fix the TOML syntax or export a fresh config and try again.", + ) + })?; + profile::validate_config(&imported)?; + config::save(paths, &imported)?; + println!( + "{}", + color::ok(&format!("Imported config from `{}`.", path.display())) + ); + } + } + Ok(()) +} + +fn execute_profile(paths: &AppPaths, command: ProfileCommands) -> Result<(), AppError> { + let mut config = config::load(paths)?; + let _state = config::load_state(paths)?; + match command.action { + ProfileAction::List => { + if config.profiles.is_empty() { + println!("No profiles configured."); + } else { + for (name, profile) in &config.profiles { + let binding_count = config + .bindings + .iter() + .filter(|binding| binding.profile == *name) + .count(); + let binding_text = binding_count_text(binding_count); + if let Some(parent) = &profile.inherits { + if binding_count > 0 { + println!( + "{} {}", + color::accent(name), + color::dim(&format!("(inherits: {parent}, {binding_text})")) + ); + } else { + println!( + "{} {}", + color::accent(name), + color::dim(&format!("(inherits: {parent})")) + ); + } + } else if binding_count > 0 { + println!( + "{} {}", + color::accent(name), + color::dim(&format!("({binding_text})")) + ); + } else { + println!("{}", color::accent(name)); + } + } + } + } + ProfileAction::Tree => { + if config.profiles.is_empty() { + println!("No profiles configured."); + } else { + println!("{} {}", color::dim("default"), color::dim("(built-in)")); + print_profile_tree(&config, None, ""); + } + } + ProfileAction::Env(command) => match command.action { + ProfileEnvAction::Set { name, key, value } => { + let profile_config = require_profile_mut(&mut config, &name)?; + profile_config + .settings + .env_vars + .get_or_insert_with(Default::default) + .insert(key.clone(), value.clone()); + config::save(paths, &config)?; + println!( + "{}", + color::ok(&format!("Set env `{key}={value}` on profile `{name}`.")) + ); + } + ProfileEnvAction::Unset { name, key } => { + let profile_config = require_profile_mut(&mut config, &name)?; + let Some(vars) = profile_config.settings.env_vars.as_mut() else { + println!("No env var `{key}` on profile `{name}`."); + return Ok(()); + }; + if vars.remove(&key).is_none() { + println!("No env var `{key}` on profile `{name}`."); + return Ok(()); + } + if vars.is_empty() { + profile_config.settings.env_vars = None; + } + config::save(paths, &config)?; + println!( + "{}", + color::ok(&format!("Unset env `{key}` on profile `{name}`.")) + ); + } + ProfileEnvAction::List { name } => { + let resolved = profile::resolve_named(&config, &name)?; + if resolved.settings.env_vars.is_empty() { + println!("(no env vars set on this profile)"); + } else { + for (key, value) in resolved.settings.env_vars { + println!("{key}={value}"); + } + } + } + ProfileEnvAction::Clear { name } => { + let profile_config = require_profile_mut(&mut config, &name)?; + profile_config.settings.env_vars = None; + config::save(paths, &config)?; + println!( + "{}", + color::ok(&format!("Cleared all env vars from profile `{name}`.")) + ); + } + }, + ProfileAction::Create { name } => { + if name == "default" { + return Err(config_error( + "`default` is reserved for the built-in default profile.", + "Choose another profile name such as `benchmark` or `recording`.", + )); + } + if config + .profiles + .insert(name.clone(), ProfileConfig::default()) + .is_some() + { + return Err(config_error( + format!("Profile `{name}` already exists."), + "Use `gamewrap profile set` to change it, or choose a new name.", + )); + } + config::save(paths, &config)?; + println!("{}", color::ok(&format!("Created profile `{name}`."))); + } + ProfileAction::Duplicate { + source, + destination, + } => { + if destination == "default" { + return Err(config_error( + "`default` is reserved for the built-in default profile.", + "Choose another profile name such as `benchmark-copy` or `recording-copy`.", + )); + } + if config.profiles.contains_key(&destination) { + return Err(config_error( + format!("Profile `{destination}` already exists."), + "Use another destination name or delete the existing profile first.", + )); + } + let profile = require_profile(&config, &source)?; + config.profiles.insert(destination.clone(), profile.clone()); + config::save(paths, &config)?; + println!("Duplicated profile `{source}` to `{destination}`."); + } + ProfileAction::Show { name } => { + if name == "default" { + print!( + "{}", + config::render_config(&ConfigFile { + defaults: config.defaults, + profiles: Default::default(), + bindings: Vec::new(), + }) + ); + } else { + let profile_config = require_profile(&config, &name)?; + let resolved = profile::resolve_named(&config, &name)?; + print!( + "{}", + config::render_profile(&name, profile_config, &resolved.settings) + ); + } + } + ProfileAction::Export { name, path } => { + if name == "default" { + return Err(config_error( + "`default` cannot be exported as a named profile.", + "Use `gamewrap config export` if you want to share your full defaults.", + )); + } + require_profile(&config, &name)?; + let exported = share::export_profile(&config, &name)?; + let content = toml::to_string_pretty(&exported).map_err(|error| { + internal_error(format!("failed to serialize profile export: {error}")) + })?; + if let Some(path) = path { + let path = share::with_default_profile_suffix(&path); + fs::write(&path, content).map_err(|error| { + io_to_internal("Failed writing profile export file", Some(&path), error) + })?; + println!("Exported profile `{name}` to `{}`.", path.display()); + } else { + print!("{content}"); + } + } + ProfileAction::Import { path } => { + let path = resolve_import_path(path, share::PROFILE_EXPORT_SUFFIX); + let content = fs::read_to_string(&path).map_err(|error| { + io_to_internal("Failed reading profile import file", Some(&path), error) + })?; + let imported: share::SharedProfileFile = toml::from_str(&content).map_err(|error| { + config_error( + format!("Profile import file {} is invalid: {error}", path.display()), + "Use a `.gamewrap-profile.toml` export from `gamewrap profile export`.", + ) + })?; + let (name, imported_profile) = share::import_profile(imported)?; + if name == "default" { + return Err(config_error( + "`default` cannot be imported as a named profile.", + "Choose a different profile name before exporting or editing the file.", + )); + } + if config.profiles.contains_key(&name) { + return Err(config_error( + format!("Profile `{name}` already exists."), + "Rename or delete the existing profile first, or edit the imported file's name.", + )); + } + config.profiles.insert(name.clone(), imported_profile); + config::save(paths, &config)?; + println!("Imported profile `{name}` from `{}`.", path.display()); + } + ProfileAction::Set { + name, + setting, + value, + } => { + let key = SettingKey::parse(&setting)?; + let profile_config = require_profile_mut(&mut config, &name)?; + set_value(&mut profile_config.settings, key, &value)?; + config::save(paths, &config)?; + println!("Updated profile `{name}`: `{setting}` = `{value}`."); + } + ProfileAction::Reset { name, setting } => { + let key = SettingKey::parse(&setting)?; + let profile_config = require_profile_mut(&mut config, &name)?; + reset_value(&mut profile_config.settings, key); + config::save(paths, &config)?; + println!("Reset profile `{name}` setting `{setting}` to inherit."); + } + ProfileAction::Inherit { name, parent } => { + if parent == "default" { + return Err(config_error( + "`default` is already the base for every profile.", + "Use `gamewrap profile clear-inherit ` if you want the profile to inherit only from the defaults.", + )); + } + if !config.profiles.contains_key(&parent) { + return Err(config_error( + format!("Parent profile `{parent}` does not exist."), + "Create it first with `gamewrap profile create `.", + )); + } + let profile_config = require_profile_mut(&mut config, &name)?; + profile_config.inherits = Some(parent.clone()); + config::save(paths, &config)?; + println!("Profile `{name}` now inherits from `{parent}`."); + } + ProfileAction::ClearInherit { name } => { + let profile_config = require_profile_mut(&mut config, &name)?; + profile_config.inherits = None; + config::save(paths, &config)?; + println!("Cleared inherited parent for `{name}`."); + } + ProfileAction::Delete { name } => { + if let Some((child, _)) = config + .profiles + .iter() + .find(|(_, profile)| profile.inherits.as_deref() == Some(name.as_str())) + { + return Err(config_error( + format!("Cannot delete profile `{name}` because `{child}` inherits from it."), + "Clear or change the child's parent first with `gamewrap profile clear-inherit ` or `gamewrap profile inherit `.", + )); + } + if config.profiles.remove(&name).is_none() { + return Err(profile_not_found_error(&name)); + } + config.bindings.retain(|binding| binding.profile != name); + config::save(paths, &config)?; + println!( + "{}", + color::ok(&format!( + "Deleted profile `{name}` and removed bindings that pointed to it." + )) + ); + } + } + Ok(()) +} + +fn execute_game(paths: &AppPaths, command: GameCommands) -> Result<(), AppError> { + let mut config = config::load(paths)?; + let mut state = config::load_state(paths)?; + match command.action { + GameAction::List { matcher, full } => { + let games = state + .games + .iter() + .filter(|game| { + matcher + .as_ref() + .is_none_or(|matcher| matches_observed_game(matcher, game)) + }) + .collect::>(); + if games.is_empty() { + println!("(no observed games yet)"); + } else { + let game_width = games + .iter() + .map(|game| display_game_name(game).len()) + .max() + .unwrap_or(4) + .max(4); + let profile_width = games + .iter() + .map(|game| { + bindings::resolve_profile_for_observed(&config, game) + .unwrap_or("default") + .len() + }) + .max() + .unwrap_or(7) + .max(7); + let path_budget = terminal_width() + .saturating_sub(game_width + profile_width + 4) + .max(30); + println!( + "{}", + color::bold(&format!( + "{: { + let game = find_observed_game(&state, &matcher) + .ok_or_else(|| game_not_found_error(&matcher))?; + let resolved_profile = + bindings::resolve_profile_for_observed(&config, game).unwrap_or("default"); + println!("{} {}", color::bold("Executable:"), game.executable); + println!( + "{} {}", + color::bold("Command path:"), + color::dim(&game.command_path) + ); + println!( + "{} {}", + color::bold("Resolved profile:"), + color::accent(resolved_profile) + ); + println!( + "{} {}", + color::bold("Last launched profile:"), + color::dim(&game.last_profile) + ); + if game.launch_count > 0 { + println!("{} {}", color::bold("Launch count:"), game.launch_count); + } + if let Some(ts) = &game.last_launched_at { + println!("{} {}", color::bold("Last launched:"), ts); + } + if let Some(total_secs) = game.total_play_seconds { + println!( + "{} {}", + color::bold("Total playtime:"), + format_duration(total_secs) + ); + } + if let Some(display_name) = &game.display_name { + println!("{} {}", color::bold("Display name:"), display_name); + } + if let Some(note) = &game.note { + println!("{} {}", color::bold("Note:"), note); + } + } + GameAction::Bind { matcher, profile } => { + bindings::set_binding(&mut config, matcher.clone(), profile.clone())?; + config::save(paths, &config)?; + println!( + "{}", + color::ok(&format!("Bound `{matcher}` to profile `{profile}`.")) + ); + } + GameAction::Unbind { matcher } => { + if bindings::remove_binding(&mut config, &matcher).is_err() { + let candidates = find_observed_game(&state, &matcher) + .map(|game| [game.executable.clone(), game.command_path.clone()]); + let removed = candidates + .into_iter() + .flatten() + .find_map(|candidate| bindings::remove_binding(&mut config, &candidate).ok()); + if removed.is_none() { + return Err(config_error( + format!("No binding exists for `{matcher}`."), + "Run `gamewrap game list` to see observed games or `gamewrap config show` to inspect current bindings.", + )); + } + } + config::save(paths, &config)?; + println!( + "{}", + color::ok(&format!("Removed binding for `{matcher}`.")) + ); + } + GameAction::Note(args) => { + let note = join_os_words(&args.note)?; + let game = find_observed_game_mut(&mut state, &args.matcher) + .ok_or_else(|| game_not_found_error(&args.matcher))?; + game.note = Some(note); + config::save_state(paths, &state)?; + println!("Saved note for `{}`.", args.matcher); + } + GameAction::Rename { matcher, name } => { + let game = find_observed_game_mut(&mut state, &matcher) + .ok_or_else(|| game_not_found_error(&matcher))?; + game.display_name = Some(name.clone()); + config::save_state(paths, &state)?; + println!( + "{}", + color::ok(&format!("Renamed `{matcher}` to `{name}`.")) + ); + } + GameAction::ClearNote { matcher } => { + let game = find_observed_game_mut(&mut state, &matcher) + .ok_or_else(|| game_not_found_error(&matcher))?; + game.note = None; + config::save_state(paths, &state)?; + println!("Cleared note for `{matcher}`."); + } + GameAction::Forget { matcher } => { + find_observed_game_mut(&mut state, &matcher) + .ok_or_else(|| game_not_found_error(&matcher))?; + state + .games + .retain(|game| !matches_observed_game(&matcher, game)); + config::save_state(paths, &state)?; + println!( + "{}", + color::ok(&format!("Removed `{matcher}` from observed games.")) + ); + } + } + Ok(()) +} + +fn binding_count_text(count: usize) -> String { + format!("{count} binding{}", if count == 1 { "" } else { "s" }) +} + +fn print_profile_tree(config: &ConfigFile, parent: Option<&str>, prefix: &str) { + let children = config + .profiles + .iter() + .filter(|(_, profile)| profile.inherits.as_deref() == parent) + .collect::>(); + + for (index, (name, _)) in children.iter().enumerate() { + let is_last = index + 1 == children.len(); + let connector = if is_last { "└── " } else { "β”œβ”€β”€ " }; + println!( + "{}{}{}", + color::dim(&format!("{prefix}{connector}")), + color::accent(name), + "" + ); + + let child_prefix = format!("{prefix}{}", if is_last { " " } else { "β”‚ " }); + print_profile_tree(config, Some(name.as_str()), &child_prefix); + } +} + +fn find_observed_game<'a>( + state: &'a StateFile, + matcher: &str, +) -> Option<&'a crate::config::ObservedGame> { + state + .games + .iter() + .find(|game| matches_observed_game(matcher, game)) +} + +fn find_observed_game_mut<'a>( + state: &'a mut StateFile, + matcher: &str, +) -> Option<&'a mut crate::config::ObservedGame> { + state + .games + .iter_mut() + .find(|game| matches_observed_game(matcher, game)) +} + +fn display_game_name(game: &crate::config::ObservedGame) -> &str { + game.display_name.as_deref().unwrap_or(&game.executable) +} + +fn matches_observed_game(matcher: &str, game: &crate::config::ObservedGame) -> bool { + let matcher = matcher.to_ascii_lowercase(); + game.executable.to_ascii_lowercase() == matcher + || game.command_path.to_ascii_lowercase().contains(&matcher) +} + +fn join_os_words(words: &[OsString]) -> Result { + let parts = words + .iter() + .map(|word| word.to_str().map(str::to_owned)) + .collect::>>() + .ok_or_else(|| { + config_error( + "Notes must be valid UTF-8 text.", + "Use plain text for notes and try again.", + ) + })?; + Ok(parts.join(" ")) +} + +fn terminal_width() -> usize { + std::env::var("COLUMNS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(120) +} + +fn abbreviate_path(path: &str, max_len: usize) -> String { + if path.len() <= max_len { + return path.to_string(); + } + let p = std::path::Path::new(path); + let file = p + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_default(); + let parent = p + .parent() + .and_then(|p| p.file_name()) + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_default(); + let with_parent = format!(".../{parent}/{file}"); + if with_parent.len() <= max_len { + return with_parent; + } + let file_only = format!(".../{file}"); + if file_only.len() <= max_len { + return file_only; + } + format!("{}...", &path[..max_len.saturating_sub(3)]) +} + +fn resolve_import_path(path: PathBuf, suffix: &str) -> PathBuf { + if path.exists() { + return path; + } + + let with_suffix = if suffix == share::CONFIG_EXPORT_SUFFIX { + share::with_default_config_suffix(&path) + } else { + share::with_default_profile_suffix(&path) + }; + + if with_suffix.exists() { + with_suffix + } else { + path + } +} + +fn launch_command( + paths: &AppPaths, + config: &mut ConfigFile, + state: &mut StateFile, + command: &[OsString], + dry_run: bool, +) -> Result<(), AppError> { + let executable = detect::inspect_command(command); + let resolved = profile::resolve(config, &executable)?; + let verbose = resolved.settings.verbose; + let settings = resolved.settings.clone(); + let plan = launch::build_plan(command, settings.clone())?; + + if dry_run { + println!( + "{}", + launch::render_plan(&plan, &resolved.profile_name, verbose) + ); + if let Some(pre_cmd) = &settings.pre_launch { + println!("Pre-launch hook:\n sh -c {:?}", pre_cmd); + } + if let Some(post_cmd) = &settings.post_launch { + println!( + "Post-launch hook (runs after game exits):\n sh -c {:?}", + post_cmd + ); + } + return Ok(()); + } + + if let Some(pre_cmd) = &settings.pre_launch { + let _ = std::process::Command::new("sh") + .arg("-c") + .arg(pre_cmd) + .status(); + } + + if env::is_steam_context() { + detect::record_launch(state, &executable, &resolved.profile_name); + config::save_state(paths, state)?; + } + + if let Some(post_cmd) = settings.post_launch.clone() { + let elapsed = launch::execute_wait(plan)?; + let elapsed_secs = elapsed.as_secs(); + if env::is_steam_context() { + detect::record_play_time(state, &executable, elapsed_secs); + config::save_state(paths, state)?; + } + let _ = std::process::Command::new("sh") + .arg("-c") + .arg(&post_cmd) + .status(); + Ok(()) + } else { + launch::execute(plan) + } +} + +fn format_duration(seconds: u64) -> String { + let hours = seconds / 3_600; + let minutes = (seconds % 3_600) / 60; + if hours > 0 { + format!("{hours}h {minutes}m") + } else if minutes > 0 { + format!("{minutes}m") + } else { + format!("{seconds}s") + } +} + +fn require_profile<'a>( + config: &'a ConfigFile, + name: &str, +) -> Result<&'a crate::config::ProfileConfig, AppError> { + config + .profiles + .get(name) + .ok_or_else(|| profile_not_found_error(name)) +} + +fn require_profile_mut<'a>( + config: &'a mut ConfigFile, + name: &str, +) -> Result<&'a mut crate::config::ProfileConfig, AppError> { + config + .profiles + .get_mut(name) + .ok_or_else(|| profile_not_found_error(name)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_unknown_terminal_word_returns_usage_error() { + let error = parse( + ["gamewrap", "game.exe", "-foo"] + .into_iter() + .map(OsString::from), + ) + .expect_err("expected parse to fail"); + + assert_eq!(error.exit_code(), 2); + } + + #[test] + fn parse_management_command() { + let command = parse(["gamewrap", "status"].into_iter().map(OsString::from)) + .expect("expected parse to succeed"); + assert!(matches!(command, ParsedCommand::Manage(_))); + } +} diff --git a/src/color.rs b/src/color.rs new file mode 100644 index 0000000..b3e21a0 --- /dev/null +++ b/src/color.rs @@ -0,0 +1,72 @@ +use owo_colors::OwoColorize; + +pub fn enabled() -> bool { + // CLICOLOR_FORCE=1 always enables (matches clap/anstream behaviour) + if std::env::var("CLICOLOR_FORCE").as_deref() == Ok("1") { + return true; + } + // NO_COLOR or CLICOLOR=0 disables + if std::env::var("NO_COLOR").is_ok() || std::env::var("CLICOLOR").as_deref() == Ok("0") { + return false; + } + std::env::var("TERM").as_deref() != Ok("dumb") +} + +pub fn ok(text: &str) -> String { + if enabled() { + text.green().to_string() + } else { + text.to_string() + } +} + +pub fn warn(text: &str) -> String { + if enabled() { + text.yellow().to_string() + } else { + text.to_string() + } +} + +pub fn fail(text: &str) -> String { + if enabled() { + text.red().to_string() + } else { + text.to_string() + } +} + +pub fn bold(text: &str) -> String { + if enabled() { + text.bold().to_string() + } else { + text.to_string() + } +} + +pub fn accent(text: &str) -> String { + if enabled() { + text.cyan().to_string() + } else { + text.to_string() + } +} + +pub fn dim(text: &str) -> String { + if enabled() { + text.dimmed().to_string() + } else { + text.to_string() + } +} + +pub fn on_off(value: bool) -> String { + if value { ok("on") } else { dim("off") } +} + +pub fn option_on_off(value: Option) -> String { + match value { + Some(v) => on_off(v), + None => dim("unset").to_string(), + } +} diff --git a/src/completion.rs b/src/completion.rs new file mode 100644 index 0000000..b45752b --- /dev/null +++ b/src/completion.rs @@ -0,0 +1,449 @@ +use std::borrow::Cow; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use clap_complete::engine::CompletionCandidate; +use clap_complete::env::Shells; +use clap_complete::{CompleteEnv, Shell}; +use directories::BaseDirs; + +use crate::config::{self, AppPaths}; +use crate::error::{AppError, config_error, internal_error, io_to_internal}; + +const COMPLETE_ENV_VAR: &str = "GAMEWRAP_COMPLETE"; +const PROGRAM_NAME: &str = "gamewrap"; +const START_MARKER: &str = "# >>> gamewrap completion >>>"; +const END_MARKER: &str = "# <<< gamewrap completion <<<"; + +pub struct InstallOutcome { + pub message: String, +} + +pub fn complete_env() { + CompleteEnv::with_factory(crate::cli::command) + .var(COMPLETE_ENV_VAR) + .bin(PROGRAM_NAME) + .completer(PROGRAM_NAME) + .complete(); +} + +pub fn render(shell: Shell) -> Result { + let mut buf = Vec::new(); + let shell_name = shell.to_string(); + let shells = Shells::builtins(); + let completer = shells + .completer(&shell_name) + .ok_or_else(|| internal_error(format!("unsupported completion shell `{shell_name}`")))?; + completer + .write_registration( + COMPLETE_ENV_VAR, + PROGRAM_NAME, + PROGRAM_NAME, + PROGRAM_NAME, + &mut buf, + ) + .map_err(|error| { + internal_error(format!("failed to render {shell_name} completion: {error}")) + })?; + + String::from_utf8(buf) + .map_err(|error| internal_error(format!("completion script was not valid UTF-8: {error}"))) +} + +pub fn install(shell: Shell, paths: &AppPaths) -> Result { + let script = render(shell)?; + let shell_files = shell_files(shell, paths)?; + write_file(&shell_files.script_path, &script)?; + + let mut message = format!( + "Installed {} completion to `{}`.", + shell.to_string(), + shell_files.script_path.display() + ); + + if let Some(startup_path) = &shell_files.startup_path { + let block = startup_block(shell, &shell_files.script_path); + upsert_startup_block(startup_path, &block)?; + message.push_str(&format!( + "\nUpdated `{}` so new shells load it automatically.", + startup_path.display() + )); + } else { + message.push_str("\nOpen a new shell after installation."); + } + + message.push_str( + "\nNew profiles and observed games show up automatically. Reinstall only if you want to move the completion file or if the shell setup itself changes.", + ); + + if shell == Shell::Elvish { + message.push_str( + "\nElvish install is manual for now. Add `-source ~/.config/gamewrap/completions/gamewrap.elv` to your `rc.elv` if needed.", + ); + } + + Ok(InstallOutcome { message }) +} + +pub fn path(shell: Shell, paths: &AppPaths) -> Result { + let shell_files = shell_files(shell, paths)?; + let mut output = format!( + "Completion script path: {}\n", + shell_files.script_path.display() + ); + if let Some(startup_path) = shell_files.startup_path { + output.push_str(&format!("Startup file: {}\n", startup_path.display())); + } else if shell == Shell::Fish { + output.push_str("Fish loads this path automatically in new shells.\n"); + } else if shell == Shell::Elvish { + output.push_str("Elvish does not have automatic install wiring here yet.\n"); + } + output.push_str( + "The installed completion script queries gamewrap live, so new profiles and observed games appear automatically.\n", + ); + Ok(output) +} + +pub fn setting_name_candidates() -> Vec { + [ + ("overlay", "Turn MangoHud on or off"), + ("performance", "Turn GameMode on or off"), + ( + "steam-host-libs", + "Prefer host libraries inside the Steam runtime", + ), + ("game-libs", "Control host library path injection for games"), + ("verbose", "Show extra diagnostic detail"), + ("gamescope", "Wrap launches in the gamescope compositor"), + ("gamescope-width", "Set the gamescope target width"), + ("gamescope-height", "Set the gamescope target height"), + ("gamescope-fps", "Set the gamescope target FPS"), + ("fps-cap", "Cap frame rate when MangoHud overlay is enabled"), + ("vkbasalt", "Enable the vkBasalt post-processing layer"), + ("esync", "Force Proton esync on or off"), + ("fsync", "Force Proton fsync on or off"), + ( + "large-address-aware", + "Set PROTON_LARGE_ADDRESS_AWARE for Proton", + ), + ("laa", "Alias for large-address-aware"), + ( + "pre-launch", + "Run a shell command before launching the game", + ), + ( + "post-launch", + "Store a shell command for a future wrapped post-game hook", + ), + ("host-libs", "Alias for steam-host-libs"), + ] + .into_iter() + .map(|(value, help)| CompletionCandidate::new(value).help(Some(help.into()))) + .collect() +} + +pub fn setting_value_candidates() -> Vec { + [ + ("on", "Enable the setting"), + ("off", "Disable the setting"), + ("auto", "Use automatic behavior"), + ("keep", "Keep the current value unchanged"), + ("gamemode", "Always add GameMode library paths"), + ] + .into_iter() + .map(|(value, help)| CompletionCandidate::new(value).help(Some(help.into()))) + .collect() +} + +pub fn named_profile_candidates() -> Vec { + load_named_profiles() + .into_iter() + .map(|name| CompletionCandidate::new(name)) + .collect() +} + +pub fn profile_show_candidates() -> Vec { + let mut names = BTreeSet::from(["default".to_string()]); + names.extend(load_named_profiles()); + names.into_iter().map(CompletionCandidate::new).collect() +} + +pub fn observed_game_candidates() -> Vec { + let (config, state) = load_config_and_state(); + let binding_matchers: Vec = config + .map(|config| { + config + .bindings + .into_iter() + .map(|binding| binding.matcher) + .collect() + }) + .unwrap_or_default(); + + let observed = state + .map(|state| { + state + .games + .into_iter() + .map(|game| { + CompletionCandidate::new(game.executable).help(Some(game.command_path.into())) + }) + .collect::>() + }) + .unwrap_or_default(); + + let mut seen = BTreeSet::new(); + let mut candidates = Vec::new(); + + for candidate in observed { + let value = candidate.get_value().to_string_lossy().into_owned(); + if seen.insert(value) { + candidates.push(candidate); + } + } + + for matcher in binding_matchers { + if matcher.contains('/') { + continue; + } + if seen.insert(matcher.clone()) { + candidates.push( + CompletionCandidate::new(matcher).help(Some("Existing binding matcher".into())), + ); + } + } + + candidates +} + +fn load_named_profiles() -> BTreeSet { + let Ok(paths) = AppPaths::discover() else { + return BTreeSet::new(); + }; + let Ok(config) = config::load(&paths) else { + return BTreeSet::new(); + }; + config.profiles.into_keys().collect() +} + +fn load_config_and_state() -> (Option, Option) { + let Ok(paths) = AppPaths::discover() else { + return (None, None); + }; + (config::load(&paths).ok(), config::load_state(&paths).ok()) +} + +struct ShellFiles { + script_path: PathBuf, + startup_path: Option, +} + +fn shell_files(shell: Shell, paths: &AppPaths) -> Result { + let base_dirs = BaseDirs::new().ok_or_else(|| { + config_error( + "Could not find your home directory.", + "Check your HOME environment and try again.", + ) + })?; + let home = base_dirs.home_dir(); + + let result = match shell { + Shell::Bash => ShellFiles { + script_path: paths.config_dir.join("completions").join("gamewrap.bash"), + startup_path: Some(home.join(".bashrc")), + }, + Shell::Zsh => ShellFiles { + script_path: paths.config_dir.join("completions").join("gamewrap.zsh"), + startup_path: Some(home.join(".zshrc")), + }, + Shell::Fish => ShellFiles { + script_path: base_dirs + .config_dir() + .join("fish") + .join("completions") + .join("gamewrap.fish"), + startup_path: None, + }, + Shell::PowerShell => ShellFiles { + script_path: paths.config_dir.join("completions").join("gamewrap.ps1"), + startup_path: Some( + base_dirs + .config_dir() + .join("powershell") + .join("Microsoft.PowerShell_profile.ps1"), + ), + }, + Shell::Elvish => ShellFiles { + script_path: paths.config_dir.join("completions").join("gamewrap.elv"), + startup_path: None, + }, + _ => { + return Err(config_error( + format!("`{}` install is not supported yet.", shell), + "Use `gamewrap completion ` to print the script manually instead.", + )); + } + }; + + Ok(result) +} + +fn startup_block(shell: Shell, script_path: &Path) -> String { + let path = shell_path_literal(shell, script_path); + match shell { + Shell::Bash => format!("{START_MARKER}\n[ -f {path} ] && source {path}\n{END_MARKER}\n"), + Shell::Zsh => format!("{START_MARKER}\n[[ -f {path} ]] && source {path}\n{END_MARKER}\n"), + Shell::PowerShell => { + format!("{START_MARKER}\nif (Test-Path {path}) {{ . {path} }}\n{END_MARKER}\n") + } + _ => String::new(), + } +} + +fn shell_path_literal(shell: Shell, path: &Path) -> String { + match shell { + Shell::PowerShell => format!("'{}'", escape_single_quotes(path)), + _ => format!("'{}'", escape_single_quotes(path)), + } +} + +fn escape_single_quotes(path: &Path) -> Cow<'_, str> { + let text = path.to_string_lossy(); + if text.contains('\'') { + Cow::Owned(text.replace('\'', "'\\''")) + } else { + text + } +} + +fn write_file(path: &Path, content: &str) -> Result<(), AppError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + io_to_internal("Failed creating completion directory", Some(parent), error) + })?; + } + fs::write(path, content) + .map_err(|error| io_to_internal("Failed writing completion file", Some(path), error)) +} + +fn upsert_startup_block(path: &Path, block: &str) -> Result<(), AppError> { + let existing = if path.exists() { + fs::read_to_string(path).map_err(|error| { + io_to_internal("Failed reading shell startup file", Some(path), error) + })? + } else { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + io_to_internal( + "Failed creating shell startup directory", + Some(parent), + error, + ) + })?; + } + String::new() + }; + + let updated = replace_managed_block(&existing, block); + fs::write(path, updated) + .map_err(|error| io_to_internal("Failed writing shell startup file", Some(path), error)) +} + +fn replace_managed_block(existing: &str, block: &str) -> String { + if let (Some(start), Some(end)) = (existing.find(START_MARKER), existing.find(END_MARKER)) { + let mut updated = String::new(); + updated.push_str(existing[..start].trim_end()); + if !updated.is_empty() { + updated.push_str("\n\n"); + } + updated.push_str(block.trim_end()); + updated.push('\n'); + let tail = &existing[end + END_MARKER.len()..]; + if !tail.trim().is_empty() { + updated.push('\n'); + updated.push_str(tail.trim_start()); + } + updated + } else if existing.trim().is_empty() { + format!("{}\n", block.trim_end()) + } else { + format!("{}\n\n{}\n", existing.trim_end(), block.trim_end()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Binding, ConfigFile, ObservedGame, ProfileConfig, Settings, StateFile}; + + #[test] + fn replace_managed_block_is_idempotent() { + let first = replace_managed_block("", &format!("{START_MARKER}\nblock\n{END_MARKER}\n")); + let second = + replace_managed_block(&first, &format!("{START_MARKER}\nblock2\n{END_MARKER}\n")); + assert!(second.contains("block2")); + assert_eq!(second.matches(START_MARKER).count(), 1); + assert_eq!(second.matches(END_MARKER).count(), 1); + } + + #[test] + fn observed_game_candidates_include_paths_and_binding_matchers() { + let root = tempfile::tempdir().expect("temp dir"); + let config_home = root.path().join("config-home"); + let state_home = root.path().join("state-home"); + std::fs::create_dir_all(&config_home).expect("config home"); + std::fs::create_dir_all(&state_home).expect("state home"); + let config = ConfigFile { + defaults: Settings::default(), + profiles: std::collections::BTreeMap::from([( + "benchmark".to_string(), + ProfileConfig::default(), + )]), + bindings: vec![Binding { + matcher: "eldenring.exe".to_string(), + profile: "benchmark".to_string(), + }], + }; + let state = StateFile { + games: vec![ObservedGame { + executable: "GrindSurvivors.exe".to_string(), + command_path: "/games/Grind Survivors/GrindSurvivors.exe".to_string(), + last_profile: "default".to_string(), + note: None, + display_name: None, + launch_count: 0, + last_launched_at: None, + total_play_seconds: None, + }], + }; + let prev_config = std::env::var_os("XDG_CONFIG_HOME"); + let prev_state = std::env::var_os("XDG_STATE_HOME"); + unsafe { + std::env::set_var("XDG_CONFIG_HOME", &config_home); + std::env::set_var("XDG_STATE_HOME", &state_home); + } + let paths = AppPaths::discover().expect("discover test paths"); + crate::config::save(&paths, &config).expect("save config"); + crate::config::save_state(&paths, &state).expect("save state"); + let candidates = observed_game_candidates(); + unsafe { + match prev_config { + Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + match prev_state { + Some(value) => std::env::set_var("XDG_STATE_HOME", value), + None => std::env::remove_var("XDG_STATE_HOME"), + } + } + + let values = candidates + .iter() + .map(|candidate| candidate.get_value().to_string_lossy().into_owned()) + .collect::>(); + assert!(values.contains(&"eldenring.exe".to_string())); + assert!(values.contains(&"GrindSurvivors.exe".to_string())); + assert!(!values.contains(&"/games/Grind Survivors/GrindSurvivors.exe".to_string())); + } +} diff --git a/src/config/keys.rs b/src/config/keys.rs new file mode 100644 index 0000000..2062b54 --- /dev/null +++ b/src/config/keys.rs @@ -0,0 +1,147 @@ +use crate::config::{GameLibsMode, Settings}; +use crate::error::{AppError, config_error}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingKey { + Overlay, + Performance, + SteamHostLibs, + GameLibs, + Verbose, + Gamescope, + GamescopeWidth, + GamescopeHeight, + GamescopeFps, + FpsCap, + Vkbasalt, + Esync, + Fsync, + LargeAddressAware, + PreLaunch, + PostLaunch, +} + +impl SettingKey { + pub fn parse(value: &str) -> Result { + match value { + "overlay" => Ok(Self::Overlay), + "performance" => Ok(Self::Performance), + "steam-host-libs" | "host-libs" => Ok(Self::SteamHostLibs), + "game-libs" => Ok(Self::GameLibs), + "verbose" => Ok(Self::Verbose), + "gamescope" => Ok(Self::Gamescope), + "gamescope-width" => Ok(Self::GamescopeWidth), + "gamescope-height" => Ok(Self::GamescopeHeight), + "gamescope-fps" => Ok(Self::GamescopeFps), + "fps-cap" => Ok(Self::FpsCap), + "vkbasalt" => Ok(Self::Vkbasalt), + "esync" => Ok(Self::Esync), + "fsync" => Ok(Self::Fsync), + "large-address-aware" | "laa" => Ok(Self::LargeAddressAware), + "pre-launch" => Ok(Self::PreLaunch), + "post-launch" => Ok(Self::PostLaunch), + _ => Err(config_error( + format!("`{value}` is not a known setting."), + "Valid settings: overlay, performance, steam-host-libs, game-libs, verbose, gamescope, gamescope-width, gamescope-height, gamescope-fps, fps-cap, vkbasalt, esync, fsync, large-address-aware, pre-launch, post-launch.", + )), + } + } +} + +pub fn set_value(settings: &mut Settings, key: SettingKey, value: &str) -> Result<(), AppError> { + match key { + SettingKey::Overlay => settings.overlay = Some(parse_toggle(value)?), + SettingKey::Performance => settings.performance = Some(parse_toggle(value)?), + SettingKey::SteamHostLibs => settings.steam_host_libs = Some(parse_toggle(value)?), + SettingKey::GameLibs => settings.game_libs = Some(parse_game_libs(value)?), + SettingKey::Verbose => settings.verbose = Some(parse_toggle(value)?), + SettingKey::Gamescope => settings.gamescope = Some(parse_toggle(value)?), + SettingKey::GamescopeWidth => { + settings.gamescope_width = Some(parse_pixel_count(value)?); + } + SettingKey::GamescopeHeight => { + settings.gamescope_height = Some(parse_pixel_count(value)?); + } + SettingKey::GamescopeFps => { + settings.gamescope_fps = Some(parse_gamescope_fps(value)?); + } + SettingKey::FpsCap => { + let n: u32 = value.parse().map_err(|_| { + config_error( + format!("`{value}` is not a valid FPS cap. Use a number like `60` or `120`."), + "Use `gamewrap config set fps-cap 60` for a 60 FPS cap.", + ) + })?; + settings.fps_cap = Some(n); + } + SettingKey::Vkbasalt => settings.vkbasalt = Some(parse_toggle(value)?), + SettingKey::Esync => settings.esync = Some(parse_toggle(value)?), + SettingKey::Fsync => settings.fsync = Some(parse_toggle(value)?), + SettingKey::LargeAddressAware => settings.large_address_aware = Some(parse_toggle(value)?), + SettingKey::PreLaunch => settings.pre_launch = Some(value.to_string()), + SettingKey::PostLaunch => settings.post_launch = Some(value.to_string()), + } + Ok(()) +} + +pub fn reset_value(settings: &mut Settings, key: SettingKey) { + match key { + SettingKey::Overlay => settings.overlay = None, + SettingKey::Performance => settings.performance = None, + SettingKey::SteamHostLibs => settings.steam_host_libs = None, + SettingKey::GameLibs => settings.game_libs = None, + SettingKey::Verbose => settings.verbose = None, + SettingKey::Gamescope => settings.gamescope = None, + SettingKey::GamescopeWidth => settings.gamescope_width = None, + SettingKey::GamescopeHeight => settings.gamescope_height = None, + SettingKey::GamescopeFps => settings.gamescope_fps = None, + SettingKey::FpsCap => settings.fps_cap = None, + SettingKey::Vkbasalt => settings.vkbasalt = None, + SettingKey::Esync => settings.esync = None, + SettingKey::Fsync => settings.fsync = None, + SettingKey::LargeAddressAware => settings.large_address_aware = None, + SettingKey::PreLaunch => settings.pre_launch = None, + SettingKey::PostLaunch => settings.post_launch = None, + } +} + +fn parse_toggle(value: &str) -> Result { + match value { + "on" => Ok(true), + "off" => Ok(false), + _ => Err(config_error( + format!("`{value}` is not a valid on/off value."), + "Use `on` or `off`.", + )), + } +} + +fn parse_game_libs(value: &str) -> Result { + match value { + "auto" => Ok(GameLibsMode::Auto), + "keep" => Ok(GameLibsMode::Keep), + "gamemode" => Ok(GameLibsMode::Gamemode), + _ => Err(config_error( + format!("`{value}` is not a valid value for game-libs."), + "Use one of: auto, keep, gamemode.", + )), + } +} + +fn parse_pixel_count(value: &str) -> Result { + value.parse::().map_err(|_| { + config_error( + format!("`{value}` is not a valid pixel count. Use a number like `1920`."), + "Use a whole-number pixel count for gamescope resolution settings.", + ) + }) +} + +fn parse_gamescope_fps(value: &str) -> Result { + value.parse::().map_err(|_| { + config_error( + format!("`{value}` is not a valid FPS. Use a number like `60`."), + "Use `gamewrap config set gamescope-fps 60` for a 60 FPS gamescope target.", + ) + }) +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..971e83e --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,524 @@ +pub mod keys; +pub mod schema; + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use directories::ProjectDirs; + +use crate::detect; +use crate::error::{AppError, config_error, internal_error, io_to_internal}; + +pub use schema::{ + Binding, ConfigFile, GameLibsMode, ObservedGame, ProfileConfig, ResolvedSettings, Settings, + StateFile, +}; + +#[derive(Debug, Clone)] +pub struct AppPaths { + pub config_dir: PathBuf, + pub config_file: PathBuf, + pub state_dir: PathBuf, + pub state_file: PathBuf, +} + +impl AppPaths { + pub fn discover() -> Result { + let project_dirs = ProjectDirs::from("", "", "gamewrap").ok_or_else(|| { + config_error( + "Could not find a config directory for gamewrap.", + "Check your XDG environment and try again.", + ) + })?; + + let config_dir = project_dirs.config_dir().to_path_buf(); + let state_dir = project_dirs + .state_dir() + .map(|path| path.to_path_buf()) + .unwrap_or_else(|| project_dirs.data_local_dir().to_path_buf()); + + Ok(Self { + config_file: config_dir.join("config.toml"), + state_file: state_dir.join("state.toml"), + config_dir, + state_dir, + }) + } +} + +pub fn load(paths: &AppPaths) -> Result { + if !paths.config_file.exists() { + return Ok(ConfigFile::default()); + } + + let content = fs::read_to_string(&paths.config_file).map_err(|error| { + io_to_internal( + "Failed reading config file", + Some(&paths.config_file), + error, + ) + })?; + + let config = toml::from_str(&content).map_err(|error| { + config_error( + format!("Config file {} is invalid: {error}", paths.config_file.display()), + "Run `gamewrap config show` after fixing the file, or delete it and let gamewrap recreate it.", + ) + })?; + crate::profile::validate_config(&config)?; + Ok(config) +} + +pub fn save(paths: &AppPaths, config: &ConfigFile) -> Result<(), AppError> { + crate::profile::validate_config(config)?; + ensure_dir(&paths.config_dir)?; + let content = toml::to_string_pretty(config) + .map_err(|error| internal_error(format!("failed to serialize config: {error}")))?; + fs::write(&paths.config_file, content).map_err(|error| { + io_to_internal( + "Failed writing config file", + Some(&paths.config_file), + error, + ) + }) +} + +pub fn load_state(paths: &AppPaths) -> Result { + if !paths.state_file.exists() { + return Ok(StateFile::default()); + } + + let content = fs::read_to_string(&paths.state_file).map_err(|error| { + io_to_internal("Failed reading state file", Some(&paths.state_file), error) + })?; + + let mut state: StateFile = toml::from_str(&content).map_err(|error| { + config_error( + format!("State file {} is invalid: {error}", paths.state_file.display()), + "Delete the state file if you do not need launch history, or fix the invalid field and try again.", + ) + })?; + let original_len = state.games.len(); + detect::sanitize_state(&mut state); + if state.games.len() != original_len { + save_state(paths, &state)?; + } + Ok(state) +} + +pub fn save_state(paths: &AppPaths, state: &StateFile) -> Result<(), AppError> { + ensure_dir(&paths.state_dir)?; + let content = toml::to_string_pretty(state) + .map_err(|error| internal_error(format!("failed to serialize state: {error}")))?; + fs::write(&paths.state_file, content).map_err(|error| { + io_to_internal("Failed writing state file", Some(&paths.state_file), error) + }) +} + +fn ensure_dir(path: &Path) -> Result<(), AppError> { + fs::create_dir_all(path) + .map_err(|error| internal_error(format!("failed creating {}: {error}", path.display()))) +} + +pub fn render_config(config: &ConfigFile) -> String { + let mut output = String::new(); + let mut resolved_defaults = ResolvedSettings::default(); + resolved_defaults.apply(&config.defaults); + + output.push_str(&format!("{}\n", crate::color::bold("[defaults]"))); + output.push_str(&render_resolved_settings(&resolved_defaults)); + + output.push_str(&format!("\n{}\n", crate::color::bold("[profiles]"))); + if config.profiles.is_empty() { + output.push_str(&format!(" {}\n", crate::color::dim("(none)"))); + } else { + for (name, profile) in &config.profiles { + output.push_str(&format!( + " {}{}\n", + crate::color::accent(name), + crate::color::dim(&inherits_suffix(&profile.inherits)) + )); + let inherited = crate::profile::resolve_named(config, name) + .map(|resolved| resolved.settings) + .unwrap_or_else(|_| resolved_defaults.clone()); + output.push_str(&render_profile_settings_with_indent( + &profile.settings, + &inherited, + " ", + )); + } + } + + output.push_str(&format!("\n{}\n", crate::color::bold("[bindings]"))); + if config.bindings.is_empty() { + output.push_str(&format!(" {}\n", crate::color::dim("(none)"))); + } else { + for binding in &config.bindings { + output.push_str(&format!( + " {} {} {}\n", + binding.matcher, + crate::color::dim("->"), + crate::color::accent(&binding.profile) + )); + } + } + + output +} + +pub fn render_profile(name: &str, profile: &ProfileConfig, inherited: &ResolvedSettings) -> String { + let mut output = String::new(); + output.push_str(&crate::color::accent(name)); + output.push('\n'); + if let Some(parent) = &profile.inherits { + output.push_str(&format!( + "{} {}\n", + crate::color::dim("inherits ="), + crate::color::accent(parent) + )); + } + output.push_str(&render_profile_settings_with_indent( + &profile.settings, + inherited, + "", + )); + output +} + +fn render_profile_settings_with_indent( + settings: &Settings, + inherited: &ResolvedSettings, + indent: &str, +) -> String { + let mut fields = BTreeMap::new(); + fields.insert( + "overlay", + settings + .overlay + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + fields.insert( + "performance", + settings + .performance + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + fields.insert( + "steam-host-libs", + settings + .steam_host_libs + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + fields.insert( + "game-libs", + settings.game_libs.map(|value| value.as_str().to_string()), + ); + fields.insert( + "verbose", + settings + .verbose + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + fields.insert( + "gamescope", + settings + .gamescope + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + if settings.gamescope_width.is_some() || inherited.gamescope_width.is_some() { + fields.insert( + "gamescope-width", + settings.gamescope_width.map(|value| value.to_string()), + ); + } + if settings.gamescope_height.is_some() || inherited.gamescope_height.is_some() { + fields.insert( + "gamescope-height", + settings.gamescope_height.map(|value| value.to_string()), + ); + } + if settings.gamescope_fps.is_some() || inherited.gamescope_fps.is_some() { + fields.insert( + "gamescope-fps", + settings.gamescope_fps.map(|value| value.to_string()), + ); + } + if settings.fps_cap.is_some() || inherited.fps_cap.is_some() { + fields.insert("fps-cap", settings.fps_cap.map(|value| value.to_string())); + } + fields.insert( + "vkbasalt", + settings + .vkbasalt + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + if settings.esync.is_some() || inherited.esync.is_some() { + fields.insert( + "esync", + settings + .esync + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + } + if settings.fsync.is_some() || inherited.fsync.is_some() { + fields.insert( + "fsync", + settings + .fsync + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + } + fields.insert( + "large-address-aware", + settings + .large_address_aware + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + if settings.pre_launch.is_some() || inherited.pre_launch.is_some() { + fields.insert("pre-launch", settings.pre_launch.clone()); + } + if settings.post_launch.is_some() || inherited.post_launch.is_some() { + fields.insert("post-launch", settings.post_launch.clone()); + } + + let mut output = String::new(); + for (name, value) in fields { + match value { + Some(ref value) => output.push_str(&format!( + "{}{} = {}\n", + indent, + crate::color::dim(name), + match value.as_str() { + "on" => crate::color::ok("on"), + "off" => crate::color::dim("off"), + _ => crate::color::accent(value), + } + )), + None => output.push_str(&format!( + "{}{} = {}\n", + indent, + crate::color::dim(name), + crate::color::dim(&format!("(inherits: {})", inherited_value(name, inherited))) + )), + } + } + if settings.env_vars.is_some() || !inherited.env_vars.is_empty() { + output.push_str(&format!("{indent}{}\n", crate::color::dim("env-vars:"))); + match &settings.env_vars { + Some(vars) if !vars.is_empty() => { + for (key, value) in vars { + output.push_str(&format!( + "{} {} = {}\n", + indent, + crate::color::accent(key), + value + )); + } + } + _ if inherited.env_vars.is_empty() => { + output.push_str(&format!("{} {}\n", indent, crate::color::dim("(none)"))); + } + _ => { + for (key, value) in &inherited.env_vars { + output.push_str(&format!( + "{} {} = {} {}\n", + indent, + crate::color::accent(key), + value, + crate::color::dim("(inherited)") + )); + } + } + } + } + output +} + +fn render_resolved_settings(settings: &ResolvedSettings) -> String { + let mut output = String::new(); + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("overlay"), + crate::color::on_off(settings.overlay) + )); + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("performance"), + crate::color::on_off(settings.performance) + )); + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("steam-host-libs"), + crate::color::on_off(settings.steam_host_libs) + )); + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("game-libs"), + crate::color::accent(settings.game_libs.as_str()) + )); + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("verbose"), + crate::color::on_off(settings.verbose) + )); + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope"), + crate::color::on_off(settings.gamescope) + )); + if let Some(width) = settings.gamescope_width { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-width"), + crate::color::accent(&width.to_string()) + )); + } + if let Some(height) = settings.gamescope_height { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-height"), + crate::color::accent(&height.to_string()) + )); + } + if let Some(fps) = settings.gamescope_fps { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-fps"), + crate::color::accent(&fps.to_string()) + )); + } + if let Some(fps_cap) = settings.fps_cap { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("fps-cap"), + crate::color::accent(&fps_cap.to_string()) + )); + } + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("vkbasalt"), + crate::color::on_off(settings.vkbasalt) + )); + if let Some(esync) = settings.esync { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("esync"), + crate::color::on_off(esync) + )); + } + if let Some(fsync) = settings.fsync { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("fsync"), + crate::color::on_off(fsync) + )); + } + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("large-address-aware"), + crate::color::on_off(settings.large_address_aware) + )); + if let Some(pre_launch) = &settings.pre_launch { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("pre-launch"), + crate::color::accent(pre_launch) + )); + } + if let Some(post_launch) = &settings.post_launch { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("post-launch"), + crate::color::accent(post_launch) + )); + } + if !settings.env_vars.is_empty() { + output.push_str(&format!("{}\n", crate::color::dim("env-vars:"))); + for (key, value) in &settings.env_vars { + output.push_str(&format!(" {} = {}\n", crate::color::accent(key), value)); + } + } + output +} + +fn inherited_value(name: &str, inherited: &ResolvedSettings) -> String { + match name { + "overlay" => crate::color::on_off(inherited.overlay), + "performance" => crate::color::on_off(inherited.performance), + "steam-host-libs" => crate::color::on_off(inherited.steam_host_libs), + "game-libs" => inherited.game_libs.as_str().to_string(), + "verbose" => crate::color::on_off(inherited.verbose), + "gamescope" => crate::color::on_off(inherited.gamescope), + "gamescope-width" => inherited + .gamescope_width + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()), + "gamescope-height" => inherited + .gamescope_height + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()), + "gamescope-fps" => inherited + .gamescope_fps + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()), + "fps-cap" => inherited + .fps_cap + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()), + "vkbasalt" => crate::color::on_off(inherited.vkbasalt), + "esync" => crate::color::option_on_off(inherited.esync), + "fsync" => crate::color::option_on_off(inherited.fsync), + "large-address-aware" => crate::color::on_off(inherited.large_address_aware), + "pre-launch" => inherited + .pre_launch + .clone() + .unwrap_or_else(|| "none".to_string()), + "post-launch" => inherited + .post_launch + .clone() + .unwrap_or_else(|| "none".to_string()), + _ => "unknown".to_string(), + } +} + +fn inherits_suffix(parent: &Option) -> String { + parent + .as_ref() + .map(|parent| format!(" (inherits: {parent})")) + .unwrap_or_default() +} + +#[cfg(test)] +pub fn test_paths(root: &Path) -> AppPaths { + AppPaths { + config_dir: root.join("config"), + config_file: root.join("config/config.toml"), + state_dir: root.join("state"), + state_file: root.join("state/state.toml"), + } +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn save_and_load_round_trip() { + let temp = tempdir().expect("temp dir"); + let paths = test_paths(temp.path()); + let mut config = ConfigFile::default(); + config.defaults.overlay = Some(true); + config + .profiles + .insert("benchmark".to_string(), ProfileConfig::default()); + + save(&paths, &config).expect("save"); + let loaded = load(&paths).expect("load"); + + assert_eq!(loaded.defaults.overlay, Some(true)); + assert!(loaded.profiles.contains_key("benchmark")); + } +} diff --git a/src/config/schema.rs b/src/config/schema.rs new file mode 100644 index 0000000..e81ec13 --- /dev/null +++ b/src/config/schema.rs @@ -0,0 +1,210 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum GameLibsMode { + #[default] + Auto, + Keep, + Gamemode, +} + +impl GameLibsMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Keep => "keep", + Self::Gamemode => "gamemode", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct Settings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overlay: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub performance: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub steam_host_libs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub game_libs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verbose: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_height: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_fps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fps_cap: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vkbasalt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub esync: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fsync: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub large_address_aware: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pre_launch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub post_launch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env_vars: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ResolvedSettings { + pub overlay: bool, + pub performance: bool, + pub steam_host_libs: bool, + pub game_libs: GameLibsMode, + pub verbose: bool, + pub gamescope: bool, + pub gamescope_width: Option, + pub gamescope_height: Option, + pub gamescope_fps: Option, + pub fps_cap: Option, + pub vkbasalt: bool, + pub esync: Option, + pub fsync: Option, + pub large_address_aware: bool, + pub pre_launch: Option, + pub post_launch: Option, + pub env_vars: BTreeMap, +} + +impl Default for ResolvedSettings { + fn default() -> Self { + Self { + overlay: true, + performance: true, + steam_host_libs: true, + game_libs: GameLibsMode::Auto, + verbose: false, + gamescope: false, + gamescope_width: None, + gamescope_height: None, + gamescope_fps: None, + fps_cap: None, + vkbasalt: false, + esync: None, + fsync: None, + large_address_aware: false, + pre_launch: None, + post_launch: None, + env_vars: BTreeMap::new(), + } + } +} + +impl ResolvedSettings { + pub fn apply(&mut self, settings: &Settings) { + if let Some(value) = settings.overlay { + self.overlay = value; + } + if let Some(value) = settings.performance { + self.performance = value; + } + if let Some(value) = settings.steam_host_libs { + self.steam_host_libs = value; + } + if let Some(value) = settings.game_libs { + self.game_libs = value; + } + if let Some(value) = settings.verbose { + self.verbose = value; + } + if let Some(value) = settings.gamescope { + self.gamescope = value; + } + if let Some(value) = settings.gamescope_width { + self.gamescope_width = Some(value); + } + if let Some(value) = settings.gamescope_height { + self.gamescope_height = Some(value); + } + if let Some(value) = settings.gamescope_fps { + self.gamescope_fps = Some(value); + } + if let Some(value) = settings.fps_cap { + self.fps_cap = Some(value); + } + if let Some(value) = settings.vkbasalt { + self.vkbasalt = value; + } + if let Some(value) = settings.esync { + self.esync = Some(value); + } + if let Some(value) = settings.fsync { + self.fsync = Some(value); + } + if let Some(value) = settings.large_address_aware { + self.large_address_aware = value; + } + if let Some(ref value) = settings.pre_launch { + self.pre_launch = Some(value.clone()); + } + if let Some(ref value) = settings.post_launch { + self.post_launch = Some(value.clone()); + } + if let Some(ref vars) = settings.env_vars { + for (key, value) in vars { + self.env_vars.insert(key.clone(), value.clone()); + } + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Binding { + pub matcher: String, + pub profile: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct ProfileConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inherits: Option, + #[serde(flatten)] + pub settings: Settings, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ConfigFile { + #[serde(default)] + pub defaults: Settings, + #[serde(default)] + pub profiles: BTreeMap, + #[serde(default)] + pub bindings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObservedGame { + pub executable: String, + pub command_path: String, + pub last_profile: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(default)] + pub launch_count: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_launched_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_play_seconds: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct StateFile { + #[serde(default)] + pub games: Vec, +} diff --git a/src/detect.rs b/src/detect.rs new file mode 100644 index 0000000..60d2bad --- /dev/null +++ b/src/detect.rs @@ -0,0 +1,256 @@ +use std::ffi::OsString; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::config::{ObservedGame, StateFile}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExecutableInfo { + pub basename: String, + pub command_path: String, +} + +pub fn inspect_command(command: &[OsString]) -> ExecutableInfo { + let command_path = detect_target_command(command).unwrap_or_else(|| { + command + .first() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_default() + }); + + let basename = Path::new(&command_path) + .file_name() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| command_path.clone()); + + ExecutableInfo { + basename, + command_path, + } +} + +pub fn record_launch(state: &mut StateFile, executable: &ExecutableInfo, profile: &str) { + if !is_recordable(executable) { + return; + } + + if let Some(existing) = state + .games + .iter_mut() + .find(|game| game.executable == executable.basename) + { + existing.command_path = executable.command_path.clone(); + existing.last_profile = profile.to_string(); + existing.launch_count += 1; + existing.last_launched_at = Some(now_rfc3339()); + return; + } + + state.games.push(ObservedGame { + executable: executable.basename.clone(), + command_path: executable.command_path.clone(), + last_profile: profile.to_string(), + note: None, + display_name: None, + launch_count: 1, + last_launched_at: Some(now_rfc3339()), + total_play_seconds: None, + }); + state + .games + .sort_by(|left, right| left.executable.cmp(&right.executable)); +} + +pub fn record_play_time(state: &mut StateFile, executable: &ExecutableInfo, elapsed_secs: u64) { + if let Some(existing) = state + .games + .iter_mut() + .find(|game| game.executable == executable.basename) + { + *existing.total_play_seconds.get_or_insert(0) += elapsed_secs; + } +} + +fn now_rfc3339() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + let days = (secs / 86_400) as i64; + let seconds_in_day = secs % 86_400; + let hour = seconds_in_day / 3_600; + let min = (seconds_in_day % 3_600) / 60; + let sec = seconds_in_day % 60; + let (year, month, day) = civil_from_days(days); + + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z") +} + +fn civil_from_days(days_since_epoch: i64) -> (i64, u32, u32) { + let days = days_since_epoch + 719_468; + let era = if days >= 0 { days } else { days - 146_096 } / 146_097; + let day_of_era = days - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += if month <= 2 { 1 } else { 0 }; + + (year, month as u32, day as u32) +} + +pub fn sanitize_state(state: &mut StateFile) { + state.games.retain(|game| { + let executable = ExecutableInfo { + basename: game.executable.clone(), + command_path: game.command_path.clone(), + }; + is_recordable(&executable) + }); +} + +fn is_recordable(executable: &ExecutableInfo) -> bool { + !(executable.basename.is_empty() + || executable.command_path.is_empty() + || executable.basename.starts_with('-') + || executable.command_path.starts_with('-') + || is_known_wrapper(&executable.basename) + || is_known_wrapper(&executable.command_path)) +} + +fn detect_target_command(command: &[OsString]) -> Option { + for argument in command.iter().rev() { + let text = argument.to_string_lossy().into_owned(); + if looks_like_real_game_target(&text) { + return Some(text); + } + } + + None +} + +fn looks_like_real_game_target(text: &str) -> bool { + if text.is_empty() || text.starts_with('-') { + return false; + } + + let lower = text.to_ascii_lowercase(); + if lower == "run" || lower == "waitforexitandrun" { + return false; + } + + if is_known_wrapper(&lower) { + return false; + } + + if has_windows_game_extension(&lower) { + return true; + } + + text.contains('/') && !is_known_wrapper(&lower) +} + +fn has_windows_game_extension(text: &str) -> bool { + [".exe", ".bat", ".com", ".cmd", ".msi"] + .iter() + .any(|extension| text.ends_with(extension)) +} + +fn is_known_wrapper(text: &str) -> bool { + let basename = Path::new(text) + .file_name() + .map(|value| value.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_else(|| text.to_ascii_lowercase()); + + matches!( + basename.as_str(), + "steam-launch-wrapper" + | "_v2-entry-point" + | "proton" + | "wine" + | "wine64" + | "wineserver" + | "bash" + | "sh" + | "env" + | "gamescope" + | "mangohud" + | "gamemoderun" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn record_launch_ignores_flag_like_targets() { + let mut state = StateFile::default(); + let executable = ExecutableInfo { + basename: "--help".to_string(), + command_path: "--help".to_string(), + }; + + record_launch(&mut state, &executable, "default"); + assert!(state.games.is_empty()); + } + + #[test] + fn sanitize_state_removes_invalid_entries() { + let mut state = StateFile { + games: vec![ObservedGame { + executable: "--help".to_string(), + command_path: "--help".to_string(), + last_profile: "default".to_string(), + note: None, + display_name: None, + launch_count: 0, + last_launched_at: None, + total_play_seconds: None, + }], + }; + + sanitize_state(&mut state); + assert!(state.games.is_empty()); + } + + #[test] + fn inspect_command_prefers_windows_game_target_over_steam_wrapper() { + let executable = inspect_command(&[ + OsString::from("/home/user/.local/share/Steam/ubuntu12_32/steam-launch-wrapper"), + OsString::from("--"), + OsString::from("/home/user/.local/share/Steam/compatibilitytools.d/Proton-GE/proton"), + OsString::from("waitforexitandrun"), + OsString::from("/games/Grind Survivors/GrindSurvivors.exe"), + ]); + + assert_eq!(executable.basename, "GrindSurvivors.exe"); + assert_eq!( + executable.command_path, + "/games/Grind Survivors/GrindSurvivors.exe" + ); + } + + #[test] + fn sanitize_state_removes_wrapper_entries() { + let mut state = StateFile { + games: vec![ObservedGame { + executable: "steam-launch-wrapper".to_string(), + command_path: "/home/user/.local/share/Steam/ubuntu12_32/steam-launch-wrapper" + .to_string(), + last_profile: "default".to_string(), + note: None, + display_name: None, + launch_count: 0, + last_launched_at: None, + total_play_seconds: None, + }], + }; + + sanitize_state(&mut state); + assert!(state.games.is_empty()); + } +} diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..e1f46de --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,118 @@ +use std::ffi::OsString; + +use crate::color; +use crate::config::ResolvedSettings; +use crate::launch::{CheckStatus, PreflightReport}; +use crate::notify; + +pub fn render( + settings: ResolvedSettings, + report: &PreflightReport, + command: Option<&[OsString]>, +) -> String { + let mut output = String::new(); + output.push_str("gamewrap doctor\n"); + output.push_str("Assumed launch context: Steam\n"); + output.push_str(&format!( + "Failure notifier: {}\n", + notify::available_notifier_name() + )); + output.push_str("\nResolved settings:\n"); + output.push_str(&format!(" overlay: {}\n", color::on_off(settings.overlay))); + output.push_str(&format!( + " performance: {}\n", + color::on_off(settings.performance) + )); + output.push_str(&format!( + " steam-host-libs: {}\n", + color::on_off(settings.steam_host_libs) + )); + output.push_str(&format!(" game-libs: {}\n", settings.game_libs.as_str())); + output.push_str(&format!(" verbose: {}\n", color::on_off(settings.verbose))); + output.push_str(&format!( + " gamescope: {}\n", + color::on_off(settings.gamescope) + )); + if settings.gamescope { + if let Some(width) = settings.gamescope_width { + output.push_str(&format!(" gamescope-width: {width}\n")); + } + if let Some(height) = settings.gamescope_height { + output.push_str(&format!(" gamescope-height: {height}\n")); + } + if let Some(fps) = settings.gamescope_fps { + output.push_str(&format!(" gamescope-fps: {fps}\n")); + } + } + output.push_str(&format!( + " vkbasalt: {}\n", + color::on_off(settings.vkbasalt) + )); + output.push_str(&format!( + " esync: {}\n", + color::option_on_off(settings.esync) + )); + output.push_str(&format!( + " fsync: {}\n", + color::option_on_off(settings.fsync) + )); + output.push_str(&format!( + " large-address-aware: {}\n", + color::on_off(settings.large_address_aware) + )); + if let Some(pre_cmd) = &settings.pre_launch { + output.push_str(&format!(" pre-launch: {pre_cmd}\n")); + } + if let Some(post_cmd) = &settings.post_launch { + output.push_str(&format!(" post-launch: {post_cmd}\n")); + } + + if let Some(command) = command { + let rendered = command + .iter() + .map(|part| part.to_string_lossy().into_owned()) + .collect::>() + .join(" "); + output.push_str(&format!("\nTarget command:\n {rendered}\n")); + } + + output.push_str("\nChecks:\n"); + for check in &report.checks { + output.push_str(&format!( + " [{}] {}: {}\n", + status_label(check.status), + check.name, + check.detail + )); + } + + output.push_str("\nSummary:\n"); + output.push_str(&format!( + " overall: {}\n", + if report.has_failures() { + color::fail("fail") + } else if report.has_warnings() { + color::warn("warn") + } else { + color::ok("ok") + } + )); + output.push_str(&format!( + " launchable: {}\n", + if report.launchable() { + color::ok("yes") + } else { + color::fail("no") + } + )); + + output +} + +fn status_label(status: CheckStatus) -> String { + match status { + CheckStatus::Ok => color::ok("ok"), + CheckStatus::Warn => color::warn("warn"), + CheckStatus::Fail => color::fail("fail"), + } +} diff --git a/src/env.rs b/src/env.rs new file mode 100644 index 0000000..0718a35 --- /dev/null +++ b/src/env.rs @@ -0,0 +1,222 @@ +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::Path; + +use crate::config::{GameLibsMode, ResolvedSettings}; + +pub fn is_steam_context() -> bool { + std::env::var_os("SteamAppId").is_some() + || std::env::var_os("STEAM_COMPAT_DATA_PATH").is_some() + || std::env::var_os("STEAM_COMPAT_CLIENT_INSTALL_PATH").is_some() + || std::env::var_os("SteamGameId").is_some() +} + +pub fn build_env(settings: ResolvedSettings) -> BTreeMap { + let mut env = BTreeMap::new(); + + if settings.steam_host_libs { + env.insert( + OsString::from("STEAM_RUNTIME_PREFER_HOST_LIBRARIES"), + OsString::from("1"), + ); + } + + if needs_host_library_injection(&settings) { + let current = std::env::var_os("LD_LIBRARY_PATH"); + let host_paths = detected_host_library_dirs(); + if !host_paths.is_empty() { + let mut value = OsString::from(host_paths.join(":")); + if let Some(current) = current.filter(|value| !value.is_empty()) { + value.push(":"); + value.push(current); + } + env.insert(OsString::from("LD_LIBRARY_PATH"), value); + } + } + + if settings.vkbasalt { + env.insert(OsString::from("ENABLE_VKBASALT"), OsString::from("1")); + } + + if let Some(fps_cap) = settings.fps_cap.filter(|_| settings.overlay) { + env.insert( + OsString::from("MANGOHUD_PARAMS"), + OsString::from(format!("fps_limit={fps_cap}")), + ); + } + + if let Some(esync) = settings.esync { + env.insert( + OsString::from("PROTON_NO_ESYNC"), + OsString::from(if esync { "0" } else { "1" }), + ); + } + if let Some(fsync) = settings.fsync { + env.insert( + OsString::from("PROTON_NO_FSYNC"), + OsString::from(if fsync { "0" } else { "1" }), + ); + } + if settings.large_address_aware { + env.insert( + OsString::from("PROTON_LARGE_ADDRESS_AWARE"), + OsString::from("1"), + ); + } + + for (key, value) in &settings.env_vars { + env.insert(OsString::from(key), OsString::from(value)); + } + + env +} + +pub fn needs_host_library_injection(settings: &ResolvedSettings) -> bool { + if !settings.performance { + return false; + } + + match settings.game_libs { + GameLibsMode::Keep => false, + GameLibsMode::Gamemode => true, + GameLibsMode::Auto => is_steam_context(), + } +} + +pub fn detected_host_library_dirs() -> Vec { + let mut dirs = Vec::new(); + + for candidate in host_library_dir_candidates() { + if Path::new(candidate).is_dir() { + dirs.push(candidate.to_string()); + } + } + + dirs +} + +pub fn host_library_dir_candidates() -> &'static [&'static str] { + &[ + "/usr/lib", + "/usr/lib32", + "/lib", + "/lib32", + "/usr/lib/x86_64-linux-gnu", + "/usr/lib/i386-linux-gnu", + "/lib/x86_64-linux-gnu", + "/lib/i386-linux-gnu", + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{GameLibsMode, ResolvedSettings}; + + #[test] + fn gamemode_mode_always_sets_library_path() { + let env = build_env(ResolvedSettings { + performance: true, + game_libs: GameLibsMode::Gamemode, + ..ResolvedSettings::default() + }); + + if detected_host_library_dirs().is_empty() { + assert!(!env.contains_key(&OsString::from("LD_LIBRARY_PATH"))); + } else { + assert!(env.contains_key(&OsString::from("LD_LIBRARY_PATH"))); + } + } + + #[test] + fn keep_mode_preserves_library_path() { + let env = build_env(ResolvedSettings { + performance: true, + game_libs: GameLibsMode::Keep, + ..ResolvedSettings::default() + }); + + assert!(!env.contains_key(&OsString::from("LD_LIBRARY_PATH"))); + } + + #[test] + fn fps_cap_uses_mangohud_params_when_overlay_is_enabled() { + let env = build_env(ResolvedSettings { + overlay: true, + fps_cap: Some(60), + ..ResolvedSettings::default() + }); + + assert_eq!( + env.get(&OsString::from("MANGOHUD_PARAMS")), + Some(&OsString::from("fps_limit=60")) + ); + } + + #[test] + fn fps_cap_is_ignored_when_overlay_is_disabled() { + let env = build_env(ResolvedSettings { + overlay: false, + fps_cap: Some(60), + ..ResolvedSettings::default() + }); + + assert!(!env.contains_key(&OsString::from("MANGOHUD_PARAMS"))); + } + + #[test] + fn proton_and_vkbasalt_settings_set_expected_env_vars() { + let env = build_env(ResolvedSettings { + vkbasalt: true, + esync: Some(true), + fsync: Some(false), + ..ResolvedSettings::default() + }); + + assert_eq!( + env.get(&OsString::from("ENABLE_VKBASALT")), + Some(&OsString::from("1")) + ); + assert_eq!( + env.get(&OsString::from("PROTON_NO_ESYNC")), + Some(&OsString::from("0")) + ); + assert_eq!( + env.get(&OsString::from("PROTON_NO_FSYNC")), + Some(&OsString::from("1")) + ); + } + + #[test] + fn large_address_aware_sets_env_var() { + let env = build_env(ResolvedSettings { + large_address_aware: true, + ..ResolvedSettings::default() + }); + assert_eq!( + env.get(&OsString::from("PROTON_LARGE_ADDRESS_AWARE")), + Some(&OsString::from("1")) + ); + } + + #[test] + fn custom_env_vars_override_gamewrap_env_vars() { + let env = build_env(ResolvedSettings { + vkbasalt: true, + env_vars: BTreeMap::from([ + ("ENABLE_VKBASALT".to_string(), "0".to_string()), + ("CUSTOM_FLAG".to_string(), "yes".to_string()), + ]), + ..ResolvedSettings::default() + }); + + assert_eq!( + env.get(&OsString::from("ENABLE_VKBASALT")), + Some(&OsString::from("0")) + ); + assert_eq!( + env.get(&OsString::from("CUSTOM_FLAG")), + Some(&OsString::from("yes")) + ); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..4a7746b --- /dev/null +++ b/src/error.rs @@ -0,0 +1,67 @@ +use std::io; +use std::path::Path; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("{0}")] + Usage(String), + #[error("{0}")] + Config(String), + #[error("{0}")] + Dependency(String), + #[error("{0}")] + Internal(String), +} + +impl AppError { + pub fn exit_code(&self) -> i32 { + match self { + Self::Usage(_) => 2, + Self::Config(_) => 3, + Self::Dependency(_) => 4, + Self::Internal(_) => 1, + } + } +} + +pub fn usage_error(message: impl Into, hint: impl Into) -> AppError { + AppError::Usage(format!("Error: {}\nHint: {}", message.into(), hint.into())) +} + +pub fn config_error(message: impl Into, hint: impl Into) -> AppError { + AppError::Config(format!("Error: {}\nHint: {}", message.into(), hint.into())) +} + +pub fn profile_not_found_error(name: &str) -> AppError { + config_error( + format!("Profile `{name}` does not exist."), + "Run `gamewrap profile list` to see available profiles, or create one with `gamewrap profile create `.", + ) +} + +pub fn game_not_found_error(matcher: &str) -> AppError { + config_error( + format!( + "No observed game matched `{}`.", + matcher.to_ascii_lowercase() + ), + "Run `gamewrap game list` after launching a game through gamewrap.", + ) +} + +pub fn dependency_error(message: impl Into, hint: impl Into) -> AppError { + AppError::Dependency(format!("Error: {}\nHint: {}", message.into(), hint.into())) +} + +pub fn internal_error(message: impl Into) -> AppError { + AppError::Internal(format!("Error: {}", message.into())) +} + +pub fn io_to_internal(context: &str, path: Option<&Path>, error: io::Error) -> AppError { + let location = path + .map(|path| format!(" at {}", path.display())) + .unwrap_or_default(); + internal_error(format!("{context}{location}: {error}")) +} diff --git a/src/help.rs b/src/help.rs new file mode 100644 index 0000000..e56666e --- /dev/null +++ b/src/help.rs @@ -0,0 +1,297 @@ +pub const HELP_TOPICS_HINT: &str = + "Use one of: settings, doctor, profiles, bindings, completion, troubleshooting."; + +pub const UNKNOWN_COMMAND_HINT: &str = "Run `gamewrap --help` to see the available commands."; + +pub const EXPLICIT_LAUNCH_HINT: &str = "Steam and terminal usage are different.\nIn Steam, use `gamewrap %command%` in launch options and Steam will pass the real game command automatically.\nIn a normal terminal, launch explicitly with `gamewrap run /path/to/game/executable` or inspect with `gamewrap dry-run /path/to/game/executable`.\nExamples:\n gamewrap game list\n gamewrap game bind \"Game.exe\" benchmark\n gamewrap game note \"Game.exe\" needs game-libs gamemode\n gamewrap run /path/to/game/executable"; + +pub const TOP_LEVEL_AFTER_HELP: &str = r#"Common tasks: + gamewrap %command% + Put this in Steam launch options β€” Steam fills in the real command automatically. + + gamewrap game list + Show all games observed through Steam launches, with their profiles and paths. + + gamewrap game bind "eldenring.exe" benchmark + Route a specific game through a named profile. + + gamewrap game rename "eldenring.exe" "Elden Ring" + Give a game a readable display name. + + gamewrap config set overlay on + Enable MangoHud overlay by default. + + gamewrap config set fps-cap 60 + Cap frame rate through MangoHud (requires overlay to be on). + + gamewrap config set gamescope on + Wrap all launches in the gamescope Wayland compositor. + + gamewrap config set gamescope-width 1920 + Set the gamescope target resolution width. + + gamewrap config set pre-launch "notify-send Starting" + Run a shell hook before launching the game. + + gamewrap profile create benchmark + Create a named profile with its own settings. + + gamewrap profile env set benchmark DXVK_ASYNC 1 + Set a per-profile environment variable override. + + gamewrap profile set benchmark fps-cap 120 + Override a setting for one specific profile. + + gamewrap profile inherit benchmark base + Make a profile build on top of another profile. + + gamewrap doctor + Check that your default setup is ready for a Steam launch. + + gamewrap dry-run /path/to/game.exe + Preview the full launch plan without running anything. + + gamewrap completion bash + Generate a bash completion script. + +Help topics (run for detailed docs): + gamewrap help settings all settings and their effects + gamewrap help profiles profiles, inheritance, and env overrides + gamewrap help bindings binding games to profiles + gamewrap help doctor understanding preflight checks + gamewrap help completion shell completion setup + gamewrap help troubleshooting"#; + +pub fn topic_text(topic: &str) -> Option<&'static str> { + match topic { + "settings" => Some(SETTINGS_HELP), + "doctor" => Some(DOCTOR_HELP), + "profiles" => Some(PROFILES_HELP), + "bindings" => Some(BINDINGS_HELP), + "completion" => Some(COMPLETION_HELP), + "troubleshooting" => Some(TROUBLESHOOTING_HELP), + _ => None, + } +} + +const SETTINGS_HELP: &str = r#"Settings + +overlay + What it does: turn MangoHud on or off. + Technical effect: prefixes the launch command with mangohud. + Values: on, off + +performance + What it does: turn GameMode on or off. + Technical effect: prefixes the launch command with gamemoderun. + Values: on, off + +steam-host-libs + What it does: prefer host libraries when Steam is using its runtime. + Technical effect: sets STEAM_RUNTIME_PREFER_HOST_LIBRARIES=1. + Values: on, off + +game-libs + What it does: control how game-related library paths are handled. + Technical effect: controls whether auto-detected host library directories are appended to LD_LIBRARY_PATH. + Values: + auto append auto-detected host library paths only in Steam-like contexts when performance is on + keep leave LD_LIBRARY_PATH alone + gamemode always append auto-detected host library paths + +verbose + What it does: show more detail in diagnostic commands. + Technical effect: enables extra explanatory output from gamewrap itself. + Values: on, off + +gamescope + What it does: wrap the game in the gamescope Wayland compositor. + Technical effect: prefixes the launch command with gamescope [-- args] --. + Values: on, off + Requires: gamescope installed (https://github.com/ValveSoftware/gamescope) + +gamescope-width + What it does: set the target game resolution width. + Technical effect: passes -W to gamescope. + Values: number (e.g. 1920). Only effective when gamescope is on. + +gamescope-height + What it does: set the target game resolution height. + Technical effect: passes -H to gamescope. + Values: number (e.g. 1080). Only effective when gamescope is on. + +gamescope-fps + What it does: set the target FPS for the gamescope compositor. + Technical effect: passes -r to gamescope. + Values: number (e.g. 60). Only effective when gamescope is on. + +fps-cap + What it does: cap frame rate. + Technical effect: sets MangoHud fps_limit through MANGOHUD_PARAMS when overlay is on. + Values: number, or reset to inherit/clear + Example: gamewrap config set fps-cap 60 + +vkbasalt + What it does: enable vkBasalt post-processing. + Technical effect: sets ENABLE_VKBASALT=1. + Values: on, off + Default: off + Requires: vkBasalt installed. + +esync + What it does: force Proton esync on or off. + Technical effect: sets PROTON_NO_ESYNC=0 when on, or PROTON_NO_ESYNC=1 when off. + Values: on, off + Default: unset. Leave unset to use Steam/Proton defaults. + +fsync + What it does: force Proton fsync on or off. + Technical effect: sets PROTON_NO_FSYNC=0 when on, or PROTON_NO_FSYNC=1 when off. + Values: on, off + Default: unset. Leave unset to use Steam/Proton defaults. + +large-address-aware / laa + What it does: help 32-bit games that need more than 2 GB of address space under Proton. Usually needed for some older games. + Technical effect: sets PROTON_LARGE_ADDRESS_AWARE=1. + Values: on, off + Default: off + Context: only active in Proton context. + +pre-launch + What it does: run a shell command before launching the game. + Technical effect: runs `sh -c ` immediately before gamewrap execs the launch plan. + Values: shell command string + Example: gamewrap profile set benchmark pre-launch 'notify-send "Starting game"' + +post-launch + What it does: run a shell command after the game exits. + Technical effect: when set, gamewrap spawns the launch plan, waits for it to exit, then runs `sh -c `. + Values: shell command string + Example: gamewrap profile set benchmark post-launch 'notify-send "Game exited"' + +env-vars + What it does: apply arbitrary environment variable overrides at launch. + Technical effect: inserts KEY=value pairs after gamewrap's standard environment variables, so they can override gamewrap defaults. + Values: set through `gamewrap profile env set `, not `gamewrap config set`. + Default: none +"#; + +const PROFILES_HELP: &str = r#"Profiles + +Profiles are reusable setting bundles. +The default profile is built from your global config. +Named profiles override only the settings you change. +Profiles can also inherit from another named profile. + +Examples: + gamewrap profile list + gamewrap profile tree + gamewrap profile create benchmark + gamewrap profile duplicate benchmark benchmark-copy + gamewrap profile inherit benchmark base + gamewrap profile clear-inherit benchmark + gamewrap profile export benchmark benchmark + gamewrap profile import benchmark + gamewrap profile set benchmark overlay on + gamewrap profile reset benchmark overlay + gamewrap profile env set benchmark DXVK_ASYNC 1 + gamewrap profile env list benchmark + gamewrap profile env unset benchmark DXVK_ASYNC + gamewrap profile env clear benchmark + gamewrap profile set benchmark performance on + gamewrap profile show benchmark +"#; + +const DOCTOR_HELP: &str = r#"Doctor + +`gamewrap doctor` runs a preflight check for your current setup. +It assumes a Steam-style launch context even when you run it in a terminal. + +Examples: + gamewrap doctor + Check whether your current defaults are ready for Steam launches. + + gamewrap doctor /path/to/game/executable + Check a specific game executable as if Steam were launching it. + +What it checks: + - MangoHud availability when overlay is on + - gamescope availability when gamescope is on + - GameMode availability when performance is on + - vkBasalt layer availability when vkbasalt is on + - auto-detected host library path injection for Steam/Proton-style launches + - whether a provided target command looks runnable + +What the summary means: + overall: ok + Everything needed for the checked launch is in place. + + overall: warn + The setup is usable, but some checks were skipped or only partially validated. + + overall: fail + A required part of the launch is missing or broken. In Steam launch options, the game should not launch. +"#; + +const BINDINGS_HELP: &str = r#"Bindings + +Bindings connect a known executable name or match string to a profile. +They are explicit and user-controlled. + +Examples: + gamewrap game bind "eldenring.exe" benchmark + gamewrap game unbind "eldenring.exe" + gamewrap game forget "eldenring.exe" + gamewrap game unbind "elden" + gamewrap game list + gamewrap game list "elden" + gamewrap game show "eldenring.exe" + gamewrap game note "eldenring.exe" needs game-libs gamemode + gamewrap game rename "eldenring.exe" Elden Ring + gamewrap game clear-note "eldenring.exe" + gamewrap game forget "eldenring.exe" + +Matching is case-insensitive and checks the executable basename first. +"#; + +const TROUBLESHOOTING_HELP: &str = r#"Troubleshooting + +Missing mangohud + Install MangoHud or turn overlay off. + +Missing gamemoderun + Install GameMode or turn performance off. + +Missing gamescope + Install gamescope or turn gamescope off. + +Steam runtime library issues + Try: + gamewrap config set steam-host-libs on + gamewrap config set game-libs gamemode + +Need to see exactly what would happen + Run: + gamewrap dry-run /path/to/game-executable +"#; + +const COMPLETION_HELP: &str = r#"Completion + +`gamewrap completion ` prints a shell completion script. +`gamewrap completion install ` writes the script to a user-local location and updates the shell startup file when supported. + +Examples: + gamewrap completion zsh + Print the zsh completion script so you can inspect it. + + gamewrap completion install zsh + Install zsh completion for your user account. + + gamewrap completion path zsh + Show where the zsh completion script is stored. + +Why install is better than copy-pasting the printed script: + - the installed script is loaded automatically in new shells + - completions query gamewrap live, so new profiles and observed games show up automatically + - you generally only need to reinstall if your shell setup changes +"#; diff --git a/src/launch.rs b/src/launch.rs new file mode 100644 index 0000000..9a8860a --- /dev/null +++ b/src/launch.rs @@ -0,0 +1,503 @@ +use std::ffi::OsString; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::Command; + +use which::which; + +use crate::config::ResolvedSettings; +use crate::env; +use crate::error::{AppError, dependency_error, internal_error, usage_error}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchPlan { + pub command: Vec, + pub env: Vec<(OsString, OsString)>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckStatus { + Ok, + Warn, + Fail, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Check { + pub name: String, + pub status: CheckStatus, + pub detail: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreflightReport { + pub checks: Vec, +} + +impl PreflightReport { + pub fn launchable(&self) -> bool { + !self.has_failures() + } + + pub fn has_failures(&self) -> bool { + self.checks + .iter() + .any(|check| check.status == CheckStatus::Fail) + } + + pub fn has_warnings(&self) -> bool { + self.checks + .iter() + .any(|check| check.status == CheckStatus::Warn) + } +} + +pub fn build_plan(target: &[OsString], settings: ResolvedSettings) -> Result { + validate_launch(target, &settings, env::is_steam_context())?; + + let mut command = Vec::with_capacity(target.len() + 10); + + if settings.gamescope { + command.push(OsString::from("gamescope")); + if let Some(width) = settings.gamescope_width { + command.push(OsString::from("-W")); + command.push(OsString::from(width.to_string())); + } + if let Some(height) = settings.gamescope_height { + command.push(OsString::from("-H")); + command.push(OsString::from(height.to_string())); + } + if let Some(fps) = settings.gamescope_fps { + command.push(OsString::from("-r")); + command.push(OsString::from(fps.to_string())); + } + command.push(OsString::from("--")); + } + + if settings.overlay { + command.push(OsString::from("mangohud")); + } + + if settings.performance { + command.push(OsString::from("gamemoderun")); + } + + command.extend_from_slice(target); + + let env = env::build_env(settings).into_iter().collect(); + Ok(LaunchPlan { command, env }) +} + +pub fn preflight( + settings: ResolvedSettings, + target: Option<&[OsString]>, + steam_context: bool, +) -> PreflightReport { + let mut checks = Vec::new(); + + if settings.overlay { + checks.push(check_dependency("overlay wrapper", "mangohud")); + } else { + checks.push(Check { + name: "overlay wrapper".to_string(), + status: CheckStatus::Ok, + detail: "overlay is off, so MangoHud is not required.".to_string(), + }); + } + + if settings.gamescope { + checks.push(check_dependency("gamescope compositor", "gamescope")); + } else { + checks.push(Check { + name: "gamescope compositor".to_string(), + status: CheckStatus::Ok, + detail: "gamescope is off.".to_string(), + }); + } + + if settings.performance { + checks.push(check_dependency("performance wrapper", "gamemoderun")); + } else { + checks.push(Check { + name: "performance wrapper".to_string(), + status: CheckStatus::Ok, + detail: "performance is off, so GameMode is not required.".to_string(), + }); + } + + checks.push(check_library_paths(&settings, steam_context)); + + if settings.vkbasalt { + let lib_found = vkbasalt_lib_found(); + checks.push(Check { + name: "vkbasalt layer".to_string(), + status: if lib_found { + CheckStatus::Ok + } else { + CheckStatus::Warn + }, + detail: if lib_found { + "libvkbasalt.so found.".to_string() + } else { + "libvkbasalt.so not found in common paths. Install vkBasalt or it may not load." + .to_string() + }, + }); + } else { + checks.push(Check { + name: "vkbasalt layer".to_string(), + status: CheckStatus::Ok, + detail: "vkbasalt is off.".to_string(), + }); + } + + if let Some(target) = target { + checks.push(check_target_command(target)); + } else { + checks.push(Check { + name: "target command".to_string(), + status: CheckStatus::Warn, + detail: "no game command was provided, so game-specific validation was skipped." + .to_string(), + }); + } + + PreflightReport { checks } +} + +pub fn execute(plan: LaunchPlan) -> Result<(), AppError> { + let executable = plan + .command + .first() + .ok_or_else(|| internal_error("Launch plan did not include a command."))?; + + let mut command = Command::new(executable); + if plan.command.len() > 1 { + command.args(&plan.command[1..]); + } + + for (key, value) in plan.env { + command.env(key, value); + } + + let error = command.exec(); + Err(internal_error(format!( + "Failed to exec launch command: {error}" + ))) +} + +pub fn execute_wait(plan: LaunchPlan) -> Result { + let executable = plan + .command + .first() + .ok_or_else(|| internal_error("Launch plan did not include a command."))?; + + let mut command = Command::new(executable); + if plan.command.len() > 1 { + command.args(&plan.command[1..]); + } + + for (key, value) in plan.env { + command.env(key, value); + } + + let start = std::time::Instant::now(); + let mut child = command + .spawn() + .map_err(|error| internal_error(format!("Failed to spawn launch command: {error}")))?; + child + .wait() + .map_err(|error| internal_error(format!("Failed to wait for game process: {error}")))?; + Ok(start.elapsed()) +} + +pub fn render_plan(plan: &LaunchPlan, profile: &str, verbose: bool) -> String { + let mut output = String::new(); + output.push_str(&format!("Resolved profile: {profile}\n")); + + if verbose { + output.push_str("Environment changes:\n"); + if plan.env.is_empty() { + output.push_str(" (none)\n"); + } else { + for (key, value) in &plan.env { + output.push_str(&format!( + " {}={}\n", + key.to_string_lossy(), + value.to_string_lossy() + )); + } + } + } + + output.push_str("Final command:\n "); + output.push_str( + &plan + .command + .iter() + .map(|part| shell_escape(part)) + .collect::>() + .join(" "), + ); + output +} + +fn ensure_dependency(binary: &str, setting: &str) -> Result<(), AppError> { + which(binary).map(|_| ()).map_err(|_| { + dependency_error( + format!("`{binary}` is required because `{setting}` is enabled."), + format!("Install `{binary}` or turn `{setting}` off with `gamewrap config set {setting} off`."), + ) + }) +} + +fn ensure_target_command(target: &OsString) -> Result<(), AppError> { + let target_text = target.to_string_lossy(); + if target_text.starts_with('-') { + return Err(usage_error( + "No runnable target command was provided.", + format!( + "`{target_text}` does not look like a real game command. If you are using Steam launch options, put `gamewrap %command%` inside Steam. In a terminal, use a real executable such as `gamewrap dry-run /path/to/game/executable`." + ), + )); + } + + if target_text.contains('/') { + let path = Path::new(target_text.as_ref()); + if !path.exists() { + return Err(dependency_error( + format!("Target command `{target_text}` does not exist."), + "Check the executable path or the Steam launch options command.", + )); + } + if !path.is_file() { + return Err(dependency_error( + format!("Target command `{target_text}` is not a file."), + "Point gamewrap at an executable file, not a directory.", + )); + } + } else if which(target_text.as_ref()).is_err() { + return Err(dependency_error( + format!("Target command `{target_text}` was not found in PATH."), + "Check the command name, or run `gamewrap dry-run /full/path/to/game` to inspect a specific executable.", + )); + } + + Ok(()) +} + +fn validate_launch( + target: &[OsString], + settings: &ResolvedSettings, + steam_context: bool, +) -> Result<(), AppError> { + if target.is_empty() { + return Err(usage_error( + "No command was provided.", + "Use `gamewrap %command%` in Steam, or run `gamewrap dry-run ` in the terminal.", + )); + } + + let target_program = target + .first() + .ok_or_else(|| internal_error("Missing target command during launch planning."))?; + ensure_target_command(target_program)?; + + if settings.overlay { + ensure_dependency("mangohud", "overlay")?; + } + + if settings.performance { + ensure_dependency("gamemoderun", "performance")?; + } + + if settings.gamescope { + ensure_dependency("gamescope", "gamescope")?; + } + + ensure_library_paths_for_context(settings, steam_context)?; + Ok(()) +} + +fn ensure_library_paths_for_context( + settings: &ResolvedSettings, + steam_context: bool, +) -> Result<(), AppError> { + if !needs_host_libs_for_context(settings, steam_context) { + return Ok(()); + } + + if env::detected_host_library_dirs().is_empty() { + return Err(dependency_error( + "No supported host library directories were auto-detected for game-libs injection.", + "Run `gamewrap config set game-libs keep` to disable library path injection, or use `gamewrap doctor` to inspect what gamewrap can detect on this system.", + )); + } + + Ok(()) +} + +fn needs_host_libs_for_context(settings: &ResolvedSettings, steam_context: bool) -> bool { + if !settings.performance { + return false; + } + + match settings.game_libs { + crate::config::GameLibsMode::Keep => false, + crate::config::GameLibsMode::Gamemode => true, + crate::config::GameLibsMode::Auto => steam_context, + } +} + +fn check_dependency(name: &str, binary: &str) -> Check { + if which(binary).is_ok() { + Check { + name: name.to_string(), + status: CheckStatus::Ok, + detail: format!("`{binary}` is installed."), + } + } else { + Check { + name: name.to_string(), + status: CheckStatus::Fail, + detail: format!("`{binary}` is missing."), + } + } +} + +fn check_library_paths(settings: &ResolvedSettings, steam_context: bool) -> Check { + if !needs_host_libs_for_context(settings, steam_context) { + return Check { + name: "host library injection".to_string(), + status: CheckStatus::Ok, + detail: "game-libs injection is not needed for this launch.".to_string(), + }; + } + + let dirs = env::detected_host_library_dirs(); + if dirs.is_empty() { + Check { + name: "host library injection".to_string(), + status: CheckStatus::Fail, + detail: "no supported host library directories were auto-detected.".to_string(), + } + } else { + Check { + name: "host library injection".to_string(), + status: CheckStatus::Ok, + detail: format!("detected host library dirs: {}", dirs.join(", ")), + } + } +} + +fn vkbasalt_lib_found() -> bool { + [ + "/usr/lib/libvkbasalt.so", + "/usr/lib64/libvkbasalt.so", + "/usr/local/lib/libvkbasalt.so", + ] + .iter() + .any(|path| Path::new(path).exists()) +} + +fn check_target_command(target: &[OsString]) -> Check { + match target.first() { + Some(target) => match ensure_target_command(target) { + Ok(()) => Check { + name: "target command".to_string(), + status: CheckStatus::Ok, + detail: format!("`{}` looks runnable.", target.to_string_lossy()), + }, + Err(error) => Check { + name: "target command".to_string(), + status: CheckStatus::Fail, + detail: error.to_string(), + }, + }, + None => Check { + name: "target command".to_string(), + status: CheckStatus::Fail, + detail: "no target command was provided.".to_string(), + }, + } +} + +// Used only for display in dry-run output. Not used for actual command execution. +// The escaping covers common cases but may not handle all shell metacharacters +// (e.g. $, backticks) in unusual paths - users copying dry-run output to a shell +// should verify carefully. +fn shell_escape(value: &OsString) -> String { + let text = value.to_string_lossy(); + if text + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || "/._-=:".contains(ch)) + { + text.into_owned() + } else { + format!("'{}'", text.replace('\'', "'\\''")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{GameLibsMode, ResolvedSettings}; + + #[test] + fn render_plan_contains_profile_and_command() { + let plan = LaunchPlan { + command: vec![OsString::from("mangohud"), OsString::from("game.exe")], + env: vec![], + }; + + let rendered = render_plan(&plan, "default", false); + assert!(rendered.contains("Resolved profile: default")); + assert!(rendered.contains("mangohud game.exe")); + } + + #[test] + fn empty_target_returns_usage_error() { + let error = build_plan( + &[], + ResolvedSettings { + overlay: false, + performance: false, + steam_host_libs: false, + game_libs: GameLibsMode::Keep, + verbose: false, + gamescope: false, + gamescope_width: None, + gamescope_height: None, + gamescope_fps: None, + fps_cap: None, + ..ResolvedSettings::default() + }, + ) + .expect_err("expected error"); + + assert_eq!(error.exit_code(), 2); + } + + #[test] + fn option_like_target_returns_usage_error() { + let error = build_plan( + &[OsString::from("--help")], + ResolvedSettings { + overlay: false, + performance: false, + steam_host_libs: false, + game_libs: GameLibsMode::Keep, + verbose: false, + gamescope: false, + gamescope_width: None, + gamescope_height: None, + gamescope_fps: None, + fps_cap: None, + ..ResolvedSettings::default() + }, + ) + .expect_err("expected error"); + + assert_eq!(error.exit_code(), 2); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..31e1627 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,27 @@ +mod bindings; +mod cli; +mod color; +mod completion; +mod config; +mod detect; +mod doctor; +mod env; +mod error; +mod help; +mod launch; +mod notify; +mod profile; +mod share; +mod status; + +pub use error::AppError; + +pub fn run() -> Result<(), AppError> { + completion::complete_env(); + let command = cli::parse(std::env::args_os())?; + cli::execute(command) +} + +pub fn report_failure(title: &str, body: &str) { + notify::notify_failure(title, body); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e86bf65 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,14 @@ +use std::process; + +fn main() { + let exit_code = match gamewrap::run() { + Ok(()) => 0, + Err(error) => { + eprintln!("{error}"); + gamewrap::report_failure("gamewrap launch failed", &error.to_string()); + error.exit_code() + } + }; + + process::exit(exit_code); +} diff --git a/src/notify.rs b/src/notify.rs new file mode 100644 index 0000000..1231fda --- /dev/null +++ b/src/notify.rs @@ -0,0 +1,130 @@ +use std::process::Command; + +use which::which; + +use crate::env; + +pub fn notify_failure(title: &str, body: &str) { + if !should_notify() { + return; + } + + if let Some(program) = available_notifier() { + let _ = send_notification(program, title, body); + } +} + +pub fn notify_test() -> Option<&'static str> { + let program = available_notifier()?; + let name = notifier_name(program); + let _ = send_notification( + program, + "gamewrap test notification", + "This is a test notification from gamewrap notify test.", + ); + Some(name) +} + +pub fn available_notifier_name() -> &'static str { + match available_notifier() { + Some(program) => notifier_name(program), + None => "none", + } +} + +fn should_notify() -> bool { + env::is_steam_context() && has_graphical_session() +} + +#[derive(Clone, Copy)] +enum Notifier { + Zenity, + KDialog, + XMessage, + NotifySend, +} + +fn available_notifier() -> Option { + if has_wayland_session() { + if which("zenity").is_ok() { + return Some(Notifier::Zenity); + } + if which("kdialog").is_ok() { + return Some(Notifier::KDialog); + } + if which("notify-send").is_ok() { + return Some(Notifier::NotifySend); + } + return None; + } + + if which("zenity").is_ok() { + Some(Notifier::Zenity) + } else if which("kdialog").is_ok() { + Some(Notifier::KDialog) + } else if has_x11_session() && which("xmessage").is_ok() { + Some(Notifier::XMessage) + } else if which("notify-send").is_ok() { + Some(Notifier::NotifySend) + } else { + None + } +} + +fn notifier_name(program: Notifier) -> &'static str { + match program { + Notifier::Zenity => "zenity", + Notifier::KDialog => "kdialog", + Notifier::XMessage => "xmessage", + Notifier::NotifySend => "notify-send", + } +} + +fn send_notification(program: Notifier, title: &str, body: &str) -> std::io::Result<()> { + match program { + Notifier::Zenity => Command::new("zenity") + .arg("--error") + .arg("--title") + .arg(title) + .arg("--text") + .arg(body) + .spawn() + .map(|_| ()), + Notifier::KDialog => Command::new("kdialog") + .arg("--error") + .arg(body) + .arg("--title") + .arg(title) + .spawn() + .map(|_| ()), + Notifier::XMessage => Command::new("xmessage") + .arg("-center") + .arg(format!("{title}\n\n{body}")) + .spawn() + .map(|_| ()), + Notifier::NotifySend => Command::new("notify-send") + .arg("--urgency=critical") + .arg(title) + .arg(body) + .spawn() + .map(|_| ()), + } +} + +fn has_graphical_session() -> bool { + has_wayland_session() || has_x11_session() +} + +fn has_wayland_session() -> bool { + std::env::var_os("WAYLAND_DISPLAY").is_some() + || std::env::var("XDG_SESSION_TYPE") + .map(|value| value.eq_ignore_ascii_case("wayland")) + .unwrap_or(false) +} + +fn has_x11_session() -> bool { + std::env::var_os("DISPLAY").is_some() + || std::env::var("XDG_SESSION_TYPE") + .map(|value| value.eq_ignore_ascii_case("x11")) + .unwrap_or(false) +} diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..b1e8a06 --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,271 @@ +use std::collections::BTreeSet; + +use crate::bindings; +use crate::config::{ConfigFile, ProfileConfig, ResolvedSettings}; +use crate::detect::ExecutableInfo; +use crate::error::{AppError, config_error}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedProfile { + pub profile_name: String, + pub settings: ResolvedSettings, +} + +pub fn resolve( + config: &ConfigFile, + executable: &ExecutableInfo, +) -> Result { + let profile_name = match bindings::resolve_profile(config, executable) { + Some(profile_name) => profile_name.to_string(), + None => "default".to_string(), + }; + + let settings = if profile_name == "default" { + let mut settings = ResolvedSettings::default(); + settings.apply(&config.defaults); + settings + } else { + resolve_named(config, &profile_name)?.settings + }; + + Ok(ResolvedProfile { + profile_name, + settings, + }) +} + +pub fn resolve_named(config: &ConfigFile, profile_name: &str) -> Result { + validate_config(config)?; + + let profile = config.profiles.get(profile_name).ok_or_else(|| { + config_error( + format!("Profile `{profile_name}` does not exist."), + "Run `gamewrap profile list` to see the available profiles.", + ) + })?; + + let mut settings = ResolvedSettings::default(); + settings.apply(&config.defaults); + apply_profile_chain(config, profile, &mut settings)?; + + Ok(ResolvedProfile { + profile_name: profile_name.to_string(), + settings, + }) +} + +pub fn validate_config(config: &ConfigFile) -> Result<(), AppError> { + for binding in &config.bindings { + if !config.profiles.contains_key(&binding.profile) { + return Err(config_error( + format!("Binding points to missing profile `{}`.", binding.profile), + "Fix the binding with `gamewrap game bind ` or remove it.", + )); + } + } + + let mut visiting = BTreeSet::new(); + let mut visited = BTreeSet::new(); + for name in config.profiles.keys() { + validate_profile_chain(config, name, &mut visiting, &mut visited)?; + } + + Ok(()) +} + +fn apply_profile_chain( + config: &ConfigFile, + profile: &ProfileConfig, + settings: &mut ResolvedSettings, +) -> Result<(), AppError> { + if let Some(parent) = &profile.inherits { + let parent_profile = config.profiles.get(parent).ok_or_else(|| { + config_error( + format!("Profile inherits from missing profile `{parent}`."), + "Fix the parent profile name or clear the inheritance chain.", + ) + })?; + apply_profile_chain(config, parent_profile, settings)?; + } + + settings.apply(&profile.settings); + Ok(()) +} + +fn validate_profile_chain( + config: &ConfigFile, + name: &str, + visiting: &mut BTreeSet, + visited: &mut BTreeSet, +) -> Result<(), AppError> { + if visited.contains(name) { + return Ok(()); + } + + if !visiting.insert(name.to_string()) { + return Err(config_error( + format!("Profile inheritance cycle detected at `{name}`."), + "Break the cycle with `gamewrap profile clear-inherit ` or choose a different parent profile.", + )); + } + + if let Some(parent) = config + .profiles + .get(name) + .ok_or_else(|| { + config_error( + format!("Profile `{name}` does not exist."), + "Run `gamewrap profile list` to see the available profiles.", + ) + })? + .inherits + .as_ref() + { + if parent == name { + return Err(config_error( + format!("Profile `{name}` cannot inherit from itself."), + "Choose another parent profile or clear the inheritance chain.", + )); + } + if !config.profiles.contains_key(parent) { + return Err(config_error( + format!("Profile `{name}` inherits from missing profile `{parent}`."), + "Create the parent profile first or clear the inheritance chain.", + )); + } + validate_profile_chain(config, parent, visiting, visited)?; + } + + visiting.remove(name); + visited.insert(name.to_string()); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::config::{ConfigFile, GameLibsMode, Settings}; + + #[test] + fn resolve_named_applies_inheritance_chain() { + let config = ConfigFile { + defaults: Settings { + overlay: Some(false), + performance: Some(false), + steam_host_libs: None, + game_libs: None, + verbose: None, + gamescope: None, + gamescope_width: None, + gamescope_height: None, + gamescope_fps: None, + fps_cap: None, + env_vars: Some(BTreeMap::from([( + "GW_DEFAULT".to_string(), + "default".to_string(), + )])), + ..Settings::default() + }, + profiles: BTreeMap::from([ + ( + "base".to_string(), + ProfileConfig { + inherits: None, + settings: Settings { + overlay: Some(true), + performance: Some(true), + steam_host_libs: None, + game_libs: Some(GameLibsMode::Keep), + verbose: None, + gamescope: None, + gamescope_width: None, + gamescope_height: None, + gamescope_fps: None, + fps_cap: None, + env_vars: Some(BTreeMap::from([ + ("GW_BASE".to_string(), "base".to_string()), + ("GW_OVERRIDE".to_string(), "base".to_string()), + ])), + ..Settings::default() + }, + }, + ), + ( + "benchmark".to_string(), + ProfileConfig { + inherits: Some("base".to_string()), + settings: Settings { + overlay: None, + performance: None, + steam_host_libs: Some(false), + game_libs: None, + verbose: Some(true), + gamescope: None, + gamescope_width: None, + gamescope_height: None, + gamescope_fps: None, + fps_cap: Some(60), + env_vars: Some(BTreeMap::from([( + "GW_OVERRIDE".to_string(), + "benchmark".to_string(), + )])), + ..Settings::default() + }, + }, + ), + ]), + bindings: Vec::new(), + }; + + let resolved = resolve_named(&config, "benchmark").expect("resolve benchmark"); + assert_eq!(resolved.profile_name, "benchmark"); + assert!(resolved.settings.overlay); + assert!(resolved.settings.performance); + assert!(!resolved.settings.steam_host_libs); + assert_eq!(resolved.settings.game_libs, GameLibsMode::Keep); + assert!(resolved.settings.verbose); + assert_eq!(resolved.settings.fps_cap, Some(60)); + assert_eq!( + resolved.settings.env_vars.get("GW_DEFAULT"), + Some(&"default".to_string()) + ); + assert_eq!( + resolved.settings.env_vars.get("GW_BASE"), + Some(&"base".to_string()) + ); + assert_eq!( + resolved.settings.env_vars.get("GW_OVERRIDE"), + Some(&"benchmark".to_string()) + ); + } + + #[test] + fn validate_config_rejects_inheritance_cycles() { + let config = ConfigFile { + defaults: Settings::default(), + profiles: BTreeMap::from([ + ( + "alpha".to_string(), + ProfileConfig { + inherits: Some("beta".to_string()), + settings: Settings::default(), + }, + ), + ( + "beta".to_string(), + ProfileConfig { + inherits: Some("alpha".to_string()), + settings: Settings::default(), + }, + ), + ]), + bindings: Vec::new(), + }; + + let error = validate_config(&config).expect_err("expected cycle validation to fail"); + assert!(error.to_string().contains("inheritance cycle")); + } +} diff --git a/src/share.rs b/src/share.rs new file mode 100644 index 0000000..4413777 --- /dev/null +++ b/src/share.rs @@ -0,0 +1,232 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::config::{Binding, ConfigFile, ProfileConfig, ResolvedSettings, Settings}; +use crate::error::{AppError, config_error}; +use crate::profile; + +const CONFIG_KIND: &str = "gamewrap-config"; +const PROFILE_KIND: &str = "gamewrap-profile"; +const FORMAT_VERSION: u32 = 1; +pub const CONFIG_EXPORT_SUFFIX: &str = ".gamewrap.toml"; +pub const PROFILE_EXPORT_SUFFIX: &str = ".gamewrap-profile.toml"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedConfigFile { + pub kind: String, + pub version: u32, + pub defaults: ResolvedSettings, + #[serde(default)] + pub profiles: BTreeMap, + #[serde(default)] + pub bindings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedProfileFile { + pub kind: String, + pub version: u32, + pub name: String, + pub settings: ResolvedSettings, +} + +pub fn export_config(config: &ConfigFile) -> Result { + let defaults = resolved_defaults(&config.defaults); + let mut profiles = BTreeMap::new(); + for name in config.profiles.keys() { + let resolved = profile::resolve_named(config, name)?; + profiles.insert(name.clone(), resolved.settings); + } + + Ok(SharedConfigFile { + kind: CONFIG_KIND.to_string(), + version: FORMAT_VERSION, + defaults, + profiles, + bindings: config.bindings.clone(), + }) +} + +pub fn import_config(shared: SharedConfigFile) -> Result { + validate_kind(&shared.kind, CONFIG_KIND, "config")?; + validate_version(shared.version, "config")?; + + Ok(ConfigFile { + defaults: explicit_settings(shared.defaults), + profiles: shared + .profiles + .into_iter() + .map(|(name, settings)| { + ( + name, + ProfileConfig { + inherits: None, + settings: explicit_settings(settings), + }, + ) + }) + .collect(), + bindings: shared.bindings, + }) +} + +pub fn export_profile(config: &ConfigFile, name: &str) -> Result { + let resolved = profile::resolve_named(config, name)?; + Ok(SharedProfileFile { + kind: PROFILE_KIND.to_string(), + version: FORMAT_VERSION, + name: name.to_string(), + settings: resolved.settings, + }) +} + +pub fn import_profile(shared: SharedProfileFile) -> Result<(String, ProfileConfig), AppError> { + validate_kind(&shared.kind, PROFILE_KIND, "profile")?; + validate_version(shared.version, "profile")?; + + Ok(( + shared.name, + ProfileConfig { + inherits: None, + settings: explicit_settings(shared.settings), + }, + )) +} + +pub fn parse_imported_config(content: &str) -> Result { + if let Ok(shared) = toml::from_str::(content) { + return import_config(shared); + } + let legacy = toml::from_str::(content).map_err(|error| { + config_error( + format!("Import file is invalid: {error}"), + "Fix the TOML syntax or export a fresh config and try again.", + ) + })?; + Ok(legacy) +} + +pub fn with_default_config_suffix(path: &std::path::Path) -> std::path::PathBuf { + with_default_suffix(path, CONFIG_EXPORT_SUFFIX) +} + +pub fn with_default_profile_suffix(path: &std::path::Path) -> std::path::PathBuf { + with_default_suffix(path, PROFILE_EXPORT_SUFFIX) +} + +fn with_default_suffix(path: &std::path::Path, suffix: &str) -> std::path::PathBuf { + let display = path.to_string_lossy(); + if display.ends_with(suffix) { + path.to_path_buf() + } else { + std::path::PathBuf::from(format!("{display}{suffix}")) + } +} + +fn validate_kind(kind: &str, expected: &str, label: &str) -> Result<(), AppError> { + if kind == expected { + Ok(()) + } else { + Err(config_error( + format!("This file is not a {label} export."), + &format!("Expected kind `{expected}`, but found `{kind}`."), + )) + } +} + +fn validate_version(version: u32, label: &str) -> Result<(), AppError> { + if version == FORMAT_VERSION { + Ok(()) + } else { + Err(config_error( + format!("This {label} export uses unsupported version `{version}`."), + &format!("Expected version `{FORMAT_VERSION}`."), + )) + } +} + +fn resolved_defaults(settings: &Settings) -> ResolvedSettings { + let mut resolved = ResolvedSettings::default(); + resolved.apply(settings); + resolved +} + +fn explicit_settings(settings: ResolvedSettings) -> Settings { + Settings { + overlay: Some(settings.overlay), + performance: Some(settings.performance), + steam_host_libs: Some(settings.steam_host_libs), + game_libs: Some(settings.game_libs), + verbose: Some(settings.verbose), + gamescope: Some(settings.gamescope), + gamescope_width: settings.gamescope_width, + gamescope_height: settings.gamescope_height, + gamescope_fps: settings.gamescope_fps, + fps_cap: settings.fps_cap, + vkbasalt: Some(settings.vkbasalt), + esync: settings.esync, + fsync: settings.fsync, + large_address_aware: Some(settings.large_address_aware), + pre_launch: settings.pre_launch, + post_launch: settings.post_launch, + env_vars: (!settings.env_vars.is_empty()).then_some(settings.env_vars), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::GameLibsMode; + + #[test] + fn import_profile_clears_inheritance_and_makes_values_explicit() { + let shared = SharedProfileFile { + kind: PROFILE_KIND.to_string(), + version: FORMAT_VERSION, + name: "benchmark".to_string(), + settings: ResolvedSettings { + overlay: true, + performance: false, + steam_host_libs: true, + game_libs: GameLibsMode::Gamemode, + verbose: false, + gamescope: true, + gamescope_width: Some(1920), + gamescope_height: Some(1080), + gamescope_fps: Some(60), + fps_cap: Some(60), + vkbasalt: true, + esync: Some(true), + fsync: Some(false), + large_address_aware: true, + env_vars: BTreeMap::from([("GW_FLAG".to_string(), "1".to_string())]), + ..ResolvedSettings::default() + }, + }; + + let (name, imported) = import_profile(shared).expect("import profile"); + assert_eq!(name, "benchmark"); + assert_eq!(imported.inherits, None); + assert_eq!(imported.settings.overlay, Some(true)); + assert_eq!(imported.settings.performance, Some(false)); + assert_eq!(imported.settings.game_libs, Some(GameLibsMode::Gamemode)); + assert_eq!(imported.settings.gamescope, Some(true)); + assert_eq!(imported.settings.gamescope_width, Some(1920)); + assert_eq!(imported.settings.gamescope_height, Some(1080)); + assert_eq!(imported.settings.gamescope_fps, Some(60)); + assert_eq!(imported.settings.fps_cap, Some(60)); + assert_eq!(imported.settings.vkbasalt, Some(true)); + assert_eq!(imported.settings.esync, Some(true)); + assert_eq!(imported.settings.fsync, Some(false)); + assert_eq!(imported.settings.large_address_aware, Some(true)); + assert_eq!( + imported + .settings + .env_vars + .as_ref() + .and_then(|vars| vars.get("GW_FLAG")), + Some(&"1".to_string()) + ); + } +} diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..594c1d6 --- /dev/null +++ b/src/status.rs @@ -0,0 +1,150 @@ +use which::which; + +use crate::bindings; +use crate::color; +use crate::config::{AppPaths, ConfigFile, ResolvedSettings, StateFile}; +use crate::notify; + +pub fn render(paths: &AppPaths, config: &ConfigFile, state: &StateFile) -> String { + let defaults = { + let mut resolved = ResolvedSettings::default(); + resolved.apply(&config.defaults); + resolved + }; + + let mut output = String::new(); + output.push_str("Paths:\n"); + output.push_str(&format!(" config: {}\n", paths.config_file.display())); + output.push_str(&format!(" state: {}\n", paths.state_file.display())); + output.push_str("\nDependencies:\n"); + output.push_str(&format!(" mangohud: {}\n", dependency_state("mangohud"))); + output.push_str(&format!( + " gamemoderun: {}\n", + dependency_state("gamemoderun") + )); + output.push_str(&format!(" zenity: {}\n", dependency_state("zenity"))); + output.push_str(&format!( + " notify-send: {}\n", + dependency_state("notify-send") + )); + output.push_str(&format!(" vkbasalt: {}\n", vkbasalt_state())); + output.push_str(&format!(" gamescope: {}\n", dependency_state("gamescope"))); + output.push_str(&format!( + " failure notifier: {}\n", + notify::available_notifier_name() + )); + output.push_str(&format!(" host library dirs: {}\n", host_dirs_summary())); + output.push_str("\nResolved defaults:\n"); + output.push_str(&format!(" overlay: {}\n", color::on_off(defaults.overlay))); + output.push_str(&format!( + " performance: {}\n", + color::on_off(defaults.performance) + )); + output.push_str(&format!( + " steam-host-libs: {}\n", + color::on_off(defaults.steam_host_libs) + )); + output.push_str(&format!(" game-libs: {}\n", defaults.game_libs.as_str())); + output.push_str(&format!(" verbose: {}\n", color::on_off(defaults.verbose))); + output.push_str(&format!( + " gamescope: {}\n", + color::on_off(defaults.gamescope) + )); + if let Some(width) = defaults.gamescope_width { + output.push_str(&format!(" gamescope-width: {width}\n")); + } + if let Some(height) = defaults.gamescope_height { + output.push_str(&format!(" gamescope-height: {height}\n")); + } + if let Some(fps) = defaults.gamescope_fps { + output.push_str(&format!(" gamescope-fps: {fps}\n")); + } + output.push_str(&format!( + " vkbasalt: {}\n", + color::on_off(defaults.vkbasalt) + )); + output.push_str(&format!( + " esync: {}\n", + color::option_on_off(defaults.esync) + )); + output.push_str(&format!( + " fsync: {}\n", + color::option_on_off(defaults.fsync) + )); + output.push_str(&format!( + " large-address-aware: {}\n", + color::on_off(defaults.large_address_aware) + )); + output.push_str("\nProfiles:\n"); + output.push_str(&format!(" count: {}\n", config.profiles.len())); + if config.profiles.is_empty() { + output.push_str(" names: (none)\n"); + } else { + let names = config + .profiles + .keys() + .map(String::as_str) + .collect::>() + .join(", "); + output.push_str(&format!(" names: {names}\n")); + } + output.push_str("Bindings:\n"); + output.push_str(&format!(" count: {}\n", config.bindings.len())); + if config.bindings.is_empty() { + output.push_str(" items: (none)\n"); + } else { + for binding in &config.bindings { + output.push_str(&format!(" {} -> {}\n", binding.matcher, binding.profile)); + } + } + output.push_str("Observed games:\n"); + output.push_str(&format!(" count: {}\n", state.games.len())); + if state.games.is_empty() { + output.push_str(" items: (none)\n"); + } else { + for game in &state.games { + let resolved_profile = + bindings::resolve_profile_for_observed(config, game).unwrap_or("default"); + output.push_str(&format!(" {}\n", game.executable)); + output.push_str(&format!(" resolved profile: {resolved_profile}\n")); + output.push_str(&format!(" last launched: {}\n", game.last_profile)); + output.push_str(&format!(" path: {}\n", game.command_path)); + if let Some(note) = &game.note { + output.push_str(&format!(" note: {note}\n")); + } + } + } + output +} + +fn dependency_state(binary: &str) -> String { + if which(binary).is_ok() { + color::ok("installed") + } else { + color::fail("missing") + } +} + +fn vkbasalt_state() -> String { + let found = [ + "/usr/lib/libvkbasalt.so", + "/usr/lib64/libvkbasalt.so", + "/usr/local/lib/libvkbasalt.so", + ] + .iter() + .any(|path| std::path::Path::new(path).exists()); + if found { + color::ok("installed") + } else { + color::warn("not found") + } +} + +fn host_dirs_summary() -> String { + let dirs = crate::env::detected_host_library_dirs(); + if dirs.is_empty() { + "none detected".to_string() + } else { + dirs.join(", ") + } +} diff --git a/tests/cli_matrix.rs b/tests/cli_matrix.rs new file mode 100644 index 0000000..276e3fa --- /dev/null +++ b/tests/cli_matrix.rs @@ -0,0 +1,1182 @@ +use std::process::Command; +use std::{fs, path::PathBuf}; + +use std::os::unix::fs::PermissionsExt; +use tempfile::TempDir; + +struct TestEnv { + _root: TempDir, + config_home: PathBuf, + state_home: PathBuf, + home: PathBuf, +} + +struct CmdResult { + status: i32, + output: String, +} + +impl TestEnv { + fn new() -> Self { + let root = tempfile::tempdir().expect("temp dir"); + let config_home = root.path().join("config"); + let state_home = root.path().join("state"); + let home = root.path().join("home"); + std::fs::create_dir_all(&config_home).expect("config dir"); + std::fs::create_dir_all(&state_home).expect("state dir"); + std::fs::create_dir_all(&home).expect("home dir"); + Self { + _root: root, + config_home, + state_home, + home, + } + } + + fn run(&self, args: &[&str]) -> CmdResult { + self.run_with_env(args, &[]) + } + + fn run_with_env(&self, args: &[&str], extra_env: &[(&str, &str)]) -> CmdResult { + let output = Command::new(env!("CARGO_BIN_EXE_gamewrap")) + .args(args) + .env("XDG_CONFIG_HOME", &self.config_home) + .env("XDG_STATE_HOME", &self.state_home) + .env("HOME", &self.home) + .env("NO_COLOR", "1") + .env_remove("SteamAppId") + .env_remove("SteamGameId") + .env_remove("STEAM_COMPAT_DATA_PATH") + .env_remove("STEAM_COMPAT_CLIENT_INSTALL_PATH") + .envs(extra_env.iter().copied()) + .output() + .expect("run gamewrap"); + + let mut combined = String::new(); + combined.push_str(&String::from_utf8_lossy(&output.stdout)); + combined.push_str(&String::from_utf8_lossy(&output.stderr)); + + CmdResult { + status: output.status.code().unwrap_or(-1), + output: combined, + } + } + + fn path(&self, relative: &str) -> PathBuf { + self.home.join(relative) + } + + fn path_with_fake_bins(&self, names: &[&str]) -> String { + let bin_dir = self.home.join("bin"); + fs::create_dir_all(&bin_dir).expect("fake bin dir"); + for name in names { + let path = bin_dir.join(name); + fs::write(&path, "#!/bin/sh\nexit 0\n").expect("write fake bin"); + let mut permissions = fs::metadata(&path) + .expect("fake bin metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod fake bin"); + } + + let current_path = std::env::var_os("PATH").unwrap_or_default(); + format!("{}:{}", bin_dir.display(), current_path.to_string_lossy()) + } +} + +fn assert_ok(result: &CmdResult) { + assert_eq!(result.status, 0, "unexpected failure:\n{}", result.output); +} + +fn assert_exit(result: &CmdResult, code: i32) { + assert_eq!(result.status, code, "unexpected output:\n{}", result.output); +} + +#[test] +fn help_and_launch_commands_work() { + let env = TestEnv::new(); + + let result = env.run(&["--version"]); + assert_ok(&result); + assert!(result.output.contains(env!("CARGO_PKG_VERSION"))); + + let result = env.run(&["--help"]); + assert_ok(&result); + assert!(result.output.contains("Commands:")); + assert!(result.output.contains("profile")); + assert!(result.output.contains("game")); + assert!(result.output.contains("last")); + assert!(result.output.contains("notify")); + assert!(result.output.contains("completion")); + + let result = env.run(&["help"]); + assert_ok(&result); + assert!(result.output.contains("Common tasks:")); + assert!(result.output.contains("gamewrap %command%")); + assert!(result.output.contains("gamewrap completion bash")); + assert!(result.output.contains("gamescope-width")); + assert!(result.output.contains("profile env set")); + assert!(result.output.contains("pre-launch")); + + let result = env.run(&[]); + assert_exit(&result, 2); + assert!(result.output.contains("No command was provided.")); + assert!( + result + .output + .contains("Steam and terminal usage are different.") + ); + + let result = env.run(&["profils"]); + assert_exit(&result, 2); + assert!(result.output.contains("not a known gamewrap command")); + assert!( + result + .output + .contains("gamewrap run /path/to/game/executable") + ); + + for topic in [ + "settings", + "doctor", + "profiles", + "bindings", + "completion", + "troubleshooting", + ] { + let result = env.run(&["help", topic]); + assert_ok(&result); + assert!(!result.output.is_empty()); + } + + let result = env.run(&["help", "nonsense"]); + assert_exit(&result, 2); + assert!(result.output.contains("not a known help topic")); + + let result = env.run(&["status"]); + assert_ok(&result); + assert!(result.output.contains("Resolved defaults:")); + assert!(result.output.contains("gamescope:")); + assert!(result.output.contains("vkbasalt:")); + assert!(result.output.contains("Bindings:")); + + let result = env.run(&["doctor"]); + assert_ok(&result); + assert!(result.output.contains("Assumed launch context: Steam")); + assert!(result.output.contains("gamescope: off")); + assert!(result.output.contains("vkbasalt: off")); + assert!(result.output.contains("gamescope compositor")); + assert!(result.output.contains("vkbasalt layer")); + + let result = env.run(&["doctor", "/usr/bin/true"]); + assert_ok(&result); + assert!(result.output.contains("/usr/bin/true")); + + let result = env.run(&["run", "--help"]); + assert_ok(&result); + assert!( + result + .output + .contains("Usage: gamewrap run [--] ...") + ); + + let result = env.run(&["dry-run", "--help"]); + assert_ok(&result); + assert!( + result + .output + .contains("Usage: gamewrap dry-run [--] ...") + ); + + let result = env.run(&["run", "/usr/bin/true"]); + assert_ok(&result); + + let result = env.run(&["dry-run", "/usr/bin/true"]); + assert_ok(&result); + assert!(result.output.contains("Resolved profile: default")); + assert!(result.output.contains("Final command:")); + + assert_ok(&env.run(&["config", "set", "pre-launch", "printf pre"])); + assert_ok(&env.run(&["config", "set", "post-launch", "printf post"])); + let result = env.run(&["dry-run", "/usr/bin/true"]); + assert_ok(&result); + assert!(result.output.contains("Pre-launch hook:")); + assert!(result.output.contains("sh -c \"printf pre\"")); + assert!( + result + .output + .contains("Post-launch hook (runs after game exits):") + ); + assert!(result.output.contains("sh -c \"printf post\"")); + + let result = env.run(&["doctor", "/usr/bin/true"]); + assert_ok(&result); + assert!(result.output.contains("pre-launch: printf pre")); + assert!(result.output.contains("post-launch: printf post")); + + assert_ok(&env.run(&["config", "set", "gamescope", "on"])); + assert_ok(&env.run(&["config", "set", "gamescope-width", "1920"])); + assert_ok(&env.run(&["config", "set", "gamescope-height", "1080"])); + assert_ok(&env.run(&["config", "set", "gamescope-fps", "60"])); + let fake_path = env.path_with_fake_bins(&["gamescope", "mangohud", "gamemoderun"]); + let result = env.run_with_env(&["dry-run", "/usr/bin/true"], &[("PATH", &fake_path)]); + assert_ok(&result); + assert!( + result + .output + .contains("gamescope -W 1920 -H 1080 -r 60 -- mangohud gamemoderun /usr/bin/true") + ); + + let result = env.run(&["completion", "bash"]); + assert_ok(&result); + assert!(result.output.contains("GAMEWRAP_COMPLETE=\"bash\"")); + + let result = env.run(&["completion", "path", "zsh"]); + assert_ok(&result); + assert!(result.output.contains("Completion script path:")); + assert!(result.output.contains(".zshrc")); + + let result = env.run(&["notify", "test"]); + assert_ok(&result); + assert!( + result.output.contains("Sent test notification via") + || result.output.contains("No notifier available.") + ); + + let result = env.run(&["completion", "install", "zsh"]); + assert_ok(&result); + assert!(result.output.contains("Installed zsh completion")); + assert!(result.output.contains("show up automatically")); + + let script_path = env + .config_home + .join("gamewrap") + .join("completions") + .join("gamewrap.zsh"); + assert!(script_path.exists(), "missing installed script"); + let script = fs::read_to_string(&script_path).expect("read installed zsh script"); + assert!(script.contains("GAMEWRAP_COMPLETE=\"zsh\"")); + + let zshrc_path = env.home.join(".zshrc"); + let zshrc = fs::read_to_string(&zshrc_path).expect("read zshrc"); + assert!(zshrc.contains("gamewrap completion")); + assert!(zshrc.contains("source")); + + let result = env.run(&["run", "--", "definitely-not-a-real-command"]); + assert_exit(&result, 4); + assert!(result.output.contains("was not found in PATH")); + + let result = env.run(&["run", "--", "--help"]); + assert_exit(&result, 2); + assert!( + result + .output + .contains("No runnable target command was provided.") + ); + + let result = env.run(&["dry-run", "--", "--help"]); + assert_exit(&result, 2); + assert!( + result + .output + .contains("No runnable target command was provided.") + ); +} + +#[test] +fn config_profiles_import_export_and_inheritance_work() { + let env = TestEnv::new(); + + let result = env.run(&["config", "--help"]); + assert_ok(&result); + assert!(result.output.contains("show")); + assert!(result.output.contains("edit")); + assert!(result.output.contains("reset")); + assert!(result.output.contains("export")); + assert!(result.output.contains("import")); + + let result = env.run(&["config", "show"]); + assert_ok(&result); + assert!(result.output.contains("overlay = on")); + + let result = env.run_with_env(&["config", "edit"], &[("EDITOR", "true")]); + assert_ok(&result); + assert!(result.output.is_empty()); + + for (setting, value) in [ + ("overlay", "off"), + ("performance", "off"), + ("steam-host-libs", "off"), + ("host-libs", "on"), + ("game-libs", "keep"), + ("verbose", "on"), + ("gamescope", "on"), + ("gamescope-width", "1920"), + ("gamescope-height", "1080"), + ("gamescope-fps", "60"), + ("fps-cap", "60"), + ("vkbasalt", "on"), + ("esync", "off"), + ("fsync", "on"), + ("large-address-aware", "on"), + ("pre-launch", "printf default-pre"), + ("post-launch", "printf default-post"), + ] { + let result = env.run(&["config", "set", setting, value]); + assert_ok(&result); + assert!(result.output.contains("Updated default setting")); + } + + let result = env.run(&["config", "show"]); + assert_ok(&result); + assert!(result.output.contains("overlay = off")); + assert!(result.output.contains("performance = off")); + assert!(result.output.contains("steam-host-libs = on")); + assert!(result.output.contains("game-libs = keep")); + assert!(result.output.contains("verbose = on")); + assert!(result.output.contains("gamescope = on")); + assert!(result.output.contains("gamescope-width = 1920")); + assert!(result.output.contains("gamescope-height = 1080")); + assert!(result.output.contains("gamescope-fps = 60")); + assert!(result.output.contains("fps-cap = 60")); + assert!(result.output.contains("vkbasalt = on")); + assert!(result.output.contains("esync = off")); + assert!(result.output.contains("fsync = on")); + assert!(result.output.contains("large-address-aware = on")); + assert!(result.output.contains("pre-launch = printf default-pre")); + assert!(result.output.contains("post-launch = printf default-post")); + + let result = env.run(&["config", "set", "banana", "on"]); + assert_exit(&result, 3); + assert!(result.output.contains("not a known setting")); + + let result = env.run(&["config", "set", "overlay", "maybe"]); + assert_exit(&result, 3); + assert!(result.output.contains("valid on/off value")); + + let result = env.run(&["config", "set", "game-libs", "nonsense"]); + assert_exit(&result, 3); + assert!(result.output.contains("valid value for game-libs")); + + let result = env.run(&["config", "set", "fps-cap", "fast"]); + assert_exit(&result, 3); + assert!(result.output.contains("valid FPS cap")); + + let result = env.run(&["config", "set", "gamescope-width", "wide"]); + assert_exit(&result, 3); + assert!(result.output.contains("valid pixel count")); + + let result = env.run(&["config", "set", "gamescope-fps", "fast"]); + assert_exit(&result, 3); + assert!(result.output.contains("valid FPS")); + + let result = env.run(&["config", "reset", "overlay"]); + assert_ok(&result); + assert!(result.output.contains("Reset default setting `overlay`.")); + + let result = env.run(&["config", "reset", "nope"]); + assert_exit(&result, 3); + assert!(result.output.contains("not a known setting")); + + let result = env.run(&["profile", "--help"]); + assert_ok(&result); + assert!(result.output.contains("tree")); + assert!(result.output.contains("reset")); + assert!(result.output.contains("duplicate")); + assert!(result.output.contains("inherit")); + assert!(result.output.contains("clear-inherit")); + assert!(result.output.contains("env")); + assert!(result.output.contains("export")); + assert!(result.output.contains("import")); + + let result = env.run(&["profile", "list"]); + assert_ok(&result); + assert!(result.output.contains("No profiles configured.")); + + for name in ["base", "benchmark", "recording"] { + let result = env.run(&["profile", "create", name]); + assert_ok(&result); + assert!(result.output.contains("Created profile")); + } + + let result = env.run(&["profile", "create", "default"]); + assert_exit(&result, 3); + assert!(result.output.contains("`default` is reserved")); + + let result = env.run(&["profile", "create", "benchmark"]); + assert_exit(&result, 3); + assert!(result.output.contains("already exists")); + + let result = env.run(&["profile", "show", "default"]); + assert_ok(&result); + assert!(result.output.contains("[defaults]")); + + let result = env.run(&["profile", "show", "benchmark"]); + assert_ok(&result); + assert!(result.output.contains("overlay = (inherits: on)")); + assert!(result.output.contains("performance = (inherits: off)")); + + let result = env.run(&["profile", "set", "base", "overlay", "on"]); + assert_ok(&result); + let result = env.run(&["profile", "set", "base", "performance", "on"]); + assert_ok(&result); + let result = env.run(&["profile", "inherit", "benchmark", "base"]); + assert_ok(&result); + assert!( + result + .output + .contains("Profile `benchmark` now inherits from `base`.") + ); + + let result = env.run(&["profile", "show", "benchmark"]); + assert_ok(&result); + assert!(result.output.contains("inherits = base")); + assert!(result.output.contains("overlay = (inherits: on)")); + + let result = env.run(&["profile", "env", "set", "base", "GW_PARENT", "base-value"]); + assert_ok(&result); + assert!( + result + .output + .contains("Set env `GW_PARENT=base-value` on profile `base`.") + ); + let result = env.run(&[ + "profile", + "env", + "set", + "benchmark", + "GW_CHILD", + "bench-value", + ]); + assert_ok(&result); + let result = env.run(&[ + "profile", + "env", + "set", + "benchmark", + "GW_PARENT", + "child-value", + ]); + assert_ok(&result); + let result = env.run(&["profile", "env", "list", "benchmark"]); + assert_ok(&result); + assert!(result.output.contains("GW_CHILD=bench-value")); + assert!(result.output.contains("GW_PARENT=child-value")); + assert!(!result.output.contains("GW_PARENT=base-value")); + + let result = env.run(&["profile", "env", "unset", "benchmark", "GW_CHILD"]); + assert_ok(&result); + assert!( + result + .output + .contains("Unset env `GW_CHILD` on profile `benchmark`.") + ); + let result = env.run(&["profile", "env", "unset", "benchmark", "GW_MISSING"]); + assert_ok(&result); + assert!( + result + .output + .contains("No env var `GW_MISSING` on profile `benchmark`.") + ); + let result = env.run(&["profile", "env", "clear", "benchmark"]); + assert_ok(&result); + assert!( + result + .output + .contains("Cleared all env vars from profile `benchmark`.") + ); + let result = env.run(&["profile", "env", "list", "benchmark"]); + assert_ok(&result); + assert!(result.output.contains("GW_PARENT=base-value")); + + let result = env.run(&["profile", "tree"]); + assert_ok(&result); + assert!(result.output.contains("default (built-in)")); + assert!(result.output.contains("base")); + assert!(result.output.contains("benchmark")); + + let result = env.run(&["game", "bind", "Demo.exe", "benchmark"]); + assert_ok(&result); + let result = env.run(&["profile", "list"]); + assert_ok(&result); + assert!( + result + .output + .contains("benchmark (inherits: base, 1 binding)") + ); + let result = env.run(&["game", "unbind", "Demo.exe"]); + assert_ok(&result); + + for (setting, value) in [ + ("overlay", "off"), + ("performance", "on"), + ("steam-host-libs", "off"), + ("game-libs", "gamemode"), + ("verbose", "off"), + ("gamescope", "on"), + ("gamescope-width", "2560"), + ("gamescope-height", "1440"), + ("gamescope-fps", "120"), + ("fps-cap", "120"), + ("vkbasalt", "off"), + ("esync", "on"), + ("fsync", "off"), + ("laa", "off"), + ("pre-launch", "printf profile-pre"), + ("post-launch", "printf profile-post"), + ] { + let result = env.run(&["profile", "set", "benchmark", setting, value]); + assert_ok(&result); + assert!(result.output.contains("Updated profile `benchmark`")); + } + + let result = env.run(&["profile", "show", "benchmark"]); + assert_ok(&result); + assert!(result.output.contains("inherits = base")); + assert!(result.output.contains("overlay = off")); + assert!(result.output.contains("performance = on")); + assert!(result.output.contains("steam-host-libs = off")); + assert!(result.output.contains("game-libs = gamemode")); + assert!(result.output.contains("verbose = off")); + assert!(result.output.contains("gamescope = on")); + assert!(result.output.contains("gamescope-width = 2560")); + assert!(result.output.contains("gamescope-height = 1440")); + assert!(result.output.contains("gamescope-fps = 120")); + assert!(result.output.contains("fps-cap = 120")); + assert!(result.output.contains("vkbasalt = off")); + assert!(result.output.contains("esync = on")); + assert!(result.output.contains("fsync = off")); + assert!(result.output.contains("large-address-aware = off")); + assert!(result.output.contains("pre-launch = printf profile-pre")); + assert!(result.output.contains("post-launch = printf profile-post")); + + let result = env.run(&["profile", "duplicate", "benchmark", "benchmark-copy"]); + assert_ok(&result); + assert!( + result + .output + .contains("Duplicated profile `benchmark` to `benchmark-copy`.") + ); + + let result = env.run(&["profile", "show", "benchmark-copy"]); + assert_ok(&result); + assert!(result.output.contains("inherits = base")); + assert!(result.output.contains("overlay = off")); + + let result = env.run(&["profile", "duplicate", "missing", "whatever"]); + assert_exit(&result, 3); + assert!(result.output.contains("Profile `missing` does not exist.")); + + let result = env.run(&["profile", "duplicate", "benchmark", "benchmark-copy"]); + assert_exit(&result, 3); + assert!( + result + .output + .contains("Profile `benchmark-copy` already exists.") + ); + + let result = env.run(&["profile", "set", "missing", "overlay", "on"]); + assert_exit(&result, 3); + assert!(result.output.contains("Profile `missing` does not exist.")); + + let result = env.run(&["profile", "set", "benchmark", "nope", "on"]); + assert_exit(&result, 3); + assert!(result.output.contains("not a known setting")); + + let result = env.run(&["profile", "set", "benchmark", "overlay", "maybe"]); + assert_exit(&result, 3); + assert!(result.output.contains("valid on/off value")); + + for setting in [ + "overlay", + "performance", + "steam-host-libs", + "game-libs", + "verbose", + "gamescope", + "gamescope-width", + "gamescope-height", + "gamescope-fps", + "fps-cap", + "vkbasalt", + "esync", + "fsync", + "large-address-aware", + "pre-launch", + "post-launch", + ] { + let result = env.run(&["profile", "reset", "benchmark", setting]); + assert_ok(&result); + assert!(result.output.contains("Reset profile `benchmark` setting")); + } + + let result = env.run(&["profile", "show", "benchmark"]); + assert_ok(&result); + assert!(result.output.contains("inherits = base")); + assert!(result.output.contains("overlay = (inherits: on)")); + assert!(result.output.contains("performance = (inherits: on)")); + assert!(result.output.contains("steam-host-libs = (inherits: on)")); + assert!(result.output.contains("game-libs = (inherits: keep)")); + assert!(result.output.contains("verbose = (inherits: on)")); + assert!(result.output.contains("gamescope = (inherits: on)")); + assert!(result.output.contains("gamescope-width = (inherits: 1920)")); + assert!( + result + .output + .contains("gamescope-height = (inherits: 1080)") + ); + assert!(result.output.contains("gamescope-fps = (inherits: 60)")); + assert!(result.output.contains("fps-cap = (inherits: 60)")); + assert!(result.output.contains("vkbasalt = (inherits: on)")); + assert!(result.output.contains("esync = (inherits: off)")); + assert!(result.output.contains("fsync = (inherits: on)")); + assert!( + result + .output + .contains("large-address-aware = (inherits: on)") + ); + assert!( + result + .output + .contains("pre-launch = (inherits: printf default-pre)") + ); + assert!( + result + .output + .contains("post-launch = (inherits: printf default-post)") + ); + + let result = env.run(&["profile", "inherit", "benchmark-copy", "recording"]); + assert_ok(&result); + let result = env.run(&["profile", "show", "benchmark-copy"]); + assert_ok(&result); + assert!(result.output.contains("inherits = recording")); + + let result = env.run(&["profile", "inherit", "benchmark-copy", "default"]); + assert_exit(&result, 3); + assert!(result.output.contains("`default` is already the base")); + + let result = env.run(&["profile", "inherit", "benchmark-copy", "missing"]); + assert_exit(&result, 3); + assert!( + result + .output + .contains("Parent profile `missing` does not exist.") + ); + + let result = env.run(&["profile", "inherit", "recording", "benchmark-copy"]); + assert_exit(&result, 3); + assert!(result.output.contains("Profile inheritance cycle detected")); + + let result = env.run(&["profile", "delete", "recording"]); + assert_exit(&result, 3); + assert!(result.output.contains("Cannot delete profile `recording`")); + + let result = env.run(&["profile", "clear-inherit", "benchmark-copy"]); + assert_ok(&result); + assert!( + result + .output + .contains("Cleared inherited parent for `benchmark-copy`.") + ); + + let result = env.run(&["profile", "show", "benchmark-copy"]); + assert_ok(&result); + assert!(!result.output.contains("inherits = ")); + assert!(result.output.contains("overlay = off")); + + let result = env.run(&["profile", "reset", "missing", "overlay"]); + assert_exit(&result, 3); + assert!(result.output.contains("Profile `missing` does not exist.")); + + let result = env.run(&["profile", "show", "missing"]); + assert_exit(&result, 3); + assert!(result.output.contains("does not exist")); + + let export_base = env.path("exported-config"); + let export_file = env.path("exported-config.gamewrap.toml"); + let result = env.run(&["config", "export"]); + assert_ok(&result); + assert!(result.output.contains("kind = \"gamewrap-config\"")); + assert!(result.output.contains("version = 1")); + assert!(result.output.contains("[defaults]")); + assert!(result.output.contains("overlay = true")); + assert!(result.output.contains("[profiles.base]")); + assert!(result.output.contains("[profiles.benchmark]")); + assert!(result.output.contains("[profiles.benchmark-copy]")); + + let result = env.run(&["config", "export", export_base.to_str().expect("utf8 path")]); + assert_ok(&result); + assert!(result.output.contains("Exported config to")); + let exported = fs::read_to_string(&export_file).expect("export file"); + assert!(exported.contains("kind = \"gamewrap-config\"")); + assert!(exported.contains("[profiles.base]")); + assert!(exported.contains("[profiles.benchmark-copy]")); + + let imported_env = TestEnv::new(); + let result = imported_env.run(&["config", "import", export_base.to_str().expect("utf8 path")]); + assert_ok(&result); + let result = imported_env.run(&["config", "show"]); + assert_ok(&result); + assert!(!result.output.contains("(inherits:")); + assert!(result.output.contains("benchmark")); + assert!(result.output.contains("benchmark-copy")); + assert!(result.output.contains("overlay = on")); + + let profile_export_base = env.path("benchmark"); + let profile_export = env.path("benchmark.gamewrap-profile.toml"); + let result = env.run(&[ + "profile", + "export", + "benchmark", + profile_export_base.to_str().expect("utf8 path"), + ]); + assert_ok(&result); + assert!(result.output.contains("Exported profile `benchmark`")); + let exported_profile = fs::read_to_string(&profile_export).expect("profile export"); + assert!(exported_profile.contains("kind = \"gamewrap-profile\"")); + assert!(exported_profile.contains("name = \"benchmark\"")); + assert!(exported_profile.contains("[settings]")); + + let imported_profile_env = TestEnv::new(); + let result = imported_profile_env.run(&[ + "profile", + "import", + profile_export_base.to_str().expect("utf8 path"), + ]); + assert_ok(&result); + assert!(result.output.contains("Imported profile `benchmark`")); + let result = imported_profile_env.run(&["profile", "show", "benchmark"]); + assert_ok(&result); + assert!(!result.output.contains("inherits =")); + assert!(result.output.contains("overlay = on")); + + let result = imported_profile_env.run(&[ + "profile", + "import", + profile_export_base.to_str().expect("utf8 path"), + ]); + assert_exit(&result, 3); + assert!( + result + .output + .contains("Profile `benchmark` already exists.") + ); + + let invalid_file = env.path("invalid.toml"); + fs::write(&invalid_file, "not = [valid").expect("write invalid config"); + let result = env.run(&[ + "config", + "import", + invalid_file.to_str().expect("utf8 path"), + ]); + assert_exit(&result, 3); + assert!(result.output.contains("is invalid")); + + let invalid_profile = env.path("invalid.gamewrap-profile.toml"); + fs::write( + &invalid_profile, + "kind = \"gamewrap-profile\"\nversion = 1\n", + ) + .expect("write invalid profile"); + let result = env.run(&[ + "profile", + "import", + invalid_profile.to_str().expect("utf8 path"), + ]); + assert_exit(&result, 3); + assert!(result.output.contains("Profile import file")); +} + +#[test] +fn games_bindings_notes_and_filters_work() { + let env = TestEnv::new(); + + assert_ok(&env.run(&["config", "set", "overlay", "off"])); + assert_ok(&env.run(&["config", "set", "performance", "off"])); + assert_ok(&env.run(&["profile", "create", "benchmark"])); + assert_ok(&env.run(&["profile", "create", "recording"])); + + let result = env.run(&["game", "--help"]); + assert_ok(&result); + assert!(result.output.contains("bind")); + assert!(result.output.contains("unbind")); + assert!(result.output.contains("forget")); + assert!(result.output.contains("note")); + assert!(result.output.contains("clear-note")); + + let result = env.run(&["game", "list"]); + assert_ok(&result); + assert!(result.output.contains("(no observed games yet)")); + + let result = env.run(&["game", "show", "missing.exe"]); + assert_exit(&result, 3); + assert!(result.output.contains("No observed game matched")); + + let result = env.run(&["game", "bind", "Example.exe", "unknown"]); + assert_exit(&result, 3); + assert!(result.output.contains("Profile `unknown` does not exist.")); + + let result = env.run_with_env( + &[ + "/usr/bin/true", + "--", + "/usr/bin/true", + "waitforexitandrun", + "/games/Grind Survivors/GrindSurvivors.exe", + ], + &[("SteamAppId", "12345")], + ); + assert_ok(&result); + + let result = env.run_with_env( + &[ + "/usr/bin/true", + "--", + "/usr/bin/true", + "waitforexitandrun", + "/games/StarRupture/StarRuptureGameSteam.exe", + ], + &[("SteamAppId", "67890")], + ); + assert_ok(&result); + + let result = env.run(&["game", "list"]); + assert_ok(&result); + assert!(result.output.contains("GrindSurvivors.exe")); + assert!(result.output.contains("StarRuptureGameSteam.exe")); + assert!(result.output.contains("default")); + + let result = env.run(&["last"]); + assert_ok(&result); + assert!( + result + .output + .contains("Last played: StarRuptureGameSteam.exe") + ); + assert!(result.output.contains("Launches: 1")); + assert!(result.output.contains("Last launch: 20")); + + let result = env.run(&["game", "list", "grind"]); + assert_ok(&result); + assert!(result.output.contains("GrindSurvivors.exe")); + assert!(!result.output.contains("StarRuptureGameSteam.exe")); + + let result = env.run(&["game", "show", "starrupturegamesteam.exe"]); + assert_ok(&result); + assert!( + result + .output + .contains("Executable: StarRuptureGameSteam.exe") + ); + assert!(result.output.contains("Launch count: 1")); + assert!(result.output.contains("Last launched: 20")); + + let result = env.run(&["game", "show", "Grind Survivors"]); + assert_ok(&result); + assert!( + result + .output + .contains("/games/Grind Survivors/GrindSurvivors.exe") + ); + + let result = env.run(&["game", "rename", "StarRuptureGameSteam.exe", "Star Rupture"]); + assert_ok(&result); + assert!( + result + .output + .contains("Renamed `StarRuptureGameSteam.exe` to `Star Rupture`.") + ); + + let result = env.run(&["game", "list"]); + assert_ok(&result); + assert!(result.output.contains("Star Rupture")); + assert!(!result.output.contains("StarRuptureGameSteam.exe default")); + + let result = env.run(&["game", "show", "StarRuptureGameSteam.exe"]); + assert_ok(&result); + assert!(result.output.contains("Display name: Star Rupture")); + + let result = env.run(&["game", "bind", "StarRuptureGameSteam.exe", "benchmark"]); + assert_ok(&result); + assert!( + result + .output + .contains("Bound `StarRuptureGameSteam.exe` to profile `benchmark`.") + ); + + let result = env.run(&[ + "game", + "bind", + "/games/Grind Survivors/GrindSurvivors.exe", + "recording", + ]); + assert_ok(&result); + assert!( + result + .output + .contains("Bound `/games/Grind Survivors/GrindSurvivors.exe` to profile `recording`.") + ); + + let result = env.run(&["game", "list"]); + assert_ok(&result); + assert!(result.output.contains("Game")); + assert!(result.output.contains("Profile")); + assert!(result.output.contains("Path")); + assert!(result.output.contains("benchmark")); + assert!(result.output.contains("recording")); + + let result = env.run(&["game", "show", "StarRuptureGameSteam.exe"]); + assert_ok(&result); + assert!(result.output.contains("Resolved profile: benchmark")); + assert!(result.output.contains("Last launched profile: default")); + + let result = env.run(&[ + "game", + "note", + "StarRuptureGameSteam.exe", + "needs", + "game-libs", + "gamemode", + ]); + assert_ok(&result); + assert!( + result + .output + .contains("Saved note for `StarRuptureGameSteam.exe`.") + ); + + let result = env.run(&["game", "show", "StarRuptureGameSteam.exe"]); + assert_ok(&result); + assert!(result.output.contains("Note: needs game-libs gamemode")); + + let result = env.run(&["status"]); + assert_ok(&result); + assert!(result.output.contains("note: needs game-libs gamemode")); + + let result = env.run(&["config", "show"]); + assert_ok(&result); + assert!( + result + .output + .contains("StarRuptureGameSteam.exe -> benchmark") + ); + assert!( + result + .output + .contains("/games/Grind Survivors/GrindSurvivors.exe -> recording") + ); + + let result = env.run(&["game", "unbind", "starrupture"]); + assert_ok(&result); + assert!(result.output.contains("Removed binding for `starrupture`.")); + + let result = env.run(&["config", "show"]); + assert_ok(&result); + assert!( + !result + .output + .contains("StarRuptureGameSteam.exe -> benchmark") + ); + assert!( + result + .output + .contains("/games/Grind Survivors/GrindSurvivors.exe -> recording") + ); + + let result = env.run(&["game", "list", "star"]); + assert_ok(&result); + assert!(result.output.contains("StarRuptureGameSteam.exe")); + assert!(result.output.contains("default")); + assert!(!result.output.contains("GrindSurvivors.exe")); + + let result = env.run(&["game", "clear-note", "StarRuptureGameSteam.exe"]); + assert_ok(&result); + assert!( + result + .output + .contains("Cleared note for `StarRuptureGameSteam.exe`.") + ); + + let result = env.run(&["game", "show", "StarRuptureGameSteam.exe"]); + assert_ok(&result); + assert!(!result.output.contains("Note:")); + + let result = env.run(&["game", "forget", "StarRuptureGameSteam.exe"]); + assert_ok(&result); + assert!( + result + .output + .contains("Removed `StarRuptureGameSteam.exe` from observed games.") + ); + + let result = env.run(&["game", "show", "StarRuptureGameSteam.exe"]); + assert_exit(&result, 3); + assert!(result.output.contains("No observed game matched")); + + let result = env.run(&["game", "list", "star"]); + assert_ok(&result); + assert!(!result.output.contains("StarRuptureGameSteam.exe")); + + let result = env.run(&["game", "unbind", "StarRuptureGameSteam.exe"]); + assert_exit(&result, 3); + assert!( + result + .output + .contains("No binding exists for `StarRuptureGameSteam.exe`.") + ); + + let result = env.run(&["profile", "delete", "benchmark"]); + assert_ok(&result); + assert!( + result + .output + .contains("Deleted profile `benchmark` and removed bindings that pointed to it.") + ); + + let result = env.run(&["game", "bind", "StarRuptureGameSteam.exe", "benchmark"]); + assert_exit(&result, 3); + assert!( + result + .output + .contains("Profile `benchmark` does not exist.") + ); + + let result = env.run(&["profile", "delete", "recording"]); + assert_ok(&result); + assert!( + result + .output + .contains("Deleted profile `recording` and removed bindings that pointed to it.") + ); + + let result = env.run(&["profile", "list"]); + assert_ok(&result); + assert!(result.output.contains("No profiles configured.")); + + let result = env.run(&["config", "show"]); + assert_ok(&result); + assert!(result.output.contains("[bindings]")); + assert!(result.output.contains("(none)")); +} + +#[test] +fn subcommand_typos_and_extra_args_fail_cleanly() { + let env = TestEnv::new(); + + for args in [&["game"][..], &["profile"][..], &["config"][..]] { + let result = env.run(args); + assert_exit(&result, 2); + assert!(result.output.contains("Usage: gamewrap")); + } + + let result = env.run(&["game", "shwo", "StarRuptureGameSteam.exe"]); + assert_exit(&result, 2); + assert!(result.output.contains("unrecognized subcommand 'shwo'")); + + let result = env.run(&["profile", "crtate", "foo"]); + assert_exit(&result, 2); + assert!(result.output.contains("unrecognized subcommand 'crtate'")); + + let result = env.run(&["config", "sett", "overlay", "on"]); + assert_exit(&result, 2); + assert!(result.output.contains("unrecognized subcommand 'sett'")); + + let result = env.run(&["status", "now"]); + assert_exit(&result, 2); + assert!(result.output.contains("unexpected argument 'now' found")); +} + +#[test] +fn post_launch_hook_runs_after_game_exits() { + let env = TestEnv::new(); + + // Disable overlay and performance so we don't need mangohud/gamemoderun. + assert_ok(&env.run(&["config", "set", "overlay", "off"])); + assert_ok(&env.run(&["config", "set", "performance", "off"])); + + // Set a post-launch hook that prints a distinctive string to stdout. + assert_ok(&env.run(&["config", "set", "post-launch", "printf POSTHOOK_RAN"])); + + // Run a real command (true exits immediately). + let result = env.run(&["run", "/usr/bin/true"]); + assert_ok(&result); + assert!( + result.output.contains("POSTHOOK_RAN"), + "post-launch hook did not run; output was:\n{}", + result.output + ); + + // dry-run should show the post-launch hook in preview. + let result = env.run(&["dry-run", "/usr/bin/true"]); + assert_ok(&result); + assert!( + result + .output + .contains("Post-launch hook (runs after game exits):") + ); + assert!(result.output.contains("printf POSTHOOK_RAN")); +} + +#[test] +fn env_vars_appear_in_verbose_dry_run() { + let env = TestEnv::new(); + + assert_ok(&env.run(&["config", "set", "overlay", "on"])); + assert_ok(&env.run(&["config", "set", "performance", "off"])); + assert_ok(&env.run(&["config", "set", "verbose", "on"])); + assert_ok(&env.run(&["config", "set", "vkbasalt", "on"])); + assert_ok(&env.run(&["config", "set", "esync", "on"])); + assert_ok(&env.run(&["config", "set", "fsync", "off"])); + assert_ok(&env.run(&["config", "set", "large-address-aware", "on"])); + assert_ok(&env.run(&["config", "set", "fps-cap", "60"])); + + let fake_path = env.path_with_fake_bins(&["mangohud"]); + let result = env.run_with_env(&["dry-run", "/usr/bin/true"], &[("PATH", &fake_path)]); + assert_ok(&result); + + // Verbose mode shows environment changes. + assert!(result.output.contains("Environment changes:")); + assert!(result.output.contains("ENABLE_VKBASALT=1")); + assert!(result.output.contains("PROTON_NO_ESYNC=0")); + assert!(result.output.contains("PROTON_NO_FSYNC=1")); + assert!(result.output.contains("PROTON_LARGE_ADDRESS_AWARE=1")); + assert!(result.output.contains("MANGOHUD_PARAMS=fps_limit=60")); + + // Final command should have mangohud prefix. + assert!(result.output.contains("mangohud /usr/bin/true")); +} + +#[test] +fn launch_count_and_playtime_tracked() { + let env = TestEnv::new(); + + assert_ok(&env.run(&["config", "set", "overlay", "off"])); + assert_ok(&env.run(&["config", "set", "performance", "off"])); + + // Simulate two Steam launches of the same game. + for _ in 0..2 { + assert_ok(&env.run_with_env( + &[ + "/usr/bin/true", + "--", + "/usr/bin/true", + "waitforexitandrun", + "/games/TestGame/TestGame.exe", + ], + &[("SteamAppId", "99999")], + )); + } + + // game show should show launch count of 2. + let result = env.run(&["game", "show", "TestGame.exe"]); + assert_ok(&result); + assert!(result.output.contains("Launch count: 2")); + assert!(result.output.contains("Last launched: 20")); + + // gamewrap last should show it. + let result = env.run(&["last"]); + assert_ok(&result); + assert!(result.output.contains("Last played: TestGame.exe")); + assert!(result.output.contains("Launches: 2")); +}