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