1use std::collections::{BTreeSet, HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use modde_core::profile::ProfileManager;
5use modde_core::resolver::GameId;
6
7use super::state::ToolLoadRequest;
8use super::tool_settings::{
9 apply_derived_tool_settings, build_tool_derived_facts, current_tool_config_async,
10 current_tool_config_blocking,
11 format_tool_availability, normalize_tool_settings_for_specs, patch_tool_setting_options,
12 save_tool_config_with_reason_async, set_tool_options, sync_optiscaler_release_options,
13 tool_apply_is_pending, tool_apply_signature, tool_options,
14};
15use super::{
16 ExecutableDraft, ExecutableUiEntry, ToolApplyResult, ToolHistoryUiEntry, ToolLoadSnapshot,
17 ToolOptionCatalog, ToolReleaseSupport, ToolRevertResult, ToolUiEntry,
18};
19
20pub(super) async fn load_tool_releases(
21 tool_id: String,
22) -> Result<Vec<modde_games::tools::ToolReleaseSummary>, String> {
23 let tool = modde_games::tools::resolve_tool(&tool_id)
24 .ok_or_else(|| format!("Tool is not registered: {tool_id}"))?;
25 if !tool.supports_releases() {
26 return Err(format!(
27 "{} does not support release selection",
28 tool.display_name()
29 ));
30 }
31 tool.list_releases().await.map_err(|err| err.to_string())
32}
33
34pub(super) async fn load_proton_versions() -> Result<Vec<String>, String> {
35 modde_games::tools::proton::list_ge_proton_versions()
36 .await
37 .map_err(|err| err.to_string())
38}
39
40pub(super) async fn install_selected_tool_release(
41 db: modde_core::db::ModdeDb,
42 game_id: String,
43 tool_id: String,
44) -> Result<String, String> {
45 let tool = modde_games::tools::resolve_tool(&tool_id)
46 .ok_or_else(|| format!("Tool is not registered: {tool_id}"))?;
47 let config = current_tool_config_async(&db, &game_id, &tool_id).await?;
48 let selected_tag = config
49 .get_str("release_tag")
50 .unwrap_or("latest")
51 .to_string();
52 let selected_asset = config.get_str("release_asset").unwrap_or("").to_string();
53 if selected_asset.trim().is_empty() {
54 return Err(format!(
55 "Select a {} release asset before installing",
56 tool.display_name()
57 ));
58 }
59 let config = tool
60 .install_release(&game_id, config, &selected_tag, &selected_asset)
61 .await
62 .map_err(|err| err.to_string())?;
63 save_tool_config_with_reason_async(
64 &db,
65 &GameId::from(game_id.as_str()),
66 &tool_id,
67 &config,
68 "ui:update",
69 )
70 .await?;
71 Ok(format!(
72 "Installed {} {}",
73 tool.display_name(),
74 config.get_str("release_tag").unwrap_or("release")
75 ))
76}
77
78pub(super) async fn install_selected_proton_version(
79 db: modde_core::db::ModdeDb,
80 game_id: String,
81) -> Result<String, String> {
82 let tool = modde_games::tools::resolve_tool("proton")
83 .ok_or_else(|| "Proton tool is not registered".to_string())?;
84 let row = db
85 .load_tool_config(&GameId::from(game_id.as_str()), "proton")
86 .await
87 .map_err(|err| err.to_string())?;
88 let config = row.map_or_else(
89 || tool.default_config(),
90 |row| modde_games::tools::ToolConfig {
91 tool_id: row.tool_id,
92 enabled: row.enabled,
93 settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
94 },
95 );
96 let version = config.get_str("selected_version").unwrap_or("latest");
97 let target = config.get_str("install_target").unwrap_or("steam");
98 modde_games::tools::proton::install_ge_proton_with_protonup_rs(version, target)
99 .map_err(|err| err.to_string())?;
100 Ok(format!("Installed GEProton {version} for {target}"))
101}
102
103pub(super) async fn load_tools_state(
104 db: modde_core::db::ModdeDb,
105 request: ToolLoadRequest,
106) -> Result<ToolLoadSnapshot, String> {
107 tokio::task::spawn_blocking(move || load_tools_state_blocking(db, request))
108 .await
109 .map_err(|err| err.to_string())?
110}
111
112pub(super) async fn load_executables_for_game(
113 db: modde_core::db::ModdeDb,
114 game_id: String,
115) -> Result<Vec<ExecutableUiEntry>, String> {
116 db.load_executable_configs(&GameId::from(game_id.as_str()))
117 .await
118 .map_err(|err| err.to_string())
119 .map(|rows| rows.into_iter().map(ExecutableUiEntry::from_row).collect())
120}
121
122pub(super) fn load_tools_state_blocking(
123 db: modde_core::db::ModdeDb,
124 request: ToolLoadRequest,
125) -> Result<ToolLoadSnapshot, String> {
126 let detected =
127 modde_games::detection::find_detected_game(&GameId::from(request.game_id.as_str()));
128 let game_dir = request.configured_game_dir.clone().or_else(|| {
129 detected
130 .as_ref()
131 .map(|detected| detected.install_path.clone())
132 .or_else(|| {
133 modde_games::resolve_game_plugin(&request.game_id)
134 .and_then(modde_games::GamePlugin::detect_install)
135 })
136 });
137 let context = Some(modde_games::tools::ToolGameContext::from_parts(
138 &request.game_id,
139 request.display_name.clone(),
140 game_dir.clone(),
141 detected.as_ref(),
142 ));
143 let game_dir_configured = game_dir.is_some();
144 let mut option_catalog = request.tool_option_catalog.clone();
145 if tool_options(&option_catalog, "proton", "selected_version").is_none() {
146 set_tool_options(
147 &mut option_catalog,
148 "proton",
149 "selected_version",
150 modde_games::tools::proton::proton_version_options(),
151 );
152 }
153 if !request.optiscaler_releases.is_empty()
154 && let Ok(mut config) = current_tool_config_blocking(&db, &request.game_id, "optiscaler")
155 {
156 sync_optiscaler_release_options(
157 &mut option_catalog,
158 &request.optiscaler_releases,
159 &mut config,
160 );
161 }
162
163 let entries = modde_games::tools::all_tools()
164 .iter()
165 .map(|tool| {
166 build_tool_ui_entry_blocking(
167 &db,
168 &request.game_id,
169 game_dir.as_deref(),
170 context.as_ref(),
171 *tool,
172 &option_catalog,
173 )
174 })
175 .collect::<Vec<_>>();
176 let active_tool_id = request
177 .previous_active_tool_id
178 .filter(|active| entries.iter().any(|entry| entry.tool_id == *active))
179 .or_else(|| entries.first().map(|entry| entry.tool_id.clone()));
180 let executables =
181 crate::app::block_on(db.load_executable_configs(&GameId::from(request.game_id.as_str())))
182 .map_err(|err| err.to_string())?
183 .into_iter()
184 .map(ExecutableUiEntry::from_row)
185 .collect();
186
187 Ok(ToolLoadSnapshot {
188 entries,
189 active_tool_id,
190 game_label: Some(request.display_name),
191 game_dir_configured,
192 tool_option_catalog: option_catalog,
193 executables,
194 })
195}
196
197#[allow(clippy::too_many_arguments)]
198pub(super) fn build_tool_ui_entry_blocking(
199 db: &modde_core::db::ModdeDb,
200 game_id: &str,
201 game_dir: Option<&std::path::Path>,
202 context: Option<&modde_games::tools::ToolGameContext>,
203 tool: &'static dyn modde_games::tools::GameTool,
204 option_catalog: &ToolOptionCatalog,
205) -> ToolUiEntry {
206 let typed_game_id = GameId::from(game_id);
207 let row = crate::app::block_on(db.load_tool_config(&typed_game_id, tool.tool_id()))
208 .ok()
209 .flatten();
210 let availability = tool.detect_available();
211 let applied_files = crate::app::block_on(db.load_applied_files(&typed_game_id, tool.tool_id()))
212 .unwrap_or_default();
213 let status_message = match &availability {
214 modde_games::tools::ToolAvailability::Available {
215 version: Some(version),
216 } => Some(format!("Detected {version}")),
217 modde_games::tools::ToolAvailability::NotInstalled { install_hint } => {
218 Some(install_hint.clone())
219 }
220 modde_games::tools::ToolAvailability::Available { version: None } => None,
221 };
222 let availability_text = format_tool_availability(&availability);
223 let mut config = row.as_ref().map_or_else(
224 || tool.default_config_for(context),
225 |row| modde_games::tools::ToolConfig {
226 tool_id: row.tool_id.clone(),
227 enabled: row.enabled,
228 settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
229 },
230 );
231 let release_config_normalized = tool.tool_id() == "optiscaler"
232 && modde_games::tools::optiscaler::normalize_optiscaler_release_config(&mut config);
233 let mut setting_specs = tool.settings_schema_for(context, &config);
234 let normalized_settings = normalize_tool_settings_for_specs(&config.settings, &setting_specs);
235 if release_config_normalized || normalized_settings != config.settings {
236 config.settings = normalized_settings;
237 if let Ok(settings_json) = serde_json::to_string(&config.settings) {
238 let _ = crate::app::block_on(db.save_tool_config(
239 &typed_game_id,
240 tool.tool_id(),
241 config.enabled,
242 &settings_json,
243 ));
244 }
245 setting_specs = tool.settings_schema_for(context, &config);
246 }
247 config.set("_game_id", serde_json::json!(game_id));
248 apply_derived_tool_settings(&mut config, context);
249 let mut apply_pending = tool_apply_is_pending(&config, &applied_files);
250 let mut apply_missing_inputs = Vec::new();
251 let generated_config_path = tool
252 .generate_config_for(context, &config)
253 .map(|generated| generated.path.display().to_string());
254 let env_preview = tool.env_vars_for(context, &config).into_iter().collect();
255 let dll_overrides = tool
256 .wine_dll_overrides_for(context, &config)
257 .into_iter()
258 .collect();
259 let wrapper_preview = tool
260 .wrapper_command(&config)
261 .map(|wrapper| {
262 if wrapper.args.is_empty() {
263 vec![wrapper.exe]
264 } else {
265 vec![format!("{} {}", wrapper.exe, wrapper.args)]
266 }
267 })
268 .unwrap_or_default();
269 patch_tool_setting_options(tool.tool_id(), &mut setting_specs, option_catalog);
270 let mut derived_facts = build_tool_derived_facts(context);
271 if matches!(tool.tool_id(), "reshade" | "optiscaler")
272 && let Some(game_dir) = game_dir
273 {
274 match tool.preview_apply_for(game_dir, context, &config) {
275 Ok(preview) => {
276 let has_changes = preview.has_changes();
277 apply_missing_inputs = preview.missing_inputs.clone();
278 apply_pending = apply_missing_inputs.is_empty() && has_changes;
279 let summary = if !apply_missing_inputs.is_empty() {
280 format!("missing input: {}", apply_missing_inputs.join("; "))
281 } else if has_changes {
282 format!(
283 "{} changed / {} unchanged",
284 preview.changed_files.len(),
285 preview.unchanged_files.len()
286 )
287 } else {
288 format!("no changes ({} file(s))", preview.planned_files.len())
289 };
290 derived_facts.push(("Apply preview".to_string(), summary));
291 }
292 Err(err) => {
293 derived_facts.push(("Apply preview".to_string(), format!("failed: {err}")));
294 }
295 }
296 }
297 let (optiscaler_state, optiscaler_latest_backup, optiscaler_detected_files) =
298 if tool.tool_id() == "optiscaler" {
299 let managed = modde_games::tools::optiscaler::managed_paths_from_config(&config);
300 if let Some(game_dir) = game_dir {
301 if let Ok(state) = modde_games::tools::optiscaler::scan_optiscaler_install(
302 game_id, game_dir, &managed,
303 ) {
304 if !matches!(
305 state.status,
306 modde_games::tools::optiscaler::OptiScalerInstallStatus::Managed
307 | modde_games::tools::optiscaler::OptiScalerInstallStatus::PartiallyManaged
308 ) {
309 apply_pending = true;
310 }
311 derived_facts.push(("OptiScaler state".to_string(), state.summary()));
312 if let Some(path) = &state.config_path {
313 derived_facts.push((
314 "OptiScaler config".to_string(),
315 format!(
316 "{} ({} setting(s))",
317 path.display(),
318 state.ini_settings.len()
319 ),
320 ));
321 }
322 if let Some(path) = &state.latest_backup {
323 derived_facts
324 .push(("OptiScaler backup".to_string(), path.display().to_string()));
325 }
326 (
327 Some(state.summary()),
328 state.latest_backup.map(|path| path.display().to_string()),
329 state.recognized_files.len(),
330 )
331 } else {
332 (None, None, 0)
333 }
334 } else {
335 (None, None, 0)
336 }
337 } else {
338 (None, None, 0)
339 };
340 let setting_history =
341 crate::app::block_on(db.list_tool_setting_history(&typed_game_id, tool.tool_id(), 8))
342 .unwrap_or_default()
343 .into_iter()
344 .map(ToolHistoryUiEntry::from_node)
345 .collect();
346
347 ToolUiEntry {
348 tool_id: tool.tool_id().to_string(),
349 display_name: tool.display_name().to_string(),
350 description: tool.description().to_string(),
351 category: tool.category().to_string(),
352 available: availability.is_available(),
353 availability_text,
354 enabled: config.enabled,
355 settings: config.settings.clone(),
356 setting_specs,
357 generated_config_path,
358 applied_files,
359 has_file_patching: matches!(tool.tool_id(), "reshade" | "optiscaler"),
360 release_support: ToolReleaseSupport::from_supports_releases(tool.supports_releases()),
361 status_message,
362 env_preview,
363 dll_overrides,
364 wrapper_preview,
365 derived_facts,
366 optiscaler_state,
367 optiscaler_latest_backup,
368 optiscaler_detected_files,
369 apply_pending,
370 apply_missing_inputs,
371 setting_history,
372 }
373}
374
375pub(super) async fn apply_tool_for_game(
376 db: modde_core::db::ModdeDb,
377 game_id: String,
378 game_dir: PathBuf,
379 tool_id: String,
380 context: Option<modde_games::tools::ToolGameContext>,
381) -> Result<ToolApplyResult, String> {
382 tokio::task::spawn_blocking(move || {
383 apply_tool_for_game_blocking(db, game_id, game_dir, tool_id, context)
384 })
385 .await
386 .map_err(|err| err.to_string())?
387}
388
389fn apply_tool_for_game_blocking(
390 db: modde_core::db::ModdeDb,
391 game_id: String,
392 game_dir: PathBuf,
393 tool_id: String,
394 context: Option<modde_games::tools::ToolGameContext>,
395) -> Result<ToolApplyResult, String> {
396 let typed_game_id = GameId::from(game_id.as_str());
397 let tool = modde_games::tools::resolve_tool(&tool_id)
398 .ok_or_else(|| format!("Unknown tool: {tool_id}"))?;
399 let row = crate::app::block_on(db.load_tool_config(&typed_game_id, &tool_id))
400 .map_err(|err| err.to_string())?;
401 let mut config = row.map_or_else(
402 || tool.default_config_for(context.as_ref()),
403 |row| modde_games::tools::ToolConfig {
404 tool_id: row.tool_id,
405 enabled: row.enabled,
406 settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
407 },
408 );
409 config.enabled = true;
410 config.set("_game_id", serde_json::json!(game_id));
411 apply_derived_tool_settings(&mut config, context.as_ref());
412 if tool_id == "optiscaler" {
413 modde_games::tools::optiscaler::apply_game_defaults(&mut config, context.as_ref());
414 }
415
416 let applied = tool
417 .apply_for(&game_dir, context.as_ref(), &config)
418 .map_err(|err| err.to_string())?;
419 let paths = applied
420 .files
421 .iter()
422 .map(|path| path.to_string_lossy().replace('\\', "/"))
423 .collect::<Vec<_>>();
424 let validation_message = if tool_id == "optiscaler" {
425 validate_optiscaler_apply(&game_id, &game_dir, &config, &applied)?;
426 Some("validated managed install".to_string())
427 } else {
428 None
429 };
430
431 if tool_id == "optiscaler" {
432 config.set(
433 "managed_manifest",
434 modde_games::tools::optiscaler::managed_manifest_json(&game_dir, &applied),
435 );
436 }
437 let apply_signature = tool_apply_signature(&config.settings);
438 config.set("_last_applied_settings", apply_signature);
439 let settings_json = serde_json::to_string(&config.settings).map_err(|err| err.to_string())?;
440 crate::app::block_on(db.save_tool_config_with_reason(
441 &typed_game_id,
442 &tool_id,
443 true,
444 &settings_json,
445 "ui:apply",
446 ))
447 .map_err(|err| err.to_string())?;
448 crate::app::block_on(db.clear_applied_files(&typed_game_id, &tool_id))
449 .map_err(|err| err.to_string())?;
450 crate::app::block_on(db.save_applied_files(&typed_game_id, &tool_id, &paths))
451 .map_err(|err| err.to_string())?;
452 crate::app::block_on(modde_games::launcher::generate_tool_configs(
453 &typed_game_id,
454 &db,
455 ))
456 .map_err(|err| err.to_string())?;
457
458 Ok(ToolApplyResult {
459 display_name: tool.display_name().to_string(),
460 applied_file_count: paths.len(),
461 validation_message,
462 })
463}
464
465pub(super) async fn revert_tool_for_game(
466 db: modde_core::db::ModdeDb,
467 game_id: String,
468 game_dir: PathBuf,
469 tool_id: String,
470) -> Result<ToolRevertResult, String> {
471 tokio::task::spawn_blocking(move || {
472 revert_tool_for_game_blocking(db, game_id, game_dir, tool_id)
473 })
474 .await
475 .map_err(|err| err.to_string())?
476}
477
478fn revert_tool_for_game_blocking(
479 db: modde_core::db::ModdeDb,
480 game_id: String,
481 game_dir: PathBuf,
482 tool_id: String,
483) -> Result<ToolRevertResult, String> {
484 let typed_game_id = GameId::from(game_id.as_str());
485 let tool = modde_games::tools::resolve_tool(&tool_id)
486 .ok_or_else(|| format!("Unknown tool: {tool_id}"))?;
487 let applied_paths = crate::app::block_on(db.load_applied_files(&typed_game_id, &tool_id))
488 .map_err(|err| err.to_string())?;
489 let applied = modde_games::tools::AppliedFiles {
490 files: applied_paths.iter().map(PathBuf::from).collect(),
491 };
492 tool.revert(&game_dir, &applied)
493 .map_err(|err| err.to_string())?;
494 crate::app::block_on(db.clear_applied_files(&typed_game_id, &tool_id))
495 .map_err(|err| err.to_string())?;
496 crate::app::block_on(modde_games::launcher::generate_tool_configs(
497 &typed_game_id,
498 &db,
499 ))
500 .map_err(|err| err.to_string())?;
501 Ok(ToolRevertResult {
502 display_name: tool.display_name().to_string(),
503 })
504}
505
506pub(super) async fn deactivate_optiscaler_for_game(
507 db: modde_core::db::ModdeDb,
508 game_id: String,
509 game_dir: PathBuf,
510) -> Result<ToolRevertResult, String> {
511 tokio::task::spawn_blocking(move || {
512 deactivate_optiscaler_for_game_blocking(db, game_id, game_dir)
513 })
514 .await
515 .map_err(|err| err.to_string())?
516}
517
518fn deactivate_optiscaler_for_game_blocking(
519 db: modde_core::db::ModdeDb,
520 game_id: String,
521 game_dir: PathBuf,
522) -> Result<ToolRevertResult, String> {
523 let typed_game_id = GameId::from(game_id.as_str());
524 let tool = modde_games::tools::resolve_tool("optiscaler")
525 .ok_or_else(|| "OptiScaler tool is not registered".to_string())?;
526 let applied_paths = crate::app::block_on(db.load_applied_files(&typed_game_id, "optiscaler"))
527 .map_err(|err| err.to_string())?;
528 if !applied_paths.is_empty() {
529 let applied = modde_games::tools::AppliedFiles {
530 files: applied_paths.iter().map(PathBuf::from).collect(),
531 };
532 tool.revert(&game_dir, &applied)
533 .map_err(|err| err.to_string())?;
534 crate::app::block_on(db.clear_applied_files(&typed_game_id, "optiscaler"))
535 .map_err(|err| err.to_string())?;
536 }
537
538 let settings_json = crate::app::block_on(db.load_tool_config(&typed_game_id, "optiscaler"))
539 .map_err(|err| err.to_string())?
540 .map_or_else(|| "{}".to_string(), |row| row.settings_json);
541 crate::app::block_on(db.save_tool_config_with_reason(
542 &typed_game_id,
543 "optiscaler",
544 false,
545 &settings_json,
546 "ui:deactivate",
547 ))
548 .map_err(|err| err.to_string())?;
549 crate::app::block_on(modde_games::launcher::generate_tool_configs(
550 &typed_game_id,
551 &db,
552 ))
553 .map_err(|err| err.to_string())?;
554
555 Ok(ToolRevertResult {
556 display_name: tool.display_name().to_string(),
557 })
558}
559
560pub(super) async fn save_executable_for_game(
561 db: modde_core::db::ModdeDb,
562 row: modde_core::db::ExecutableConfigRow,
563) -> Result<String, String> {
564 let name = row.name.clone();
565 let game_id = row.game_id.clone();
566 db.save_executable_config(&row)
567 .await
568 .map_err(|err| err.to_string())?;
569 Ok(format!("Saved executable '{name}' for {game_id}"))
570}
571
572pub(super) async fn remove_executable_for_game(
573 db: modde_core::db::ModdeDb,
574 game_id: String,
575 name: String,
576) -> Result<String, String> {
577 if db
578 .delete_executable_config(&GameId::from(game_id.as_str()), &name)
579 .await
580 .map_err(|err| err.to_string())?
581 {
582 Ok(format!("Removed executable '{name}'"))
583 } else {
584 Err(format!(
585 "No executable named '{name}' is configured for {game_id}"
586 ))
587 }
588}
589
590pub(super) async fn run_saved_executable_for_game(
591 db: modde_core::db::ModdeDb,
592 game_id: String,
593 name: String,
594 profile_name: Option<String>,
595) -> Result<String, String> {
596 tokio::task::spawn_blocking(move || {
597 run_saved_executable_for_game_blocking(db, game_id, name, profile_name)
598 })
599 .await
600 .map_err(|err| err.to_string())?
601}
602
603fn run_saved_executable_for_game_blocking(
604 db: modde_core::db::ModdeDb,
605 game_id: String,
606 name: String,
607 profile_name: Option<String>,
608) -> Result<String, String> {
609 let row =
610 crate::app::block_on(db.load_executable_config(&GameId::from(game_id.as_str()), &name))
611 .map_err(|err| err.to_string())?
612 .ok_or_else(|| format!("No executable named '{name}' is configured for {game_id}"))?;
613 run_executable_row_blocking(db, row, profile_name)
614}
615
616pub(super) fn run_executable_row_blocking(
617 db: modde_core::db::ModdeDb,
618 row: modde_core::db::ExecutableConfigRow,
619 profile_name: Option<String>,
620) -> Result<String, String> {
621 let pm = ProfileManager::with_db(db);
622 let row_game_id = GameId::from(row.game_id.as_str());
623 let profile = if let Some(profile_name) = profile_name {
624 crate::app::block_on(pm.load(&profile_name, Some(&row_game_id)))
625 .map_err(|err| err.to_string())?
626 } else {
627 let summaries = crate::app::block_on(pm.list()).map_err(|err| err.to_string())?;
628 let first = summaries
629 .iter()
630 .find(|profile| profile.game_id.as_str() == row.game_id)
631 .ok_or_else(|| format!("No profile found for {}", row.game_id))?;
632 crate::app::block_on(pm.load(&first.name, Some(&row_game_id)))
633 .map_err(|err| err.to_string())?
634 };
635 let game_plugin = modde_games::resolve_game_plugin(profile.game_id.as_str())
636 .ok_or_else(|| format!("Unsupported game: {}", profile.game_id))?;
637 let install_dir = game_plugin.detect_install().ok_or_else(|| {
638 format!(
639 "Could not detect install directory for {}",
640 game_plugin.display_name()
641 )
642 })?;
643 let mod_dir = game_plugin
644 .mod_root(&install_dir)
645 .map_err(|err| err.to_string())?;
646 let before = snapshot_dir_for_executable(&mod_dir)?;
647 let args: Vec<String> = serde_json::from_str(&row.arguments_json)
648 .map_err(|err| format!("Stored arguments are invalid JSON: {err}"))?;
649 let environment: HashMap<String, String> = serde_json::from_str(&row.environment_json)
650 .map_err(|err| format!("Stored environment is invalid JSON: {err}"))?;
651 let working_dir = row.working_dir.as_ref().unwrap_or(&install_dir);
652 let mut command = std::process::Command::new(&row.executable_path);
653 command.args(args).current_dir(working_dir);
654 for (key, value) in environment {
655 command.env(key, value);
656 }
657 if let Some(overrides) = &row.wine_dll_overrides {
658 command.env("WINEDLLOVERRIDES", overrides);
659 }
660 let status = command
661 .status()
662 .map_err(|err| format!("Failed to execute {}: {err}", row.executable_path.display()))?;
663 let after = snapshot_dir_for_executable(&mod_dir)?;
664 let new_files = after.difference(&before).cloned().collect::<Vec<_>>();
665 if !new_files.is_empty() {
666 let output_dir = modde_core::paths::store_dir().join(&row.output_mod);
667 std::fs::create_dir_all(&output_dir).map_err(|err| err.to_string())?;
668 for rel_path in &new_files {
669 let src = mod_dir.join(rel_path);
670 let dst = output_dir.join(rel_path);
671 if let Some(parent) = dst.parent() {
672 std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;
673 }
674 std::fs::rename(&src, &dst)
675 .or_else(|_| {
676 std::fs::copy(&src, &dst)?;
677 std::fs::remove_file(&src)
678 })
679 .map_err(|err| format!("Failed to move {rel_path} to output mod: {err}"))?;
680 }
681 }
682 let suffix = if status.success() {
683 String::new()
684 } else {
685 format!("; process exited with status {status}")
686 };
687 Ok(format!(
688 "Ran '{}' and captured {} file(s) to {}{}",
689 row.name,
690 new_files.len(),
691 row.output_mod,
692 suffix
693 ))
694}
695
696pub(super) fn snapshot_dir_for_executable(dir: &Path) -> Result<HashSet<String>, String> {
697 if !dir.exists() {
698 return Ok(HashSet::new());
699 }
700 modde_core::fs::walk_files_relative(dir)
701 .map(|files| files.into_iter().map(|(rel, _)| rel).collect())
702 .map_err(|err| err.to_string())
703}
704
705pub fn parse_executable_environment(input: &str) -> Result<HashMap<String, String>, String> {
706 let mut env = HashMap::new();
707 for (idx, line) in input.lines().enumerate() {
708 let line = line.trim();
709 if line.is_empty() {
710 continue;
711 }
712 let Some((key, value)) = line.split_once('=') else {
713 return Err(format!("Environment line {} must be KEY=VALUE", idx + 1));
714 };
715 let key = key.trim();
716 if key.is_empty() {
717 return Err(format!("Environment line {} has an empty key", idx + 1));
718 }
719 env.insert(key.to_string(), value.trim().to_string());
720 }
721 Ok(env)
722}
723
724pub(super) fn executable_draft_to_row(
725 game_id: &str,
726 draft: &ExecutableDraft,
727) -> Result<modde_core::db::ExecutableConfigRow, String> {
728 let name = draft.name.trim();
729 if name.is_empty() {
730 return Err("Executable name is required".to_string());
731 }
732 let executable_path = draft.executable_path.trim();
733 if executable_path.is_empty() {
734 return Err("Executable path is required".to_string());
735 }
736 let output_mod = if draft.output_mod.trim().is_empty() {
737 "__overwrite__"
738 } else {
739 draft.output_mod.trim()
740 };
741 let args = draft
742 .arguments
743 .split_whitespace()
744 .map(str::to_string)
745 .collect::<Vec<_>>();
746 let env = parse_executable_environment(&draft.environment)?;
747 Ok(modde_core::db::ExecutableConfigRow {
748 game_id: game_id.to_string(),
749 name: name.to_string(),
750 executable_path: PathBuf::from(executable_path),
751 arguments_json: serde_json::to_string(&args).map_err(|err| err.to_string())?,
752 working_dir: (!draft.working_dir.trim().is_empty())
753 .then(|| PathBuf::from(draft.working_dir.trim())),
754 environment_json: serde_json::to_string(&env).map_err(|err| err.to_string())?,
755 wine_dll_overrides: (!draft.wine_dll_overrides.trim().is_empty())
756 .then(|| draft.wine_dll_overrides.trim().to_string()),
757 output_mod: output_mod.to_string(),
758 enabled: true,
759 })
760}
761
762pub(super) fn validate_optiscaler_apply(
763 game_id: &str,
764 game_dir: &std::path::Path,
765 config: &modde_games::tools::ToolConfig,
766 applied: &modde_games::tools::AppliedFiles,
767) -> Result<(), String> {
768 let managed_paths = applied
769 .files
770 .iter()
771 .map(|path| {
772 path.to_string_lossy()
773 .replace('\\', "/")
774 .to_ascii_lowercase()
775 })
776 .collect::<BTreeSet<_>>();
777 let state =
778 modde_games::tools::optiscaler::scan_optiscaler_install(game_id, game_dir, &managed_paths)
779 .map_err(|err| err.to_string())?;
780 if !matches!(
781 state.status,
782 modde_games::tools::optiscaler::OptiScalerInstallStatus::Managed
783 | modde_games::tools::optiscaler::OptiScalerInstallStatus::PartiallyManaged
784 ) {
785 return Err(format!(
786 "OptiScaler validation failed: install is {} after apply",
787 state.status
788 ));
789 }
790 let proxy_dll = config
791 .get_str("proxy_dll")
792 .or_else(|| config.get_str("dll_name"))
793 .unwrap_or("dxgi.dll");
794 if !state
795 .proxy_dlls
796 .iter()
797 .any(|dll| dll.eq_ignore_ascii_case(proxy_dll))
798 {
799 return Err(format!(
800 "OptiScaler validation failed: missing configured proxy DLL {proxy_dll}"
801 ));
802 }
803 if state.config_path.is_none() {
804 return Err("OptiScaler validation failed: missing OptiScaler.ini".to_string());
805 }
806 Ok(())
807}