@@ -7,7 +7,9 @@ pub use state::{AppState, ConfirmAction, ConfirmDialog, InputMode, Screen, UiMod
77
88use crate :: config:: { Config , DeploymentMethod , ExternalTool , ToolRuntimeMode } ;
99use 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+ } ;
1113use crate :: mods:: ModManager ;
1214use crate :: nexus:: NexusClient ;
1315use 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
4557impl 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
412552impl App {
0 commit comments