Skip to content

Commit a23fc94

Browse files
authored
Updated
Added a few fixes noted from Issues
1 parent 338553f commit a23fc94

46 files changed

Lines changed: 4569 additions & 1858 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/app/actions.rs

Lines changed: 392 additions & 130 deletions
Large diffs are not rendered by default.

src/app/mod.rs

Lines changed: 166 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ pub use state::{AppState, ConfirmAction, ConfirmDialog, InputMode, Screen, UiMod
77

88
use crate::config::{Config, DeploymentMethod, ExternalTool, ToolRuntimeMode};
99
use crate::db::Database;
10-
use crate::games::{detect_proton_runtimes, Game, GameDetector, GamePlatform, GameType, ProtonRuntime};
10+
use crate::games::{
11+
detect_proton_runtimes, Game, GameDetector, GamePlatform, GameType, ProtonRuntime,
12+
};
1113
use crate::mods::ModManager;
1214
use crate::nexus::NexusClient;
1315
use crate::profiles::ProfileManager;
@@ -40,17 +42,29 @@ pub struct App {
4042

4143
/// Detected games
4244
pub games: Vec<Game>,
45+
46+
/// Global CLI verbosity (`-v`, `-vv`, `-vvv`)
47+
pub cli_verbosity: u8,
48+
}
49+
50+
#[derive(Debug, Clone)]
51+
pub struct ExternalToolLaunchResult {
52+
pub exit_code: i32,
53+
pub stdout: String,
54+
pub stderr: String,
4355
}
4456

4557
impl App {
4658
/// Create a new App instance
4759
pub async fn new(config: Config) -> Result<Self> {
4860
// Ensure directories exist
49-
config.ensure_dirs().context("Failed to create directories")?;
61+
config
62+
.ensure_dirs()
63+
.context("Failed to create directories")?;
5064

5165
// Initialize database
52-
let db = Database::open(&config.paths.database_file())
53-
.context("Failed to open database")?;
66+
let db =
67+
Database::open(&config.paths.database_file()).context("Failed to open database")?;
5468
let db = Arc::new(db);
5569

5670
// Detect games (Steam + GOG + user-configured custom paths).
@@ -67,18 +81,15 @@ impl App {
6781
let state = AppState::new(active_game);
6882

6983
// Initialize Nexus API client if API key is available
70-
let nexus = config
71-
.nexus_api_key
72-
.as_ref()
73-
.and_then(|key| {
74-
NexusClient::new(key.clone())
75-
.map(Arc::new)
76-
.map_err(|e| {
77-
tracing::warn!("Failed to initialize Nexus API client: {}", e);
78-
e
79-
})
80-
.ok()
81-
});
84+
let nexus = config.nexus_api_key.as_ref().and_then(|key| {
85+
NexusClient::new(key.clone())
86+
.map(Arc::new)
87+
.map_err(|e| {
88+
tracing::warn!("Failed to initialize Nexus API client: {}", e);
89+
e
90+
})
91+
.ok()
92+
});
8293

8394
// Wrap config
8495
let config = Arc::new(RwLock::new(config));
@@ -97,9 +108,14 @@ impl App {
97108
profiles,
98109
nexus,
99110
games,
111+
cli_verbosity: 0,
100112
})
101113
}
102114

115+
pub fn set_cli_verbosity(&mut self, verbosity: u8) {
116+
self.cli_verbosity = verbosity;
117+
}
118+
103119
/// Run the TUI interface
104120
pub async fn run_tui(&mut self) -> Result<()> {
105121
let mut tui = Tui::new()?;
@@ -269,7 +285,9 @@ impl App {
269285
let config = self.config.read().await;
270286
let tool_path = config
271287
.external_tool_path(tool)
272-
.ok_or_else(|| anyhow::anyhow!("Tool path not configured for {}", tool.display_name()))?
288+
.ok_or_else(|| {
289+
anyhow::anyhow!("Tool path not configured for {}", tool.display_name())
290+
})?
273291
.to_string();
274292
let mode = config.external_tool_runtime_mode(tool);
275293
let proton_cmd = if mode == ToolRuntimeMode::Proton {
@@ -289,9 +307,12 @@ impl App {
289307
let resolved_proton_cmd = expand_user_path(proton_cmd.as_deref().unwrap_or("proton"));
290308
let mut command = tokio::process::Command::new(&resolved_proton_cmd);
291309
command.arg("run").arg(&resolved_tool_path);
292-
// Typical Proton/Wine env for out-of-steam launches.
293-
command.env("STEAM_COMPAT_DATA_PATH", &proton_prefix);
294-
command.env("WINEPREFIX", proton_prefix.join("pfx"));
310+
Self::apply_proton_launch_env(
311+
&mut command,
312+
&game,
313+
&proton_prefix,
314+
&resolved_proton_cmd,
315+
);
295316
command
296317
} else {
297318
tokio::process::Command::new(&resolved_tool_path)
@@ -311,6 +332,112 @@ impl App {
311332
Ok(status.code().unwrap_or_default())
312333
}
313334

335+
/// Launch an external tool and capture stdout/stderr (used by TUI to keep output in-app).
336+
pub async fn launch_external_tool_captured(
337+
&self,
338+
tool: ExternalTool,
339+
args: &[String],
340+
) -> Result<ExternalToolLaunchResult> {
341+
let game = self
342+
.active_game()
343+
.await
344+
.ok_or_else(|| anyhow::anyhow!("No game selected"))?;
345+
let (proton_cmd, tool_path, runtime_mode) = {
346+
let config = self.config.read().await;
347+
let tool_path = config
348+
.external_tool_path(tool)
349+
.ok_or_else(|| {
350+
anyhow::anyhow!("Tool path not configured for {}", tool.display_name())
351+
})?
352+
.to_string();
353+
let mode = config.external_tool_runtime_mode(tool);
354+
let proton_cmd = if mode == ToolRuntimeMode::Proton {
355+
Some(self.resolve_proton_launcher_from_config(&config)?)
356+
} else {
357+
None
358+
};
359+
(proton_cmd, tool_path, mode)
360+
};
361+
362+
let resolved_tool_path = expand_user_path(&tool_path);
363+
let mut command = if runtime_mode == ToolRuntimeMode::Proton {
364+
let proton_prefix = game
365+
.proton_prefix
366+
.clone()
367+
.ok_or_else(|| anyhow::anyhow!("Active game has no Proton prefix detected"))?;
368+
let resolved_proton_cmd = expand_user_path(proton_cmd.as_deref().unwrap_or("proton"));
369+
let mut command = tokio::process::Command::new(&resolved_proton_cmd);
370+
command.arg("run").arg(&resolved_tool_path);
371+
Self::apply_proton_launch_env(
372+
&mut command,
373+
&game,
374+
&proton_prefix,
375+
&resolved_proton_cmd,
376+
);
377+
command
378+
} else {
379+
tokio::process::Command::new(&resolved_tool_path)
380+
};
381+
for arg in args {
382+
command.arg(arg);
383+
}
384+
if let Some(parent) = Path::new(&resolved_tool_path).parent() {
385+
command.current_dir(parent);
386+
}
387+
388+
let output = command
389+
.output()
390+
.await
391+
.with_context(|| format!("Failed to launch {} via Proton", tool.display_name()))?;
392+
393+
Ok(ExternalToolLaunchResult {
394+
exit_code: output.status.code().unwrap_or_default(),
395+
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
396+
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
397+
})
398+
}
399+
400+
fn apply_proton_launch_env(
401+
command: &mut tokio::process::Command,
402+
game: &Game,
403+
proton_prefix: &Path,
404+
proton_cmd: &str,
405+
) {
406+
// Core Proton/Wine env for out-of-steam launches.
407+
command.env("STEAM_COMPAT_DATA_PATH", proton_prefix);
408+
command.env("WINEPREFIX", proton_prefix.join("pfx"));
409+
command.env("STEAM_COMPAT_INSTALL_PATH", &game.install_path);
410+
411+
if let Some(proton_dir) = Path::new(proton_cmd).parent() {
412+
command.env("STEAM_COMPAT_TOOL_PATHS", proton_dir);
413+
}
414+
415+
let compat_client_install = std::env::var("STEAM_COMPAT_CLIENT_INSTALL_PATH")
416+
.ok()
417+
.filter(|s| !s.trim().is_empty())
418+
.or_else(|| Self::infer_steam_client_install_path(proton_cmd));
419+
if let Some(path) = compat_client_install {
420+
command.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", path);
421+
}
422+
423+
if let Some(game_type) = GameType::from_id(&game.id) {
424+
let app_id = game_type.steam_app_id().to_string();
425+
command.env("SteamAppId", &app_id);
426+
command.env("SteamGameId", &app_id);
427+
}
428+
}
429+
430+
fn infer_steam_client_install_path(proton_cmd: &str) -> Option<String> {
431+
let mut cur = Path::new(proton_cmd).parent();
432+
while let Some(path) = cur {
433+
if path.file_name().and_then(|n| n.to_str()) == Some("steamapps") {
434+
return path.parent().map(|p| p.display().to_string());
435+
}
436+
cur = path.parent();
437+
}
438+
None
439+
}
440+
314441
/// Register/update a custom game install path (GOG/manual/steam override).
315442
pub async fn add_custom_game_path(
316443
&mut self,
@@ -365,14 +492,22 @@ impl App {
365492
}
366493

367494
/// Remove a custom game install path.
368-
pub async fn remove_custom_game_path(&mut self, game_id: &str, install_path: &str) -> Result<()> {
495+
pub async fn remove_custom_game_path(
496+
&mut self,
497+
game_id: &str,
498+
install_path: &str,
499+
) -> Result<()> {
369500
let mut config = self.config.write().await;
370501
let before = config.custom_games.len();
371502
config.custom_games.retain(|entry| {
372503
!(entry.game_id.eq_ignore_ascii_case(game_id) && entry.install_path == install_path)
373504
});
374505
if config.custom_games.len() == before {
375-
anyhow::bail!("No matching custom game path found for {} at {}", game_id, install_path);
506+
anyhow::bail!(
507+
"No matching custom game path found for {} at {}",
508+
game_id,
509+
install_path
510+
);
376511
}
377512
config.save().await?;
378513
let custom_games = config.custom_games.clone();
@@ -387,7 +522,10 @@ fn find_runtime<'a>(runtimes: &'a [ProtonRuntime], selection: &str) -> Option<&'
387522
runtimes.iter().find(|rt| {
388523
rt.id.eq_ignore_ascii_case(selection)
389524
|| rt.name.eq_ignore_ascii_case(selection)
390-
|| rt.proton_path.to_string_lossy().eq_ignore_ascii_case(selection)
525+
|| rt
526+
.proton_path
527+
.to_string_lossy()
528+
.eq_ignore_ascii_case(selection)
391529
})
392530
}
393531

@@ -404,9 +542,11 @@ fn pick_auto_runtime(runtimes: &[ProtonRuntime]) -> Option<&ProtonRuntime> {
404542
return Some(exp);
405543
}
406544

407-
runtimes
408-
.iter()
409-
.max_by(|a, b| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()))
545+
runtimes.iter().max_by(|a, b| {
546+
a.name
547+
.to_ascii_lowercase()
548+
.cmp(&b.name.to_ascii_lowercase())
549+
})
410550
}
411551

412552
impl App {

src/app/state.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Application state management
22
33
use crate::collections::Collection;
4-
use crate::db::{CategoryRecord, ModlistRecord, ModlistEntryRecord, NexusCatalogRecord};
4+
use crate::db::{CategoryRecord, ModlistEntryRecord, ModlistRecord, NexusCatalogRecord};
55
use crate::games::Game;
66
use crate::mods::fomod::{FileInstruction, FomodInstaller, WizardState};
77
use crate::mods::InstalledMod;
@@ -132,9 +132,18 @@ pub struct AppState {
132132
/// Error message to display
133133
pub error_message: Option<String>,
134134

135+
/// Recent raw command output lines (stderr/stdout snippets).
136+
pub command_output_log: Vec<String>,
137+
135138
/// Installation progress (0-100)
136139
pub installation_progress: Option<InstallProgress>,
137140

141+
/// Whether a bulk install task is currently active.
142+
pub bulk_install_running: bool,
143+
144+
/// Request cooperative cancellation of a bulk install task.
145+
pub bulk_install_cancel_requested: bool,
146+
138147
/// Categorization progress
139148
pub categorization_progress: Option<CategorizationProgress>,
140149

@@ -546,6 +555,26 @@ impl AppState {
546555
self.status_message = None;
547556
}
548557

558+
pub fn push_command_output_line(&mut self, line: impl Into<String>) {
559+
let line = line.into().replace('\r', "");
560+
let trimmed = line.trim();
561+
if trimmed.is_empty() {
562+
return;
563+
}
564+
self.command_output_log.push(trimmed.to_string());
565+
const MAX_LOG_LINES: usize = 8;
566+
if self.command_output_log.len() > MAX_LOG_LINES {
567+
let overflow = self.command_output_log.len() - MAX_LOG_LINES;
568+
self.command_output_log.drain(0..overflow);
569+
}
570+
}
571+
572+
pub fn push_command_output_text(&mut self, text: &str) {
573+
for line in text.lines() {
574+
self.push_command_output_line(line);
575+
}
576+
}
577+
549578
pub fn toggle_ui_mode(&mut self) {
550579
self.ui_mode = match self.ui_mode {
551580
UiMode::Guided => UiMode::Advanced,
@@ -640,6 +669,6 @@ pub struct CatalogProgress {
640669
pub mods_inserted: i64,
641670
pub mods_updated: i64,
642671
pub current_page: i32,
643-
pub total_count: i64, // Total mods in catalog
672+
pub total_count: i64, // Total mods in catalog
644673
pub current_offset: i32, // Current offset position
645674
}

src/collections/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ pub fn load_collection(path: &Path) -> Result<Collection> {
7373
let content = std::fs::read_to_string(path)
7474
.with_context(|| format!("Failed to read collection at {}", path.display()))?;
7575

76-
let collection: Collection = serde_json::from_str(&content)
77-
.context("Failed to parse collection JSON")?;
76+
let collection: Collection =
77+
serde_json::from_str(&content).context("Failed to parse collection JSON")?;
7878

7979
Ok(collection)
8080
}

0 commit comments

Comments
 (0)