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
28pub(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 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#[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#[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 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 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 pub collapsed_categories: HashSet<Option<i64>>,
247 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 pub browse_nexus: crate::views::browse_nexus::NexusBrowseState,
253 pub diagnostics_state: crate::views::diagnostics::DiagnosticsState,
254 pub tool_state: ToolState,
255 pub filter_mode: FilterMode,
257 pub filter_criteria: Vec<FilterCriterion>,
259 pub compact_mod_list: bool,
261 pub collapsed_sidebar_groups: HashSet<SidebarGroup>,
263 pub update_available: Option<modde_core::update_check::UpdateInfo>,
264 pub context_generation: u64,
269 pub data_tab_generation: u64,
273 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#[derive(Debug, Clone)]
468pub enum Message {
469 ExternalRefresh,
472
473 ProfileContextLoaded {
477 generation: u64,
478 result: Result<ProfileContextSnapshot, String>,
479 },
480 DataTabConflictsLoaded {
483 generation: u64,
484 result: Result<DataTabConflicts, String>,
485 },
486
487 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 OpenNewProfileDialog,
508 NewProfileNameChanged(String),
509 CancelNewProfileDialog,
510 SubmitNewProfileDialog,
511
512 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 GotWindowId(Option<window::Id>),
535 TitleBarDrag,
536 WindowMinimize,
537 WindowToggleMaximize,
538 WindowClose,
539
540 ToggleMod {
542 mod_id: String,
543 enabled: bool,
544 },
545 FilterChanged(String),
546 AddMod,
547 AddModFromPath(PathBuf),
548 RemoveMod(usize),
549 SelectMod(usize),
550 ModDetailsLoaded {
554 nexus_mod_id: modde_core::NexusModId,
555 result: Result<modde_sources::nexus::api::NexusMod, String>,
556 },
557 ModGalleryLoaded {
559 nexus_mod_id: modde_core::NexusModId,
560 urls: Vec<String>,
561 },
562 ModThumbnailLoaded {
566 nexus_mod_id: modde_core::NexusModId,
567 gallery_index: usize,
568 bytes: Vec<u8>,
569 },
570 ModGalleryNext,
572 OpenModPage,
574 Deploy,
575 DeployComplete(Result<String, String>),
576
577 ReorderMod {
584 mod_id: String,
585 direction: ReorderDirection,
586 },
587 LockMod {
589 mod_id: String,
590 },
591 UnlockMod {
593 mod_id: String,
594 },
595
596 SearchCollections(String),
598 InstallCollection {
599 slug: String,
600 version: String,
601 },
602
603 BrowseTabSwitched(crate::views::browse_nexus::BrowseTab),
607 BrowseGameChanged(Option<String>),
609 BrowseSearchChanged(String),
611 BrowseSearchSubmit,
614 BrowseModsLoaded(Result<Vec<modde_sources::nexus::graphql::GqlModTile>, String>),
616 BrowseCollectionsLoaded(Result<Vec<modde_sources::nexus::graphql::GqlCollectionTile>, String>),
618 BrowseInstallMod {
621 game_domain: String,
622 mod_id: modde_core::NexusModId,
623 },
624 BrowseInstallResult {
627 download_key: String,
628 result: Result<String, String>,
629 },
630
631 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 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 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 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 CreateStockSnapshot,
711 StockSnapshotCreated(Result<String, String>),
712 VerifyStockSnapshot,
713 StockVerifyResult(Result<String, String>),
714
715 TryProfile,
717 RollbackExperiment,
718 CommitExperiment,
719 ExperimentWriteDone {
720 generation: u64,
721 kind: ExperimentWriteKind,
722 result: Result<ExperimentWriteOutcome, String>,
723 },
724
725 LoadSaveHistory,
727 RestoreSaveSnapshot(String),
728 SelectSaveSnapshot(String),
729
730 ToggleSeparator(Option<i64>),
732
733 DataTabFilterChanged(String),
735 DataTabToggleConflicts(bool),
736
737 RunDiagnostics,
739 DiagnosticsComputed {
740 generation: u64,
741 result: Result<DiagnosticsComputed, String>,
742 },
743
744 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 PauseDownload(usize),
829 ResumeDownload(usize),
830 CancelDownload(usize),
831
832 ModEndorseToggle,
835 ModEndorseResult {
839 nexus_mod_id: modde_core::NexusModId,
840 new_status: String,
841 result: Result<(), String>,
842 },
843 ModTrackToggle,
845 ModTrackResult {
847 nexus_mod_id: modde_core::NexusModId,
848 new_tracked: bool,
849 result: Result<(), String>,
850 },
851 ModTrackedSetLoaded {
854 nexus_mod_id: modde_core::NexusModId,
855 is_tracked: bool,
856 },
857
858 ClearOverwrite,
860 MoveOverwriteToMod(String),
861
862 ToggleFilterMode,
864 CycleFilter(FilterKind),
865 ClearFilters,
866 ToggleCompactModList,
867
868 ButtonHoverStarted {
870 id: u64,
871 description: &'static str,
872 },
873 ButtonHoverElapsed {
874 id: u64,
875 },
876 ButtonHoverEnded {
877 id: u64,
878 },
879
880 Noop,
882 UpdateCheckLoaded(Result<Option<modde_core::update_check::UpdateInfo>, String>),
883 OpenUpdateReleasePage,
884 DismissUpdateBanner,
885}
886
887#[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 let path = modde_core::ipc::gui_socket_path();
900 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 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 let mut buf = [0u8; 64];
941 let _ = stream.read(&mut buf).await;
942 if output.send(Message::ExternalRefresh).await.is_err() {
943 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#[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
977const 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 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
1000pub(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
1013pub 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;