diff --git a/.gitignore b/.gitignore
index 95d8e6f..da861b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
/target
.vscode/
+.claude/
+CLAUDE.md
# Packaging build artifacts
packaging/pkg/
diff --git a/Cargo.lock b/Cargo.lock
index b93e0eb..19851fc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -189,6 +189,28 @@ dependencies = [
"libloading 0.7.4",
]
+[[package]]
+name = "ashpd"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
+dependencies = [
+ "async-fs",
+ "async-net",
+ "enumflags2",
+ "futures-channel",
+ "futures-util",
+ "rand 0.9.4",
+ "raw-window-handle",
+ "serde",
+ "serde_repr",
+ "url",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "zbus 5.14.0",
+]
+
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -267,6 +289,17 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "async-net"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
+dependencies = [
+ "async-io",
+ "blocking",
+ "futures-lite",
+]
+
[[package]]
name = "async-process"
version = "2.5.0"
@@ -411,6 +444,15 @@ dependencies = [
"objc2 0.5.2",
]
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2 0.6.4",
+]
+
[[package]]
name = "blocking"
version = "1.6.2"
@@ -614,7 +656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f"
dependencies = [
"objc2 0.5.2",
- "objc2-app-kit",
+ "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
]
@@ -871,7 +913,16 @@ dependencies = [
"rust-ini",
"web-sys",
"winreg",
- "zbus",
+ "zbus 4.4.0",
+]
+
+[[package]]
+name = "dataview"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daba87f72c730b508641c9fb6411fc9bba73939eed2cab611c399500511880d0"
+dependencies = [
+ "derive_pod",
]
[[package]]
@@ -911,6 +962,12 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7046468a81e6a002061c01e6a7c83139daf91b11c30e66795b13217c2d885c8b"
+[[package]]
+name = "derive_pod"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2ea6706d74fca54e15f1d40b5cf7fe7f764aaec61352a9fcec58fe27e042fc8"
+
[[package]]
name = "detect-desktop-environment"
version = "0.2.0"
@@ -990,6 +1047,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.11.1",
+ "block2 0.6.2",
+ "libc",
"objc2 0.6.4",
]
@@ -2525,6 +2584,12 @@ dependencies = [
"memoffset",
]
+[[package]]
+name = "no-std-compat"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2598,7 +2663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [
"bitflags 2.11.1",
- "block2",
+ "block2 0.5.1",
"libc",
"objc2 0.5.2",
"objc2-core-data",
@@ -2607,6 +2672,18 @@ dependencies = [
"objc2-quartz-core 0.2.2",
]
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2 0.6.2",
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+]
+
[[package]]
name = "objc2-cloud-kit"
version = "0.2.2"
@@ -2614,7 +2691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [
"bitflags 2.11.1",
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-core-location",
"objc2-foundation 0.2.2",
@@ -2626,7 +2703,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
dependencies = [
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2638,7 +2715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
"bitflags 2.11.1",
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2673,7 +2750,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal",
@@ -2685,7 +2762,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
dependencies = [
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-contacts",
"objc2-foundation 0.2.2",
@@ -2704,7 +2781,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
"bitflags 2.11.1",
- "block2",
+ "block2 0.5.1",
"dispatch",
"libc",
"objc2 0.5.2",
@@ -2738,9 +2815,9 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
- "objc2-app-kit",
+ "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
]
@@ -2751,7 +2828,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
"bitflags 2.11.1",
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2763,7 +2840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
"bitflags 2.11.1",
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal",
@@ -2798,7 +2875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [
"bitflags 2.11.1",
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-cloud-kit",
"objc2-core-data",
@@ -2818,7 +2895,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
dependencies = [
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2830,7 +2907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [
"bitflags 2.11.1",
- "block2",
+ "block2 0.5.1",
"objc2 0.5.2",
"objc2-core-location",
"objc2-foundation 0.2.2",
@@ -3036,6 +3113,25 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+[[package]]
+name = "pelite"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88dccf4bd32294364aeb7bd55d749604450e9db54605887551f21baea7617685"
+dependencies = [
+ "dataview",
+ "libc",
+ "no-std-compat",
+ "pelite-macros",
+ "winapi",
+]
+
+[[package]]
+name = "pelite-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a7cf3f8ecebb0f4895f4892a8be0a0dc81b498f9d56735cb769dc31bf00815b"
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -3059,7 +3155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
- "rand",
+ "rand 0.8.6",
]
[[package]]
@@ -3166,6 +3262,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "pollster"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -3261,8 +3363,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
- "rand_chacha",
- "rand_core",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
]
[[package]]
@@ -3272,7 +3384,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
- "rand_core",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
]
[[package]]
@@ -3284,6 +3406,15 @@ dependencies = [
"getrandom 0.2.17",
]
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -3427,6 +3558,30 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "rfd"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
+dependencies = [
+ "ashpd",
+ "block2 0.6.2",
+ "dispatch2",
+ "js-sys",
+ "log",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "pollster",
+ "raw-window-handle",
+ "urlencoding",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -4436,7 +4591,9 @@ dependencies = [
"iced_fonts",
"ksni",
"owo-colors",
+ "pelite",
"reqwest",
+ "rfd",
"serde",
"serde_json",
"tokio",
@@ -4519,8 +4676,15 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
+ "serde_derive",
]
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4533,6 +4697,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "uuid"
+version = "1.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
+dependencies = [
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -5232,7 +5407,7 @@ dependencies = [
"android-activity",
"atomic-waker",
"bitflags 2.11.1",
- "block2",
+ "block2 0.5.1",
"bytemuck",
"calloop 0.13.0",
"cfg_aliases 0.2.1",
@@ -5246,7 +5421,7 @@ dependencies = [
"memmap2",
"ndk",
"objc2 0.5.2",
- "objc2-app-kit",
+ "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
"objc2-ui-kit",
"orbclient",
@@ -5527,7 +5702,7 @@ dependencies = [
"hex",
"nix",
"ordered-stream",
- "rand",
+ "rand 0.8.6",
"serde",
"serde_repr",
"sha1",
@@ -5536,9 +5711,44 @@ dependencies = [
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
- "zbus_macros",
- "zbus_names",
- "zvariant",
+ "zbus_macros 4.4.0",
+ "zbus_names 3.0.0",
+ "zvariant 4.2.0",
+]
+
+[[package]]
+name = "zbus"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix 1.1.4",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow 0.7.15",
+ "zbus_macros 5.14.0",
+ "zbus_names 4.3.1",
+ "zvariant 5.10.0",
]
[[package]]
@@ -5551,7 +5761,22 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
- "zvariant_utils",
+ "zvariant_utils 2.1.0",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zbus_names 4.3.1",
+ "zvariant 5.10.0",
+ "zvariant_utils 3.3.0",
]
[[package]]
@@ -5562,7 +5787,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
- "zvariant",
+ "zvariant 4.2.0",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
+dependencies = [
+ "serde",
+ "winnow 0.7.15",
+ "zvariant 5.10.0",
]
[[package]]
@@ -5667,7 +5903,22 @@ dependencies = [
"enumflags2",
"serde",
"static_assertions",
- "zvariant_derive",
+ "zvariant_derive 4.2.0",
+]
+
+[[package]]
+name = "zvariant"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "url",
+ "winnow 0.7.15",
+ "zvariant_derive 5.10.0",
+ "zvariant_utils 3.3.0",
]
[[package]]
@@ -5680,7 +5931,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
- "zvariant_utils",
+ "zvariant_utils 2.1.0",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zvariant_utils 3.3.0",
]
[[package]]
@@ -5693,3 +5957,16 @@ dependencies = [
"quote",
"syn 2.0.117",
]
+
+[[package]]
+name = "zvariant_utils"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 2.0.117",
+ "winnow 0.7.15",
+]
diff --git a/Cargo.toml b/Cargo.toml
index 5cadc2a..15aa2a5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -46,3 +46,9 @@ reqwest = { version = "0.12", features = ["blocking", "json"] }
iced = { version = "0.13", features = ["tokio"] }
iced_fonts = { version = "0.1", features = ["bootstrap"] }
tokio = { version = "1.52.1", features = ["rt"] }
+
+# PE exe version info (game name detection)
+pelite = "0.10"
+
+# Native file dialogs via XDG Desktop Portal
+rfd = "0.15"
diff --git a/README.md b/README.md
index 15a7078..8f64300 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ Launch / Kill entries. Users can add or remove launchers in `config edit`.
(no ~600 MB in-memory buffering), with a progress indicator.
- `diagnose` — sanity-checks umu-run, Vulkan, display server, per-launcher
prefix / exe / ownership / running state.
-- `service` — installs an XDG autostart entry so the tray autostarts with
+- `autostart` — manages the XDG autostart entry so the tray starts with
the graphical session.
- `setup` — graphical wizard (iced) that downloads an installer URL
(with progress bar) or accepts a local `.exe`, then runs it via
@@ -51,7 +51,7 @@ sudo pacman -S umu-launcher vulkan-tools
Then enable autostart:
```sh
-umutray service install
+umutray autostart install
```
## Usage
@@ -79,9 +79,9 @@ umutray service install
| `umutray config add-game …` | Attach a game to a launcher (needs `--exe-path`) |
| `umutray config remove-game …` | Drop a game from a launcher |
| `umutray config set-game-flags …`| Per-game overlay toggles: gamemode/mangohud/gamescope |
-| `umutray service install` | Write XDG autostart entry (tray starts on login) |
-| `umutray service uninstall` | Remove the autostart and desktop entries |
-| `umutray service status` | Show whether XDG autostart is enabled |
+| `umutray autostart install` | Write XDG autostart entry (tray starts on login) |
+| `umutray autostart uninstall` | Remove the autostart and desktop entries |
+| `umutray autostart status` | Show whether XDG autostart is enabled |
## Config
diff --git a/assets/umutray.svg b/assets/umutray.svg
new file mode 100644
index 0000000..94d5ac4
--- /dev/null
+++ b/assets/umutray.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD
index 050bcdb..3b1ae10 100644
--- a/packaging/PKGBUILD
+++ b/packaging/PKGBUILD
@@ -20,8 +20,6 @@ license=('MIT')
depends=('umu-launcher')
makedepends=('rust' 'cargo')
optdepends=(
- 'zenity: folder picker in setup wizard (GNOME/GTK)'
- 'kdialog: folder picker in setup wizard (KDE)'
'gamemode: per-game GameMode support'
'mangohud: per-game MangoHud overlay'
'gamescope: per-game Gamescope compositor'
@@ -54,6 +52,9 @@ package() {
# App menu entry
install -Dm644 umutray.desktop "$pkgdir/usr/share/applications/umutray.desktop"
+ # Icon
+ install -Dm644 assets/umutray.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/umutray.svg"
+
# License
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
diff --git a/src/service.rs b/src/autostart.rs
similarity index 67%
rename from src/service.rs
rename to src/autostart.rs
index e549b9c..7ecf84a 100644
--- a/src/service.rs
+++ b/src/autostart.rs
@@ -1,8 +1,9 @@
use anyhow::{Context, Result};
use owo_colors::OwoColorize;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
const DESKTOP_NAME: &str = "umutray.desktop";
+const ICON_SVG: &[u8] = include_bytes!("../assets/umutray.svg");
fn home() -> Result {
dirs::home_dir().context("Cannot determine home directory")
@@ -27,7 +28,7 @@ fn render_desktop(exe: &std::path::Path, autostart: bool) -> String {
Name=umutray\n\
Comment=Wine launcher manager for Windows game launchers\n\
Exec={exec}\n\
- Icon=applications-games\n\
+ Icon=umutray\n\
Type=Application\n\
Categories=Game;\n\
Keywords=wine;proton;gaming;launcher;\n\
@@ -42,16 +43,57 @@ fn render_desktop(exe: &std::path::Path, autostart: bool) -> String {
s
}
+fn ensure_parent(path: &Path) -> Result<()> {
+ if let Some(p) = path.parent() {
+ std::fs::create_dir_all(p)
+ .with_context(|| format!("Failed to create {}", p.display()))?;
+ }
+ Ok(())
+}
+
+fn write_file(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
+ ensure_parent(path)?;
+ std::fs::write(path, contents)
+ .with_context(|| format!("Failed to write {}", path.display()))
+}
+
+fn remove_file(path: &Path) -> Result<()> {
+ if path.exists() {
+ std::fs::remove_file(path)
+ .with_context(|| format!("Failed to remove {}", path.display()))?;
+ }
+ Ok(())
+}
+
+fn icon_path() -> Result {
+ Ok(home()?.join(".local/share/icons/hicolor/scalable/apps/umutray.svg"))
+}
+
+fn install_icon() -> Result<()> {
+ write_file(&icon_path()?, ICON_SVG)
+}
+
+/// Ensure the tray icon SVG is present in the XDG icon theme directory.
+/// Called automatically on startup so the icon works without needing
+/// a separate install step.
+pub fn ensure_icon() {
+ let Ok(path) = icon_path() else { return };
+ if !path.exists() {
+ let _ = install_icon();
+ }
+}
+
+fn uninstall_icon() -> Result<()> {
+ remove_file(&icon_path()?)
+}
+
/// Install only the .desktop file so umutray appears in the app menu.
/// Called automatically on first `umutray gui` run.
pub fn install_desktop() -> Result<()> {
let exe = std::env::current_exe().context("Cannot determine path to own executable")?;
let desktop = desktop_path()?;
- if let Some(p) = desktop.parent() {
- std::fs::create_dir_all(p).with_context(|| format!("Failed to create {}", p.display()))?;
- }
- std::fs::write(&desktop, render_desktop(&exe, false))
- .with_context(|| format!("Failed to write desktop file {}", desktop.display()))?;
+ write_file(&desktop, render_desktop(&exe, false))?;
+ install_icon()?;
println!("{} App menu entry written: {}", "✓".green().bold(), desktop.display());
Ok(())
}
@@ -60,8 +102,7 @@ pub fn install_desktop() -> Result<()> {
pub fn uninstall_desktop() -> Result<()> {
let desktop = desktop_path()?;
if desktop.exists() {
- std::fs::remove_file(&desktop)
- .with_context(|| format!("Failed to remove {}", desktop.display()))?;
+ remove_file(&desktop)?;
println!("Removed {}", desktop.display());
} else {
println!("No desktop file at {}", desktop.display());
@@ -75,11 +116,7 @@ pub fn install() -> Result<()> {
// XDG autostart
let autostart = autostart_path()?;
- if let Some(p) = autostart.parent() {
- std::fs::create_dir_all(p).with_context(|| format!("Failed to create {}", p.display()))?;
- }
- std::fs::write(&autostart, render_desktop(&exe, true))
- .with_context(|| format!("Failed to write autostart file {}", autostart.display()))?;
+ write_file(&autostart, render_desktop(&exe, true))?;
println!("Wrote autostart: {}", autostart.display());
// App-menu entry
@@ -96,14 +133,14 @@ pub fn install() -> Result<()> {
pub fn uninstall() -> Result<()> {
let autostart = autostart_path()?;
if autostart.exists() {
- std::fs::remove_file(&autostart)
- .with_context(|| format!("Failed to remove {}", autostart.display()))?;
+ remove_file(&autostart)?;
println!("Removed {}", autostart.display());
} else {
println!("No autostart file at {}", autostart.display());
}
uninstall_desktop()?;
+ uninstall_icon()?;
println!("{} Autostart removed.", "✓".green().bold());
Ok(())
diff --git a/src/detect.rs b/src/detect.rs
index 9f185d4..e96d767 100644
--- a/src/detect.rs
+++ b/src/detect.rs
@@ -1,6 +1,8 @@
use crate::config::{Config, Launcher};
use anyhow::Result;
use owo_colors::OwoColorize;
+use pelite::pe64::Pe as _;
+use pelite::pe32::Pe as _;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
@@ -123,8 +125,7 @@ static STORE_TITLES: LazyLock> =
/// return a map of absolute install directory → game title.
fn build_store_titles() -> HashMap {
let mut map = HashMap::new();
- let Ok(home) = std::env::var("HOME") else { return map };
- let home = PathBuf::from(home);
+ let Some(home) = dirs::home_dir() else { return map };
// Legendary standalone + Heroic's bundled copy (native and Flatpak).
let legendary_candidates = [
@@ -279,7 +280,9 @@ fn scan_exe_dir(
/// Epic `.egstore/*.item` JSON files at the game's installation root.
/// 4. Launcher path — reads the game name from well-known directory
/// structures laid down by the launcher (e.g. `Epic Games//`).
-/// 5. Nearest non-generic parent directory name, or raw exe stem.
+/// 5. PE version info — reads `ProductName` or `FileDescription` from
+/// the exe's embedded Windows version resource.
+/// 6. Nearest non-generic parent directory name, or raw exe stem.
/// No name generation — if the directory name is unknown, it is used
/// as-is rather than being fabricated from the exe filename.
///
@@ -322,9 +325,14 @@ fn resolve_uncached(exe_path: &Path) -> String {
return name;
}
- // Stage 5 – nearest non-generic parent directory, or raw exe stem.
+ // Stage 5 – PE version info embedded in the exe itself
+ if let Some(name) = read_pe_product_name(exe_path) {
+ return name;
+ }
+
+ // Stage 6 – nearest non-generic parent directory, or raw exe stem.
// No name generation: if we don't know, we say so honestly.
- nearest_dir_name(exe_path)
+ prettify_exe_name(exe_path)
}
/// Walk up from `exe_path` looking for platform manifest files that record the
@@ -418,7 +426,38 @@ fn name_from_launcher_path(exe_path: &Path) -> Option {
None
}
-fn nearest_dir_name(path: &Path) -> String {
+/// Read the `ProductName` (preferred) or `FileDescription` from the exe's
+/// embedded PE version info resource. This is the same data Windows uses for
+/// "Properties → Details" and is present in the vast majority of game exes.
+fn read_pe_product_name(exe_path: &Path) -> Option {
+ let map = pelite::FileMap::open(exe_path).ok()?;
+ // Try 64-bit first, fall back to 32-bit.
+ let vi = pelite::pe64::PeFile::from_bytes(&map)
+ .ok()
+ .and_then(|pe| pe.resources().ok()?.version_info().ok())
+ .or_else(|| {
+ pelite::pe32::PeFile::from_bytes(&map)
+ .ok()?
+ .resources().ok()?
+ .version_info().ok()
+ })?;
+ let lang = *vi.translation().first()?;
+ // ProductName is the canonical game title; FileDescription is a fallback.
+ let name = vi
+ .value(lang, "ProductName")
+ .or_else(|| vi.value(lang, "FileDescription"))?;
+ let name = name.trim();
+ if name.is_empty() {
+ return None;
+ }
+ Some(name.to_string())
+}
+
+/// Heuristic last-resort name derivation from an exe path.
+///
+/// Walks up parent directories looking for a non-generic name; falls back to
+/// inserting spaces into the CamelCase / digit-boundary exe stem.
+pub fn prettify_exe_name(path: &Path) -> String {
const GENERIC_DIRS: &[&str] = &[
"bin", "binaries", "x64", "x86", "win64", "win32", "retail",
"shipping", "game", "runtime", "_retail_", "_commonredist",
@@ -473,7 +512,7 @@ pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> {
}
fn default_roots() -> Vec {
- let Ok(home) = std::env::var("HOME").map(PathBuf::from) else {
+ let Some(home) = dirs::home_dir() else {
return Vec::new();
};
vec![
diff --git a/src/gui.rs b/src/gui.rs
index 016faff..aca52a0 100644
--- a/src/gui.rs
+++ b/src/gui.rs
@@ -1,10 +1,10 @@
use crate::{
- config::Config, detect, diagnose, launcher, proton, service,
+ config::Config, detect, diagnose, launcher, proton, autostart,
theme::{
btn_accent, btn_danger, btn_ghost, card_style, icon, section_heading, sub_card_style,
surface_bg, ACCENT, BORDER_CLR, DIM, GREEN, MUTED, NO_SHADOW, RED,
},
- util::{async_blocking, pick_file, pick_folder},
+ util::{pick_file, pick_folder},
};
use anyhow::Result;
use iced::widget::{
@@ -77,9 +77,9 @@ pub enum Message {
BrowseCompatDir,
BrowseCompatDirDone(Option),
SaveSettings,
- ServiceInstall,
- ServiceUninstall,
- ServiceActionDone(Result<(), String>),
+ AutostartInstall,
+ AutostartUninstall,
+ AutostartDone(Result<(), String>),
LaunchProtontricks,
// Close dialog
CloseRequested(iced::window::Id),
@@ -112,8 +112,8 @@ struct Dashboard {
settings_proton_version: String,
settings_compat_dir: String,
proton_versions: Vec,
- service_busy: bool,
- service_status: String,
+ autostart_busy: bool,
+ autostart_status: String,
// Close dialog
close_dialog_open: bool,
close_action: Arc>>,
@@ -147,8 +147,8 @@ impl Dashboard {
settings_proton_version,
settings_compat_dir,
proton_versions,
- service_busy: false,
- service_status: String::new(),
+ autostart_busy: false,
+ autostart_status: String::new(),
close_dialog_open: false,
close_action,
}
@@ -163,19 +163,21 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
.map(|l| (l.name.clone(), l.process_pattern.clone()))
.collect();
Task::perform(
- async_blocking(move || {
- let mut map = HashMap::new();
- for (name, pattern) in launchers {
- let running = std::process::Command::new("pgrep")
- .args(["-f", &pattern])
- .stdout(std::process::Stdio::null())
- .stderr(std::process::Stdio::null())
- .status()
- .is_ok_and(|s| s.success());
- map.insert(name, running);
- }
- map
- }),
+ async {
+ tokio::task::spawn_blocking(move || {
+ let mut map = HashMap::new();
+ for (name, pattern) in launchers {
+ let running = std::process::Command::new("pgrep")
+ .args(["-f", &pattern])
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .status()
+ .is_ok_and(|s| s.success());
+ map.insert(name, running);
+ }
+ map
+ }).await.expect("blocking task panicked")
+ },
Message::PollDone,
)
}
@@ -197,8 +199,12 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
}
Message::AddLauncher => {
state.context_menu = None;
- let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
- let _ = std::process::Command::new(exe).arg("setup").spawn();
+ let config = state.config.clone();
+ std::thread::spawn(move || {
+ if let Err(e) = crate::setup::run_new(&config) {
+ eprintln!("umutray: setup picker failed: {e}");
+ }
+ });
Task::none()
}
Message::Launch(name) => {
@@ -211,7 +217,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
let l = l.clone();
let name2 = name.clone();
Task::perform(
- async_blocking(move || launcher::launch(&config, &l).map_err(|e| e.to_string())),
+ async { tokio::task::spawn_blocking(move || launcher::launch(&config, &l).map_err(|e| e.to_string())).await.expect("blocking task panicked") },
move |res| Message::LaunchDone(name2.clone(), res),
)
}
@@ -236,7 +242,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
let name2 = name.clone();
state.running.insert(name, false);
Task::perform(
- async_blocking(move || { launcher::kill(&l); Ok::<(), String>(()) }),
+ async { tokio::task::spawn_blocking(move || { launcher::kill(&l); Ok::<(), String>(()) }).await.expect("blocking task panicked") },
move |res| Message::KillDone(name2.clone(), res),
)
}
@@ -257,9 +263,11 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
let lname2 = lname.clone();
let gname2 = gname.clone();
return Task::perform(
- async_blocking(move || {
- launcher::play_game(&config, &l, &g).map_err(|e| e.to_string())
- }),
+ async {
+ tokio::task::spawn_blocking(move || {
+ launcher::play_game(&config, &l, &g).map_err(|e| e.to_string())
+ }).await.expect("blocking task panicked")
+ },
move |res| Message::PlayDone(lname2.clone(), gname2.clone(), res),
);
}
@@ -273,11 +281,16 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
Task::none()
}
Message::Setup(name) => {
- let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
- let _ = std::process::Command::new(exe)
- .arg("setup")
- .arg(&name)
- .spawn();
+ let config = state.config.clone();
+ let name2 = name.clone();
+ std::thread::spawn(move || {
+ if let Some(l) = config.find(&name2) {
+ let l = l.clone();
+ if let Err(e) = crate::setup::run(&config, &l) {
+ eprintln!("umutray: setup for {} failed: {e}", l.name);
+ }
+ }
+ });
Task::none()
}
Message::ToggleGameMode(lname, gname) => {
@@ -304,9 +317,11 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
state.last_error = None;
let config = state.config.clone();
Task::perform(
- async_blocking(move || {
- crate::proton::install_latest(&config).map_err(|e| e.to_string())
- }),
+ async {
+ tokio::task::spawn_blocking(move || {
+ crate::proton::install_latest(&config).map_err(|e| e.to_string())
+ }).await.expect("blocking task panicked")
+ },
Message::ProtonDone,
)
}
@@ -346,11 +361,16 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
}
Message::RerunSetup(name) => {
state.context_menu = None;
- let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
- let _ = std::process::Command::new(exe)
- .arg("setup")
- .arg(&name)
- .spawn();
+ let config = state.config.clone();
+ let name2 = name.clone();
+ std::thread::spawn(move || {
+ if let Some(l) = config.find(&name2) {
+ let l = l.clone();
+ if let Err(e) = crate::setup::run(&config, &l) {
+ eprintln!("umutray: setup for {} failed: {e}", l.name);
+ }
+ }
+ });
Task::none()
}
Message::RemoveLauncher(name) => {
@@ -368,7 +388,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
state.detect_result = "Scanning…".into();
let config = state.config.clone();
Task::perform(
- async_blocking(move || detect::scan_for_gui(&config)),
+ async { tokio::task::spawn_blocking(move || detect::scan_for_gui(&config)).await.expect("blocking task panicked") },
Message::DetectDone,
)
}
@@ -399,14 +419,16 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
let config = state.config.clone();
let lname = name.clone();
Task::perform(
- async_blocking(move || {
- diagnose::run_checks(&config, Some(&name))
- .unwrap_or_else(|e| vec![diagnose::CheckResult {
- label: "error".into(),
- pass: false,
- detail: e.to_string(),
- }])
- }),
+ async {
+ tokio::task::spawn_blocking(move || {
+ diagnose::run_checks(&config, Some(&name))
+ .unwrap_or_else(|e| vec![diagnose::CheckResult {
+ label: "error".into(),
+ pass: false,
+ detail: e.to_string(),
+ }])
+ }).await.expect("blocking task panicked")
+ },
move |checks| Message::DiagnoseDone(lname.clone(), checks),
)
}
@@ -476,7 +498,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
let lname2 = lname.clone();
state.scan_busy.insert(lname);
return Task::perform(
- async_blocking(move || detect::scan_games_in_prefix(&l)),
+ async { tokio::task::spawn_blocking(move || detect::scan_games_in_prefix(&l)).await.expect("blocking task panicked") },
move |hits| Message::ScanGamesDone(lname2.clone(), hits),
);
}
@@ -516,7 +538,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
.unwrap_or_default();
let lname2 = lname.clone();
Task::perform(
- async_blocking(move || pick_file("Select game executable", &start)),
+ async { tokio::task::spawn_blocking(move || pick_file("Select game executable", &start)).await.expect("blocking task panicked") },
move |res| Message::BrowseGameExeDone(lname2.clone(), res),
)
}
@@ -550,7 +572,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
state.settings_compat_dir =
state.config.proton_compat_dir.to_string_lossy().into_owned();
state.proton_versions = proton::list_installed(&state.config);
- state.service_status = String::new();
+ state.autostart_status = String::new();
Task::none()
}
Message::HideSettings => {
@@ -566,7 +588,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
Task::none()
}
Message::BrowseCompatDir => Task::perform(
- async_blocking(|| pick_folder("Choose GE-Proton compat directory")),
+ async { tokio::task::spawn_blocking(|| pick_folder("Choose GE-Proton compat directory")).await.expect("blocking task panicked") },
Message::BrowseCompatDirDone,
),
Message::BrowseCompatDirDone(path) => {
@@ -603,7 +625,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
match state.config.set_globals(Some(version_key), Some(compat)) {
Ok(()) => {
state.proton_versions = proton::list_installed(&state.config);
- state.service_status = "Settings saved.".into();
+ state.autostart_status = "Settings saved.".into();
}
Err(e) => {
state.last_error = Some(format!("Save failed: {e}"));
@@ -619,34 +641,34 @@ fn update(state: &mut Dashboard, msg: Message) -> Task {
});
Task::none()
}
- Message::ServiceInstall => {
- state.service_busy = true;
- state.service_status = "Installing autostart…".into();
+ Message::AutostartInstall => {
+ state.autostart_busy = true;
+ state.autostart_status = "Installing autostart…".into();
Task::perform(
- async_blocking(|| service::install().map_err(|e| e.to_string())),
- Message::ServiceActionDone,
+ async { tokio::task::spawn_blocking(|| autostart::install().map_err(|e| e.to_string())).await.expect("blocking task panicked") },
+ Message::AutostartDone,
)
}
- Message::ServiceUninstall => {
- state.service_busy = true;
- state.service_status = "Removing autostart…".into();
+ Message::AutostartUninstall => {
+ state.autostart_busy = true;
+ state.autostart_status = "Removing autostart…".into();
Task::perform(
- async_blocking(|| service::uninstall().map_err(|e| e.to_string())),
- Message::ServiceActionDone,
+ async { tokio::task::spawn_blocking(|| autostart::uninstall().map_err(|e| e.to_string())).await.expect("blocking task panicked") },
+ Message::AutostartDone,
)
}
- Message::ServiceActionDone(res) => {
- state.service_busy = false;
+ Message::AutostartDone(res) => {
+ state.autostart_busy = false;
match res {
Ok(()) => {
- state.service_status = if service_is_installed() {
+ state.autostart_status = if autostart_is_installed() {
"Autostart enabled — starts on next login.".into()
} else {
"Autostart removed.".into()
};
}
Err(e) => {
- state.service_status = format!("Failed: {e}");
+ state.autostart_status = format!("Failed: {e}");
}
}
Task::none()
@@ -697,7 +719,7 @@ fn toggle_flag(
let _ = config.save();
}
-fn service_is_installed() -> bool {
+fn autostart_is_installed() -> bool {
dirs::home_dir()
.is_some_and(|h| h.join(".config/autostart/umutray.desktop").exists())
}
@@ -1626,25 +1648,25 @@ fn view_settings(state: &Dashboard) -> Element<'_, Message> {
);
// ── Autostart section ─────────────────────────────────────────────────
- let installed = service_is_installed();
+ let installed = autostart_is_installed();
let svc_status_color = if installed { GREEN } else { MUTED };
let svc_status_text = if installed { "Enabled — starts on login" } else { "Disabled" };
let svc_status_icon = if installed { "\u{f26a}" } else { "\u{f28a}" };
let svc_install_btn = button(
- row![icon("\u{f64d}", 12), text(if state.service_busy { " Working…" } else { " Enable" }).size(12)]
+ row![icon("\u{f64d}", 12), text(if state.autostart_busy { " Working…" } else { " Enable" }).size(12)]
.align_y(Alignment::Center).spacing(4),
)
- .on_press_maybe((!state.service_busy && !installed).then_some(Message::ServiceInstall))
+ .on_press_maybe((!state.autostart_busy && !installed).then_some(Message::AutostartInstall))
.style(btn_accent)
.padding([7, 14]);
let svc_uninstall_btn = button(
- row![icon("\u{f659}", 12), text(if state.service_busy { " Working…" } else { " Disable" }).size(12)]
+ row![icon("\u{f659}", 12), text(if state.autostart_busy { " Working…" } else { " Disable" }).size(12)]
.align_y(Alignment::Center).spacing(4),
)
- .on_press_maybe((!state.service_busy && installed).then_some(Message::ServiceUninstall))
+ .on_press_maybe((!state.autostart_busy && installed).then_some(Message::AutostartUninstall))
.style(btn_danger)
.padding([7, 14]);
@@ -1659,7 +1681,7 @@ fn view_settings(state: &Dashboard) -> Element<'_, Message> {
svc_install_btn,
svc_uninstall_btn,
].align_y(Alignment::Center).spacing(8),
- text(&state.service_status).size(11).style(|_: &Theme| text::Style {
+ text(&state.autostart_status).size(11).style(|_: &Theme| text::Style {
color: Some(DIM),
}),
].spacing(6).into(),
diff --git a/src/launcher.rs b/src/launcher.rs
index a18f8f7..60a76ba 100644
--- a/src/launcher.rs
+++ b/src/launcher.rs
@@ -34,11 +34,29 @@ pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
let proton_path = resolve_proton_path(config, launcher);
- std::process::Command::new("umu-run")
- .env("WINEPREFIX", &launcher.prefix_dir)
+ // Propagate overlay env vars from any configured game so that games
+ // launched from within the launcher (e.g. Battle.net → Diablo) inherit
+ // them. gamemoderun wraps umu-run so the whole process tree gets
+ // gamemode; MANGOHUD=1 is inherited by all child processes.
+ let any_gamemode = launcher.games.iter().any(|g| g.gamemode);
+ let any_mangohud = launcher.games.iter().any(|g| g.mangohud);
+
+ let (prog, args): (OsString, Vec) = if any_gamemode {
+ let mut a = vec![OsString::from("umu-run")];
+ a.push(exe.into_os_string());
+ ("gamemoderun".into(), a)
+ } else {
+ (OsString::from("umu-run"), vec![exe.into_os_string()])
+ };
+
+ let mut cmd = std::process::Command::new(&prog);
+ cmd.env("WINEPREFIX", &launcher.prefix_dir)
.env("GAMEID", &launcher.gameid)
- .env("PROTONPATH", &proton_path)
- .arg(&exe)
+ .env("PROTONPATH", &proton_path);
+ if any_mangohud {
+ cmd.env("MANGOHUD", "1");
+ }
+ cmd.args(&args)
.spawn()
.context(
"Failed to spawn umu-run. Is it installed?\n\
@@ -48,9 +66,9 @@ pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
Ok(())
}
-/// Launch a game installed through `launcher`, wrapped in the per-game
-/// overlays (gamescope, gamemoderun, MANGOHUD). The launcher itself is
-/// never wrapped — only games run through this path pick up overlays.
+/// Launch a game directly via umu-run, wrapped in the per-game overlays
+/// (gamescope, gamemoderun, MANGOHUD). Ensures the parent launcher is
+/// running first so the game can authenticate online.
pub fn play_game(config: &Config, launcher: &Launcher, game: &Game) -> Result<()> {
let exe = game.full_exe_path(launcher);
if !exe.exists() {
@@ -63,6 +81,14 @@ pub fn play_game(config: &Config, launcher: &Launcher, game: &Game) -> Result<()
);
}
+ // Start the launcher if it isn't already running so the game has an
+ // active authentication session (avoids offline-mode).
+ if !is_running(launcher) {
+ launch(config, launcher)?;
+ // Give the launcher a moment to initialise before spawning the game.
+ thread::sleep(Duration::from_secs(5));
+ }
+
let proton_path = resolve_proton_path(config, launcher);
let (prog, args) = build_wrapped_argv(&exe, game);
diff --git a/src/main.rs b/src/main.rs
index 76b7d15..6d7e060 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,7 +6,7 @@ mod diagnose;
mod gui;
mod launcher;
mod proton;
-mod service;
+mod autostart;
mod setup;
mod theme;
mod tray;
@@ -109,10 +109,10 @@ enum Commands {
action: ConfigAction,
},
- /// Manage the XDG autostart entry that starts the tray on login
- Service {
+ /// Manage the XDG autostart and desktop entries
+ Autostart {
#[command(subcommand)]
- action: ServiceAction,
+ action: AutostartAction,
},
}
@@ -222,7 +222,7 @@ enum ConfigAction {
}
#[derive(Subcommand)]
-enum ServiceAction {
+enum AutostartAction {
/// Write ~/.config/autostart/umutray.desktop so the tray starts on login (includes app menu entry)
Install,
/// Remove the autostart entry and app menu entry
@@ -239,6 +239,10 @@ fn main() -> Result<()> {
let cli = Cli::parse();
let config = config::Config::load()?;
+ // Ensure the SVG icon is present in the XDG icon theme so the tray
+ // and desktop entries can find it without a separate install step.
+ autostart::ensure_icon();
+
match cli.command.unwrap_or(Commands::Tray) {
Commands::Tray => tray::run(&config)?,
@@ -428,12 +432,12 @@ fn main() -> Result<()> {
}
},
- Commands::Service { action } => match action {
- ServiceAction::Install => service::install()?,
- ServiceAction::Uninstall => service::uninstall()?,
- ServiceAction::Status => service::status()?,
- ServiceAction::InstallDesktop => service::install_desktop()?,
- ServiceAction::UninstallDesktop => service::uninstall_desktop()?,
+ Commands::Autostart { action } => match action {
+ AutostartAction::Install => autostart::install()?,
+ AutostartAction::Uninstall => autostart::uninstall()?,
+ AutostartAction::Status => autostart::status()?,
+ AutostartAction::InstallDesktop => autostart::install_desktop()?,
+ AutostartAction::UninstallDesktop => autostart::uninstall_desktop()?,
},
}
diff --git a/src/setup.rs b/src/setup.rs
index f792b33..d5be97d 100644
--- a/src/setup.rs
+++ b/src/setup.rs
@@ -4,7 +4,7 @@ use crate::{
btn_accent, btn_ghost, card_style, icon, sub_card_style, surface_bg, ACCENT,
DIM, GREEN, MUTED, RED, SURFACE_RAISED,
},
- util::{async_blocking, pick_folder},
+ util::pick_folder,
};
use anyhow::Result;
use iced::widget::{
@@ -154,7 +154,7 @@ fn update(state: &mut State, message: Message) -> Task {
Task::none()
}
Message::BrowsePrefix => Task::perform(
- async_blocking(|| pick_folder("Choose install location (Wine prefix)")),
+ async { tokio::task::spawn_blocking(|| pick_folder("Choose install location (Wine prefix)")).await.expect("blocking task panicked") },
Message::BrowsePrefixDone,
),
Message::BrowsePrefixDone(path) => {
@@ -204,7 +204,7 @@ fn update(state: &mut State, message: Message) -> Task {
.clone();
let progress = state.download.clone();
return Task::perform(
- async_blocking(move || download_blocking(&url, &name, progress)),
+ async { tokio::task::spawn_blocking(move || download_blocking(&url, &name, progress)).await.expect("blocking task panicked") },
Message::PrepareDone,
);
}
@@ -259,7 +259,7 @@ fn update(state: &mut State, message: Message) -> Task {
.clone();
let progress = state.download.clone();
Task::perform(
- async_blocking(move || download_blocking(&url, &name, progress)),
+ async { tokio::task::spawn_blocking(move || download_blocking(&url, &name, progress)).await.expect("blocking task panicked") },
Message::PrepareDone,
)
}
@@ -303,7 +303,7 @@ fn update(state: &mut State, message: Message) -> Task {
.clone();
let progress = state.download.clone();
Task::perform(
- async_blocking(move || download_blocking(&src, &name, progress)),
+ async { tokio::task::spawn_blocking(move || download_blocking(&src, &name, progress)).await.expect("blocking task panicked") },
Message::PrepareDone,
)
}
@@ -346,7 +346,7 @@ fn update(state: &mut State, message: Message) -> Task {
.expect("launcher set before install");
let log = state.log.clone();
Task::perform(
- async_blocking(move || run_installer(&config, &launcher, &installer, log)),
+ async { tokio::task::spawn_blocking(move || run_installer(&config, &launcher, &installer, log)).await.expect("blocking task panicked") },
Message::InstallDone,
)
}
diff --git a/src/tray.rs b/src/tray.rs
index 7f5f9d5..b2e56e8 100644
--- a/src/tray.rs
+++ b/src/tray.rs
@@ -1,33 +1,39 @@
use crate::{config::Config, launcher};
use anyhow::Result;
use std::collections::HashMap;
-use std::path::PathBuf;
use std::thread;
use std::time::Duration;
-fn spawn_setup(name: &str) {
- let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
- if let Err(e) = std::process::Command::new(exe)
- .arg("setup")
- .arg(name)
- .spawn()
- {
- eprintln!("umutray: failed to launch setup for {name}: {e}");
- }
+fn spawn_setup(config: &Config, name: &str) {
+ let config = config.clone();
+ let name = name.to_owned();
+ thread::spawn(move || {
+ if let Some(l) = config.find(&name) {
+ let l = l.clone();
+ if let Err(e) = crate::setup::run(&config, &l) {
+ eprintln!("umutray: setup for {name} failed: {e}");
+ }
+ }
+ });
}
-fn spawn_gui() {
- let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
- if let Err(e) = std::process::Command::new(exe).arg("gui").spawn() {
- eprintln!("umutray: failed to launch dashboard: {e}");
- }
+fn spawn_gui(config: &Config) {
+ let config = config.clone();
+ thread::spawn(move || {
+ match crate::gui::run(&config) {
+ Ok(_) => {}
+ Err(e) => eprintln!("umutray: failed to launch dashboard: {e}"),
+ }
+ });
}
-fn spawn_setup_picker() {
- let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
- if let Err(e) = std::process::Command::new(exe).arg("setup").spawn() {
- eprintln!("umutray: failed to launch setup picker: {e}");
- }
+fn spawn_setup_picker(config: &Config) {
+ let config = config.clone();
+ thread::spawn(move || {
+ if let Err(e) = crate::setup::run_new(&config) {
+ eprintln!("umutray: failed to launch setup picker: {e}");
+ }
+ });
}
enum GameFlag {
@@ -79,7 +85,7 @@ pub struct UmuTray {
pub config: Config,
/// Per-launcher running state keyed by launcher.name
pub running: HashMap,
- /// Set after the service spawns so Quit can shut down the SNI item
+ /// Set after the tray spawns so Quit can shut down the SNI item
/// cleanly instead of yanking it off the bus via exit().
pub handle: Option>,
}
@@ -90,7 +96,7 @@ impl ksni::Tray for UmuTray {
}
fn icon_name(&self) -> String {
- "applications-games".into()
+ "umutray".into()
}
fn title(&self) -> String {
@@ -107,7 +113,7 @@ impl ksni::Tray for UmuTray {
StandardItem {
label: "Open Dashboard".into(),
icon_name: "applications-games".into(),
- activate: Box::new(|_: &mut Self| spawn_gui()),
+ activate: Box::new(|this: &mut Self| spawn_gui(&this.config)),
..Default::default()
}
.into(),
@@ -126,8 +132,8 @@ impl ksni::Tray for UmuTray {
StandardItem {
label: format!("Setup {display}…"),
icon_name: "document-new".into(),
- activate: Box::new(move |_this: &mut Self| {
- spawn_setup(&setup_name);
+ activate: Box::new(move |this: &mut Self| {
+ spawn_setup(&this.config, &setup_name);
}),
..Default::default()
}
@@ -245,7 +251,7 @@ impl ksni::Tray for UmuTray {
StandardItem {
label: "Add Launcher…".into(),
icon_name: "list-add".into(),
- activate: Box::new(|_: &mut Self| spawn_setup_picker()),
+ activate: Box::new(|this: &mut Self| spawn_setup_picker(&this.config)),
..Default::default()
}
.into(),
diff --git a/src/util.rs b/src/util.rs
index 0b0b758..cd41b56 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -1,59 +1,18 @@
-/// Run a blocking closure on the tokio blocking thread pool and await its result.
-/// Used to offload blocking work (HTTP, disk, process spawning) without
-/// stalling the iced event loop.
-pub async fn async_blocking(f: F) -> T
-where
- T: Send + 'static,
- F: FnOnce() -> T + Send + 'static,
-{
- tokio::task::spawn_blocking(f)
- .await
- .expect("blocking task panicked")
-}
-
/// Open a native folder picker dialog and return the chosen path, or None if
-/// the user cancelled. Tries zenity (GNOME/GTK) then kdialog (KDE) in order.
+/// the user cancelled. Uses XDG Desktop Portal where available.
pub fn pick_folder(title: &str) -> Option {
- for (cmd, args) in [
- ("zenity", vec!["--file-selection", "--directory", "--title", title]),
- ("kdialog", vec!["--getexistingdirectory", "/home", "--title", title]),
- ] {
- let Ok(out) = std::process::Command::new(cmd).args(&args).output() else {
- continue;
- };
- if out.status.success() {
- let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
- if !s.is_empty() {
- return Some(s);
- }
- }
- }
- None
+ rfd::FileDialog::new()
+ .set_title(title)
+ .pick_folder()
+ .map(|p| p.to_string_lossy().into_owned())
}
/// Open a native file picker dialog starting in `start_dir`, or None if
-/// the user cancelled. Tries zenity then kdialog.
+/// the user cancelled. Uses XDG Desktop Portal where available.
pub fn pick_file(title: &str, start_dir: &str) -> Option {
- // zenity uses --filename with a trailing slash to open a directory
- let start_slash = format!("{}/", start_dir.trim_end_matches('/'));
- let zenity_args = vec![
- "--file-selection",
- "--title",
- title,
- "--filename",
- &start_slash,
- ];
- let kdialog_args = vec!["--getopenfilename", start_dir, "--title", title];
- for (cmd, args) in [("zenity", zenity_args.as_slice()), ("kdialog", kdialog_args.as_slice())] {
- let Ok(out) = std::process::Command::new(cmd).args(args).output() else {
- continue;
- };
- if out.status.success() {
- let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
- if !s.is_empty() {
- return Some(s);
- }
- }
- }
- None
+ rfd::FileDialog::new()
+ .set_title(title)
+ .set_directory(start_dir)
+ .pick_file()
+ .map(|p| p.to_string_lossy().into_owned())
}
diff --git a/umutray.desktop b/umutray.desktop
index eee941f..b3a1ab8 100644
--- a/umutray.desktop
+++ b/umutray.desktop
@@ -2,7 +2,7 @@
Name=umutray
Comment=Wine launcher manager for Windows game launchers
Exec=umutray gui
-Icon=applications-games
+Icon=umutray
Type=Application
Categories=Game;
Keywords=wine;proton;gaming;launcher;