Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bacf345f0 | |||
| e72ee69c14 | |||
| b72c642223 | |||
| 22fa1efabf | |||
| 14eccf4ef0 |
Generated
+3553
-27
File diff suppressed because it is too large
Load Diff
@@ -35,3 +35,6 @@ ksni = "0.2"
|
|||||||
|
|
||||||
# HTTP for GE-Proton GitHub releases API
|
# HTTP for GE-Proton GitHub releases API
|
||||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||||
|
|
||||||
|
# GUI for the setup wizard
|
||||||
|
iced = { version = "0.13", features = ["tokio"] }
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ Launch / Kill entries. Users can add or remove launchers in `config edit`.
|
|||||||
prefix / exe / ownership / running state.
|
prefix / exe / ownership / running state.
|
||||||
- `service` — installs a `systemd --user` unit so the tray autostarts with
|
- `service` — installs a `systemd --user` unit so the tray autostarts with
|
||||||
the graphical session.
|
the graphical session.
|
||||||
- `setup` — prints the manual setup steps for a launcher (a graphical
|
- `setup` — graphical wizard (iced) that downloads an installer URL
|
||||||
wizard via iced is planned).
|
(with progress bar) or accepts a local `.exe`, then runs it via
|
||||||
|
`umu-run` in the launcher's Wine prefix with a live log pane.
|
||||||
|
Uninstalled launchers expose a **Setup…** entry directly in the tray.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -56,14 +58,22 @@ umutray service install
|
|||||||
| `umutray launchers` | List configured launchers and their state |
|
| `umutray launchers` | List configured launchers and their state |
|
||||||
| `umutray launch <name>` | Launch a specific launcher (e.g. `umutray launch epic`) |
|
| `umutray launch <name>` | Launch a specific launcher (e.g. `umutray launch epic`) |
|
||||||
| `umutray kill [<name>]` | Kill one launcher, or all if no name is given |
|
| `umutray kill [<name>]` | Kill one launcher, or all if no name is given |
|
||||||
|
| `umutray play <launcher> <game>` | Play a game with its configured overlays |
|
||||||
|
| `umutray games [<launcher>]` | List configured games and their overlay flags |
|
||||||
| `umutray diagnose [<name>]` | Health checks (one launcher or all) |
|
| `umutray diagnose [<name>]` | Health checks (one launcher or all) |
|
||||||
| `umutray setup <name>` | Print setup steps for a launcher |
|
| `umutray setup <name>` | Open the graphical setup wizard for a launcher |
|
||||||
|
| `umutray detect [--apply]` | Scan common Wine prefixes for installed launchers |
|
||||||
| `umutray update-proton --latest` | Install newest GE-Proton release |
|
| `umutray update-proton --latest` | Install newest GE-Proton release |
|
||||||
| `umutray update-proton --list` | Show recent releases without installing |
|
| `umutray update-proton --list` | Show recent releases without installing |
|
||||||
| `umutray update-proton` | Interactive version picker |
|
| `umutray update-proton` | Interactive version picker |
|
||||||
| `umutray config show` / `path` | Print current config or its file path |
|
| `umutray config show` / `path` | Print current config or its file path |
|
||||||
| `umutray config edit` | Open config in `$EDITOR` |
|
| `umutray config edit` | Open config in `$EDITOR` |
|
||||||
| `umutray config set …` | Update globals (`--proton-version`, `--compat-dir`) |
|
| `umutray config set …` | Update globals (`--proton-version`, `--compat-dir`) |
|
||||||
|
| `umutray config add-launcher …` | Append a new launcher (needs `--exe-path`) |
|
||||||
|
| `umutray config remove-launcher` | Drop a launcher (prefix on disk is left untouched) |
|
||||||
|
| `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 + enable a `systemd --user` unit |
|
| `umutray service install` | Write + enable a `systemd --user` unit |
|
||||||
| `umutray service uninstall` | Stop, disable, and remove the unit |
|
| `umutray service uninstall` | Stop, disable, and remove the unit |
|
||||||
| `umutray service status` | `systemctl --user status umutray.service` |
|
| `umutray service status` | `systemctl --user status umutray.service` |
|
||||||
|
|||||||
+203
-6
@@ -24,6 +24,10 @@ pub struct Launcher {
|
|||||||
/// Config::proton_version).
|
/// Config::proton_version).
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub proton_version: Option<String>,
|
pub proton_version: Option<String>,
|
||||||
|
/// Games installed through this launcher. Overlays (gamemode, mangohud,
|
||||||
|
/// gamescope) only apply to games — the launcher itself always runs bare.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub games: Vec<Game>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Launcher {
|
impl Launcher {
|
||||||
@@ -31,6 +35,40 @@ impl Launcher {
|
|||||||
pub fn full_exe_path(&self) -> PathBuf {
|
pub fn full_exe_path(&self) -> PathBuf {
|
||||||
self.prefix_dir.join("drive_c").join(&self.exe_path)
|
self.prefix_dir.join("drive_c").join(&self.exe_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn find_game(&self, name: &str) -> Option<&Game> {
|
||||||
|
self.games.iter().find(|g| g.name == name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Game {
|
||||||
|
/// Short CLI name (e.g. "overwatch").
|
||||||
|
pub name: String,
|
||||||
|
/// Display name for tray / menus.
|
||||||
|
pub display: String,
|
||||||
|
/// Path to the game exe relative to the launcher's prefix `drive_c/`.
|
||||||
|
pub exe_path: PathBuf,
|
||||||
|
/// Optional extra args passed to the game exe after umu-run.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Wrap the game in `gamemoderun`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub gamemode: bool,
|
||||||
|
/// Set `MANGOHUD=1` for the game process.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mangohud: bool,
|
||||||
|
/// Wrap the game in `gamescope`. `None` = disabled; `Some(vec)` = enabled
|
||||||
|
/// with those CLI args (empty vec = gamescope defaults).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gamescope: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Game {
|
||||||
|
/// Absolute path to the game exe inside the launcher's prefix.
|
||||||
|
pub fn full_exe_path(&self, launcher: &Launcher) -> PathBuf {
|
||||||
|
launcher.prefix_dir.join("drive_c").join(&self.exe_path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -61,6 +99,20 @@ fn default_proton_version() -> String {
|
|||||||
"GE-Proton".into()
|
"GE-Proton".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn regex_escape(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len() + 4);
|
||||||
|
for c in s.chars() {
|
||||||
|
if matches!(
|
||||||
|
c,
|
||||||
|
'.' | '*' | '?' | '+' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' | '^' | '$'
|
||||||
|
) {
|
||||||
|
out.push('\\');
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// The six launchers umutray ships out of the box. `exe_path`, `gameid`,
|
/// The six launchers umutray ships out of the box. `exe_path`, `gameid`,
|
||||||
/// and `process_pattern` are best-effort defaults for typical installs —
|
/// and `process_pattern` are best-effort defaults for typical installs —
|
||||||
/// users can adjust per-launcher via `umutray config edit`.
|
/// users can adjust per-launcher via `umutray config edit`.
|
||||||
@@ -78,6 +130,7 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
process_pattern: r"Battle\.net".into(),
|
process_pattern: r"Battle\.net".into(),
|
||||||
installer_url: None,
|
installer_url: None,
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
|
games: vec![],
|
||||||
},
|
},
|
||||||
Launcher {
|
Launcher {
|
||||||
name: "eaapp".into(),
|
name: "eaapp".into(),
|
||||||
@@ -90,6 +143,7 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
process_pattern: r"EADesktop\.exe".into(),
|
process_pattern: r"EADesktop\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: None,
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
|
games: vec![],
|
||||||
},
|
},
|
||||||
Launcher {
|
Launcher {
|
||||||
name: "epic".into(),
|
name: "epic".into(),
|
||||||
@@ -102,6 +156,7 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
process_pattern: r"EpicGamesLauncher\.exe".into(),
|
process_pattern: r"EpicGamesLauncher\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: None,
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
|
games: vec![],
|
||||||
},
|
},
|
||||||
Launcher {
|
Launcher {
|
||||||
name: "ubisoft".into(),
|
name: "ubisoft".into(),
|
||||||
@@ -114,6 +169,7 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
process_pattern: r"UbisoftConnect\.exe|upc\.exe".into(),
|
process_pattern: r"UbisoftConnect\.exe|upc\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: None,
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
|
games: vec![],
|
||||||
},
|
},
|
||||||
Launcher {
|
Launcher {
|
||||||
name: "gog".into(),
|
name: "gog".into(),
|
||||||
@@ -126,6 +182,7 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
process_pattern: r"GalaxyClient\.exe".into(),
|
process_pattern: r"GalaxyClient\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: None,
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
|
games: vec![],
|
||||||
},
|
},
|
||||||
Launcher {
|
Launcher {
|
||||||
name: "rockstar".into(),
|
name: "rockstar".into(),
|
||||||
@@ -138,6 +195,7 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
process_pattern: r"Rockstar Games.*Launcher\.exe".into(),
|
process_pattern: r"Rockstar Games.*Launcher\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: None,
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
|
games: vec![],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -178,9 +236,8 @@ impl Config {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let bak = path.with_extension("toml.bak");
|
let bak = path.with_extension("toml.bak");
|
||||||
std::fs::rename(&path, &bak).with_context(|| {
|
std::fs::rename(&path, &bak)
|
||||||
format!("Failed to back up stale config to {bak:?}")
|
.with_context(|| format!("Failed to back up stale config to {bak:?}"))?;
|
||||||
})?;
|
|
||||||
eprintln!("warning: couldn't parse {}: {e}", path.display());
|
eprintln!("warning: couldn't parse {}: {e}", path.display());
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" backed up to {} — writing fresh config with presets",
|
" backed up to {} — writing fresh config with presets",
|
||||||
@@ -231,6 +288,148 @@ impl Config {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn add_launcher(
|
||||||
|
&mut self,
|
||||||
|
name: String,
|
||||||
|
display: Option<String>,
|
||||||
|
exe_path: PathBuf,
|
||||||
|
prefix_dir: Option<PathBuf>,
|
||||||
|
gameid: Option<String>,
|
||||||
|
process_pattern: Option<String>,
|
||||||
|
installer_url: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if self.launchers.iter().any(|l| l.name == name) {
|
||||||
|
anyhow::bail!("launcher '{name}' already exists");
|
||||||
|
}
|
||||||
|
let display = display.unwrap_or_else(|| name.clone());
|
||||||
|
let prefix_dir = prefix_dir.unwrap_or_else(|| home_dir().join("Games").join(&name));
|
||||||
|
let gameid = gameid.unwrap_or_else(|| format!("umu-{name}"));
|
||||||
|
let process_pattern = process_pattern.unwrap_or_else(|| {
|
||||||
|
exe_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(regex_escape)
|
||||||
|
.unwrap_or_else(|| name.clone())
|
||||||
|
});
|
||||||
|
self.launchers.push(Launcher {
|
||||||
|
name: name.clone(),
|
||||||
|
display,
|
||||||
|
prefix_dir,
|
||||||
|
exe_path,
|
||||||
|
gameid,
|
||||||
|
process_pattern,
|
||||||
|
installer_url,
|
||||||
|
proton_version: None,
|
||||||
|
games: vec![],
|
||||||
|
});
|
||||||
|
self.save()?;
|
||||||
|
println!("\x1b[1;32m✓\x1b[0m Added launcher '{name}'.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn add_game(
|
||||||
|
&mut self,
|
||||||
|
launcher: &str,
|
||||||
|
name: String,
|
||||||
|
display: Option<String>,
|
||||||
|
exe_path: PathBuf,
|
||||||
|
gamemode: bool,
|
||||||
|
mangohud: bool,
|
||||||
|
gamescope: Option<Vec<String>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let l = self
|
||||||
|
.launchers
|
||||||
|
.iter_mut()
|
||||||
|
.find(|l| l.name == launcher)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no launcher named '{launcher}'"))?;
|
||||||
|
if l.games.iter().any(|g| g.name == name) {
|
||||||
|
anyhow::bail!("launcher '{launcher}' already has a game named '{name}'");
|
||||||
|
}
|
||||||
|
let display = display.unwrap_or_else(|| name.clone());
|
||||||
|
l.games.push(Game {
|
||||||
|
name: name.clone(),
|
||||||
|
display,
|
||||||
|
exe_path,
|
||||||
|
args: vec![],
|
||||||
|
gamemode,
|
||||||
|
mangohud,
|
||||||
|
gamescope,
|
||||||
|
});
|
||||||
|
self.save()?;
|
||||||
|
println!("\x1b[1;32m✓\x1b[0m Added game '{name}' under launcher '{launcher}'.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_game(&mut self, launcher: &str, name: &str) -> Result<()> {
|
||||||
|
let l = self
|
||||||
|
.launchers
|
||||||
|
.iter_mut()
|
||||||
|
.find(|l| l.name == launcher)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no launcher named '{launcher}'"))?;
|
||||||
|
let before = l.games.len();
|
||||||
|
l.games.retain(|g| g.name != name);
|
||||||
|
if l.games.len() == before {
|
||||||
|
anyhow::bail!("launcher '{launcher}' has no game named '{name}'");
|
||||||
|
}
|
||||||
|
self.save()?;
|
||||||
|
println!("\x1b[1;32m✓\x1b[0m Removed game '{name}' from '{launcher}'.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update per-game overlay flags. Each arg is `None` = leave as-is.
|
||||||
|
/// `gamescope = Some(None)` disables it; `Some(Some(vec))` enables with args.
|
||||||
|
pub fn set_game_flags(
|
||||||
|
&mut self,
|
||||||
|
launcher: &str,
|
||||||
|
name: &str,
|
||||||
|
gamemode: Option<bool>,
|
||||||
|
mangohud: Option<bool>,
|
||||||
|
gamescope: Option<Option<Vec<String>>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if gamemode.is_none() && mangohud.is_none() && gamescope.is_none() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"nothing to set — pass --gamemode, --mangohud, --gamescope, or --no-gamescope"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let l = self
|
||||||
|
.launchers
|
||||||
|
.iter_mut()
|
||||||
|
.find(|l| l.name == launcher)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no launcher named '{launcher}'"))?;
|
||||||
|
let g =
|
||||||
|
l.games.iter_mut().find(|g| g.name == name).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("launcher '{launcher}' has no game named '{name}'")
|
||||||
|
})?;
|
||||||
|
if let Some(v) = gamemode {
|
||||||
|
g.gamemode = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = mangohud {
|
||||||
|
g.mangohud = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = gamescope {
|
||||||
|
g.gamescope = v;
|
||||||
|
}
|
||||||
|
self.save()?;
|
||||||
|
println!("\x1b[1;32m✓\x1b[0m Updated flags for '{launcher}/{name}'.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_launcher(&mut self, name: &str) -> Result<()> {
|
||||||
|
let before = self.launchers.len();
|
||||||
|
self.launchers.retain(|l| l.name != name);
|
||||||
|
if self.launchers.len() == before {
|
||||||
|
anyhow::bail!("no launcher named '{name}'");
|
||||||
|
}
|
||||||
|
self.save()?;
|
||||||
|
println!(
|
||||||
|
"\x1b[1;32m✓\x1b[0m Removed '{name}'. \
|
||||||
|
The Wine prefix on disk was left untouched."
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Update global fields non-interactively, then save.
|
/// Update global fields non-interactively, then save.
|
||||||
/// Use `config edit` for per-launcher changes.
|
/// Use `config edit` for per-launcher changes.
|
||||||
pub fn set_globals(
|
pub fn set_globals(
|
||||||
@@ -239,9 +438,7 @@ impl Config {
|
|||||||
compat_dir: Option<PathBuf>,
|
compat_dir: Option<PathBuf>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if proton_version.is_none() && compat_dir.is_none() {
|
if proton_version.is_none() && compat_dir.is_none() {
|
||||||
anyhow::bail!(
|
anyhow::bail!("nothing to set — pass --proton-version or --compat-dir");
|
||||||
"nothing to set — pass --proton-version or --compat-dir"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if let Some(v) = proton_version {
|
if let Some(v) = proton_version {
|
||||||
self.proton_version = v;
|
self.proton_version = v;
|
||||||
|
|||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const MAX_DEPTH: u32 = 3;
|
||||||
|
|
||||||
|
pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> {
|
||||||
|
let mut roots = default_roots();
|
||||||
|
roots.extend(extra_dirs.iter().cloned());
|
||||||
|
roots.sort();
|
||||||
|
roots.dedup();
|
||||||
|
let existing: Vec<PathBuf> = roots.into_iter().filter(|r| r.is_dir()).collect();
|
||||||
|
|
||||||
|
let prefixes = scan_prefixes(&existing);
|
||||||
|
println!(
|
||||||
|
"Scanned {} root{} → found {} prefix{}.\n",
|
||||||
|
existing.len(),
|
||||||
|
if existing.len() == 1 { "" } else { "s" },
|
||||||
|
prefixes.len(),
|
||||||
|
if prefixes.len() == 1 { "" } else { "es" },
|
||||||
|
);
|
||||||
|
|
||||||
|
let by_launcher = match_launchers(config, &prefixes);
|
||||||
|
|
||||||
|
if apply {
|
||||||
|
apply_findings(config, &by_launcher)?;
|
||||||
|
} else {
|
||||||
|
print_findings(config, &by_launcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_roots() -> Vec<PathBuf> {
|
||||||
|
let Ok(home) = std::env::var("HOME").map(PathBuf::from) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
home.join("Games"),
|
||||||
|
home.join(".wine"),
|
||||||
|
home.join(".local/share/lutris/runners/wine"),
|
||||||
|
home.join(".local/share/bottles/bottles"),
|
||||||
|
home.join(".var/app/com.usebottles.bottles/data/bottles/bottles"),
|
||||||
|
home.join("Games/Heroic/Prefixes/default"),
|
||||||
|
home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic/Prefixes/default"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_prefixes(roots: &[PathBuf]) -> Vec<PathBuf> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for root in roots {
|
||||||
|
collect_prefixes(root, 0, &mut out);
|
||||||
|
}
|
||||||
|
out.sort();
|
||||||
|
out.dedup();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_prefixes(dir: &Path, depth: u32, out: &mut Vec<PathBuf>) {
|
||||||
|
if dir.join("drive_c").is_dir() {
|
||||||
|
out.push(dir.to_path_buf());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Proton / umu layout: <gameid>/pfx/drive_c
|
||||||
|
if dir.join("pfx/drive_c").is_dir() {
|
||||||
|
out.push(dir.join("pfx"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if depth >= MAX_DEPTH {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||||
|
collect_prefixes(&entry.path(), depth + 1, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn match_launchers(config: &Config, prefixes: &[PathBuf]) -> HashMap<String, Vec<PathBuf>> {
|
||||||
|
let mut by_launcher: HashMap<String, Vec<PathBuf>> = HashMap::new();
|
||||||
|
for l in &config.launchers {
|
||||||
|
for prefix in prefixes {
|
||||||
|
if prefix.join("drive_c").join(&l.exe_path).exists() {
|
||||||
|
by_launcher
|
||||||
|
.entry(l.name.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(prefix.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
by_launcher
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_findings(config: &Config, by_launcher: &HashMap<String, Vec<PathBuf>>) {
|
||||||
|
let mut any_divergent = false;
|
||||||
|
for l in &config.launchers {
|
||||||
|
match by_launcher.get(&l.name) {
|
||||||
|
None => {
|
||||||
|
println!(" · {:12} not found", l.name);
|
||||||
|
}
|
||||||
|
Some(matches) if matches.len() > 1 => {
|
||||||
|
println!(" \x1b[33m⚠\x1b[0m {:12} multiple prefixes:", l.name);
|
||||||
|
for p in matches {
|
||||||
|
println!(" {}", p.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(matches) => {
|
||||||
|
let detected = &matches[0];
|
||||||
|
if *detected == l.prefix_dir {
|
||||||
|
println!(" \x1b[1;32m✓\x1b[0m {:12} {}", l.name, detected.display());
|
||||||
|
} else {
|
||||||
|
any_divergent = true;
|
||||||
|
println!(
|
||||||
|
" \x1b[36m→\x1b[0m {:12} {} (was {})",
|
||||||
|
l.name,
|
||||||
|
detected.display(),
|
||||||
|
l.prefix_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if any_divergent {
|
||||||
|
println!("\nRerun with --apply to update config.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_findings(config: &Config, by_launcher: &HashMap<String, Vec<PathBuf>>) -> Result<()> {
|
||||||
|
let mut c = config.clone();
|
||||||
|
let mut updated = 0;
|
||||||
|
let mut ambiguous = 0;
|
||||||
|
for l in c.launchers.iter_mut() {
|
||||||
|
let Some(matches) = by_launcher.get(&l.name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if matches.len() > 1 {
|
||||||
|
ambiguous += 1;
|
||||||
|
println!(
|
||||||
|
" \x1b[33m⚠\x1b[0m {:12} ambiguous — update via `config edit`",
|
||||||
|
l.name
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let detected = &matches[0];
|
||||||
|
if *detected == l.prefix_dir {
|
||||||
|
println!(" \x1b[1;32m✓\x1b[0m {:12} unchanged", l.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
" \x1b[1;32m→\x1b[0m {:12} {} → {}",
|
||||||
|
l.name,
|
||||||
|
l.prefix_dir.display(),
|
||||||
|
detected.display()
|
||||||
|
);
|
||||||
|
l.prefix_dir = detected.clone();
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
if updated > 0 {
|
||||||
|
c.save()?;
|
||||||
|
println!(
|
||||||
|
"\nUpdated {updated} launcher{}.",
|
||||||
|
if updated == 1 { "" } else { "s" }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("\nNothing to update.");
|
||||||
|
}
|
||||||
|
if ambiguous > 0 {
|
||||||
|
println!(
|
||||||
|
"{ambiguous} launcher{} skipped (multiple matches).",
|
||||||
|
if ambiguous == 1 { "" } else { "s" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+45
-7
@@ -12,10 +12,18 @@ struct Check {
|
|||||||
|
|
||||||
impl Check {
|
impl Check {
|
||||||
fn pass(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
fn pass(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||||
Self { label: label.into(), pass: true, detail: detail.into() }
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
pass: true,
|
||||||
|
detail: detail.into(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||||
Self { label: label.into(), pass: false, detail: detail.into() }
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
pass: false,
|
||||||
|
detail: detail.into(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +33,7 @@ pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
|
|||||||
global_vulkan_check(),
|
global_vulkan_check(),
|
||||||
global_display_check(),
|
global_display_check(),
|
||||||
compat_dir_check(config),
|
compat_dir_check(config),
|
||||||
|
wineserver_check(config),
|
||||||
];
|
];
|
||||||
|
|
||||||
let launchers: Vec<&Launcher> = if let Some(n) = name {
|
let launchers: Vec<&Launcher> = if let Some(n) = name {
|
||||||
@@ -80,7 +89,10 @@ fn global_vulkan_check() -> Check {
|
|||||||
if ok {
|
if ok {
|
||||||
Check::pass("vulkan", "vulkaninfo OK")
|
Check::pass("vulkan", "vulkaninfo OK")
|
||||||
} else {
|
} else {
|
||||||
Check::fail("vulkan", "vulkaninfo failed — check GPU drivers / vulkan-tools")
|
Check::fail(
|
||||||
|
"vulkan",
|
||||||
|
"vulkaninfo failed — check GPU drivers / vulkan-tools",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +136,35 @@ fn compat_dir_check(config: &Config) -> Check {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn wineserver_check(config: &Config) -> Check {
|
||||||
|
let count = wineserver_count();
|
||||||
|
if count == 0 {
|
||||||
|
return Check::pass("wine procs", "no wineserver running");
|
||||||
|
}
|
||||||
|
let any_running = config.launchers.iter().any(crate::launcher::is_running);
|
||||||
|
if any_running {
|
||||||
|
Check::pass(
|
||||||
|
"wine procs",
|
||||||
|
format!("{count} wineserver process(es); launcher active"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Check::fail(
|
||||||
|
"wine procs",
|
||||||
|
format!("{count} stale wineserver process(es) — try: umutray kill"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wineserver_count() -> usize {
|
||||||
|
Command::new("pgrep")
|
||||||
|
.args(["-c", "-f", "wineserver"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
fn launcher_checks(l: &Launcher) -> Vec<Check> {
|
fn launcher_checks(l: &Launcher) -> Vec<Check> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let tag = format!("[{}]", l.name);
|
let tag = format!("[{}]", l.name);
|
||||||
@@ -155,10 +196,7 @@ fn launcher_checks(l: &Launcher) -> Vec<Check> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if is_owned_by_current_user(&l.prefix_dir) {
|
if is_owned_by_current_user(&l.prefix_dir) {
|
||||||
out.push(Check::pass(
|
out.push(Check::pass(format!("{tag} owner"), "owned by current user"));
|
||||||
format!("{tag} owner"),
|
|
||||||
"owned by current user",
|
|
||||||
));
|
|
||||||
} else {
|
} else {
|
||||||
out.push(Check::fail(
|
out.push(Check::fail(
|
||||||
format!("{tag} owner"),
|
format!("{tag} owner"),
|
||||||
|
|||||||
+70
-1
@@ -1,5 +1,7 @@
|
|||||||
use crate::config::{Config, Launcher};
|
use crate::config::{Config, Game, Launcher};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::Path;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -42,6 +44,73 @@ pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
|
|||||||
Ok(())
|
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.
|
||||||
|
pub fn play_game(config: &Config, launcher: &Launcher, game: &Game) -> Result<()> {
|
||||||
|
let exe = game.full_exe_path(launcher);
|
||||||
|
if !exe.exists() {
|
||||||
|
bail!(
|
||||||
|
"game exe not found at {:?}\n\
|
||||||
|
Check exe_path for '{}/{}' in config, or install the game via the launcher first.",
|
||||||
|
exe,
|
||||||
|
launcher.name,
|
||||||
|
game.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = launcher
|
||||||
|
.proton_version
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&config.proton_version);
|
||||||
|
let proton_path: OsString = if version == "GE-Proton" {
|
||||||
|
version.to_string().into()
|
||||||
|
} else {
|
||||||
|
config.proton_compat_dir.join(version).into_os_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (prog, args) = build_wrapped_argv(&exe, game);
|
||||||
|
|
||||||
|
let mut cmd = std::process::Command::new(&prog);
|
||||||
|
cmd.env("WINEPREFIX", &launcher.prefix_dir)
|
||||||
|
.env("GAMEID", &launcher.gameid)
|
||||||
|
.env("PROTONPATH", &proton_path);
|
||||||
|
if game.mangohud {
|
||||||
|
cmd.env("MANGOHUD", "1");
|
||||||
|
}
|
||||||
|
cmd.args(&args);
|
||||||
|
cmd.spawn().with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Failed to spawn '{}'. Is umu-run / gamemoderun / gamescope on PATH?",
|
||||||
|
prog.to_string_lossy()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outermost → innermost: gamescope → gamemoderun → umu-run → exe [args]
|
||||||
|
fn build_wrapped_argv(exe: &Path, game: &Game) -> (OsString, Vec<OsString>) {
|
||||||
|
let mut argv: Vec<OsString> = Vec::new();
|
||||||
|
if let Some(gs_args) = &game.gamescope {
|
||||||
|
argv.push("gamescope".into());
|
||||||
|
for a in gs_args {
|
||||||
|
argv.push(a.into());
|
||||||
|
}
|
||||||
|
argv.push("--".into());
|
||||||
|
}
|
||||||
|
if game.gamemode {
|
||||||
|
argv.push("gamemoderun".into());
|
||||||
|
}
|
||||||
|
argv.push("umu-run".into());
|
||||||
|
argv.push(exe.as_os_str().to_os_string());
|
||||||
|
for a in &game.args {
|
||||||
|
argv.push(a.into());
|
||||||
|
}
|
||||||
|
let mut iter = argv.into_iter();
|
||||||
|
let prog = iter.next().expect("argv contains at least umu-run");
|
||||||
|
(prog, iter.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// SIGTERM → wait 3 s → SIGKILL for a single launcher.
|
/// SIGTERM → wait 3 s → SIGKILL for a single launcher.
|
||||||
pub fn kill(launcher: &Launcher) -> Result<()> {
|
pub fn kill(launcher: &Launcher) -> Result<()> {
|
||||||
kill_pattern(&launcher.process_pattern);
|
kill_pattern(&launcher.process_pattern);
|
||||||
|
|||||||
+244
-3
@@ -1,4 +1,5 @@
|
|||||||
mod config;
|
mod config;
|
||||||
|
mod detect;
|
||||||
mod diagnose;
|
mod diagnose;
|
||||||
mod launcher;
|
mod launcher;
|
||||||
mod proton;
|
mod proton;
|
||||||
@@ -46,12 +47,38 @@ enum Commands {
|
|||||||
/// List configured launchers and whether they're installed / running
|
/// List configured launchers and whether they're installed / running
|
||||||
Launchers,
|
Launchers,
|
||||||
|
|
||||||
/// Print setup instructions for a launcher (automated wizard coming soon)
|
/// Play a specific game through its launcher's prefix, applying the
|
||||||
|
/// per-game overlay flags (gamemode, mangohud, gamescope).
|
||||||
|
Play {
|
||||||
|
/// Launcher name (e.g. battlenet)
|
||||||
|
launcher: String,
|
||||||
|
/// Game name (e.g. overwatch)
|
||||||
|
game: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// List configured games per launcher
|
||||||
|
Games {
|
||||||
|
/// Only show games for this launcher (omit for all)
|
||||||
|
launcher: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Open the graphical setup wizard for a launcher
|
||||||
Setup {
|
Setup {
|
||||||
/// Launcher name
|
/// Launcher name
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Scan common Wine prefix locations for installed launchers
|
||||||
|
Detect {
|
||||||
|
/// Additional directory to scan (repeatable)
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
dir: Vec<PathBuf>,
|
||||||
|
|
||||||
|
/// Write detected prefix_dirs to config
|
||||||
|
#[arg(long)]
|
||||||
|
apply: bool,
|
||||||
|
},
|
||||||
|
|
||||||
/// Download and switch GE-Proton versions
|
/// Download and switch GE-Proton versions
|
||||||
UpdateProton {
|
UpdateProton {
|
||||||
/// Install the latest release automatically
|
/// Install the latest release automatically
|
||||||
@@ -98,6 +125,91 @@ enum ConfigAction {
|
|||||||
#[arg(long, value_name = "PATH")]
|
#[arg(long, value_name = "PATH")]
|
||||||
compat_dir: Option<PathBuf>,
|
compat_dir: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
|
/// Add a new launcher to the config
|
||||||
|
AddLauncher {
|
||||||
|
/// Short CLI name (e.g. "heroic")
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
/// Windows exe path relative to drive_c/ (e.g. "Program Files/Foo/foo.exe")
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
exe_path: PathBuf,
|
||||||
|
|
||||||
|
/// Display name for menus (defaults to NAME)
|
||||||
|
#[arg(long)]
|
||||||
|
display: Option<String>,
|
||||||
|
|
||||||
|
/// Wine prefix dir (defaults to ~/Games/NAME)
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
prefix_dir: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// umu GAMEID (defaults to "umu-NAME")
|
||||||
|
#[arg(long)]
|
||||||
|
gameid: Option<String>,
|
||||||
|
|
||||||
|
/// pgrep -f regex (defaults to escaped exe basename)
|
||||||
|
#[arg(long)]
|
||||||
|
process_pattern: Option<String>,
|
||||||
|
|
||||||
|
/// Optional installer URL
|
||||||
|
#[arg(long)]
|
||||||
|
installer_url: Option<String>,
|
||||||
|
},
|
||||||
|
/// Remove a launcher from the config (leaves its prefix on disk)
|
||||||
|
RemoveLauncher {
|
||||||
|
/// Short CLI name
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// Add a game under an existing launcher
|
||||||
|
AddGame {
|
||||||
|
/// Launcher that owns this game
|
||||||
|
launcher: String,
|
||||||
|
|
||||||
|
/// Short CLI name for the game (e.g. "overwatch")
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
/// Game exe path relative to drive_c/
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
exe_path: PathBuf,
|
||||||
|
|
||||||
|
/// Display name (defaults to NAME)
|
||||||
|
#[arg(long)]
|
||||||
|
display: Option<String>,
|
||||||
|
|
||||||
|
/// Wrap the game in gamemoderun
|
||||||
|
#[arg(long)]
|
||||||
|
gamemode: bool,
|
||||||
|
|
||||||
|
/// Set MANGOHUD=1 for the game
|
||||||
|
#[arg(long)]
|
||||||
|
mangohud: bool,
|
||||||
|
|
||||||
|
/// Enable gamescope with these args (space-separated, e.g. "-f -W 2560")
|
||||||
|
#[arg(long, value_name = "ARGS")]
|
||||||
|
gamescope: Option<String>,
|
||||||
|
},
|
||||||
|
/// Remove a game from a launcher
|
||||||
|
RemoveGame { launcher: String, name: String },
|
||||||
|
/// Toggle per-game overlay flags
|
||||||
|
SetGameFlags {
|
||||||
|
launcher: String,
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
/// true / false — wrap in gamemoderun
|
||||||
|
#[arg(long, value_name = "BOOL")]
|
||||||
|
gamemode: Option<bool>,
|
||||||
|
|
||||||
|
/// true / false — set MANGOHUD=1
|
||||||
|
#[arg(long, value_name = "BOOL")]
|
||||||
|
mangohud: Option<bool>,
|
||||||
|
|
||||||
|
/// Enable gamescope with these args (space-separated)
|
||||||
|
#[arg(long, value_name = "ARGS", conflicts_with = "no_gamescope")]
|
||||||
|
gamescope: Option<String>,
|
||||||
|
|
||||||
|
/// Disable gamescope wrapping
|
||||||
|
#[arg(long)]
|
||||||
|
no_gamescope: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -152,6 +264,47 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Commands::Play {
|
||||||
|
launcher: lname,
|
||||||
|
game: gname,
|
||||||
|
} => {
|
||||||
|
let l = config.find(&lname).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("unknown launcher '{lname}' — try `umutray launchers`")
|
||||||
|
})?;
|
||||||
|
let g = l.find_game(&gname).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"launcher '{lname}' has no game named '{gname}' — try `umutray games {lname}`"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
launcher::play_game(&config, l, g)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Games { launcher: lname } => {
|
||||||
|
let launchers: Vec<&config::Launcher> = match &lname {
|
||||||
|
Some(n) => vec![config.find(n).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("unknown launcher '{n}' — try `umutray launchers`")
|
||||||
|
})?],
|
||||||
|
None => config.launchers.iter().collect(),
|
||||||
|
};
|
||||||
|
for l in launchers {
|
||||||
|
if l.games.is_empty() {
|
||||||
|
println!(" {}: (no games)", l.display);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
println!(" {}:", l.display);
|
||||||
|
for g in &l.games {
|
||||||
|
let installed = g.full_exe_path(l).exists();
|
||||||
|
let marker = if installed {
|
||||||
|
"\x1b[1;32m✓\x1b[0m"
|
||||||
|
} else {
|
||||||
|
"·"
|
||||||
|
};
|
||||||
|
let flags = format_game_flags(g);
|
||||||
|
println!(" {marker} {:14} {}{}", g.name, g.display, flags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Commands::Setup { name } => {
|
Commands::Setup { name } => {
|
||||||
let l = config.find(&name).ok_or_else(|| {
|
let l = config.find(&name).ok_or_else(|| {
|
||||||
anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`")
|
anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`")
|
||||||
@@ -159,7 +312,15 @@ fn main() -> Result<()> {
|
|||||||
setup::run(&config, l)?;
|
setup::run(&config, l)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Commands::UpdateProton { latest, version, list } => {
|
Commands::Detect { dir, apply } => {
|
||||||
|
detect::run(&config, &dir, apply)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::UpdateProton {
|
||||||
|
latest,
|
||||||
|
version,
|
||||||
|
list,
|
||||||
|
} => {
|
||||||
proton::run(&config, latest, version, list)?;
|
proton::run(&config, latest, version, list)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,10 +330,72 @@ fn main() -> Result<()> {
|
|||||||
println!("{}", config::Config::config_path()?.display());
|
println!("{}", config::Config::config_path()?.display());
|
||||||
}
|
}
|
||||||
ConfigAction::Edit => config::Config::edit()?,
|
ConfigAction::Edit => config::Config::edit()?,
|
||||||
ConfigAction::Set { proton_version, compat_dir } => {
|
ConfigAction::Set {
|
||||||
|
proton_version,
|
||||||
|
compat_dir,
|
||||||
|
} => {
|
||||||
let mut c = config;
|
let mut c = config;
|
||||||
c.set_globals(proton_version, compat_dir)?;
|
c.set_globals(proton_version, compat_dir)?;
|
||||||
}
|
}
|
||||||
|
ConfigAction::AddLauncher {
|
||||||
|
name,
|
||||||
|
exe_path,
|
||||||
|
display,
|
||||||
|
prefix_dir,
|
||||||
|
gameid,
|
||||||
|
process_pattern,
|
||||||
|
installer_url,
|
||||||
|
} => {
|
||||||
|
let mut c = config;
|
||||||
|
c.add_launcher(
|
||||||
|
name,
|
||||||
|
display,
|
||||||
|
exe_path,
|
||||||
|
prefix_dir,
|
||||||
|
gameid,
|
||||||
|
process_pattern,
|
||||||
|
installer_url,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
ConfigAction::RemoveLauncher { name } => {
|
||||||
|
let mut c = config;
|
||||||
|
c.remove_launcher(&name)?;
|
||||||
|
}
|
||||||
|
ConfigAction::AddGame {
|
||||||
|
launcher,
|
||||||
|
name,
|
||||||
|
exe_path,
|
||||||
|
display,
|
||||||
|
gamemode,
|
||||||
|
mangohud,
|
||||||
|
gamescope,
|
||||||
|
} => {
|
||||||
|
let mut c = config;
|
||||||
|
let gs =
|
||||||
|
gamescope.map(|s| s.split_whitespace().map(String::from).collect::<Vec<_>>());
|
||||||
|
c.add_game(&launcher, name, display, exe_path, gamemode, mangohud, gs)?;
|
||||||
|
}
|
||||||
|
ConfigAction::RemoveGame { launcher, name } => {
|
||||||
|
let mut c = config;
|
||||||
|
c.remove_game(&launcher, &name)?;
|
||||||
|
}
|
||||||
|
ConfigAction::SetGameFlags {
|
||||||
|
launcher,
|
||||||
|
name,
|
||||||
|
gamemode,
|
||||||
|
mangohud,
|
||||||
|
gamescope,
|
||||||
|
no_gamescope,
|
||||||
|
} => {
|
||||||
|
let gs_update = if no_gamescope {
|
||||||
|
Some(None)
|
||||||
|
} else {
|
||||||
|
gamescope
|
||||||
|
.map(|s| Some(s.split_whitespace().map(String::from).collect::<Vec<_>>()))
|
||||||
|
};
|
||||||
|
let mut c = config;
|
||||||
|
c.set_game_flags(&launcher, &name, gamemode, mangohud, gs_update)?;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
Commands::Service { action } => match action {
|
Commands::Service { action } => match action {
|
||||||
@@ -184,3 +407,21 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_game_flags(g: &config::Game) -> String {
|
||||||
|
let mut tags: Vec<&str> = Vec::new();
|
||||||
|
if g.gamemode {
|
||||||
|
tags.push("gamemode");
|
||||||
|
}
|
||||||
|
if g.mangohud {
|
||||||
|
tags.push("mangohud");
|
||||||
|
}
|
||||||
|
if g.gamescope.is_some() {
|
||||||
|
tags.push("gamescope");
|
||||||
|
}
|
||||||
|
if tags.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" [{}]", tags.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+18
-11
@@ -3,8 +3,7 @@ use anyhow::{Context, Result};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
const GITHUB_API: &str =
|
const GITHUB_API: &str = "https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases";
|
||||||
"https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases";
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Release {
|
struct Release {
|
||||||
@@ -153,7 +152,11 @@ fn print_list(config: &Config) -> Result<()> {
|
|||||||
let releases = fetch_releases(10)?;
|
let releases = fetch_releases(10)?;
|
||||||
for r in &releases {
|
for r in &releases {
|
||||||
let installed = config.proton_compat_dir.join(&r.tag_name).exists();
|
let installed = config.proton_compat_dir.join(&r.tag_name).exists();
|
||||||
let marker = if installed { " \x1b[1;32m✓ installed\x1b[0m" } else { "" };
|
let marker = if installed {
|
||||||
|
" \x1b[1;32m✓ installed\x1b[0m"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
println!(" {}{}", r.tag_name, marker);
|
println!(" {}{}", r.tag_name, marker);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -170,7 +173,12 @@ struct ProgressWriter<W: Write> {
|
|||||||
|
|
||||||
impl<W: Write> ProgressWriter<W> {
|
impl<W: Write> ProgressWriter<W> {
|
||||||
fn new(inner: W, total: Option<u64>) -> Self {
|
fn new(inner: W, total: Option<u64>) -> Self {
|
||||||
Self { inner, total, written: 0, last_print: 0 }
|
Self {
|
||||||
|
inner,
|
||||||
|
total,
|
||||||
|
written: 0,
|
||||||
|
last_print: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn finish(&mut self) {
|
fn finish(&mut self) {
|
||||||
@@ -189,12 +197,7 @@ impl<W: Write> Write for ProgressWriter<W> {
|
|||||||
match self.total {
|
match self.total {
|
||||||
Some(t) => {
|
Some(t) => {
|
||||||
let pct = (self.written as f64 / t as f64) * 100.0;
|
let pct = (self.written as f64 / t as f64) * 100.0;
|
||||||
eprint!(
|
eprint!("\r {:.1}% ({} / {} MiB)", pct, self.written >> 20, t >> 20,);
|
||||||
"\r {:.1}% ({} / {} MiB)",
|
|
||||||
pct,
|
|
||||||
self.written >> 20,
|
|
||||||
t >> 20,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
None => eprint!("\r {} MiB", self.written >> 20),
|
None => eprint!("\r {} MiB", self.written >> 20),
|
||||||
}
|
}
|
||||||
@@ -217,7 +220,11 @@ fn pick_interactively(config: &Config) -> Result<String> {
|
|||||||
println!("Recent GE-Proton releases:");
|
println!("Recent GE-Proton releases:");
|
||||||
for (i, r) in releases.iter().enumerate() {
|
for (i, r) in releases.iter().enumerate() {
|
||||||
let installed = config.proton_compat_dir.join(&r.tag_name).exists();
|
let installed = config.proton_compat_dir.join(&r.tag_name).exists();
|
||||||
let marker = if installed { " \x1b[1;32m✓\x1b[0m" } else { "" };
|
let marker = if installed {
|
||||||
|
" \x1b[1;32m✓\x1b[0m"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
println!(" {:2}) {}{}", i + 1, r.tag_name, marker);
|
println!(" {:2}) {}{}", i + 1, r.tag_name, marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-6
@@ -43,13 +43,11 @@ fn systemctl(args: &[&str]) -> Result<()> {
|
|||||||
|
|
||||||
/// Write the unit, reload systemd, and enable+start the service.
|
/// Write the unit, reload systemd, and enable+start the service.
|
||||||
pub fn install() -> Result<()> {
|
pub fn install() -> Result<()> {
|
||||||
let exe = std::env::current_exe()
|
let exe = std::env::current_exe().context("Cannot determine path to own executable")?;
|
||||||
.context("Cannot determine path to own executable")?;
|
|
||||||
let path = unit_path()?;
|
let path = unit_path()?;
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent).with_context(|| format!("Failed to create {parent:?}"))?;
|
||||||
.with_context(|| format!("Failed to create {parent:?}"))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let contents = render_unit(&exe);
|
let contents = render_unit(&exe);
|
||||||
@@ -77,8 +75,7 @@ pub fn uninstall() -> Result<()> {
|
|||||||
let _ = systemctl(&["disable", "--now", UNIT_NAME]);
|
let _ = systemctl(&["disable", "--now", UNIT_NAME]);
|
||||||
|
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
std::fs::remove_file(&path)
|
std::fs::remove_file(&path).with_context(|| format!("Failed to remove {path:?}"))?;
|
||||||
.with_context(|| format!("Failed to remove {path:?}"))?;
|
|
||||||
println!("Removed {}", path.display());
|
println!("Removed {}", path.display());
|
||||||
} else {
|
} else {
|
||||||
println!("No unit file at {}", path.display());
|
println!("No unit file at {}", path.display());
|
||||||
|
|||||||
+364
-47
@@ -1,60 +1,377 @@
|
|||||||
use crate::config::{Config, Launcher};
|
use crate::config::{Config, Launcher};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use iced::futures::channel::oneshot;
|
||||||
|
use iced::widget::{
|
||||||
|
button, column, container, progress_bar, row, scrollable, text, text_input, Column,
|
||||||
|
};
|
||||||
|
use iced::{Element, Length, Subscription, Task, Theme};
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Message {
|
||||||
|
SourceChanged(String),
|
||||||
|
PreparePressed,
|
||||||
|
PrepareDone(Result<PathBuf, String>),
|
||||||
|
InstallPressed,
|
||||||
|
InstallDone(Result<i32, String>),
|
||||||
|
Tick,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Stage {
|
||||||
|
Idle,
|
||||||
|
Busy,
|
||||||
|
Ready,
|
||||||
|
Installing,
|
||||||
|
Finished,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
struct DownloadProgress {
|
||||||
|
bytes: u64,
|
||||||
|
total: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
config: Config,
|
||||||
|
launcher: Launcher,
|
||||||
|
source: String,
|
||||||
|
installer: Option<PathBuf>,
|
||||||
|
stage: Stage,
|
||||||
|
status: String,
|
||||||
|
download: Arc<Mutex<DownloadProgress>>,
|
||||||
|
log: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
fn new(config: Config, launcher: Launcher) -> Self {
|
||||||
|
let status = format!(
|
||||||
|
"Paste an installer URL or a local .exe path. It will install into {}.",
|
||||||
|
launcher.prefix_dir.display()
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
launcher,
|
||||||
|
source: String::new(),
|
||||||
|
installer: None,
|
||||||
|
stage: Stage::Idle,
|
||||||
|
status,
|
||||||
|
download: Arc::new(Mutex::new(DownloadProgress::default())),
|
||||||
|
log: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||||
|
match message {
|
||||||
|
Message::Tick => Task::none(),
|
||||||
|
Message::SourceChanged(s) => {
|
||||||
|
state.source = s;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::PreparePressed => {
|
||||||
|
let src = state.source.trim().to_string();
|
||||||
|
if src.is_empty() {
|
||||||
|
state.status = "Enter an installer URL or local path first.".into();
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
let path = PathBuf::from(&src);
|
||||||
|
if path.is_file() {
|
||||||
|
state.installer = Some(path.clone());
|
||||||
|
state.stage = Stage::Ready;
|
||||||
|
state.status = format!("Ready: {}", path.display());
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
if !src.starts_with("http://") && !src.starts_with("https://") {
|
||||||
|
state.status = format!("Not a local file and not an http(s) URL: {src}");
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
state.stage = Stage::Busy;
|
||||||
|
state.status = format!("Downloading {src} …");
|
||||||
|
if let Ok(mut p) = state.download.lock() {
|
||||||
|
*p = DownloadProgress::default();
|
||||||
|
}
|
||||||
|
let name = state.launcher.name.clone();
|
||||||
|
let progress = state.download.clone();
|
||||||
|
Task::perform(
|
||||||
|
blocking(move || download_blocking(&src, &name, progress)),
|
||||||
|
Message::PrepareDone,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Message::PrepareDone(Ok(path)) => {
|
||||||
|
state.installer = Some(path.clone());
|
||||||
|
state.stage = Stage::Ready;
|
||||||
|
state.status = format!("Downloaded to {}", path.display());
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::PrepareDone(Err(e)) => {
|
||||||
|
state.stage = Stage::Idle;
|
||||||
|
state.status = format!("Download failed: {e}");
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::InstallPressed => {
|
||||||
|
let Some(installer) = state.installer.clone() else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
state.stage = Stage::Installing;
|
||||||
|
state.status = "Running installer via umu-run (this may take several minutes)…".into();
|
||||||
|
if let Ok(mut v) = state.log.lock() {
|
||||||
|
v.clear();
|
||||||
|
}
|
||||||
|
let config = state.config.clone();
|
||||||
|
let launcher = state.launcher.clone();
|
||||||
|
let log = state.log.clone();
|
||||||
|
Task::perform(
|
||||||
|
blocking(move || run_installer(&config, &launcher, &installer, log)),
|
||||||
|
Message::InstallDone,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Message::InstallDone(res) => {
|
||||||
|
state.stage = Stage::Finished;
|
||||||
|
let exe = state.launcher.full_exe_path();
|
||||||
|
state.status = match res {
|
||||||
|
Ok(code) if exe.exists() => format!(
|
||||||
|
"✓ Installer finished (umu exit {code}); {} is present.\n\
|
||||||
|
You can now run: umutray launch {}",
|
||||||
|
exe.display(),
|
||||||
|
state.launcher.name,
|
||||||
|
),
|
||||||
|
Ok(code) => format!(
|
||||||
|
"umu-run exited {code} but the expected exe is not at {}.\n\
|
||||||
|
Check the installer's destination path, or edit exe_path in config.",
|
||||||
|
exe.display(),
|
||||||
|
),
|
||||||
|
Err(e) => format!("Install failed: {e}"),
|
||||||
|
};
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscription(state: &State) -> Subscription<Message> {
|
||||||
|
match state.stage {
|
||||||
|
Stage::Busy | Stage::Installing => {
|
||||||
|
iced::time::every(Duration::from_millis(250)).map(|_| Message::Tick)
|
||||||
|
}
|
||||||
|
_ => Subscription::none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(state: &State) -> Element<'_, Message> {
|
||||||
|
let header = text(format!("Setup: {}", state.launcher.display)).size(24);
|
||||||
|
let prefix = text(format!("Prefix: {}", state.launcher.prefix_dir.display())).size(13);
|
||||||
|
let expected = text(format!(
|
||||||
|
"Expected: {}",
|
||||||
|
state.launcher.full_exe_path().display()
|
||||||
|
))
|
||||||
|
.size(13);
|
||||||
|
|
||||||
|
let input = text_input("https://… or /path/to/installer.exe", &state.source)
|
||||||
|
.on_input(Message::SourceChanged)
|
||||||
|
.padding(8);
|
||||||
|
|
||||||
|
let prepare_enabled = matches!(state.stage, Stage::Idle | Stage::Ready | Stage::Finished);
|
||||||
|
let install_enabled = matches!(state.stage, Stage::Ready);
|
||||||
|
|
||||||
|
let prepare_btn = button(text("Download / Prepare"))
|
||||||
|
.on_press_maybe(prepare_enabled.then_some(Message::PreparePressed));
|
||||||
|
let install_btn = button(text("Run installer"))
|
||||||
|
.on_press_maybe(install_enabled.then_some(Message::InstallPressed));
|
||||||
|
|
||||||
|
let status = text(state.status.clone());
|
||||||
|
|
||||||
|
let progress_row: Element<Message> = if matches!(state.stage, Stage::Busy) {
|
||||||
|
let p = state
|
||||||
|
.download
|
||||||
|
.lock()
|
||||||
|
.map(|p| (p.bytes, p.total))
|
||||||
|
.unwrap_or((0, None));
|
||||||
|
let (bytes, total) = p;
|
||||||
|
let fraction = match total {
|
||||||
|
Some(t) if t > 0 => (bytes as f32) / (t as f32),
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
let label = match total {
|
||||||
|
Some(t) => format!("{} / {}", fmt_bytes(bytes), fmt_bytes(t)),
|
||||||
|
None => format!("{} downloaded", fmt_bytes(bytes)),
|
||||||
|
};
|
||||||
|
column![progress_bar(0.0..=1.0, fraction), text(label).size(12)]
|
||||||
|
.spacing(4)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
text("").into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let log_pane: Element<Message> = if matches!(state.stage, Stage::Installing | Stage::Finished) {
|
||||||
|
let lines: Vec<String> = state.log.lock().map(|v| v.clone()).unwrap_or_default();
|
||||||
|
let tail: Vec<Element<Message>> = lines
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(80)
|
||||||
|
.rev()
|
||||||
|
.map(|l| text(l.clone()).size(11).into())
|
||||||
|
.collect();
|
||||||
|
scrollable(Column::with_children(tail).spacing(2))
|
||||||
|
.height(Length::Fixed(220.0))
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
text("").into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = column![
|
||||||
|
header,
|
||||||
|
prefix,
|
||||||
|
expected,
|
||||||
|
input,
|
||||||
|
row![prepare_btn, install_btn].spacing(12),
|
||||||
|
progress_row,
|
||||||
|
status,
|
||||||
|
log_pane,
|
||||||
|
]
|
||||||
|
.spacing(12)
|
||||||
|
.padding(20);
|
||||||
|
|
||||||
|
container(body).into()
|
||||||
|
}
|
||||||
|
|
||||||
/// Print manual setup steps for a launcher.
|
|
||||||
///
|
|
||||||
/// This is a stub until the iced-based setup wizard lands. It walks the
|
|
||||||
/// user through creating the prefix directory, obtaining the installer,
|
|
||||||
/// and running it through umu.
|
|
||||||
pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
|
pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
|
||||||
|
let config = config.clone();
|
||||||
|
let launcher = launcher.clone();
|
||||||
|
let title = format!("umutray setup — {}", launcher.display);
|
||||||
|
|
||||||
|
iced::application(move |_: &State| title.clone(), update, view)
|
||||||
|
.subscription(subscription)
|
||||||
|
.theme(|_| Theme::Dark)
|
||||||
|
.run_with(move || (State::new(config.clone(), launcher.clone()), Task::none()))
|
||||||
|
.map_err(|e| anyhow::anyhow!("iced: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn blocking<T, F>(f: F) -> T
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
F: FnOnce() -> T + Send + 'static,
|
||||||
|
{
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = tx.send(f());
|
||||||
|
});
|
||||||
|
rx.await.expect("setup helper thread panicked")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_blocking(
|
||||||
|
url: &str,
|
||||||
|
name: &str,
|
||||||
|
progress: Arc<Mutex<DownloadProgress>>,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.user_agent("umutray/0.1")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut resp = client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let total = resp.content_length();
|
||||||
|
if let Ok(mut p) = progress.lock() {
|
||||||
|
p.bytes = 0;
|
||||||
|
p.total = total;
|
||||||
|
}
|
||||||
|
let base = url.split('?').next().unwrap_or(url);
|
||||||
|
let candidate = base
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.filter(|s| s.contains('.'))
|
||||||
|
.unwrap_or("");
|
||||||
|
let filename = if candidate.is_empty() {
|
||||||
|
format!("{name}-installer.exe")
|
||||||
|
} else {
|
||||||
|
candidate.to_string()
|
||||||
|
};
|
||||||
|
let tmp = std::env::temp_dir().join(filename);
|
||||||
|
let mut f = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
|
||||||
|
let mut buf = [0u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let n = resp.read(&mut buf).map_err(|e| e.to_string())?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
f.write_all(&buf[..n]).map_err(|e| e.to_string())?;
|
||||||
|
if let Ok(mut p) = progress.lock() {
|
||||||
|
p.bytes += n as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(tmp)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_installer(
|
||||||
|
config: &Config,
|
||||||
|
launcher: &Launcher,
|
||||||
|
installer: &Path,
|
||||||
|
log: Arc<Mutex<Vec<String>>>,
|
||||||
|
) -> Result<i32, String> {
|
||||||
|
std::fs::create_dir_all(&launcher.prefix_dir).map_err(|e| e.to_string())?;
|
||||||
let version = launcher
|
let version = launcher
|
||||||
.proton_version
|
.proton_version
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or(&config.proton_version);
|
.unwrap_or(&config.proton_version);
|
||||||
let proton_path: String = if version == "GE-Proton" {
|
let proton_path: OsString = if version == "GE-Proton" {
|
||||||
version.to_string()
|
version.to_string().into()
|
||||||
} else {
|
} else {
|
||||||
config
|
config.proton_compat_dir.join(version).into_os_string()
|
||||||
.proton_compat_dir
|
|
||||||
.join(version)
|
|
||||||
.display()
|
|
||||||
.to_string()
|
|
||||||
};
|
};
|
||||||
|
let mut child = Command::new("umu-run")
|
||||||
|
.env("WINEPREFIX", &launcher.prefix_dir)
|
||||||
|
.env("GAMEID", &launcher.gameid)
|
||||||
|
.env("PROTONPATH", &proton_path)
|
||||||
|
.arg(installer)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
println!("Setup steps for \x1b[1m{}\x1b[0m ({})", launcher.display, launcher.name);
|
let stdout = child.stdout.take().expect("piped stdout");
|
||||||
println!();
|
let stderr = child.stderr.take().expect("piped stderr");
|
||||||
println!("1. Create the prefix directory:");
|
let log_out = log.clone();
|
||||||
println!(" mkdir -p {}", launcher.prefix_dir.display());
|
let log_err = log.clone();
|
||||||
println!();
|
let t_out = std::thread::spawn(move || stream_into(stdout, log_out));
|
||||||
println!("2. Obtain the Windows installer for {}.", launcher.display);
|
let t_err = std::thread::spawn(move || stream_into(stderr, log_err));
|
||||||
if let Some(url) = &launcher.installer_url {
|
|
||||||
println!(" (configured source: {url})");
|
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
" No installer URL is configured for '{}'.",
|
|
||||||
launcher.name
|
|
||||||
);
|
|
||||||
println!(" Download the installer from the vendor and save it locally.");
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
println!("3. Run the installer under umu (replace INSTALLER.EXE with the path):");
|
|
||||||
println!(
|
|
||||||
" WINEPREFIX={} \\",
|
|
||||||
launcher.prefix_dir.display()
|
|
||||||
);
|
|
||||||
println!(" GAMEID={} \\", launcher.gameid);
|
|
||||||
println!(" PROTONPATH={proton_path} \\");
|
|
||||||
println!(" umu-run INSTALLER.EXE");
|
|
||||||
println!();
|
|
||||||
println!("4. After the installer finishes, verify it placed:");
|
|
||||||
println!(" {}", launcher.full_exe_path().display());
|
|
||||||
println!();
|
|
||||||
println!("5. Then: umutray launch {}", launcher.name);
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
"(A graphical setup wizard via iced is planned — this stub prints the manual\n \
|
|
||||||
steps in the meantime.)"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
let status = child.wait().map_err(|e| e.to_string())?;
|
||||||
|
let _ = t_out.join();
|
||||||
|
let _ = t_err.join();
|
||||||
|
Ok(status.code().unwrap_or(-1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_into<R: Read>(r: R, log: Arc<Mutex<Vec<String>>>) {
|
||||||
|
for line in BufReader::new(r).lines().map_while(Result::ok) {
|
||||||
|
if let Ok(mut v) = log.lock() {
|
||||||
|
v.push(line);
|
||||||
|
if v.len() > 500 {
|
||||||
|
let drop = v.len() - 500;
|
||||||
|
v.drain(0..drop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_bytes(n: u64) -> String {
|
||||||
|
const KIB: u64 = 1024;
|
||||||
|
const MIB: u64 = 1024 * 1024;
|
||||||
|
const GIB: u64 = 1024 * 1024 * 1024;
|
||||||
|
if n >= GIB {
|
||||||
|
format!("{:.2} GiB", n as f64 / GIB as f64)
|
||||||
|
} else if n >= MIB {
|
||||||
|
format!("{:.1} MiB", n as f64 / MIB as f64)
|
||||||
|
} else if n >= KIB {
|
||||||
|
format!("{:.0} KiB", n as f64 / KIB as f64)
|
||||||
|
} else {
|
||||||
|
format!("{n} B")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-4
@@ -1,9 +1,66 @@
|
|||||||
use crate::{config::Config, launcher};
|
use crate::{config::Config, launcher};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GameFlag {
|
||||||
|
GameMode,
|
||||||
|
MangoHud,
|
||||||
|
Gamescope,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_flag(this: &mut UmuTray, launcher: &str, game: &str, flag: GameFlag) {
|
||||||
|
for l in this.config.launchers.iter_mut() {
|
||||||
|
if l.name != launcher {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for g in l.games.iter_mut() {
|
||||||
|
if g.name != game {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match flag {
|
||||||
|
GameFlag::GameMode => g.gamemode = !g.gamemode,
|
||||||
|
GameFlag::MangoHud => g.mangohud = !g.mangohud,
|
||||||
|
GameFlag::Gamescope => {
|
||||||
|
g.gamescope = if g.gamescope.is_some() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(vec![])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(e) = this.config.save() {
|
||||||
|
eprintln!("umutray: failed to persist flag toggle: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_from_tray(config: &Config, launcher: &str, game: &str) {
|
||||||
|
let Some(l) = config.find(launcher) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(g) = l.find_game(game) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(e) = crate::launcher::play_game(config, l, g) {
|
||||||
|
eprintln!("umutray: play {game} failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct UmuTray {
|
pub struct UmuTray {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
/// Per-launcher running state keyed by launcher.name
|
/// Per-launcher running state keyed by launcher.name
|
||||||
@@ -41,10 +98,14 @@ impl ksni::Tray for UmuTray {
|
|||||||
let display = l.display.clone();
|
let display = l.display.clone();
|
||||||
|
|
||||||
if !installed {
|
if !installed {
|
||||||
|
let setup_name = name.clone();
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: format!("{display} (not installed)"),
|
label: format!("Setup {display}…"),
|
||||||
enabled: false,
|
icon_name: "document-new".into(),
|
||||||
|
activate: Box::new(move |_this: &mut Self| {
|
||||||
|
spawn_setup(&setup_name);
|
||||||
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
@@ -53,12 +114,13 @@ impl ksni::Tray for UmuTray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if running {
|
if running {
|
||||||
|
let kill_name = name.clone();
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: format!("Kill {display}"),
|
label: format!("Kill {display}"),
|
||||||
icon_name: "process-stop".into(),
|
icon_name: "process-stop".into(),
|
||||||
activate: Box::new(move |this: &mut Self| {
|
activate: Box::new(move |this: &mut Self| {
|
||||||
if let Some(l) = this.config.find(&name) {
|
if let Some(l) = this.config.find(&kill_name) {
|
||||||
let _ = launcher::kill(l);
|
let _ = launcher::kill(l);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -67,12 +129,13 @@ impl ksni::Tray for UmuTray {
|
|||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
let launch_name = name.clone();
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: format!("Launch {display}"),
|
label: format!("Launch {display}"),
|
||||||
icon_name: "media-playback-start".into(),
|
icon_name: "media-playback-start".into(),
|
||||||
activate: Box::new(move |this: &mut Self| {
|
activate: Box::new(move |this: &mut Self| {
|
||||||
if let Some(l) = this.config.find(&name) {
|
if let Some(l) = this.config.find(&launch_name) {
|
||||||
if let Err(e) = launcher::launch(&this.config, l) {
|
if let Err(e) = launcher::launch(&this.config, l) {
|
||||||
eprintln!("umutray: launch {} failed: {e}", l.name);
|
eprintln!("umutray: launch {} failed: {e}", l.name);
|
||||||
}
|
}
|
||||||
@@ -83,6 +146,75 @@ impl ksni::Tray for UmuTray {
|
|||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-game submenus with Play + overlay toggles.
|
||||||
|
for g in &l.games {
|
||||||
|
let gdisplay = g.display.clone();
|
||||||
|
let gname_play = g.name.clone();
|
||||||
|
let lname_play = name.clone();
|
||||||
|
let gname_gm = g.name.clone();
|
||||||
|
let lname_gm = name.clone();
|
||||||
|
let gname_mh = g.name.clone();
|
||||||
|
let lname_mh = name.clone();
|
||||||
|
let gname_gs = g.name.clone();
|
||||||
|
let lname_gs = name.clone();
|
||||||
|
|
||||||
|
let mut sub: Vec<ksni::MenuItem<Self>> = Vec::new();
|
||||||
|
sub.push(
|
||||||
|
StandardItem {
|
||||||
|
label: format!("Play {gdisplay}"),
|
||||||
|
icon_name: "media-playback-start".into(),
|
||||||
|
activate: Box::new(move |this: &mut Self| {
|
||||||
|
play_from_tray(&this.config, &lname_play, &gname_play);
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
sub.push(ksni::MenuItem::Separator);
|
||||||
|
sub.push(
|
||||||
|
CheckmarkItem {
|
||||||
|
label: "GameMode".into(),
|
||||||
|
checked: g.gamemode,
|
||||||
|
activate: Box::new(move |this: &mut Self| {
|
||||||
|
toggle_flag(this, &lname_gm, &gname_gm, GameFlag::GameMode);
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
sub.push(
|
||||||
|
CheckmarkItem {
|
||||||
|
label: "MangoHud".into(),
|
||||||
|
checked: g.mangohud,
|
||||||
|
activate: Box::new(move |this: &mut Self| {
|
||||||
|
toggle_flag(this, &lname_mh, &gname_mh, GameFlag::MangoHud);
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
sub.push(
|
||||||
|
CheckmarkItem {
|
||||||
|
label: "Gamescope".into(),
|
||||||
|
checked: g.gamescope.is_some(),
|
||||||
|
activate: Box::new(move |this: &mut Self| {
|
||||||
|
toggle_flag(this, &lname_gs, &gname_gs, GameFlag::Gamescope);
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
SubMenu {
|
||||||
|
label: gdisplay,
|
||||||
|
submenu: sub,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push(ksni::MenuItem::Separator);
|
items.push(ksni::MenuItem::Separator);
|
||||||
|
|||||||
Reference in New Issue
Block a user