Skip to main content

modde_ui/
app.rs

1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3use std::time::Duration;
4
5#[cfg(test)]
6use iced::Theme;
7use iced::window;
8use smallvec::SmallVec;
9
10use modde_core::filter::{FilterCriterion, FilterKind, FilterMode};
11use modde_core::manifest::collection::CollectionManifest;
12use modde_core::profile::ProfileManager;
13#[cfg(test)]
14use modde_core::resolver::GameId;
15use modde_core::save::SaveSnapshot;
16use modde_core::settings::AppSettings;
17
18mod fomod_wizard_state;
19mod install_ops;
20mod model;
21mod profile_ops;
22mod state;
23mod tool_ops;
24mod tool_settings;
25mod update;
26mod view;
27
28/// Drive an async database future to completion from a synchronous blocking
29/// bridge.
30///
31/// modde's storage layer is async (`sqlx`), but some CPU/filesystem-heavy
32/// loaders intentionally run in `tokio::task::spawn_blocking` because they
33/// also perform synchronous CPU/filesystem work. Those blocking bodies still
34/// need to call async DB APIs. This shim provides that bridge without starting
35/// a nested Tokio runtime on iced's ambient executor. It also remains
36/// acceptable for the one-time startup DB open before the first render.
37///
38/// Do not call this on the iced render/update/view path. If the work is pure DB
39/// with owned inputs, make the loader `async` and `.await` the shared
40/// `ModdeDb` handle inside `Task::perform`; if it also does CPU/filesystem work,
41/// keep the whole synchronous body inside `spawn_blocking` and use this shim
42/// there.
43pub(crate) fn block_on<F>(future: F) -> F::Output
44where
45    F: std::future::Future,
46{
47    use std::sync::OnceLock;
48    static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
49    let rt = RT.get_or_init(|| {
50        tokio::runtime::Builder::new_multi_thread()
51            .worker_threads(2)
52            .enable_all()
53            .build()
54            .expect("failed to build modde-ui database runtime")
55    });
56    // Enter the shared runtime so sqlx (which needs the tokio reactor/timer)
57    // has a handle, then drive the future to completion on *this* thread with
58    // a current-thread executor. Driving on the current thread sidesteps the
59    // "Cannot start a runtime from within a runtime" panic that `Runtime::block_on`
60    // would hit on iced's ambient thread, and avoids requiring the future to be
61    // `Send` (sqlx connection futures are not `Send` under a higher-ranked
62    // bound), so it works for futures that borrow `&db`/`&self`.
63    let _guard = rt.enter();
64    futures::executor::block_on(future)
65}
66
67pub use self::fomod_wizard_state::FOMODWizardState;
68pub(crate) use self::state::format_lock_reason;
69pub use self::state::{
70    AddCustomGameDraft, AddCustomGameDraftField, AddCustomGameState, DataTabConflicts,
71    DiagnosticsComputed, ExecutableDraft, ExecutableDraftField, ExecutableUiEntry,
72    ProfileContextSnapshot, ProfileLoadOutcome, ReorderDirection, SidebarGroup, ToolApplyResult,
73    ToolHistoryUiEntry, ToolLoadSnapshot, ToolReleaseSupport, ToolRevertResult,
74    ToolSettingWriteResult, ToolState, ToolUiEntry, View, WabbajackInstallerState, WabbajackTab,
75};
76pub use self::tool_ops::parse_executable_environment;
77#[cfg(test)]
78use self::tool_ops::{apply_tool_for_game, validate_optiscaler_apply};
79#[cfg(test)]
80use self::tool_settings::{
81    get_tool_setting_value, normalize_tool_settings_for_specs, set_nested_tool_setting,
82    tool_apply_is_pending, tool_apply_signature,
83};
84
85pub type ToolOptionCatalog = HashMap<String, Vec<String>>;
86
87const BUTTON_HOVER_TOAST_DELAY: Duration = Duration::from_secs(2);
88// The delayed hover-toast lifecycle is still owned by Modde: app::update
89// schedules Message::ButtonHoverElapsed after this delay.
90
91/// Settings view state — consumed by the settings view.
92#[derive(Debug, Clone, Default)]
93pub struct SettingsState {
94    pub nexus_api_key_draft: String,
95    pub nexus_api_key_visible: bool,
96    pub nexus_api_key_source: Option<modde_sources::nexus::auth::ApiKeySource>,
97    pub nexus_config_key_exists: bool,
98    pub game_install_paths: Vec<SettingsGameInstall>,
99    pub download_dir: Option<PathBuf>,
100    pub effective_download_dir: PathBuf,
101    pub has_stock_snapshot: bool,
102    pub theme_name: String,
103    pub nexus_status: Option<NexusAuthStatus>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct SettingsGameInstall {
108    pub game_id: String,
109    pub display_name: String,
110    pub source: String,
111    pub path: PathBuf,
112}
113
114#[derive(Debug, Clone)]
115pub enum NexusAuthStatus {
116    Checking,
117    Valid { username: String, is_premium: bool },
118    Invalid(String),
119}
120
121#[derive(Debug, Clone)]
122pub enum ProfileWriteKind {
123    Create {
124        name: String,
125        game_id: String,
126    },
127    Delete {
128        name: String,
129    },
130    Fork {
131        new_name: String,
132    },
133    AddMod {
134        mod_id: String,
135    },
136    RemoveMod,
137    ToggleMod {
138        mod_id: String,
139        enabled: bool,
140    },
141    Reorder {
142        mod_id: String,
143        direction: ReorderDirection,
144    },
145    Lock {
146        mod_id: String,
147    },
148    Unlock {
149        mod_id: String,
150    },
151}
152
153#[derive(Debug, Clone)]
154pub struct ProfileWriteOutcome {
155    pub status_message: Option<String>,
156    pub reload: bool,
157}
158
159#[derive(Debug, Clone)]
160pub enum ExperimentWriteKind {
161    Try,
162    Rollback,
163    Commit,
164}
165
166#[derive(Debug, Clone)]
167pub struct ExperimentWriteOutcome {
168    pub previous_profile: Option<String>,
169    pub status_message: String,
170    pub reload: bool,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub struct ButtonHoverToast {
175    pub id: u64,
176    pub description: &'static str,
177}
178
179#[derive(Debug, Clone, Default, PartialEq, Eq)]
180pub struct ButtonHoverToastState {
181    pub pending: Option<ButtonHoverToast>,
182    pub visible: Option<ButtonHoverToast>,
183}
184
185/// Top-level application state.
186#[allow(clippy::struct_excessive_bools)]
187pub struct Modde {
188    pub(crate) db: modde_core::db::ModdeDb,
189    pub active_view: View,
190    pub active_profile: Option<String>,
191    pub profiles: Vec<modde_core::profile::ProfileSummary>,
192    pub status_message: String,
193    pub button_hover_toast: ButtonHoverToastState,
194    pub pending_tools_load_status_message: Option<String>,
195    pub settings: AppSettings,
196    pub collection_search: String,
197    pub collections: Vec<CollectionManifest>,
198    pub fomod_installer: Option<FOMODWizardState>,
199    pub fomod_visible_step_indices: SmallVec<[usize; 16]>,
200    pub fomod_wizard_pos: usize,
201    pub fomod_source_dir: Option<PathBuf>,
202    pub fomod_dest_dir: Option<PathBuf>,
203    pub fomod_conflicts: SmallVec<[String; 4]>,
204    pub fomod_can_undo: bool,
205    pub fomod_selections: HashMap<(usize, usize), Vec<usize>>,
206    pub selected_mod_index: Option<usize>,
207    /// Loaded Nexus metadata for the currently selected mod — populates the
208    /// detail panel at the bottom of the left nav sidebar. `None` means no
209    /// Nexus-tracked mod is selected (either nothing is selected or the
210    /// selected mod has no `nexus_mod_id`).
211    pub selected_mod_details: Option<crate::views::mod_details::ModDetailsState>,
212    pub mod_filter: String,
213    pub mod_id_filter_keys: Vec<String>,
214    pub theme_name: String,
215    pub wabbajack_manifest: Option<modde_core::WabbajackManifest>,
216    pub active_downloads: Vec<crate::views::collections::CollectionDownload>,
217    pub download_queue: modde_sources::queue::DownloadQueue,
218    pub download_lookup: HashMap<String, usize>,
219    // ── New state fields ──
220    pub loaded_profile: Option<modde_core::Profile>,
221    pub save_snapshots: Vec<SaveSnapshot>,
222    pub current_fingerprint: Option<modde_core::save::SaveFingerprint>,
223    pub selected_save_details: Option<crate::views::save_details::SaveDetailsState>,
224    pub experiment_depth: usize,
225    pub nexus_status: Option<NexusAuthStatus>,
226    pub nexus_api_key_draft: String,
227    pub nexus_api_key_visible: bool,
228    pub nexus_api_key_source: Option<modde_sources::nexus::auth::ApiKeySource>,
229    pub nexus_config_key_exists: bool,
230    pub new_profile_name: String,
231    pub new_profile_dialog_open: bool,
232    pub game_path_dialog_open: bool,
233    pub add_custom_game_dialog_open: bool,
234    pub manage_custom_games_dialog_open: bool,
235    pub pending_game_path_game_id: Option<String>,
236    pub previous_game_before_path_dialog: Option<String>,
237    pub game_path_dialog_error: Option<String>,
238    pub add_custom_game: AddCustomGameState,
239    pub available_games: SmallVec<[(String, String); 8]>,
240    pub detected_games: HashSet<String>,
241    pub selected_game: Option<String>,
242    pub stock_snapshot_exists: bool,
243    pub window_id: window::Id,
244    /// Which category groups are collapsed in the mod list view.
245    /// `None` key = the "Uncategorized" group.
246    pub collapsed_categories: HashSet<Option<i64>>,
247    /// Category id-to-name mapping for the mod list view.
248    pub mod_categories: Vec<(Option<i64>, String)>,
249    pub data_tab_state: crate::views::data_tab::DataTabState,
250    pub data_tab_conflicts: Vec<(String, Vec<String>)>,
251    /// State for the Browse Nexus view (Phase 6 of the installer pipeline).
252    pub browse_nexus: crate::views::browse_nexus::NexusBrowseState,
253    pub diagnostics_state: crate::views::diagnostics::DiagnosticsState,
254    pub tool_state: ToolState,
255    /// Filter mode (AND/OR) for the mod list filter toolbar.
256    pub filter_mode: FilterMode,
257    /// Active tri-state filter criteria for the mod list.
258    pub filter_criteria: Vec<FilterCriterion>,
259    /// Whether the mod list uses compact row rendering.
260    pub compact_mod_list: bool,
261    /// Sidebar groups the user has collapsed for this session.
262    pub collapsed_sidebar_groups: HashSet<SidebarGroup>,
263    pub update_available: Option<modde_core::update_check::UpdateInfo>,
264    /// Generation guard for the async profile/game-context load. Bumped on
265    /// every `Message::ProfileContextLoaded`-producing kickoff; a resolved load
266    /// whose captured generation no longer matches is discarded, so a slow load
267    /// for game A can never clobber state after the user switched to game B.
268    pub context_generation: u64,
269    /// Generation guard for the lightweight async data-tab conflict refresh
270    /// (`Message::DataTabConflictsLoaded`). Independent of `context_generation`
271    /// so opening the Data tab never cancels an in-flight profile reload.
272    pub data_tab_generation: u64,
273    /// Generation guard for async diagnostics runs. Bumped by diagnostics
274    /// kickoffs and by profile-context reloads so stale reports for a previous
275    /// active profile never overwrite the current profile's diagnostics state.
276    pub diagnostics_generation: u64,
277}
278
279fn load_hidden_files_blocking(
280    pm: &ProfileManager,
281    profile: &modde_core::Profile,
282) -> HashSet<(String, String)> {
283    profile
284        .id
285        .and_then(|profile_id| crate::app::block_on(pm.db().list_hidden_files(profile_id)).ok())
286        .map(|rows| {
287            rows.into_iter()
288                .map(|row| (row.mod_id, row.rel_path))
289                .collect()
290        })
291        .unwrap_or_default()
292}
293
294fn load_active_plugins_blocking(pm: &ProfileManager, profile: &modde_core::Profile) -> Vec<String> {
295    let mut plugins = profile
296        .id
297        .and_then(|profile_id| crate::app::block_on(pm.db().get_plugin_order(profile_id)).ok())
298        .unwrap_or_default();
299
300    if plugins.is_empty() {
301        plugins =
302            modde_games::read_native_plugin_order(profile.game_id.as_str()).unwrap_or_default();
303        if let Some(profile_id) = profile.id {
304            let _ = crate::app::block_on(pm.db().set_plugin_order(profile_id, &plugins));
305        }
306    }
307
308    plugins
309        .into_iter()
310        .filter(|plugin| plugin.enabled)
311        .map(|plugin| plugin.plugin_name)
312        .collect()
313}
314
315fn detected_game_ids(
316    settings: &AppSettings,
317    available_games: &[(String, String)],
318) -> HashSet<String> {
319    let mut detected: HashSet<String> = settings
320        .game_paths
321        .iter()
322        .filter(|game_path| game_path.path.is_dir())
323        .map(|game_path| game_path.game_id.to_string())
324        .collect();
325
326    detected.extend(
327        modde_games::scan_installed_games()
328            .into_iter()
329            .map(|game| game.game_id.to_string()),
330    );
331
332    for (game_id, _) in available_games {
333        if !detected.contains(game_id)
334            && modde_games::resolve_game_plugin(game_id)
335                .and_then(modde_games::GamePlugin::detect_install)
336                .is_some()
337        {
338            detected.insert(game_id.clone());
339        }
340    }
341
342    detected
343}
344
345fn settings_game_install_paths(
346    settings: &AppSettings,
347    detected_games: Vec<modde_games::detection::DetectedGame>,
348) -> Vec<SettingsGameInstall> {
349    let mut seen = HashSet::new();
350    let mut installs = Vec::new();
351
352    for detected in detected_games {
353        let game_id = detected.game_id.to_string();
354        let path = detected.install_path;
355        if !seen.insert((game_id.clone(), path.clone())) {
356            continue;
357        }
358        installs.push(SettingsGameInstall {
359            game_id,
360            display_name: detected.display_name.to_string(),
361            source: detected.source.to_string(),
362            path,
363        });
364    }
365
366    for game_path in &settings.game_paths {
367        if !game_path.path.is_dir() {
368            continue;
369        }
370        let game_id = game_path.game_id.to_string();
371        let path = game_path.path.clone();
372        if !seen.insert((game_id.clone(), path.clone())) {
373            continue;
374        }
375        let display_name = modde_games::resolve_game_plugin(&game_id)
376            .map(|plugin| plugin.display_name().to_string())
377            .unwrap_or_else(|| game_id.clone());
378        installs.push(SettingsGameInstall {
379            game_id,
380            display_name,
381            source: "Configured".to_string(),
382            path,
383        });
384    }
385
386    installs.sort_by(|a, b| {
387        a.display_name
388            .cmp(&b.display_name)
389            .then_with(|| a.path.cmp(&b.path))
390            .then_with(|| a.source.cmp(&b.source))
391    });
392    installs
393}
394
395fn build_conflict_rows(
396    analysis: &modde_core::diagnostics::ProfileAnalysis,
397    hidden: &HashSet<(String, String)>,
398) -> Vec<(String, Vec<String>)> {
399    let mut rows: Vec<(String, Vec<String>)> = analysis
400        .conflict_map
401        .resolved_conflicts(&analysis.resolved_order, hidden)
402        .into_iter()
403        .filter(|(_, providers, _)| providers.len() > 1)
404        .map(|(path, providers, winner)| {
405            let mut provider_list: Vec<String> = providers
406                .iter()
407                .map(|provider| {
408                    if winner.as_ref() == Some(provider) {
409                        format!("{provider} (winner)")
410                    } else {
411                        provider.to_string()
412                    }
413                })
414                .collect();
415            provider_list.sort();
416            (path.to_string(), provider_list)
417        })
418        .collect();
419    rows.sort_by(|a, b| a.0.cmp(&b.0));
420    rows
421}
422
423fn format_diagnostic_entry(
424    diagnostic: &modde_core::diagnostics::Diagnostic,
425) -> crate::views::diagnostics::DiagnosticEntry {
426    let severity = match diagnostic.severity {
427        modde_core::diagnostics::Severity::Info => {
428            crate::views::diagnostics::DiagnosticSeverity::Info
429        }
430        modde_core::diagnostics::Severity::Warning => {
431            crate::views::diagnostics::DiagnosticSeverity::Warning
432        }
433        modde_core::diagnostics::Severity::Error => {
434            crate::views::diagnostics::DiagnosticSeverity::Error
435        }
436    };
437
438    let mut message = diagnostic.title.clone();
439    if !diagnostic.detail.is_empty() {
440        message.push_str(": ");
441        message.push_str(&diagnostic.detail);
442    }
443    if let Some(mod_id) = &diagnostic.affected_mod {
444        message.push_str(&format!(" [mod: {mod_id}]"));
445    }
446
447    crate::views::diagnostics::DiagnosticEntry { severity, message }
448}
449
450fn build_default_download_meta(id: &str, name: &str) -> modde_sources::meta::DownloadMeta {
451    modde_sources::meta::DownloadMeta {
452        url: id.to_string(),
453        expected_hash: None,
454        bytes_downloaded: 0,
455        total_bytes: None,
456        nexus_mod_id: None,
457        nexus_file_id: None,
458        game_domain: None,
459        mod_name: Some(name.to_string()),
460        version: None,
461        status: "queued".to_string(),
462    }
463}
464
465// ─── Application Messages ────────────────────────────────────────
466
467#[derive(Debug, Clone)]
468pub enum Message {
469    /// External process (typically the CLI) notified the GUI that the
470    /// profile DB has changed. Triggers a profile reload.
471    ExternalRefresh,
472
473    /// Async result of `load_profile_context` — the off-thread profile +
474    /// data-tab + tool reload. `generation` guards against stale loads
475    /// clobbering newer state (see `Modde::context_generation`).
476    ProfileContextLoaded {
477        generation: u64,
478        result: Result<ProfileContextSnapshot, String>,
479    },
480    /// Async result of the lightweight data-tab conflict refresh fired when the
481    /// Data tab is opened (see `Modde::data_tab_generation`).
482    DataTabConflictsLoaded {
483        generation: u64,
484        result: Result<DataTabConflicts, String>,
485    },
486
487    // Navigation
488    SwitchView(View),
489    ToggleSidebarGroup(SidebarGroup),
490    SwitchProfile(String),
491    CreateProfile {
492        name: String,
493        game_id: String,
494    },
495    DeleteProfile(String),
496    ForkProfile {
497        source: String,
498        new_name: String,
499    },
500    ProfileWriteDone {
501        generation: u64,
502        kind: ProfileWriteKind,
503        result: Result<ProfileWriteOutcome, String>,
504    },
505
506    // Profile dialog
507    OpenNewProfileDialog,
508    NewProfileNameChanged(String),
509    CancelNewProfileDialog,
510    SubmitNewProfileDialog,
511
512    // Game selection
513    SelectGame(String),
514    GamePathDialogBrowse,
515    GamePathDialogPathSelected {
516        game_id: String,
517        path: PathBuf,
518    },
519    CancelGamePathDialog,
520    OpenAddCustomGame,
521    BrowseAddCustomGameInstallPath,
522    AddCustomGameFieldChanged {
523        field: AddCustomGameDraftField,
524        value: String,
525    },
526    AddCustomGameInstallPathPicked(PathBuf),
527    AddCustomGameSubmit,
528    AddCustomGameCancel,
529    OpenManageCustomGames,
530    CloseManageCustomGames,
531    RemoveCustomGame(String),
532
533    // Window controls (custom title bar)
534    GotWindowId(Option<window::Id>),
535    TitleBarDrag,
536    WindowMinimize,
537    WindowToggleMaximize,
538    WindowClose,
539
540    // Mod list
541    ToggleMod {
542        mod_id: String,
543        enabled: bool,
544    },
545    FilterChanged(String),
546    AddMod,
547    AddModFromPath(PathBuf),
548    RemoveMod(usize),
549    SelectMod(usize),
550    /// Initial Nexus v1 `get_mod` response for the selected mod. Carries
551    /// `nexus_mod_id` so stale responses (from a previous selection) are
552    /// discarded when they race a newer click.
553    ModDetailsLoaded {
554        nexus_mod_id: modde_core::NexusModId,
555        result: Result<modde_sources::nexus::api::NexusMod, String>,
556    },
557    /// Gallery image URL list returned by the v2 GraphQL endpoint.
558    ModGalleryLoaded {
559        nexus_mod_id: modde_core::NexusModId,
560        urls: Vec<String>,
561    },
562    /// Image bytes downloaded for a specific gallery slot. Guarded by both
563    /// `nexus_mod_id` and `gallery_index` so clicking through the gallery
564    /// rapidly doesn't let an old image overwrite a newer one.
565    ModThumbnailLoaded {
566        nexus_mod_id: modde_core::NexusModId,
567        gallery_index: usize,
568        bytes: Vec<u8>,
569    },
570    /// User clicked the thumbnail — advance to the next image in the gallery.
571    ModGalleryNext,
572    /// User clicked the "Open in Nexus" link.
573    OpenModPage,
574    Deploy,
575    DeployComplete(Result<String, String>),
576
577    // Load order
578    /// Move a specific mod up or down by one position. Mod-id-based (not
579    /// index-based) because the `load_order` view and `mod_list` view operate
580    /// on different index spaces — `resolved_order` vs. `profile.mods` —
581    /// and an index-based message was latently unsound. Also lets the
582    /// handler consult the per-mod lock without an index round-trip.
583    ReorderMod {
584        mod_id: String,
585        direction: ReorderDirection,
586    },
587    /// Pin an individual mod in place (per-mod lock).
588    LockMod {
589        mod_id: String,
590    },
591    /// Release an individual mod's per-mod pin.
592    UnlockMod {
593        mod_id: String,
594    },
595
596    // Collections
597    SearchCollections(String),
598    InstallCollection {
599        slug: String,
600        version: String,
601    },
602
603    // ── Browse Nexus (Phase 6) ───────────────────────────────
604    /// Switch the active browse tab. Fires a task to load the feed
605    /// for the new tab if its contents are empty.
606    BrowseTabSwitched(crate::views::browse_nexus::BrowseTab),
607    /// Switch the Nexus browser to a different supported game.
608    BrowseGameChanged(Option<String>),
609    /// Live search box keystroke.
610    BrowseSearchChanged(String),
611    /// Submit the search (Enter pressed). Runs the appropriate query
612    /// depending on the active tab.
613    BrowseSearchSubmit,
614    /// Async result of a mods feed fetch.
615    BrowseModsLoaded(Result<Vec<modde_sources::nexus::graphql::GqlModTile>, String>),
616    /// Async result of a collections feed fetch.
617    BrowseCollectionsLoaded(Result<Vec<modde_sources::nexus::graphql::GqlCollectionTile>, String>),
618    /// User clicked "Install" on a mod tile. Runs the install
619    /// pipeline via `modde_sources::nexus::install::install_single_mod`.
620    BrowseInstallMod {
621        game_domain: String,
622        mod_id: modde_core::NexusModId,
623    },
624    /// Async completion of a browse install. The `Ok` payload is a
625    /// short human-readable status message; `Err` is an error string.
626    BrowseInstallResult {
627        download_key: String,
628        result: Result<String, String>,
629    },
630
631    // Wabbajack
632    LoadWabbajackCatalog,
633    WabbajackCatalogLoaded(
634        Result<Vec<modde_sources::wabbajack::catalog::WabbajackCatalogEntry>, String>,
635    ),
636    WabbajackTabChanged(WabbajackTab),
637    WabbajackSearchChanged(String),
638    WabbajackGameFilterChanged(Option<String>),
639    WabbajackToggleOfficialOnly(bool),
640    WabbajackToggleNsfw(bool),
641    WabbajackToggleDown(bool),
642    WabbajackSelectEntry(usize),
643    WabbajackManualSourceChanged(String),
644    WabbajackHmProfileChanged(String),
645    WabbajackHmGameChanged(String),
646    WabbajackHmGameDirChanged(String),
647    WabbajackDownloadSelected,
648    WabbajackDownloadComplete(Result<PathBuf, String>),
649    WabbajackGenerateHmSnippet,
650    WabbajackHmSnippetGenerated(Result<String, String>),
651    WabbajackCopyHmSnippet,
652    WabbajackSaveHmSnippet,
653    WabbajackHmSnippetSaved(Result<PathBuf, String>),
654    WabbajackOpenUrl(String),
655    OpenWabbajackFile,
656    WabbajackFileSelected(PathBuf),
657    WabbajackProgress(f32),
658    WabbajackStartInstall,
659    WabbajackInstallComplete(Result<(String, Vec<String>), String>),
660    WabbajackLog(String),
661
662    // FOMOD
663    StartFOMOD {
664        mod_path: PathBuf,
665        dest_path: PathBuf,
666    },
667    FOMODChoice {
668        step: usize,
669        group: usize,
670        option: usize,
671        selected: bool,
672    },
673    FOMODNext,
674    FOMODBack,
675    FOMODCancel,
676    FOMODUndo,
677    FOMODInstallComplete(Result<(), String>),
678
679    // Downloads
680    DownloadProgress {
681        id: String,
682        bytes: u64,
683        total: u64,
684    },
685    DownloadComplete {
686        id: String,
687    },
688    DownloadFailed {
689        id: String,
690        error: String,
691    },
692
693    // Settings
694    SetNexusApiKeyDraft(String),
695    ToggleNexusApiKeyVisibility,
696    ReplaceNexusApiKey,
697    RemoveNexusConfigKey,
698    SetGamePath {
699        game_id: String,
700        path: PathBuf,
701    },
702    SetDownloadDir(PathBuf),
703    BrowseGamePath,
704    BrowseDownloadDir,
705    SetTheme(String),
706    ValidateNexusKey,
707    NexusKeyValidated(Result<(String, bool), String>),
708
709    // Stock game
710    CreateStockSnapshot,
711    StockSnapshotCreated(Result<String, String>),
712    VerifyStockSnapshot,
713    StockVerifyResult(Result<String, String>),
714
715    // Experiments
716    TryProfile,
717    RollbackExperiment,
718    CommitExperiment,
719    ExperimentWriteDone {
720        generation: u64,
721        kind: ExperimentWriteKind,
722        result: Result<ExperimentWriteOutcome, String>,
723    },
724
725    // Saves
726    LoadSaveHistory,
727    RestoreSaveSnapshot(String),
728    SelectSaveSnapshot(String),
729
730    // Mod list – category separators
731    ToggleSeparator(Option<i64>),
732
733    // Data tab
734    DataTabFilterChanged(String),
735    DataTabToggleConflicts(bool),
736
737    // Diagnostics
738    RunDiagnostics,
739    DiagnosticsComputed {
740        generation: u64,
741        result: Result<DiagnosticsComputed, String>,
742    },
743
744    // Tools
745    LoadTools,
746    ToolsLoaded {
747        generation: u64,
748        result: Result<ToolLoadSnapshot, String>,
749    },
750    RefreshTools,
751    LoadExecutables,
752    RefreshExecutables,
753    ExecutablesLoaded {
754        generation: u64,
755        result: Result<Vec<ExecutableUiEntry>, String>,
756    },
757    SelectToolTab(String),
758    UpdateToolSetting {
759        tool_id: String,
760        key: String,
761        value: serde_json::Value,
762    },
763    ToggleTool {
764        tool_id: String,
765        enabled: bool,
766    },
767    ToolSettingWritten {
768        tool_id: String,
769        result: Result<ToolSettingWriteResult, String>,
770    },
771    ToggleToolAdvancedSettings,
772    ApplyTool(String),
773    RevertTool(String),
774    ActivateOptiScaler,
775    DeactivateOptiScaler,
776    AdoptOptiScaler,
777    RestoreOptiScalerBackup,
778    ResetOptiScalerConfig,
779    RestoreToolSettings {
780        tool_id: String,
781        node_id: String,
782    },
783    ToolSettingsRestored {
784        tool_id: String,
785        result: Result<String, String>,
786    },
787    RefreshOptiScalerReleases,
788    OptiScalerReleasesLoaded(Result<Vec<modde_games::tools::ToolReleaseSummary>, String>),
789    InstallOptiScalerRelease,
790    OptiScalerReleaseInstalled(Result<String, String>),
791    RefreshProtonVersions,
792    ProtonVersionsLoaded(Result<Vec<String>, String>),
793    InstallProtonVersion,
794    ProtonVersionInstalled(Result<String, String>),
795    ToolApplied {
796        tool_id: String,
797        result: Result<ToolApplyResult, String>,
798    },
799    ToolReverted {
800        tool_id: String,
801        result: Result<ToolRevertResult, String>,
802    },
803    UpdateExecutableDraft {
804        field: ExecutableDraftField,
805        value: String,
806    },
807    OpenExecutableEditor,
808    ClearExecutableDraft,
809    EditExecutable(String),
810    SaveExecutable,
811    ExecutableSaved(Result<String, String>),
812    RemoveExecutable(String),
813    ExecutableRemoved {
814        name: String,
815        result: Result<String, String>,
816    },
817    RunExecutable(String),
818    ExecutableRunComplete {
819        name: String,
820        result: Result<String, String>,
821    },
822    BrowseExecutablePath,
823    ExecutablePathSelected(Option<PathBuf>),
824    BrowseExecutableWorkingDir,
825    ExecutableWorkingDirSelected(Option<PathBuf>),
826
827    // Downloads
828    PauseDownload(usize),
829    ResumeDownload(usize),
830    CancelDownload(usize),
831
832    // Sidebar mod detail — Nexus interactions
833    /// User clicked the endorse/abstain toggle button.
834    ModEndorseToggle,
835    /// Async result of an endorse or abstain call. Carries the target
836    /// status the handler optimistically applied, so it can roll back if
837    /// the request failed.
838    ModEndorseResult {
839        nexus_mod_id: modde_core::NexusModId,
840        new_status: String,
841        result: Result<(), String>,
842    },
843    /// User clicked the track/untrack toggle button.
844    ModTrackToggle,
845    /// Async result of a track or untrack call.
846    ModTrackResult {
847        nexus_mod_id: modde_core::NexusModId,
848        new_tracked: bool,
849        result: Result<(), String>,
850    },
851    /// Async result of the initial `get_tracked_mods` call fired alongside
852    /// `get_mod` when a mod is selected.
853    ModTrackedSetLoaded {
854        nexus_mod_id: modde_core::NexusModId,
855        is_tracked: bool,
856    },
857
858    // Overwrite management
859    ClearOverwrite,
860    MoveOverwriteToMod(String),
861
862    // Mod list filter toolbar
863    ToggleFilterMode,
864    CycleFilter(FilterKind),
865    ClearFilters,
866    ToggleCompactModList,
867
868    // Button hover help
869    ButtonHoverStarted {
870        id: u64,
871        description: &'static str,
872    },
873    ButtonHoverElapsed {
874        id: u64,
875    },
876    ButtonHoverEnded {
877        id: u64,
878    },
879
880    // Misc
881    Noop,
882    UpdateCheckLoaded(Result<Option<modde_core::update_check::UpdateInfo>, String>),
883    OpenUpdateReleasePage,
884    DismissUpdateBanner,
885}
886
887// ─── Application Logic ──────────────────────────────────────────
888
889#[cfg(unix)]
890fn external_refresh_stream() -> impl iced::futures::Stream<Item = Message> {
891    use iced::futures::SinkExt as _;
892    use tokio::io::AsyncReadExt as _;
893
894    iced::stream::channel(8, async move |mut output| {
895        // Per-process socket path: `modde-${euid}-${pid}.sock`. Each
896        // GUI gets its own; the CLI fan-outs to every matching file
897        // in `$XDG_RUNTIME_DIR`, so multi-window installs all see
898        // every change.
899        let path = modde_core::ipc::gui_socket_path();
900        // Pid collisions are essentially impossible inside a single
901        // boot, but be defensive: if a previous run of *this exact
902        // pid* (rare; happens with pid wraparound on long-uptime
903        // systems) left a node behind, unlink it.
904        let _ = std::fs::remove_file(&path);
905
906        let listener = match tokio::net::UnixListener::bind(&path) {
907            Ok(l) => l,
908            Err(e) => {
909                tracing::warn!(
910                    error = %e,
911                    socket = %path.display(),
912                    "could not bind refresh socket; CLI → GUI live updates disabled \
913                     for this window"
914                );
915                return;
916            }
917        };
918
919        // Best-effort cleanup at process exit so the CLI's GC pass
920        // doesn't have to do it. Held in a guard captured by the
921        // listening task: dropped when the subscription stream is
922        // torn down (window close / app shutdown), which unlinks
923        // the socket. If the process panics the file leaks, but the
924        // CLI side GCs unreachable sockets on every notify pass.
925        let _guard = SocketGuard::new(path.clone());
926
927        tracing::info!(socket = %path.display(), "listening for CLI refresh signals");
928
929        loop {
930            let (mut stream, _addr) = match listener.accept().await {
931                Ok(p) => p,
932                Err(e) => {
933                    tracing::warn!(error = %e, "accept failed; restarting listen loop");
934                    continue;
935                }
936            };
937            // Drain whatever the peer sent so the kernel buffer is
938            // freed; we don't actually parse the payload — the
939            // existence of the connection is the signal.
940            let mut buf = [0u8; 64];
941            let _ = stream.read(&mut buf).await;
942            if output.send(Message::ExternalRefresh).await.is_err() {
943                // Application is shutting down.
944                break;
945            }
946        }
947    })
948}
949
950#[cfg(not(unix))]
951fn external_refresh_stream() -> impl iced::futures::Stream<Item = Message> {
952    iced::stream::channel(1, |_output| async move {
953        std::future::pending::<()>().await;
954    })
955}
956
957/// Drop guard that unlinks a Unix socket when the listening task ends.
958#[cfg(unix)]
959struct SocketGuard {
960    path: std::path::PathBuf,
961}
962
963#[cfg(unix)]
964impl SocketGuard {
965    fn new(path: std::path::PathBuf) -> Self {
966        Self { path }
967    }
968}
969
970#[cfg(unix)]
971impl Drop for SocketGuard {
972    fn drop(&mut self) {
973        modde_core::ipc::cleanup_socket(&self.path);
974    }
975}
976
977/// Max thumbnail dimensions — matches the sidebar image slot
978/// (~166 px wide, 96 px tall).  We decode the fetched bytes, scale
979/// down with a Lanczos3 filter, and hand the smaller RGBA buffer to
980/// iced so it doesn't keep a multi-megapixel texture around.
981const THUMB_MAX_W: u32 = 340;
982const THUMB_MAX_H: u32 = 192;
983
984fn resize_thumbnail_bytes(raw: &[u8]) -> iced::widget::image::Handle {
985    let Ok(img) = image::load_from_memory(raw) else {
986        // If decoding fails, fall back to letting iced try the raw bytes.
987        return iced::widget::image::Handle::from_bytes(raw.to_vec());
988    };
989
990    let resized = img.resize(
991        THUMB_MAX_W,
992        THUMB_MAX_H,
993        image::imageops::FilterType::Lanczos3,
994    );
995    let rgba = resized.to_rgba8();
996    let (w, h) = rgba.dimensions();
997    iced::widget::image::Handle::from_rgba(w, h, rgba.into_raw())
998}
999
1000/// Map a matched shortcut action string to the corresponding
1001/// `Message`. Returns `None` for actions whose handlers aren't wired
1002/// yet — those shortcuts still register in `all_shortcuts()` for
1003/// help-text purposes but produce no messages until their handlers
1004/// exist (most need state-aware lookups or new `Message` variants).
1005pub(crate) fn shortcut_action_to_message(action: &str) -> Option<Message> {
1006    match action {
1007        "deploy" => Some(Message::Deploy),
1008        "dismiss_modal" => Some(Message::CancelNewProfileDialog),
1009        _ => None,
1010    }
1011}
1012
1013/// Run the iced application.
1014pub fn run() -> iced::Result {
1015    iced::application(Modde::new, Modde::update, Modde::view)
1016        .title(Modde::title)
1017        .theme(Modde::theme)
1018        .subscription(Modde::subscription)
1019        .decorations(false)
1020        .run()
1021}
1022
1023#[cfg(test)]
1024mod tests;