From d0db4a13a81ee99ece6b3cdfca3573ff3a38c1b3 Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Tue, 24 Mar 2026 08:51:41 -0400 Subject: [PATCH 1/5] Avalonia migration Phase 1 --- global.json | 7 +- src/.DS_Store | Bin 0 -> 6148 bytes src/UniGetUI.Avalonia/App.axaml | 103 +- src/UniGetUI.Avalonia/App.axaml.cs | 89 +- .../Infrastructure/AvaloniaAutoUpdater.cs | 1 + .../Infrastructure/AvaloniaBootstrapper.cs | 1 + .../AvaloniaOperationRegistry.cs | 82 +- .../Infrastructure/MacOsNotificationBridge.cs | 223 ++ src/UniGetUI.Avalonia/MainWindow.axaml | 18 - src/UniGetUI.Avalonia/MainWindow.axaml.cs | 735 ----- .../Models/PackageCollections.cs | 158 ++ src/UniGetUI.Avalonia/Program.cs | 453 +-- src/UniGetUI.Avalonia/Styles/AppStyles.axaml | 238 -- .../UniGetUI.Avalonia.csproj | 124 +- src/UniGetUI.Avalonia/ViewLocator.cs | 37 + .../DialogPages/InstallOptionsViewModel.cs | 446 +++ .../ManageIgnoredUpdatesViewModel.cs | 189 ++ .../DialogPages/OperationViewModel.cs | 189 ++ .../DialogPages/PackageDetailsViewModel.cs | 317 +++ .../ViewModels/MainWindowViewModel.cs | 346 +++ .../SettingsPages/AdministratorViewModel.cs | 12 + .../Pages/SettingsPages/BackupViewModel.cs | 46 + .../SettingsPages/ExperimentalViewModel.cs | 7 + .../Pages/SettingsPages/GeneralViewModel.cs | 68 + .../SettingsPages/Interface_PViewModel.cs | 39 + .../Pages/SettingsPages/InternetViewModel.cs | 36 + .../ManagersHomepageViewModel.cs | 11 + .../SettingsPages/NotificationsViewModel.cs | 10 + .../SettingsPages/OperationsViewModel.cs | 7 + .../SettingsBasePageViewModel.cs | 14 + .../SettingsHomepageViewModel.cs | 7 + .../Pages/SettingsPages/UpdatesViewModel.cs | 9 + .../ViewModels/SidebarViewModel.cs | 54 + .../SoftwarePages/PackagesPageViewModel.cs | 657 +++++ .../ViewModels/ViewModelBase.cs | 11 + src/UniGetUI.Avalonia/Views/BaseView.cs | 13 + .../Views/Controls/Settings/ButtonCard.cs | 58 + .../Controls/Settings/CheckboxButtonCard.cs | 88 + .../Views/Controls/Settings/CheckboxCard.cs | 156 ++ .../Views/Controls/Settings/ComboboxCard.cs | 78 + .../Controls/Settings/SecureCheckboxCard.cs | 135 + .../Views/Controls/Settings/SettingsCard.cs | 208 ++ .../Controls/Settings/SettingsPageButton.cs | 62 + .../Views/Controls/Settings/TextboxCard.cs | 86 + .../Controls/Settings/TranslatedTextBlock.cs | 17 + .../Views/Controls/SvgIcon.cs | 134 + .../DialogPages/InstallOptionsWindow.axaml | 296 ++ .../DialogPages/InstallOptionsWindow.axaml.cs | 32 + .../ManageIgnoredUpdatesWindow.axaml | 190 ++ .../ManageIgnoredUpdatesWindow.axaml.cs | 15 + .../DialogPages/OperationOutputWindow.cs | 53 + .../DialogPages/PackageDetailsWindow.axaml | 371 +++ .../DialogPages/PackageDetailsWindow.axaml.cs | 117 + .../Views/DialogPages/TelemetryDialog.axaml | 45 + .../DialogPages/TelemetryDialog.axaml.cs | 38 + src/UniGetUI.Avalonia/Views/ErrorView.axaml | 27 - .../Views/ErrorView.axaml.cs | 39 - src/UniGetUI.Avalonia/Views/IShellPage.cs | 14 - src/UniGetUI.Avalonia/Views/LoadingView.axaml | 40 - .../Views/LoadingView.axaml.cs | 20 - .../Views/MainShellView.axaml | 291 -- .../Views/MainShellView.axaml.cs | 1055 ------- src/UniGetUI.Avalonia/Views/MainWindow.axaml | 183 ++ .../Views/MainWindow.axaml.cs | 123 + .../Views/Pages/AboutPageWindow.axaml | 163 -- .../Views/Pages/AboutPageWindow.axaml.cs | 153 -- .../Views/Pages/AdminWarningWindow.axaml | 47 - .../Views/Pages/AdminWarningWindow.axaml.cs | 26 - .../Views/Pages/BundlesPageView.axaml | 240 -- .../Views/Pages/BundlesPageView.axaml.cs | 1058 ------- .../Views/Pages/DesktopShortcutsWindow.axaml | 99 - .../Pages/DesktopShortcutsWindow.axaml.cs | 150 - .../Pages/DocumentationBrowserWindow.axaml | 115 - .../Pages/DocumentationBrowserWindow.axaml.cs | 510 ---- .../Views/Pages/HelpPageView.axaml | 99 - .../Views/Pages/HelpPageView.axaml.cs | 131 - .../Views/Pages/IgnoredUpdatesWindow.axaml | 155 -- .../Views/Pages/IgnoredUpdatesWindow.axaml.cs | 131 - .../Pages/InstallOptionsEditorView.axaml | 235 -- .../Pages/InstallOptionsEditorView.axaml.cs | 444 --- .../Views/Pages/InstallOptionsWindow.axaml | 39 - .../Views/Pages/InstallOptionsWindow.axaml.cs | 56 - .../Views/Pages/LogsPageView.axaml | 95 - .../Views/Pages/LogsPageView.axaml.cs | 344 --- .../Pages/Managers/IManagerSectionView.cs | 10 - .../Pages/Managers/ManagerDetailView.axaml | 101 - .../Pages/Managers/ManagerDetailView.axaml.cs | 999 ------- .../Views/Pages/Managers/ManagerRowView.axaml | 63 - .../Pages/Managers/ManagerRowView.axaml.cs | 154 -- .../Pages/Managers/ManagersHomeView.axaml | 24 - .../Pages/Managers/ManagersHomeView.axaml.cs | 95 - .../Pages/Managers/ManagersPageView.axaml | 47 - .../Pages/Managers/ManagersPageView.axaml.cs | 111 - .../Views/Pages/MissingDependencyDialog.axaml | 63 - .../Pages/MissingDependencyDialog.axaml.cs | 110 - .../Views/Pages/OperationFailedWindow.axaml | 64 - .../Pages/OperationFailedWindow.axaml.cs | 193 -- .../Views/Pages/OperationLogWindow.axaml | 46 - .../Views/Pages/OperationLogWindow.axaml.cs | 133 - .../Views/Pages/OperationWidgetView.axaml | 50 - .../Views/Pages/OperationWidgetView.axaml.cs | 234 -- .../Views/Pages/PackageDetailsWindow.axaml | 421 --- .../Views/Pages/PackageDetailsWindow.axaml.cs | 1069 -------- .../Views/Pages/PackagePageView.axaml | 481 ---- .../Views/Pages/PackagePageView.axaml.cs | 2435 ----------------- .../Views/Pages/ReleaseNotesWindow.axaml | 93 - .../Views/Pages/ReleaseNotesWindow.axaml.cs | 195 -- .../Settings/AdministratorSettingsView.axaml | 210 -- .../AdministratorSettingsView.axaml.cs | 285 -- .../Pages/Settings/BackupSettingsView.axaml | 150 - .../Settings/BackupSettingsView.axaml.cs | 699 ----- .../Settings/ExperimentalSettingsView.axaml | 172 -- .../ExperimentalSettingsView.axaml.cs | 267 -- .../Pages/Settings/GeneralSettingsView.axaml | 146 - .../Settings/GeneralSettingsView.axaml.cs | 381 --- .../Settings/ISettingsSectionStateNotifier.cs | 6 - .../Pages/Settings/ISettingsSectionView.cs | 10 - .../Settings/InterfaceSettingsView.axaml | 132 - .../Settings/InterfaceSettingsView.axaml.cs | 314 --- .../Pages/Settings/InternetSettingsView.axaml | 107 - .../Settings/InternetSettingsView.axaml.cs | 425 --- .../Pages/Settings/ManagersSettingsView.axaml | 16 - .../Settings/ManagersSettingsView.axaml.cs | 105 - .../Settings/NotificationsSettingsView.axaml | 77 - .../NotificationsSettingsView.axaml.cs | 179 -- .../Settings/OperationsSettingsView.axaml | 89 - .../Settings/OperationsSettingsView.axaml.cs | 219 -- .../Pages/Settings/SettingsHomeView.axaml | 28 - .../Pages/Settings/SettingsHomeView.axaml.cs | 141 - .../Pages/Settings/SettingsPageView.axaml | 50 - .../Pages/Settings/SettingsPageView.axaml.cs | 181 -- .../SettingsPlaceholderSectionView.axaml | 23 - .../SettingsPlaceholderSectionView.axaml.cs | 49 - .../Pages/Settings/SettingsSectionRoute.cs | 16 - .../Pages/Settings/UpdatesSettingsView.axaml | 64 - .../Settings/UpdatesSettingsView.axaml.cs | 273 -- .../Pages/SettingsPages/Administrator.axaml | 100 + .../SettingsPages/Administrator.axaml.cs | 114 + .../Views/Pages/SettingsPages/Backup.axaml | 64 + .../Views/Pages/SettingsPages/Backup.axaml.cs | 78 + .../Pages/SettingsPages/Experimental.axaml | 63 + .../Pages/SettingsPages/Experimental.axaml.cs | 64 + .../Views/Pages/SettingsPages/General.axaml | 79 + .../Pages/SettingsPages/General.axaml.cs | 84 + .../Pages/SettingsPages/ISettingsPage.cs | 10 + .../Pages/SettingsPages/Interface_P.axaml | 63 + .../Pages/SettingsPages/Interface_P.axaml.cs | 68 + .../Views/Pages/SettingsPages/Internet.axaml | 49 + .../Pages/SettingsPages/Internet.axaml.cs | 186 ++ .../SettingsPages/ManagersHomepage.axaml | 18 + .../SettingsPages/ManagersHomepage.axaml.cs | 34 + .../Pages/SettingsPages/Notifications.axaml | 54 + .../SettingsPages/Notifications.axaml.cs | 49 + .../Pages/SettingsPages/Operations.axaml | 59 + .../Pages/SettingsPages/Operations.axaml.cs | 63 + .../SettingsPages/SettingsBasePage.axaml | 56 + .../SettingsPages/SettingsBasePage.axaml.cs | 181 ++ .../SettingsPages/SettingsHomepage.axaml | 95 + .../SettingsPages/SettingsHomepage.axaml.cs | 31 + .../Pages/SettingsPages/SettingsPageStubs.cs | 4 + .../Views/Pages/SettingsPages/Updates.axaml | 70 + .../Pages/SettingsPages/Updates.axaml.cs | 78 + .../Views/Pages/SimplePageView.axaml | 22 - .../Views/Pages/SimplePageView.axaml.cs | 52 - .../Views/Pages/TelemetryConsentWindow.axaml | 67 - .../Pages/TelemetryConsentWindow.axaml.cs | 45 - src/UniGetUI.Avalonia/Views/SidebarView.axaml | 186 ++ .../Views/SidebarView.axaml.cs | 78 + .../SoftwarePages/AbstractPackagesPage.axaml | 625 +++++ .../AbstractPackagesPage.axaml.cs | 367 +++ .../SoftwarePages/DiscoverSoftwarePage.cs | 201 ++ .../SoftwarePages/InstalledPackagesPage.cs | 358 +++ .../Interfaces/PageInterfaces.cs | 43 + .../Views/SoftwarePages/PackageBundlesPage.cs | 653 +++++ .../Views/SoftwarePages/PageStubs.cs | 93 + .../SoftwarePages/SoftwareUpdatesPage.cs | 368 +++ src/UniGetUI.Avalonia/app.manifest | 18 + src/UniGetUI.Interface.Enums/Enums.cs | 1 + .../Helpers/HomebrewPkgDetailsHelper.cs | 126 + .../Helpers/HomebrewPkgOperationHelper.cs | 61 + .../Helpers/HomebrewSourceHelper.cs | 90 + .../Homebrew.cs | 299 ++ ...tUI.PackageEngine.Managers.Homebrew.csproj | 23 + .../Helpers/NpmPkgOperationHelper.cs | 14 +- .../Npm.cs | 68 +- .../Pip.cs | 2 + .../Vcpkg.cs | 7 +- .../PEInterface.cs | 12 + .../UniGetUI.PackageEngine.PEInterface.csproj | 4 + .../DiscoverablePackagesLoader.cs | 17 + src/UniGetUI.sln | 11 + src/UniGetUI/Assets/.DS_Store | Bin 0 -> 6148 bytes src/UniGetUI/Assets/Symbols/.DS_Store | Bin 0 -> 6148 bytes src/UniGetUI/Assets/Symbols/Font/.DS_Store | Bin 0 -> 6148 bytes src/UniGetUI/Assets/Symbols/homebrew.svg | 2 + 195 files changed, 11244 insertions(+), 19506 deletions(-) create mode 100644 src/.DS_Store create mode 100644 src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs delete mode 100644 src/UniGetUI.Avalonia/MainWindow.axaml delete mode 100644 src/UniGetUI.Avalonia/MainWindow.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Models/PackageCollections.cs delete mode 100644 src/UniGetUI.Avalonia/Styles/AppStyles.axaml create mode 100644 src/UniGetUI.Avalonia/ViewLocator.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageIgnoredUpdatesViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/DialogPages/PackageDetailsViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AdministratorViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/BackupViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/ExperimentalViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/GeneralViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/Interface_PViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/InternetViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/ManagersHomepageViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/NotificationsViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/OperationsViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/SettingsBasePageViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/SettingsHomepageViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/UpdatesViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs create mode 100644 src/UniGetUI.Avalonia/ViewModels/ViewModelBase.cs create mode 100644 src/UniGetUI.Avalonia/Views/BaseView.cs create mode 100644 src/UniGetUI.Avalonia/Views/Controls/Settings/ButtonCard.cs create mode 100644 src/UniGetUI.Avalonia/Views/Controls/Settings/CheckboxButtonCard.cs create mode 100644 src/UniGetUI.Avalonia/Views/Controls/Settings/CheckboxCard.cs create mode 100644 src/UniGetUI.Avalonia/Views/Controls/Settings/ComboboxCard.cs create mode 100644 src/UniGetUI.Avalonia/Views/Controls/Settings/SecureCheckboxCard.cs create mode 100644 src/UniGetUI.Avalonia/Views/Controls/Settings/SettingsCard.cs create mode 100644 src/UniGetUI.Avalonia/Views/Controls/Settings/SettingsPageButton.cs create mode 100644 src/UniGetUI.Avalonia/Views/Controls/Settings/TextboxCard.cs create mode 100644 src/UniGetUI.Avalonia/Views/Controls/Settings/TranslatedTextBlock.cs create mode 100644 src/UniGetUI.Avalonia/Views/Controls/SvgIcon.cs create mode 100644 src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml create mode 100644 src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/DialogPages/ManageIgnoredUpdatesWindow.axaml create mode 100644 src/UniGetUI.Avalonia/Views/DialogPages/ManageIgnoredUpdatesWindow.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/DialogPages/OperationOutputWindow.cs create mode 100644 src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml create mode 100644 src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/DialogPages/TelemetryDialog.axaml create mode 100644 src/UniGetUI.Avalonia/Views/DialogPages/TelemetryDialog.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/ErrorView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/ErrorView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/IShellPage.cs delete mode 100644 src/UniGetUI.Avalonia/Views/LoadingView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/LoadingView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/MainShellView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/MainShellView.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/MainWindow.axaml create mode 100644 src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/AboutPageWindow.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/AboutPageWindow.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/AdminWarningWindow.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/AdminWarningWindow.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/BundlesPageView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/BundlesPageView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/DesktopShortcutsWindow.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/DesktopShortcutsWindow.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/DocumentationBrowserWindow.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/DocumentationBrowserWindow.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/HelpPageView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/HelpPageView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/IgnoredUpdatesWindow.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/IgnoredUpdatesWindow.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/InstallOptionsEditorView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/InstallOptionsEditorView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/InstallOptionsWindow.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/InstallOptionsWindow.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/LogsPageView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/LogsPageView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Managers/IManagerSectionView.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Managers/ManagerDetailView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Managers/ManagerDetailView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Managers/ManagerRowView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Managers/ManagerRowView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Managers/ManagersHomeView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Managers/ManagersHomeView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Managers/ManagersPageView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Managers/ManagersPageView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/MissingDependencyDialog.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/MissingDependencyDialog.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/OperationFailedWindow.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/OperationFailedWindow.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/OperationLogWindow.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/OperationLogWindow.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/OperationWidgetView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/OperationWidgetView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/PackageDetailsWindow.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/PackageDetailsWindow.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/PackagePageView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/PackagePageView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/ReleaseNotesWindow.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/ReleaseNotesWindow.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/AdministratorSettingsView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/AdministratorSettingsView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/BackupSettingsView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/BackupSettingsView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/ExperimentalSettingsView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/ExperimentalSettingsView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/GeneralSettingsView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/GeneralSettingsView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/ISettingsSectionStateNotifier.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/ISettingsSectionView.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/InterfaceSettingsView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/InterfaceSettingsView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/InternetSettingsView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/InternetSettingsView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/ManagersSettingsView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/ManagersSettingsView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/NotificationsSettingsView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/NotificationsSettingsView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/OperationsSettingsView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/OperationsSettingsView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/SettingsHomeView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/SettingsHomeView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/SettingsPageView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/SettingsPageView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/SettingsPlaceholderSectionView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/SettingsPlaceholderSectionView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/SettingsSectionRoute.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/UpdatesSettingsView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/Settings/UpdatesSettingsView.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Administrator.axaml create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Administrator.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Backup.axaml create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Backup.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Experimental.axaml create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Experimental.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/General.axaml create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/General.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/ISettingsPage.cs create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Internet.axaml create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Internet.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/ManagersHomepage.axaml create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/ManagersHomepage.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Notifications.axaml create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Notifications.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Operations.axaml create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Operations.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsBasePage.axaml create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsBasePage.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsHomepage.axaml create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsHomepage.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsPageStubs.cs create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml create mode 100644 src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/SimplePageView.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/SimplePageView.axaml.cs delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/TelemetryConsentWindow.axaml delete mode 100644 src/UniGetUI.Avalonia/Views/Pages/TelemetryConsentWindow.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/SidebarView.axaml create mode 100644 src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml create mode 100644 src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs create mode 100644 src/UniGetUI.Avalonia/Views/SoftwarePages/DiscoverSoftwarePage.cs create mode 100644 src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs create mode 100644 src/UniGetUI.Avalonia/Views/SoftwarePages/Interfaces/PageInterfaces.cs create mode 100644 src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs create mode 100644 src/UniGetUI.Avalonia/Views/SoftwarePages/PageStubs.cs create mode 100644 src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs create mode 100644 src/UniGetUI.Avalonia/app.manifest create mode 100644 src/UniGetUI.PackageEngine.Managers.Homebrew/Helpers/HomebrewPkgDetailsHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Homebrew/Helpers/HomebrewPkgOperationHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Homebrew/Helpers/HomebrewSourceHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Homebrew/UniGetUI.PackageEngine.Managers.Homebrew.csproj create mode 100644 src/UniGetUI/Assets/.DS_Store create mode 100644 src/UniGetUI/Assets/Symbols/.DS_Store create mode 100644 src/UniGetUI/Assets/Symbols/Font/.DS_Store create mode 100644 src/UniGetUI/Assets/Symbols/homebrew.svg diff --git a/global.json b/global.json index 58bcbf1f69..148fd17be6 100644 --- a/global.json +++ b/global.json @@ -1,6 +1 @@ -{ - "sdk": { - "version": "10.0.103", - "rollForward": "latestPatch" - } -} \ No newline at end of file +{"sdk":{"version":"10.0.101","rollForward":"disable"}} diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..f7425d1dc32c2b3fbd7ebc65d13c34ffc47ca663 GIT binary patch literal 6148 zcmeHK%}N6?5T3NvZYe?q1@W}twQ8$U6)$V8h*#I2gG$|{MHkmi>2BLYDeMF23;7^E zk26UsmMVAPUs4?1*zy+Mb z1{_Xhv*SN9Kzp}{dlo?eC-8ay`rttbQbjy}5O@4g`k4Ldcfue}DwQ{p$>!$f^H$y} zTDR_l9Jq;>^y0eLy{6u|l%YSiJ^wOj51OT=BN-)L5VgCiAZWEQ<>o4gT5?dAy{Hwd zT2}|Gf>mgiR)@p&YOS_cws&@SM`e4szg0ndyEYmXtmU=MgR|yi_!P-!O|ZakQOSbA zF}$GhgSGbEZWPMs9<#?IjYnW)2ABb6U^W;qr<+xrjpgtvm;q+s7a5@Q!A2!?45k{@ z(SZ&9K2khKNP;%KB?xVUj=@wTMo@$ - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.Avalonia/App.axaml.cs b/src/UniGetUI.Avalonia/App.axaml.cs index 7a144e8d83..cbb4186e96 100644 --- a/src/UniGetUI.Avalonia/App.axaml.cs +++ b/src/UniGetUI.Avalonia/App.axaml.cs @@ -1,12 +1,12 @@ -using System.ComponentModel; +using System.Diagnostics; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core.Plugins; using Avalonia.Markup.Xaml; using Avalonia.Styling; -using Avalonia.Themes.Fluent; -using Devolutions.AvaloniaTheme.DevExpress; -using Devolutions.AvaloniaTheme.Linux; -using Devolutions.AvaloniaTheme.MacOS; +using UniGetUI.Avalonia.Views; +using UniGetUI.PackageEngine; +using CoreSettings = global::UniGetUI.Core.SettingsEngine.Settings; namespace UniGetUI.Avalonia; @@ -15,38 +15,77 @@ public partial class App : Application public override void Initialize() { AvaloniaXamlLoader.Load(this); + } - Styles.Insert(0, CreatePlatformTheme()); + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + // Avoid duplicate validations from both Avalonia and the CommunityToolkit. + // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins + DisableAvaloniaDataAnnotationValidation(); + if (OperatingSystem.IsMacOS()) + ExpandMacOSPath(); + PEInterface.LoadLoaders(); + ApplyTheme(CoreSettings.GetValue(CoreSettings.K.PreferredTheme)); + var mainWindow = new MainWindow(); +#if DEBUG + mainWindow.AttachDevTools(); +#endif + desktop.MainWindow = mainWindow; + _ = Task.Run(PEInterface.LoadManagers); + } - Name = "UniGetUI.Avalonia"; + base.OnFrameworkInitializationCompleted(); } - private static Styles CreatePlatformTheme() + /// + /// macOS GUI apps start with a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin). + /// Ask the user's login shell for its full PATH so package managers (npm, pip, + /// cargo, brew-installed tools, …) can be found. + /// + private static void ExpandMacOSPath() { - Styles styles = OperatingSystem.IsWindows() - ? new DevolutionsDevExpressTheme() - : OperatingSystem.IsMacOS() - ? new DevolutionsMacOsTheme() - : OperatingSystem.IsLinux() - ? new DevolutionsLinuxYaruTheme() - : new FluentTheme(); - - if (styles is ISupportInitialize initializable) + try { - initializable.BeginInit(); - initializable.EndInit(); + using var process = new Process + { + StartInfo = new ProcessStartInfo("zsh", ["-l", "-c", "printenv PATH"]) + { + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }, + }; + process.Start(); + string shellPath = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + if (!string.IsNullOrEmpty(shellPath)) + Environment.SetEnvironmentVariable("PATH", shellPath); } + catch { /* keep the existing PATH if the shell can't be launched */ } + } - return styles; + public static void ApplyTheme(string value) + { + Current!.RequestedThemeVariant = value switch + { + "light" => ThemeVariant.Light, + "dark" => ThemeVariant.Dark, + _ => ThemeVariant.Default, + }; } - public override void OnFrameworkInitializationCompleted() + private void DisableAvaloniaDataAnnotationValidation() { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + // Get an array of plugins to remove + var dataValidationPluginsToRemove = + BindingPlugins.DataValidators.OfType().ToArray(); + + // remove each entry found + foreach (var plugin in dataValidationPluginsToRemove) { - desktop.MainWindow = new MainWindow(); + BindingPlugins.DataValidators.Remove(plugin); } - - base.OnFrameworkInitializationCompleted(); } } diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs index ea2f2be19a..3090a4bba3 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs @@ -9,6 +9,7 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Threading; using Microsoft.Win32; +using UniGetUI.Avalonia.Views; using UniGetUI.Core.Data; using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs index beb32137e4..a4f0b6dbe0 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs @@ -1,5 +1,6 @@ using Avalonia.Threading; using UniGetUI.Avalonia.Models; +using UniGetUI.Avalonia.Views; using UniGetUI.Core.Data; using UniGetUI.Core.IconEngine; using UniGetUI.Core.Logging; diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs index 76938e68ed..7860121cb3 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs @@ -3,6 +3,7 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Threading; +using UniGetUI.Avalonia.ViewModels; using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; @@ -13,23 +14,32 @@ namespace UniGetUI.Avalonia.Infrastructure; /// -/// Global registry of instances for the Avalonia shell. -/// The operations panel in binds to -/// to show active, queued, or recently finished operations. +/// Global registry of operations for the Avalonia shell. +/// The operations panel binds to . /// public static class AvaloniaOperationRegistry { + /// Raw operations — kept for compatibility / queue checks. public static readonly ObservableCollection Operations = new(); + /// Bindable view-models shown in the operations panel. + public static readonly ObservableCollection OperationViewModels = new(); + /// - /// Register an operation. Must be called before operation.MainThread(). + /// Register an operation and create its UI view-model. + /// Must be called before operation.MainThread(). /// public static void Add(AbstractOperation op) { + var vm = new OperationViewModel(op); + Dispatcher.UIThread.Post(() => { if (!Operations.Contains(op)) + { Operations.Add(op); + OperationViewModels.Add(vm); + } }); op.OperationStarting += (_, _) => @@ -68,16 +78,32 @@ public static void Add(AbstractOperation op) }; } + /// Remove a view-model (and its backing operation) from the panel. Called by the Close button. + public static void Remove(OperationViewModel vm) + { + Dispatcher.UIThread.Post(() => + { + OperationViewModels.Remove(vm); + Operations.Remove(vm.Operation); + }); + while (AbstractOperation.OperationQueue.Remove(vm.Operation)) ; + } + private static async Task RemoveAfterDelayAsync(AbstractOperation op, int milliseconds) { await Task.Delay(milliseconds); - Dispatcher.UIThread.Post(() => Operations.Remove(op)); + Dispatcher.UIThread.Post(() => + { + var vm = OperationViewModels.FirstOrDefault(v => v.Operation == op); + if (vm is not null) OperationViewModels.Remove(vm); + Operations.Remove(op); + }); } private static void UpdateTrayStatus() { if (Application.Current?.ApplicationLifetime - is IClassicDesktopStyleApplicationLifetime { MainWindow: UniGetUI.Avalonia.MainWindow mw }) + is IClassicDesktopStyleApplicationLifetime { MainWindow: UniGetUI.Avalonia.Views.MainWindow mw }) mw.UpdateSystemTrayStatus(); } @@ -89,6 +115,9 @@ private static void ShowOperationProgressNotification(AbstractOperation op) if (WindowsAppNotificationBridge.ShowProgress(op)) return; + if (MacOsNotificationBridge.ShowProgress(op)) + return; + if (TryGetMainWindow() is not { } mainWindow) return; @@ -103,7 +132,7 @@ private static void ShowOperationProgressNotification(AbstractOperation op) mainWindow.ShowRuntimeNotification( title, message, - UniGetUI.Avalonia.MainWindow.RuntimeNotificationLevel.Progress); + UniGetUI.Avalonia.Views.MainWindow.RuntimeNotificationLevel.Progress); } private static void ShowOperationSuccessNotification(AbstractOperation op) @@ -116,6 +145,9 @@ private static void ShowOperationSuccessNotification(AbstractOperation op) if (WindowsAppNotificationBridge.ShowSuccess(op)) return; + if (MacOsNotificationBridge.ShowSuccess(op)) + return; + if (TryGetMainWindow() is not { } mainWindow) return; @@ -130,7 +162,7 @@ private static void ShowOperationSuccessNotification(AbstractOperation op) mainWindow.ShowRuntimeNotification( title, message, - UniGetUI.Avalonia.MainWindow.RuntimeNotificationLevel.Success); + UniGetUI.Avalonia.Views.MainWindow.RuntimeNotificationLevel.Success); } private static void ShowOperationFailureNotification(AbstractOperation op) @@ -143,6 +175,9 @@ private static void ShowOperationFailureNotification(AbstractOperation op) if (WindowsAppNotificationBridge.ShowError(op)) return; + if (MacOsNotificationBridge.ShowError(op)) + return; + if (TryGetMainWindow() is not { } mainWindow) return; @@ -157,13 +192,13 @@ private static void ShowOperationFailureNotification(AbstractOperation op) mainWindow.ShowRuntimeNotification( title, message, - UniGetUI.Avalonia.MainWindow.RuntimeNotificationLevel.Error); + UniGetUI.Avalonia.Views.MainWindow.RuntimeNotificationLevel.Error); } - private static UniGetUI.Avalonia.MainWindow? TryGetMainWindow() + private static UniGetUI.Avalonia.Views.MainWindow? TryGetMainWindow() { return Application.Current?.ApplicationLifetime - is IClassicDesktopStyleApplicationLifetime { MainWindow: UniGetUI.Avalonia.MainWindow mw } + is IClassicDesktopStyleApplicationLifetime { MainWindow: UniGetUI.Avalonia.Views.MainWindow mw } ? mw : null; } @@ -210,22 +245,17 @@ private static async Task RunPostOperationChecksAsync() await CoreTools.ResetUACForCurrentProcess(); } - // Show desktop shortcut dialog if applicable - if (!anyStillRunning - && Settings.Get(Settings.K.AskToDeleteNewDesktopShortcuts) - && DesktopShortcutsDatabase.GetUnknownShortcuts().Count > 0) + if (OperatingSystem.IsWindows()) { - var unknownShortcuts = DesktopShortcutsDatabase.GetUnknownShortcuts().ToList(); - WindowsAppNotificationBridge.ShowNewShortcutsNotification(unknownShortcuts); - Dispatcher.UIThread.Post(() => - { - var window = new UniGetUI.Avalonia.Views.Pages.DesktopShortcutsWindow(unknownShortcuts); - if (Application.Current?.ApplicationLifetime - is IClassicDesktopStyleApplicationLifetime { MainWindow: Window mainWin }) - _ = window.ShowDialog(mainWin); - else - window.Show(); - }); + var unknownShortcuts = UniGetUI.PackageEngine.Classes.Packages.Classes.DesktopShortcutsDatabase.GetUnknownShortcuts(); + if (unknownShortcuts.Count > 0) + WindowsAppNotificationBridge.ShowNewShortcutsNotification(unknownShortcuts); + } + else if (OperatingSystem.IsMacOS()) + { + var unknownShortcuts = UniGetUI.PackageEngine.Classes.Packages.Classes.DesktopShortcutsDatabase.GetUnknownShortcuts(); + if (unknownShortcuts.Count > 0) + MacOsNotificationBridge.ShowNewShortcutsNotification(unknownShortcuts); } } } diff --git a/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs b/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs new file mode 100644 index 0000000000..c207d46231 --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs @@ -0,0 +1,223 @@ +using System.Runtime.InteropServices; +using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageOperations; + +namespace UniGetUI.Avalonia.Infrastructure; + +/// +/// macOS system notification delivery via NSUserNotificationCenter (ObjC runtime P/Invoke). +/// Mirrors the pattern of WindowsAppNotificationBridge: guards on OS check, silent fallback on failure. +/// +internal static class MacOsNotificationBridge +{ + private static bool? _available; + private static readonly object _lock = new(); + + private static bool IsAvailable() + { + if (!OperatingSystem.IsMacOS()) return false; + lock (_lock) + { + if (_available.HasValue) return _available.Value; + try + { + _available = objc_getClass("NSUserNotificationCenter") != IntPtr.Zero; + } + catch + { + _available = false; + } + return _available.Value; + } + } + + // ── Operation notifications ──────────────────────────────────────────── + + public static bool ShowProgress(AbstractOperation operation) + { + if (!IsAvailable() || Settings.AreProgressNotificationsDisabled()) return false; + try + { + string title = operation.Metadata.Title.Length > 0 + ? operation.Metadata.Title + : CoreTools.Translate("Operation in progress"); + string message = operation.Metadata.Status.Length > 0 + ? operation.Metadata.Status + : CoreTools.Translate("Please wait..."); + DeliverNotification(title, message); + return true; + } + catch (Exception ex) + { + Logger.Warn("macOS progress notification failed"); + Logger.Warn(ex); + return false; + } + } + + public static bool ShowSuccess(AbstractOperation operation) + { + if (!IsAvailable() || Settings.AreSuccessNotificationsDisabled()) return false; + try + { + string title = operation.Metadata.SuccessTitle.Length > 0 + ? operation.Metadata.SuccessTitle + : CoreTools.Translate("Success!"); + string message = operation.Metadata.SuccessMessage.Length > 0 + ? operation.Metadata.SuccessMessage + : CoreTools.Translate("Success!"); + DeliverNotification(title, message); + return true; + } + catch (Exception ex) + { + Logger.Warn("macOS success notification failed"); + Logger.Warn(ex); + return false; + } + } + + public static bool ShowError(AbstractOperation operation) + { + if (!IsAvailable() || Settings.AreErrorNotificationsDisabled()) return false; + try + { + string title = operation.Metadata.FailureTitle.Length > 0 + ? operation.Metadata.FailureTitle + : CoreTools.Translate("Failed"); + string message = operation.Metadata.FailureMessage.Length > 0 + ? operation.Metadata.FailureMessage + : CoreTools.Translate("An error occurred while processing this package"); + DeliverNotification(title, message); + return true; + } + catch (Exception ex) + { + Logger.Warn("macOS error notification failed"); + Logger.Warn(ex); + return false; + } + } + + // ── Feature notifications ────────────────────────────────────────────── + + public static void ShowUpdatesAvailableNotification(IReadOnlyList upgradable) + { + if (!IsAvailable() || Settings.AreUpdatesNotificationsDisabled()) return; + try + { + string title, message; + if (upgradable.Count == 1) + { + title = CoreTools.Translate("An update was found!"); + message = CoreTools.Translate("{0} can be updated to version {1}", + upgradable[0].Name, upgradable[0].NewVersionString); + } + else + { + title = CoreTools.Translate("Updates found!"); + message = CoreTools.Translate("{0} packages can be updated", upgradable.Count); + } + DeliverNotification(title, message); + } + catch (Exception ex) + { + Logger.Warn("macOS updates-available notification failed"); + Logger.Warn(ex); + } + } + + public static void ShowSelfUpdateAvailableNotification(string newVersion) + { + if (!IsAvailable()) return; + try + { + DeliverNotification( + CoreTools.Translate("{0} can be updated to version {1}", "UniGetUI", newVersion), + CoreTools.Translate("You have currently version {0} installed", CoreData.VersionName)); + } + catch (Exception ex) + { + Logger.Warn("macOS self-update notification failed"); + Logger.Warn(ex); + } + } + + public static void ShowNewShortcutsNotification(IReadOnlyList shortcuts) + { + if (!IsAvailable() || Settings.AreNotificationsDisabled()) return; + try + { + string title, message; + if (shortcuts.Count == 1) + { + title = CoreTools.Translate("Desktop shortcut created"); + message = CoreTools.Translate( + "UniGetUI has detected a new desktop shortcut that can be deleted automatically.") + + "\n" + shortcuts[0].Split('/')[^1]; + } + else + { + title = CoreTools.Translate("{0} desktop shortcuts created", shortcuts.Count); + message = CoreTools.Translate( + "UniGetUI has detected {0} new desktop shortcuts that can be deleted automatically.", + shortcuts.Count); + } + DeliverNotification(title, message); + } + catch (Exception ex) + { + Logger.Warn("macOS shortcuts notification failed"); + Logger.Warn(ex); + } + } + + // ── Core delivery ────────────────────────────────────────────────────── + + private static void DeliverNotification(string title, string message) + { + var centerClass = objc_getClass("NSUserNotificationCenter"); + var center = MsgSend(centerClass, Sel("defaultUserNotificationCenter")); + + var notifClass = objc_getClass("NSUserNotification"); + var notif = MsgSend(MsgSend(notifClass, Sel("alloc")), Sel("init")); + + MsgSend(notif, Sel("setTitle:"), ToNSString(title)); + MsgSend(notif, Sel("setInformativeText:"), ToNSString(message)); + MsgSend(center, Sel("deliverNotification:"), notif); + MsgSend(notif, Sel("autorelease")); + } + + private static IntPtr ToNSString(string s) + { + IntPtr ptr = Marshal.StringToCoTaskMemUTF8(s); + try + { + return MsgSend(objc_getClass("NSString"), Sel("stringWithUTF8String:"), ptr); + } + finally + { + Marshal.FreeCoTaskMem(ptr); + } + } + + private static IntPtr Sel(string name) => sel_registerName(name); + + // ── ObjC runtime P/Invoke ────────────────────────────────────────────── + + [DllImport("/usr/lib/libobjc.A.dylib")] + private static extern IntPtr objc_getClass(string name); + + [DllImport("/usr/lib/libobjc.A.dylib")] + private static extern IntPtr sel_registerName(string name); + + [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] + private static extern IntPtr MsgSend(IntPtr receiver, IntPtr sel); + + [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] + private static extern IntPtr MsgSend(IntPtr receiver, IntPtr sel, IntPtr arg); +} diff --git a/src/UniGetUI.Avalonia/MainWindow.axaml b/src/UniGetUI.Avalonia/MainWindow.axaml deleted file mode 100644 index dd8d6c5568..0000000000 --- a/src/UniGetUI.Avalonia/MainWindow.axaml +++ /dev/null @@ -1,18 +0,0 @@ - - \ No newline at end of file diff --git a/src/UniGetUI.Avalonia/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/MainWindow.axaml.cs deleted file mode 100644 index 2997361aa9..0000000000 --- a/src/UniGetUI.Avalonia/MainWindow.axaml.cs +++ /dev/null @@ -1,735 +0,0 @@ -using System.Net; -using System.Runtime.InteropServices; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.Notifications; -using Avalonia.Layout; -using Avalonia.Markup.Xaml; -using Avalonia.Media; -using Avalonia.Platform; -using Avalonia.Styling; -using Avalonia.Threading; -using UniGetUI.Avalonia.Infrastructure; -using UniGetUI.Avalonia.Models; -using UniGetUI.Avalonia.Views; -using UniGetUI.Avalonia.Views.Pages; -using UniGetUI.Core.Data; -using UniGetUI.Core.Logging; -using UniGetUI.Core.SettingsEngine; -using UniGetUI.Core.Tools; -using UniGetUI.Interface.Enums; - -namespace UniGetUI.Avalonia; - -public partial class MainWindow : Window -{ - /// Gets the currently active instance. - public static MainWindow? Instance { get; private set; } - - private bool _initialized; - private TrayIcon? _trayIcon; - private WindowNotificationManager? _notificationManager; - private bool _isExplicitQuit; - private CancellationTokenSource? _geometrySaveCts; - - public MainWindow() - { - Instance = this; - ApplyTheme(); - InitializeComponent(); - InitializeNotificationManager(); - Title = BuildWindowTitle(); - Opened += OnOpened; - SizeChanged += (_, _) => _ = SaveGeometryDebounced(); - PositionChanged += (_, _) => _ = SaveGeometryDebounced(); - } - - private void InitializeNotificationManager() - { - _notificationManager = new WindowNotificationManager(this) - { - Position = NotificationPosition.TopRight, - MaxItems = 4, - }; - - // ── B2: hook notification activation callback ────────────────────── - WindowsAppNotificationBridge.NotificationActivated += OnNotificationActivated; - } - - private void OnNotificationActivated(string action) - { - Dispatcher.UIThread.Post(() => - { - try - { - if (action == NotificationArguments.UpdateAllPackages) - { - _ = AvaloniaPackageOperationHelper.UpdateAllAsync(); - } - else if (action == NotificationArguments.ShowOnUpdatesTab) - { - ShowFromTray(); - if (Content is Views.MainShellView shell) - shell.OpenPage(Models.ShellPageType.Updates); - } - else if (action == NotificationArguments.Show) - { - ShowFromTray(); - } - else if (action == NotificationArguments.ReleaseSelfUpdateLock) - { - AvaloniaAutoUpdater.ReleaseLockForAutoupdate_Notification = true; - } - } - catch (Exception ex) - { - Logger.Error("OnNotificationActivated error:"); - Logger.Error(ex); - } - }); - } - - private async void OnOpened(object? sender, EventArgs e) - { - if (_initialized) - { - return; - } - - _initialized = true; - RestoreGeometry(); - - // Handle --daemon: launch straight to tray without showing the window - if (Environment.GetCommandLineArgs().Contains("--daemon")) - { - Hide(); - } - - Content = new LoadingView(); - - try - { - await AvaloniaBootstrapper.InitializeAsync(); - Content = new MainShellView(); - Closing += OnWindowClosing; - Closed += OnWindowClosed; - InitTrayIcon(); - - // Background integrity check (non-blocking) - _ = Task.Run(() => IntegrityTester.CheckIntegrity()).ContinueWith(async t => - { - if (!t.Result.Passed && !Settings.Get(Settings.K.DisableIntegrityChecks)) - await Dispatcher.UIThread.InvokeAsync(() => ShowIntegrityWarningAsync(this)); - }, TaskScheduler.Default); - - // Check for missing package manager dependencies (non-blocking — runs after shell is shown) - _ = Task.Run(AvaloniaBootstrapper.GetMissingDependenciesAsync) - .ContinueWith(async t => - { - if (t.IsCompletedSuccessfully && t.Result.Count > 0) - await Dispatcher.UIThread.InvokeAsync(() => HandleMissingDependenciesAsync(t.Result)); - else if (t.IsFaulted) - Logger.Error(t.Exception!); - }, TaskScheduler.Default); - } - catch (Exception ex) - { - Logger.Error("UniGetUI initialization failed"); - Logger.Error(ex); - Content = new ErrorView( - CoreTools.Translate("An error occurred"), - ex.Message - ); - } - } - - /// Sequentially shows a for each missing dependency. - private async Task HandleMissingDependenciesAsync( - IReadOnlyList dependencies) - { - int current = 1, total = dependencies.Count; - foreach (var dep in dependencies) - { - var dialog = new Views.Pages.MissingDependencyDialog(dep, current++, total); - await dialog.ShowDialog(this); - } - } - - private void InitTrayIcon() - { - try - { - using var iconStream = AssetLoader.Open(new Uri("avares://UniGetUI.Avalonia/Assets/icon.ico")); - var windowIcon = new WindowIcon(iconStream); - - _trayIcon = new TrayIcon - { - Icon = windowIcon, - ToolTipText = "UniGetUI", - IsVisible = !Settings.Get(Settings.K.DisableSystemTray), - }; - - var menu = new NativeMenu(); - - var showItem = new NativeMenuItem(CoreTools.Translate("Open UniGetUI")); - showItem.Click += (_, _) => ShowFromTray(); - menu.Items.Add(showItem); - - menu.Items.Add(new NativeMenuItemSeparator()); - - var discoverItem = new NativeMenuItem(CoreTools.Translate("Discover packages")); - discoverItem.Click += (_, _) => { ShowFromTray(); NavigateShell(ShellPageType.Discover); }; - menu.Items.Add(discoverItem); - - var updatesItem = new NativeMenuItem(CoreTools.Translate("Software Updates")); - updatesItem.Click += (_, _) => { ShowFromTray(); NavigateShell(ShellPageType.Updates); }; - menu.Items.Add(updatesItem); - - var installedItem = new NativeMenuItem(CoreTools.Translate("Installed packages")); - installedItem.Click += (_, _) => { ShowFromTray(); NavigateShell(ShellPageType.Installed); }; - menu.Items.Add(installedItem); - - menu.Items.Add(new NativeMenuItemSeparator()); - - var releaseNotesItem = new NativeMenuItem(CoreTools.Translate("Release notes")); - releaseNotesItem.Click += (_, _) => - { - ShowFromTray(); - var win = new ReleaseNotesWindow(); - _ = win.ShowDialog(this); - }; - menu.Items.Add(releaseNotesItem); - - var aboutItem = new NativeMenuItem(CoreTools.Translate("About WingetUI")); - aboutItem.Click += (_, _) => new AboutPageWindow().Show(); - menu.Items.Add(aboutItem); - - menu.Items.Add(new NativeMenuItemSeparator()); - - var quitItem = new NativeMenuItem(CoreTools.Translate("Quit")); - quitItem.Click += (_, _) => QuitApplication(); - menu.Items.Add(quitItem); - - _trayIcon.Menu = menu; - _trayIcon.Clicked += (_, _) => ShowFromTray(); - } - catch (Exception ex) - { - Logger.Error("Failed to initialize system tray icon:"); - Logger.Error(ex); - } - } - - internal void NavigateShell(ShellPageType pageType) - { - if (Content is MainShellView shell) - shell.OpenPage(pageType); - } - - private string _lastTrayIconVariant = ""; - - public void UpdateSystemTrayStatus() - { - if (_trayIcon is null) return; - try - { - bool anyRunning = AvaloniaOperationRegistry.Operations.Any( - o => o.Status is UniGetUI.PackageEngine.Enums.OperationStatus.Running - or UniGetUI.PackageEngine.Enums.OperationStatus.InQueue); - - bool anyFailed = AvaloniaOperationRegistry.Operations.Any( - o => o.Status == UniGetUI.PackageEngine.Enums.OperationStatus.Failed); - - int updates = UniGetUI.PackageEngine.PackageLoader.UpgradablePackagesLoader.Instance.Count(); - - string modifier; - string tooltip; - - if (anyRunning) - { - modifier = "blue"; - tooltip = CoreTools.Translate("Operation in progress") + " — UniGetUI"; - } - else if (anyFailed) - { - modifier = "orange"; - tooltip = CoreTools.Translate("Failed") + " — UniGetUI"; - } - else if (updates == 1) - { - modifier = "green"; - tooltip = CoreTools.Translate("1 update is available") + " — UniGetUI"; - } - else if (updates > 1) - { - modifier = "green"; - tooltip = CoreTools.Translate("{0} updates are available", updates) + " — UniGetUI"; - } - else - { - modifier = "empty"; - tooltip = CoreTools.Translate("Everything is up to date") + " — UniGetUI"; - } - - _trayIcon.ToolTipText = tooltip; - - // Determine light/dark theme from registry to pick black/white icon variant - string themeSuffix = "white"; // default: dark taskbar → white icon -#pragma warning disable CA1416 - try - { - using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey( - @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); - if (key?.GetValue("SystemUsesLightTheme") is int val && val > 0) - themeSuffix = "black"; - } - catch { /* registry unavailable; keep default */ } -#pragma warning restore CA1416 - - string variant = $"tray_{modifier}_{themeSuffix}"; - if (variant == _lastTrayIconVariant) return; - _lastTrayIconVariant = variant; - - using var stream = AssetLoader.Open(new Uri($"avares://UniGetUI.Avalonia/Assets/{variant}.ico")); - _trayIcon.Icon = new WindowIcon(stream); - } - catch (Exception ex) - { - Logger.Warn("Failed to update system tray status"); - Logger.Warn(ex); - } - } - - public void ApplyTrayIconVisibility() - { - if (_trayIcon is null) return; - _trayIcon.IsVisible = !Settings.Get(Settings.K.DisableSystemTray); - } - - internal void ShowFromTray() - { - SetEfficiencyMode(false); // B3: leave EcoQoS when coming back to foreground - Show(); - Activate(); - WindowState = WindowState.Normal; - - // Silently reload the Installed packages list so changes made outside - // UniGetUI while it was minimized/hidden are reflected immediately. - if (!UniGetUI.PackageEngine.PackageLoader.InstalledPackagesLoader.Instance.IsLoading) - _ = UniGetUI.PackageEngine.PackageLoader.InstalledPackagesLoader.Instance.ReloadPackagesSilently(); - } - - public void ShowRuntimeNotification(string title, string message, RuntimeNotificationLevel level) - { - if (_notificationManager is null) - { - return; - } - - NotificationType type = level switch - { - RuntimeNotificationLevel.Success => NotificationType.Success, - RuntimeNotificationLevel.Error => NotificationType.Error, - _ => NotificationType.Information, - }; - - TimeSpan expiration = level == RuntimeNotificationLevel.Error - ? TimeSpan.FromSeconds(8) - : TimeSpan.FromSeconds(5); - - _notificationManager.Show(new Notification(title, message, type, expiration)); - } - - public void QuitApplication() - { - // A3: release the auto-updater window lock so a pending installer can proceed - AvaloniaAutoUpdater.ReleaseLockForAutoupdate_Window = true; - _isExplicitQuit = true; - Close(); - } - - /// - /// Restart the application by spawning a fresh process and exiting immediately. - /// Mirrors WinUI3's MainApp.Instance.KillAndRestart(). - /// - public static void KillAndRestart() - { - try - { - System.Diagnostics.Process.Start(CoreData.UniGetUIExecutableFile); - } - catch (Exception ex) - { - Logger.Error("KillAndRestart: failed to start new process"); - Logger.Error(ex); - } - finally - { - Instance?.QuitApplication(); - } - } - - private static async Task ShowIntegrityWarningAsync(Window owner) - { - try - { - var dialog = new Window - { - Title = CoreTools.Translate("An error occurred"), - Width = 520, - SizeToContent = SizeToContent.Height, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - Content = new StackPanel - { - Margin = new Thickness(20), - Spacing = 12, - Children = - { - new TextBlock - { - Text = CoreTools.Translate("UniGetUI or some of its components are missing or corrupt.") - + " " + CoreTools.Translate("It is strongly recommended to reinstall UniGetUI to adress the situation."), - TextWrapping = TextWrapping.Wrap, - FontWeight = FontWeight.SemiBold, - }, - new TextBlock - { - Text = " ● " + CoreTools.Translate("Refer to the UniGetUI Logs to get more details regarding the affected file(s)"), - TextWrapping = TextWrapping.Wrap, - }, - new TextBlock - { - Text = " ● " + CoreTools.Translate("Integrity checks can be disabled from the Experimental Settings"), - TextWrapping = TextWrapping.Wrap, - }, - new Button - { - Content = CoreTools.Translate("Close"), - HorizontalAlignment = HorizontalAlignment.Right, - }, - }, - }, - }; - // Wire close button - if (dialog.Content is StackPanel sp - && sp.Children[^1] is Button btn) - btn.Click += (_, _) => dialog.Close(); - - await dialog.ShowDialog(owner); - } - catch (Exception ex) - { - Logger.Warn("Failed to show integrity warning dialog"); - Logger.Warn(ex); - } - } - - private void OnWindowClosing(object? sender, WindowClosingEventArgs e) - { - if (!_isExplicitQuit && !Settings.Get(Settings.K.DisableSystemTray)) - { - e.Cancel = true; - Hide(); - SetEfficiencyMode(true); // B3: enter EcoQoS while hidden to tray - return; - } - - // When tray is disabled (or user did an explicit quit via tray menu), - // check for active operations and ask for confirmation. - if (!_isExplicitQuit && HasRunningOperations()) - { - e.Cancel = true; - Dispatcher.UIThread.Post(async () => await ConfirmQuitWithRunningOpsAsync()); - } - } - - private static bool HasRunningOperations() - { - return AvaloniaOperationRegistry.Operations.Any( - o => o.Status is UniGetUI.PackageEngine.Enums.OperationStatus.Running - or UniGetUI.PackageEngine.Enums.OperationStatus.InQueue); - } - - private async Task ConfirmQuitWithRunningOpsAsync() - { - try - { - var tcs = new TaskCompletionSource(); - var dialog = new Window - { - Title = CoreTools.Translate("Operation in progress"), - Width = 460, - SizeToContent = SizeToContent.Height, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - Content = new StackPanel - { - Margin = new Thickness(20), - Spacing = 16, - Children = - { - new TextBlock - { - Text = CoreTools.Translate("There are ongoing operations. Quitting WingetUI may cause them to fail. Do you want to continue?"), - TextWrapping = TextWrapping.Wrap, - }, - new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Spacing = 8, - Children = - { - new Button { Content = CoreTools.Translate("Quit") }, - new Button { Content = CoreTools.Translate("Cancel") }, - }, - }, - }, - }, - }; - - if (dialog.Content is StackPanel sp - && sp.Children[1] is StackPanel btns) - { - ((Button)btns.Children[0]).Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); }; - ((Button)btns.Children[1]).Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); }; - } - - dialog.Closed += (_, _) => tcs.TrySetResult(false); - - await dialog.ShowDialog(this); - if (await tcs.Task) - { - QuitApplication(); - } - } - catch (Exception ex) - { - Logger.Error("Failed to show quit-confirmation dialog:"); - Logger.Error(ex); - } - } - - private void OnWindowClosed(object? sender, EventArgs e) - { - _trayIcon?.Dispose(); - _trayIcon = null; - } - - // ── B3: Windows EcoQoS / efficiency mode ────────────────────────────── - // Applied when the window hides to tray to reduce CPU/battery consumption; - // reverted when the window is brought back to the foreground. - // P/Invoke is guarded by RuntimeInformation so it compiles on all platforms. - -#pragma warning disable CA1416 - private const int ProcessPowerThrottling = 4; - - [StructLayout(LayoutKind.Sequential)] - private struct PROCESS_POWER_THROTTLING_STATE - { - public uint Version; - public uint ControlMask; - public uint StateMask; - } - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool SetProcessInformation( - IntPtr hProcess, - int ProcessInformationClass, - ref PROCESS_POWER_THROTTLING_STATE ProcessInformation, - uint ProcessInformationSize); -#pragma warning restore CA1416 - - private static void SetEfficiencyMode(bool enable) - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return; - try - { - var state = new PROCESS_POWER_THROTTLING_STATE - { - Version = 1, - ControlMask = 1, // PROCESS_POWER_THROTTLING_EXECUTION_SPEED - StateMask = enable ? 1u : 0u, - }; - SetProcessInformation( - System.Diagnostics.Process.GetCurrentProcess().Handle, - ProcessPowerThrottling, - ref state, - (uint)Marshal.SizeOf()); - } - catch (Exception ex) - { - Logger.Warn("EcoQoS efficiency mode change failed:"); - Logger.Warn(ex); - } - } - - private static string BuildWindowTitle() - { - var details = new List(); - - if ( - Settings.Get(Settings.K.ShowVersionNumberOnTitlebar) - && !string.IsNullOrWhiteSpace(CoreData.VersionName) - ) - { - details.Add(CoreTools.Translate("version {0}", CoreData.VersionName)); - } - - if (CoreTools.IsAdministrator()) - { - details.Add(CoreTools.Translate("[RAN AS ADMINISTRATOR]")); - } - - if (CoreData.IsPortable) - { - details.Add(CoreTools.Translate("Portable mode")); - } - -#if DEBUG - details.Add(CoreTools.Translate("DEBUG BUILD")); -#endif - - return details.Count == 0 ? "UniGetUI" : $"UniGetUI - {string.Join(" - ", details)}"; - } - - public void RefreshWindowTitle() - { - Title = BuildWindowTitle(); - } - - public static void ApplyProxyVariableToProcess() - { - try - { - var proxyUri = Settings.GetProxyUrl(); - if (proxyUri is null || !Settings.Get(Settings.K.EnableProxy)) - { - Environment.SetEnvironmentVariable( - "HTTP_PROXY", - string.Empty, - EnvironmentVariableTarget.Process - ); - return; - } - - string content; - if (!Settings.Get(Settings.K.EnableProxyAuth)) - { - content = proxyUri.ToString(); - } - else - { - var credentials = Settings.GetProxyCredentials(); - if (credentials is null) - { - content = proxyUri.ToString(); - } - else - { - content = - $"{proxyUri.Scheme}://{Uri.EscapeDataString(credentials.UserName)}" - + $":{Uri.EscapeDataString(credentials.Password)}" - + $"@{proxyUri.AbsoluteUri.Replace($"{proxyUri.Scheme}://", string.Empty)}"; - } - } - - Environment.SetEnvironmentVariable( - "HTTP_PROXY", - content, - EnvironmentVariableTarget.Process - ); - } - catch (Exception ex) - { - Logger.Error("Failed to apply proxy settings:"); - Logger.Error(ex); - } - } - - private void ApplyTheme() - { - RequestedThemeVariant = Settings.GetValue(Settings.K.PreferredTheme) switch - { - "dark" => ThemeVariant.Dark, - "light" => ThemeVariant.Light, - _ => ThemeVariant.Default, - }; - } - - private async Task SaveGeometryDebounced() - { - _geometrySaveCts?.Cancel(); - _geometrySaveCts = new CancellationTokenSource(); - var token = _geometrySaveCts.Token; - try - { - await Task.Delay(300, token); - if (token.IsCancellationRequested) return; - SaveGeometry(); - } - catch (TaskCanceledException) { } - } - - private void SaveGeometry() - { - try - { - if (WindowState == WindowState.Minimized) return; - int state = WindowState == WindowState.Maximized ? 1 : 0; - string geometry = $"v2,{Position.X},{Position.Y},{(int)Width},{(int)Height},{state}"; - Logger.Debug($"Saving window geometry: {geometry}"); - Settings.SetValue(Settings.K.WindowGeometry, geometry); - } - catch (Exception ex) - { - Logger.Error("Failed to save window geometry:"); - Logger.Error(ex); - } - } - - private void RestoreGeometry() - { - try - { - var geometry = Settings.GetValue(Settings.K.WindowGeometry); - var parts = geometry.Split(','); - if (parts.Length == 6 && parts[0] == "v2") - { - int x = int.Parse(parts[1]); - int y = int.Parse(parts[2]); - int w = int.Parse(parts[3]); - int h = int.Parse(parts[4]); - int state = int.Parse(parts[5]); - - if (w > 200 && h > 100) - { - Width = w; - Height = h; - Position = new PixelPoint(x, y); - } - if (state == 1) - WindowState = WindowState.Maximized; - - Logger.Debug($"Restored window geometry: {geometry}"); - } - } - catch (Exception ex) - { - Logger.Warn("Failed to restore window geometry:"); - Logger.Warn(ex); - } - } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - - public enum RuntimeNotificationLevel - { - Progress, - Success, - Error, - } -} diff --git a/src/UniGetUI.Avalonia/Models/PackageCollections.cs b/src/UniGetUI.Avalonia/Models/PackageCollections.cs new file mode 100644 index 0000000000..f2460aab17 --- /dev/null +++ b/src/UniGetUI.Avalonia/Models/PackageCollections.cs @@ -0,0 +1,158 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using UniGetUI.Avalonia.ViewModels.Pages; +using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine.Interfaces; + +// ReSharper disable once CheckNamespace +namespace UniGetUI.PackageEngine.PackageClasses; + +/// +/// Avalonia-compatible package wrapper (replaces the WinUI PackageWrapper that uses Microsoft.UI.Xaml). +/// +public sealed class PackageWrapper : INotifyPropertyChanged, IDisposable +{ + public IPackage Package { get; } + public PackageWrapper Self => this; + public int Index { get; set; } + + public event PropertyChangedEventHandler? PropertyChanged; + + private readonly PackagesPageViewModel _page; + + public bool IsChecked + { + get => Package.IsChecked; + set + { + Package.IsChecked = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsChecked))); + _page.UpdatePackageCount(); + } + } + + public string VersionComboString { get; } + public string ListedNameTooltip { get; private set; } = ""; + public float ListedOpacity { get; private set; } = 1.0f; + + public string SourceIconPath => IconTypeToSvgPath(Package.Source.IconId); + + private static string IconTypeToSvgPath(IconType icon) + { + string name = icon switch + { + IconType.Chocolatey => "choco", + IconType.MsStore => "ms_store", + IconType.LocalPc => "local_pc", + IconType.SaveAs => "save_as", + IconType.SysTray => "sys_tray", + IconType.ClipboardList => "clipboard_list", + IconType.OpenFolder => "open_folder", + IconType.AddTo => "add_to", + _ => icon.ToString().ToLowerInvariant(), + }; + return $"avares://UniGetUI.Avalonia/Assets/Symbols/{name}.svg"; + } + + public PackageWrapper(IPackage package, PackagesPageViewModel page) + { + Package = package; + _page = page; + VersionComboString = package.IsUpgradable + ? $"{package.VersionString} -> {package.NewVersionString}" + : package.VersionString; + + Package.PropertyChanged += Package_PropertyChanged; + UpdateDisplayState(); + } + + private void Package_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Package.Tag)) + { + UpdateDisplayState(); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ListedOpacity))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ListedNameTooltip))); + } + else if (e.PropertyName == nameof(Package.IsChecked)) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsChecked))); + } + else + { + PropertyChanged?.Invoke(this, e); + } + } + + private void UpdateDisplayState() + { + ListedOpacity = Package.Tag switch + { + PackageTag.OnQueue or PackageTag.BeingProcessed or PackageTag.Unavailable => 0.5f, + _ => 1.0f, + }; + ListedNameTooltip = Package.Name; + } + + public void Dispose() + { + Package.PropertyChanged -= Package_PropertyChanged; + } +} + +/// +/// Avalonia-compatible observable collection of PackageWrapper with sorting support +/// (replaces WinUI's ObservablePackageCollection that used SortableObservableCollection). +/// +public sealed class ObservablePackageCollection : ObservableCollection +{ + public enum Sorter + { + Checked, + Name, + Id, + Version, + NewVersion, + Source, + } + + public Sorter CurrentSorter { get; private set; } = Sorter.Name; + private bool _ascending = true; + + public List GetPackages() => + this.Select(w => w.Package).ToList(); + + public List GetCheckedPackages() => + this.Where(w => w.IsChecked).Select(w => w.Package).ToList(); + + public void SelectAll() + { + foreach (var w in this) w.IsChecked = true; + } + + public void ClearSelection() + { + foreach (var w in this) w.IsChecked = false; + } + + public void SortBy(Sorter sorter) => CurrentSorter = sorter; + + public void SetSortDirection(bool ascending) => _ascending = ascending; + + /// Returns in the current sort order. + public IEnumerable ApplyToList(IEnumerable items) => + _ascending + ? items.OrderBy(GetSortKey, StringComparer.OrdinalIgnoreCase) + : items.OrderByDescending(GetSortKey, StringComparer.OrdinalIgnoreCase); + + private string GetSortKey(PackageWrapper w) => CurrentSorter switch + { + Sorter.Checked => w.IsChecked ? "0" : "1", + Sorter.Name => w.Package.Name, + Sorter.Id => w.Package.Id, + Sorter.Version => w.Package.NormalizedVersion.ToString(), + Sorter.NewVersion => w.Package.NormalizedNewVersion.ToString(), + Sorter.Source => w.Package.Source.AsString_DisplayName, + _ => w.Package.Name, + }; +} diff --git a/src/UniGetUI.Avalonia/Program.cs b/src/UniGetUI.Avalonia/Program.cs index e16ca9d2c9..3423fbd27f 100644 --- a/src/UniGetUI.Avalonia/Program.cs +++ b/src/UniGetUI.Avalonia/Program.cs @@ -1,453 +1,20 @@ -using System.Threading; using Avalonia; -#if AVALONIA_DIAGNOSTICS_ENABLED -using AvaloniaUI.DiagnosticsProtocol; -#endif -using UniGetUI.Avalonia.Infrastructure; -using UniGetUI.Core.Data; -using UniGetUI.Core.Logging; -using UniGetUI.Core.SettingsEngine; -using UniGetUI.Core.Tools; +using System; namespace UniGetUI.Avalonia; -internal sealed class Program +sealed class Program { - private const string DevToolsEnvVar = "UNIGETUI_AVALONIA_DEVTOOLS"; - - private enum DevToolsRuntimeMode - { - Auto, - Enabled, - Disabled, - } - - // Kept alive for the lifetime of the process to enforce single-instance - // ReSharper disable once NotAccessedField.Local - private static Mutex? _singleInstanceMutex; - - private static DevToolsRuntimeMode _devToolsRuntimeMode = DevToolsRuntimeMode.Auto; - private static string _devToolsRuntimeModeSource = "default"; - + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. [STAThread] - public static void Main(string[] args) - { - // ── Pre-UI headless CLI dispatch (A1) ────────────────────────────── - // These commands operate purely on settings/files and exit without - // showing any UI. They mirror WinUI's EntryPoint.cs dispatch block. - if (args.Contains("--import-settings")) - { - Environment.Exit(RunCli_ImportSettings(args)); - return; - } - if (args.Contains("--export-settings")) - { - Environment.Exit(RunCli_ExportSettings(args)); - return; - } - if (args.Contains("--enable-setting")) - { - Environment.Exit(RunCli_EnableSetting(args)); - return; - } - if (args.Contains("--disable-setting")) - { - Environment.Exit(RunCli_DisableSetting(args)); - return; - } - if (args.Contains("--set-setting-value")) - { - Environment.Exit(RunCli_SetSettingValue(args)); - return; - } - - // ── A6: WinGetUI→UniGetUI shortcut migration (called by installer) ── - if (args.Contains("--migrate-wingetui-to-unigetui")) - { - Environment.Exit(RunCli_MigrateWingetUI()); - return; - } - - // ── Stub: prevents re-launch during MSI/MSIX uninstall ──────────── - if (args.Contains("--uninstall-unigetui") || args.Contains("--uninstall-wingetui")) - { - Environment.Exit(0); - return; - } - - (_devToolsRuntimeMode, _devToolsRuntimeModeSource) = ResolveDevToolsRuntimeMode(args); - Logger.Info( - $"Avalonia DevTools runtime mode: {_devToolsRuntimeMode} (source: {_devToolsRuntimeModeSource})"); - - // ── Single-instance enforcement ──────────────────────────────────── - _singleInstanceMutex = new Mutex( - initiallyOwned: true, - name: "UniGetUI_" + CoreData.MainWindowIdentifier, - createdNew: out bool createdNew); - - if (!createdNew) - { - // Forward args to the first instance then exit (mirrors WinUI3's AppInstance.RedirectActivationToAsync). - Logger.Warn("UniGetUI is already running. Forwarding args to first instance."); - SingleInstanceRedirector.TryForwardToFirstInstance(args); - _singleInstanceMutex.Close(); - _singleInstanceMutex = null; - return; - } - - // Start the pipe listener so future second instances can forward their args. - SingleInstanceRedirector.StartListener(OnIncomingArgs); - - // Register global exception handlers for logging and crash reporting - RegisterErrorHandling(); - - try - { - BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); - } - catch (Exception ex) - { - // A7: top-level crash handler - ReportFatalException(ex); - } - finally - { - _singleInstanceMutex.ReleaseMutex(); - _singleInstanceMutex.Dispose(); - _singleInstanceMutex = null; - } - } - - // ── Second-instance arg handler ────────────────────────────────────── - - private static void OnIncomingArgs(string[] incomingArgs) - { - // Show the main window. - MainWindow.Instance?.ShowFromTray(); - - // Route the forwarded arguments through the shell's arg processor. - if (MainWindow.Instance?.Content is Views.MainShellView shell) - shell.ProcessIncomingArgs(incomingArgs); - } - - // ── Headless CLI helpers ─────────────────────────────────────────────── - - private static int RunCli_ImportSettings(string[] args) - { - int pos = Array.IndexOf(args, "--import-settings"); - if (pos < 0 || pos + 1 >= args.Length) return -1073741811; // STATUS_INVALID_PARAMETER - string file = args[pos + 1].Trim('"').Trim('\''); - if (!File.Exists(file)) return -1073741809; // STATUS_NO_SUCH_FILE - try { Settings.ImportFromFile_JSON(file); return 0; } - catch (Exception ex) { return ex.HResult; } - } - - private static int RunCli_ExportSettings(string[] args) - { - int pos = Array.IndexOf(args, "--export-settings"); - if (pos < 0 || pos + 1 >= args.Length) return -1073741811; - string file = args[pos + 1].Trim('"').Trim('\''); - try { Settings.ExportToFile_JSON(file); return 0; } - catch (Exception ex) { return ex.HResult; } - } - - private static int RunCli_EnableSetting(string[] args) - { - int pos = Array.IndexOf(args, "--enable-setting"); - if (pos < 0 || pos + 1 >= args.Length) return -1073741811; - string name = args[pos + 1].Trim('"').Trim('\''); - if (!Enum.TryParse(name, out Settings.K key)) return -2; // STATUS_UNKNOWN_SETTINGS_KEY - try { Settings.Set(key, true); return 0; } - catch (Exception ex) { return ex.HResult; } - } - - private static int RunCli_DisableSetting(string[] args) - { - int pos = Array.IndexOf(args, "--disable-setting"); - if (pos < 0 || pos + 1 >= args.Length) return -1073741811; - string name = args[pos + 1].Trim('"').Trim('\''); - if (!Enum.TryParse(name, out Settings.K key)) return -2; - try { Settings.Set(key, false); return 0; } - catch (Exception ex) { return ex.HResult; } - } - - private static int RunCli_SetSettingValue(string[] args) - { - int pos = Array.IndexOf(args, "--set-setting-value"); - if (pos < 0 || pos + 2 >= args.Length) return -1073741811; - string name = args[pos + 1].Trim('"').Trim('\''); - string value = args[pos + 2]; - if (!Enum.TryParse(name, out Settings.K key)) return -2; - try { Settings.SetValue(key, value); return 0; } - catch (Exception ex) { return ex.HResult; } - } - - // ── A6: WinGetUI→UniGetUI shortcut migrator ──────────────────────────── - - private static int RunCli_MigrateWingetUI() - { - try - { - string[] basePaths = - [ - Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), - Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), - Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory), - Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu), - ]; - - foreach (string basePath in basePaths) - { - foreach (string oldName in new[] - { - "WingetUI.lnk", - "WingetUI .lnk", - "UniGetUI (formerly WingetUI) .lnk", - "UniGetUI (formerly WingetUI).lnk", - }) - { - try - { - string oldFile = Path.Join(basePath, oldName); - string newFile = Path.Join(basePath, "UniGetUI.lnk"); - if (!File.Exists(oldFile)) - continue; - - if (File.Exists(newFile)) - { - Logger.Info($"Deleting old shortcut '{oldFile}' (new one already exists)"); - File.Delete(oldFile); - } - else - { - Logger.Info($"Renaming shortcut '{oldFile}' → '{newFile}'"); - File.Move(oldFile, newFile); - } - } - catch (Exception ex) - { - Logger.Warn($"Could not migrate shortcut '{Path.Join(basePath, oldName)}'"); - Logger.Warn(ex); - } - } - } - - return 0; - } - catch (Exception ex) - { - Logger.Error(ex); - return ex.HResult; - } - } - - // ── A7: crash reporter ───────────────────────────────────────────────── - - private static void RegisterErrorHandling() - { - // Log unobserved task exceptions and mark them as observed to - // prevent .NET from terminating the process on GC finalization. - TaskScheduler.UnobservedTaskException += (_, e) => - { - Logger.Error("Unobserved task exception:"); - Logger.Error(e.Exception); - e.SetObserved(); - }; - - // Log truly unhandled exceptions on any thread. These are fatal — - // the process is about to terminate (e.IsTerminating == true). - AppDomain.CurrentDomain.UnhandledException += (_, e) => - { - if (e.ExceptionObject is Exception ex) - ReportFatalException(ex); - }; - } - - private static void ReportFatalException(Exception ex) - { - try - { - // Write a crash log next to the settings directory - string crashPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "UniGetUI", - $"crash_{DateTime.UtcNow:yyyyMMdd_HHmmss}.log"); - Directory.CreateDirectory(Path.GetDirectoryName(crashPath)!); - File.WriteAllText(crashPath, - $"UniGetUI fatal crash at {DateTime.UtcNow:O}\n\n{ex}"); - Logger.Error("FATAL EXCEPTION — crash log written to: " + crashPath); - Logger.Error(ex); - } - catch { /* best-effort */ } - } - - private static (DevToolsRuntimeMode Mode, string Source) ResolveDevToolsRuntimeMode( - string[] args) - { - if (TryGetDevToolsModeFromCli(args, out DevToolsRuntimeMode cliMode)) - { - return (cliMode, "cli"); - } - - string? envValue = Environment.GetEnvironmentVariable(DevToolsEnvVar); - if (TryParseDevToolsRuntimeMode(envValue, out DevToolsRuntimeMode envMode)) - { - return (envMode, "env"); - } - - if (!string.IsNullOrWhiteSpace(envValue)) - { - Logger.Warn( - $"Ignoring invalid {DevToolsEnvVar} value '{envValue}'. Expected one of: auto, on, off, true, false, 1, 0."); - } - - return (DevToolsRuntimeMode.Auto, "default"); - } - - private static bool TryGetDevToolsModeFromCli( - IEnumerable args, - out DevToolsRuntimeMode mode) - { - bool found = false; - mode = DevToolsRuntimeMode.Auto; - - foreach (string rawArg in args) - { - string arg = rawArg.Trim(); - - if (arg.Equals("--enable-devtools", StringComparison.OrdinalIgnoreCase)) - { - mode = DevToolsRuntimeMode.Enabled; - found = true; - continue; - } - - if (arg.Equals("--disable-devtools", StringComparison.OrdinalIgnoreCase)) - { - mode = DevToolsRuntimeMode.Disabled; - found = true; - continue; - } - - const string modePrefix = "--devtools-mode="; - if (!arg.StartsWith(modePrefix, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - string modeValue = arg[modePrefix.Length..].Trim(); - if (TryParseDevToolsRuntimeMode(modeValue, out DevToolsRuntimeMode parsedMode)) - { - mode = parsedMode; - found = true; - } - else - { - Logger.Warn( - $"Ignoring invalid --devtools-mode value '{modeValue}'. Expected one of: auto, enabled, disabled."); - } - } - - return found; - } - - private static bool TryParseDevToolsRuntimeMode( - string? value, - out DevToolsRuntimeMode mode) - { - mode = DevToolsRuntimeMode.Auto; - if (string.IsNullOrWhiteSpace(value)) - { - return false; - } - - switch (value.Trim().ToLowerInvariant()) - { - case "auto": - mode = DevToolsRuntimeMode.Auto; - return true; - case "enabled": - case "enable": - case "on": - case "true": - case "1": - mode = DevToolsRuntimeMode.Enabled; - return true; - case "disabled": - case "disable": - case "off": - case "false": - case "0": - mode = DevToolsRuntimeMode.Disabled; - return true; - default: - return false; - } - } + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + // Avalonia configuration, don't remove; also used by visual designer. public static AppBuilder BuildAvaloniaApp() - { - var builder = AppBuilder.Configure() + => AppBuilder.Configure() .UsePlatformDetect() .LogToTrace(); - -#if AVALONIA_DIAGNOSTICS_ENABLED - bool isWsl = IsRunningInWsl(); - bool shouldEnableDevTools = _devToolsRuntimeMode switch - { - DevToolsRuntimeMode.Enabled => true, - DevToolsRuntimeMode.Disabled => false, - _ => !isWsl, - }; - - if (_devToolsRuntimeMode == DevToolsRuntimeMode.Auto && isWsl) - { - Logger.Warn("Avalonia DevTools auto mode disabled on WSL to avoid avdt runner crashes."); - } - - if (_devToolsRuntimeMode == DevToolsRuntimeMode.Enabled && isWsl) - { - Logger.Warn("Avalonia DevTools explicitly enabled on WSL. This configuration may be unstable."); - } - - if (shouldEnableDevTools) - { - builder = builder.WithDeveloperTools(options => - { - options.ApplicationName = "UniGetUI.Avalonia"; - options.ConnectOnStartup = true; - options.EnableDiscovery = true; - options.DiagnosticLogger = DiagnosticLogger.CreateConsole(); - }); - Logger.Info( - $"Avalonia DevTools enabled (mode: {_devToolsRuntimeMode}, source: {_devToolsRuntimeModeSource})."); - } - else - { - Logger.Info( - $"Avalonia DevTools disabled (mode: {_devToolsRuntimeMode}, source: {_devToolsRuntimeModeSource})."); - } -#else - if (_devToolsRuntimeMode != DevToolsRuntimeMode.Auto) - { - Logger.Warn( - "Avalonia DevTools runtime toggle was requested, but diagnostics support is not included in this build."); - } -#endif - - return builder; - } - -#if AVALONIA_DIAGNOSTICS_ENABLED - private static bool IsRunningInWsl() - { - if (!OperatingSystem.IsLinux()) - return false; - - if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("WSL_DISTRO_NAME"))) - return true; - - return !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("WSL_INTEROP")); - } -#endif -} +} \ No newline at end of file diff --git a/src/UniGetUI.Avalonia/Styles/AppStyles.axaml b/src/UniGetUI.Avalonia/Styles/AppStyles.axaml deleted file mode 100644 index d501daa2bc..0000000000 --- a/src/UniGetUI.Avalonia/Styles/AppStyles.axaml +++ /dev/null @@ -1,238 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index 68de308166..9e39a8861a 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -6,6 +6,7 @@ UniGetUI.Avalonia UniGetUI.Avalonia ..\UniGetUI\icon.ico + true @@ -14,12 +15,15 @@ + - + + + @@ -37,18 +41,112 @@ + + + + - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + App.axaml + + + SidebarView.axaml + + + MainWindow.axaml + + + AbstractPackagesPage.axaml + + + InstallOptionsWindow.axaml + + + InstallOptionsWindow.axaml + Code + + + ManageIgnoredUpdatesWindow.axaml + Code + + + PackageDetailsWindow.axaml.axaml + Code + + + SettingsBasePage.axaml + Code + + + SettingsHomepage.axaml + Code + + + Notifications.axaml + Code + + + Updates.axaml + Code + + + Operations.axaml + Code + + + Experimental.axaml + Code + + + Administrator.axaml + Code + + + ManagersHomepage.axaml + Code + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.Avalonia/ViewLocator.cs b/src/UniGetUI.Avalonia/ViewLocator.cs new file mode 100644 index 0000000000..ef1ecb9658 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewLocator.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using UniGetUI.Avalonia.ViewModels; + +namespace UniGetUI.Avalonia; + +/// +/// Given a view model, returns the corresponding view if possible. +/// +[RequiresUnreferencedCode( + "Default implementation of ViewLocator involves reflection which may be trimmed away.", + Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] +public class ViewLocator : IDataTemplate +{ + public Control? Build(object? param) + { + if (param is null) + return null; + + var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + + return new TextBlock { Text = "Not Found: " + name }; + } + + public bool Match(object? data) + { + return data is ViewModelBase; + } +} \ No newline at end of file diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs new file mode 100644 index 0000000000..7b475a39e1 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs @@ -0,0 +1,446 @@ +using System.Collections.ObjectModel; +using System.Net.Http; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UniGetUI.Core.Language; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Serializable; + +namespace UniGetUI.Avalonia.ViewModels; + +public partial class InstallOptionsViewModel : ObservableObject +{ + // ── Public result ────────────────────────────────────────────────────────── + public bool ShouldProceedWithOperation { get; private set; } + public event EventHandler? CloseRequested; + + private readonly IPackage _package; + private readonly InstallOptions _options; + private bool _uiLoaded; + + // ── Translated static labels ─────────────────────────────────────────────── + public string DialogTitle { get; } + public string ProfileLabel { get; } = CoreTools.Translate("Operation profile:"); + public string FollowGlobalLabel { get; } = CoreTools.Translate("Follow the default options when installing, upgrading or uninstalling this package"); + public string GeneralInfoLabel { get; } = CoreTools.Translate("The following settings will be applied each time this package is installed, updated or removed."); + public string VersionLabel { get; } = CoreTools.Translate("Version to install:"); + public string ArchLabel { get; } = CoreTools.Translate("Architecture to install:"); + public string ScopeLabel { get; } = CoreTools.Translate("Installation scope:"); + public string LocationLabel { get; } = CoreTools.Translate("Install location:"); + public string SelectDirLabel { get; } = CoreTools.Translate("Select"); + public string ResetDirLabel { get; } = CoreTools.Translate("Reset"); + public string ParamsInstallLabel { get; } = CoreTools.Translate("Custom install arguments:"); + public string ParamsUpdateLabel { get; } = CoreTools.Translate("Custom update arguments:"); + public string ParamsUninstallLabel { get; } = CoreTools.Translate("Custom uninstall arguments:"); + public string PreInstallLabel { get; } = CoreTools.Translate("Pre-install command:"); + public string PostInstallLabel { get; } = CoreTools.Translate("Post-install command:"); + public string AbortInstallLabel { get; } = CoreTools.Translate("Abort install if pre-install command fails"); + public string PreUpdateLabel { get; } = CoreTools.Translate("Pre-update command:"); + public string PostUpdateLabel { get; } = CoreTools.Translate("Post-update command:"); + public string AbortUpdateLabel { get; } = CoreTools.Translate("Abort update if pre-update command fails"); + public string PreUninstallLabel { get; } = CoreTools.Translate("Pre-uninstall command:"); + public string PostUninstallLabel { get; } = CoreTools.Translate("Post-uninstall command:"); + public string AbortUninstallLabel { get; } = CoreTools.Translate("Abort uninstall if pre-uninstall command fails"); + public string CommandPreviewLabel { get; } = CoreTools.Translate("Command-line to run:"); + public string SaveLabel { get; } = CoreTools.Translate("Save and close"); + public string TabGeneralLabel { get; } = CoreTools.Translate("General"); + public string TabLocationLabel { get; } = CoreTools.Translate("Architecture & Location"); + public string TabCLILabel { get; } = CoreTools.Translate("Command-line"); + public string TabPrePostLabel { get; } = CoreTools.Translate("Pre/Post install"); + + // Checkbox content labels + public string AdminCheckBox_Content { get; } = CoreTools.Translate("Run as admin"); + public string InteractiveCheckBox_Content { get; } = CoreTools.Translate("Interactive installation"); + public string SkipHashCheckBox_Content { get; } = CoreTools.Translate("Skip hash check"); + public string UninstallPrevCheckBox_Content { get; } = CoreTools.Translate("Uninstall previous versions when updated"); + public string SkipMinorCheckBox_Content { get; } = CoreTools.Translate("Skip minor updates for this package"); + public string AutoUpdateCheckBox_Content { get; } = CoreTools.Translate("Automatically update this package"); + + // ── Capability flags (for IsEnabled bindings) ───────────────────────────── + public bool CanRunAsAdmin { get; } + public bool CanRunInteractively { get; } + public bool CanSkipHash { get; } + public bool CanUninstallPrev { get; } + public bool HasCustomScopes { get; } + public bool HasCustomLocations { get; } + + // ── Package icon ─────────────────────────────────────────────────────────── + [ObservableProperty] private Bitmap? _packageIcon; + + // ── Follow-global toggle ─────────────────────────────────────────────────── + [ObservableProperty] private bool _followGlobal; + [ObservableProperty] private bool _isCustomMode; + [ObservableProperty] private double _optionsOpacity = 1.0; + + partial void OnFollowGlobalChanged(bool value) + { + IsCustomMode = !value; + OptionsOpacity = value ? 0.35 : 1.0; + if (_uiLoaded) _ = RefreshCommandPreviewAsync(); + } + + // ── Profile combo ────────────────────────────────────────────────────────── + public ObservableCollection ProfileOptions { get; } = []; + + [ObservableProperty] private string? _selectedProfile; + [ObservableProperty] private string _proceedButtonLabel = ""; + + partial void OnSelectedProfileChanged(string? value) + { + ProceedButtonLabel = value ?? ""; + ApplyProfileEnableState(); + if (_uiLoaded) _ = RefreshCommandPreviewAsync(); + } + + // ── General tab ─────────────────────────────────────────────────────────── + [ObservableProperty] private bool _adminChecked; + [ObservableProperty] private bool _interactiveChecked; + [ObservableProperty] private bool _skipHashChecked; + [ObservableProperty] private bool _skipHashEnabled; + [ObservableProperty] private bool _uninstallPrevChecked; + + [ObservableProperty] private bool _versionEnabled; + public ObservableCollection VersionOptions { get; } = []; + [ObservableProperty] private string? _selectedVersion; + + [ObservableProperty] private bool _skipMinorChecked; + [ObservableProperty] private bool _autoUpdateChecked; + + partial void OnAdminCheckedChanged(bool _) => Refresh(); + partial void OnInteractiveCheckedChanged(bool _) => Refresh(); + partial void OnSkipHashCheckedChanged(bool _) => Refresh(); + partial void OnSelectedVersionChanged(string? _) => Refresh(); + + // ── Architecture / Scope / Location tab ─────────────────────────────────── + [ObservableProperty] private bool _archEnabled; + public ObservableCollection ArchOptions { get; } = []; + [ObservableProperty] private string? _selectedArch; + + [ObservableProperty] private bool _scopeEnabled; + public ObservableCollection ScopeOptions { get; } = []; + [ObservableProperty] private string? _selectedScope; + + [ObservableProperty] private string _locationText = ""; + [ObservableProperty] private bool _locationEnabled; + + partial void OnSelectedArchChanged(string? _) => Refresh(); + partial void OnSelectedScopeChanged(string? _) => Refresh(); + + // ── CLI params tab ──────────────────────────────────────────────────────── + [ObservableProperty] private string _paramsInstall = ""; + [ObservableProperty] private string _paramsUpdate = ""; + [ObservableProperty] private string _paramsUninstall = ""; + + partial void OnParamsInstallChanged(string _) => Refresh(); + partial void OnParamsUpdateChanged(string _) => Refresh(); + partial void OnParamsUninstallChanged(string _) => Refresh(); + + // ── Pre/Post commands tab ───────────────────────────────────────────────── + [ObservableProperty] private string _preInstallText = ""; + [ObservableProperty] private string _postInstallText = ""; + [ObservableProperty] private bool _abortInstall; + + [ObservableProperty] private string _preUpdateText = ""; + [ObservableProperty] private string _postUpdateText = ""; + [ObservableProperty] private bool _abortUpdate; + + [ObservableProperty] private string _preUninstallText = ""; + [ObservableProperty] private string _postUninstallText = ""; + [ObservableProperty] private bool _abortUninstall; + + // ── Command preview ─────────────────────────────────────────────────────── + [ObservableProperty] private string _commandPreview = ""; + + // ── Constructor ─────────────────────────────────────────────────────────── + public InstallOptionsViewModel(IPackage package, OperationType operation, InstallOptions options) + { + _package = package; + _options = options; + var caps = package.Manager.Capabilities; + + DialogTitle = CoreTools.Translate("{0} installation options", package.Name); + + // Capability flags + CanRunAsAdmin = caps.CanRunAsAdmin; + CanRunInteractively = caps.CanRunInteractively; + CanSkipHash = caps.CanSkipIntegrityChecks; + CanUninstallPrev = caps.CanUninstallPreviousVersionsAfterUpdate; + HasCustomScopes = caps.SupportsCustomScopes; + HasCustomLocations = caps.SupportsCustomLocations; + + // Profile + string installLabel = CoreTools.Translate("Install"); + string updateLabel = CoreTools.Translate("Update"); + string uninstallLabel = CoreTools.Translate("Uninstall"); + ProfileOptions.Add(installLabel); + ProfileOptions.Add(updateLabel); + ProfileOptions.Add(uninstallLabel); + SelectedProfile = operation switch + { + OperationType.Update => updateLabel, + OperationType.Uninstall => uninstallLabel, + _ => installLabel, + }; + ProceedButtonLabel = SelectedProfile; + + // Follow-global + FollowGlobal = !options.OverridesNextLevelOpts; + IsCustomMode = options.OverridesNextLevelOpts; + OptionsOpacity= options.OverridesNextLevelOpts ? 1.0 : 0.35; + + // General checkboxes + AdminChecked = options.RunAsAdministrator; + InteractiveChecked = options.InteractiveInstallation; + SkipHashChecked = options.SkipHashCheck; + SkipHashEnabled = caps.CanSkipIntegrityChecks; + UninstallPrevChecked= options.UninstallPreviousVersionsOnUpdate; + SkipMinorChecked = options.SkipMinorUpdates; + AutoUpdateChecked = options.AutoUpdatePackage; + + // Version + VersionOptions.Add(CoreTools.Translate("Latest")); + if (caps.SupportsPreRelease) + VersionOptions.Add(CoreTools.Translate("PreRelease")); + SelectedVersion = options.PreRelease + ? CoreTools.Translate("PreRelease") + : CoreTools.Translate("Latest"); + VersionEnabled = caps.SupportsCustomVersions || caps.SupportsPreRelease; + + // Architecture + string defaultLabel = CoreTools.Translate("Default"); + ArchOptions.Add(defaultLabel); + SelectedArch = defaultLabel; + if (caps.SupportsCustomArchitectures) + { + foreach (var arch in caps.SupportedCustomArchitectures) + { + ArchOptions.Add(arch); + if (options.Architecture == arch) SelectedArch = arch; + } + } + ArchEnabled = caps.SupportsCustomArchitectures; + + // Scope + ScopeOptions.Add(CoreTools.Translate("Default")); + SelectedScope = CoreTools.Translate("Default"); + if (caps.SupportsCustomScopes) + { + string localName = CoreTools.Translate(CommonTranslations.ScopeNames[PackageScope.Local]); + string globalName = CoreTools.Translate(CommonTranslations.ScopeNames[PackageScope.Global]); + ScopeOptions.Add(localName); + ScopeOptions.Add(globalName); + if (options.InstallationScope == "Local") SelectedScope = localName; + if (options.InstallationScope == "Global") SelectedScope = globalName; + } + ScopeEnabled = caps.SupportsCustomScopes; + + // Location + LocationText = options.CustomInstallLocation; + LocationEnabled = caps.SupportsCustomLocations; + + // CLI params + ParamsInstall = string.Join(' ', options.CustomParameters_Install); + ParamsUpdate = string.Join(' ', options.CustomParameters_Update); + ParamsUninstall = string.Join(' ', options.CustomParameters_Uninstall); + + // Pre/Post commands + PreInstallText = options.PreInstallCommand; + PostInstallText = options.PostInstallCommand; + AbortInstall = options.AbortOnPreInstallFail; + PreUpdateText = options.PreUpdateCommand; + PostUpdateText = options.PostUpdateCommand; + AbortUpdate = options.AbortOnPreUpdateFail; + PreUninstallText = options.PreUninstallCommand; + PostUninstallText = options.PostUninstallCommand; + AbortUninstall = options.AbortOnPreUninstallFail; + + // Show fallback immediately, then replace with real icon if available + using var fallback = AssetLoader.Open(_fallbackIconUri); + PackageIcon = new Bitmap(fallback); + _ = LoadIconAsync(); + + if (caps.SupportsCustomVersions) + _ = LoadVersionsAsync(options.Version); + + _uiLoaded = true; + _ = RefreshCommandPreviewAsync(); + } + + // ── Commands ────────────────────────────────────────────────────────────── + [RelayCommand] + private void Save() + { + ApplyToOptions(); + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + [RelayCommand] + private void Proceed() + { + ApplyToOptions(); + ShouldProceedWithOperation = true; + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + [RelayCommand] + private void ResetLocation() => LocationText = ""; + + // ── Enable/disable based on selected operation profile ──────────────────── + private void ApplyProfileEnableState() + { + if (!_uiLoaded) return; + var op = CurrentOp(); + var caps = _package.Manager.Capabilities; + + SkipHashEnabled = op is not OperationType.Uninstall && caps.CanSkipIntegrityChecks; + ArchEnabled = op is not OperationType.Uninstall && caps.SupportsCustomArchitectures; + VersionEnabled = op is OperationType.Install && (caps.SupportsCustomVersions || caps.SupportsPreRelease); + } + + // ── Live command preview ────────────────────────────────────────────────── + private async Task RefreshCommandPreviewAsync() + { + if (!_uiLoaded) return; + var snap = SnapshotOptions(); + var op = CurrentOp(); + var applied = await InstallOptionsFactory.LoadApplicableAsync(_package, overridePackageOptions: snap); + var args = await Task.Run(() => _package.Manager.OperationHelper.GetParameters(_package, applied, op)); + CommandPreview = _package.Manager.Properties.ExecutableFriendlyName + " " + string.Join(' ', args); + } + + private void Refresh() { if (_uiLoaded) _ = RefreshCommandPreviewAsync(); } + + // ── Package icon ────────────────────────────────────────────────────────── + private static readonly HttpClient _iconHttp = new(CoreTools.GenericHttpClientParameters); + + private static readonly Uri _fallbackIconUri = + new("avares://UniGetUI.Avalonia/Assets/package_color.png"); + + private async Task LoadIconAsync() + { + try + { + var uri = await Task.Run(_package.GetIconUrlIfAny); + if (uri is null) return; + + Bitmap bmp; + if (uri.IsFile) + bmp = new Bitmap(uri.LocalPath); + else if (uri.Scheme is "http" or "https") + { + var bytes = await _iconHttp.GetByteArrayAsync(uri); + using var ms = new MemoryStream(bytes); + bmp = new Bitmap(ms); + } + else return; + + PackageIcon = bmp; + } + catch (Exception ex) { Logger.Warn($"[InstallOptionsViewModel] Failed to load icon for {_package.Id}: {ex.Message}"); } + } + + // ── Async version loader ────────────────────────────────────────────────── + private async Task LoadVersionsAsync(string selectedVersion) + { + VersionEnabled = false; + var versions = await Task.Run(() => _package.Manager.DetailsHelper.GetVersions(_package)); + foreach (var ver in versions) + { + VersionOptions.Add(ver); + if (selectedVersion == ver) + SelectedVersion = ver; + } + var op = CurrentOp(); + VersionEnabled = op is OperationType.Install && + (_package.Manager.Capabilities.SupportsCustomVersions || + _package.Manager.Capabilities.SupportsPreRelease); + } + + // ── Snapshot & apply ────────────────────────────────────────────────────── + private OperationType CurrentOp() => SelectedProfile switch + { + var s when s == CoreTools.Translate("Update") => OperationType.Update, + var s when s == CoreTools.Translate("Uninstall") => OperationType.Uninstall, + _ => OperationType.Install, + }; + + private InstallOptions SnapshotOptions() + { + var o = new InstallOptions(); + o.RunAsAdministrator = AdminChecked; + o.InteractiveInstallation = InteractiveChecked; + o.SkipHashCheck = SkipHashChecked; + o.UninstallPreviousVersionsOnUpdate = UninstallPrevChecked; + o.AutoUpdatePackage = AutoUpdateChecked; + o.SkipMinorUpdates = SkipMinorChecked; + o.OverridesNextLevelOpts = !FollowGlobal; + + var ver = SelectedVersion ?? ""; + o.PreRelease = ver == CoreTools.Translate("PreRelease"); + o.Version = (ver != CoreTools.Translate("Latest") && ver != CoreTools.Translate("PreRelease") && ver.Length > 0) ? ver : ""; + + string defaultLabel = CoreTools.Translate("Default"); + o.Architecture = (SelectedArch != defaultLabel && SelectedArch is not null) ? SelectedArch : ""; + o.InstallationScope= ScopeToString(SelectedScope); + + o.CustomInstallLocation = LocationText; + o.CustomParameters_Install = Split(ParamsInstall); + o.CustomParameters_Update = Split(ParamsUpdate); + o.CustomParameters_Uninstall = Split(ParamsUninstall); + o.PreInstallCommand = PreInstallText; + o.PostInstallCommand = PostInstallText; + o.AbortOnPreInstallFail = AbortInstall; + o.PreUpdateCommand = PreUpdateText; + o.PostUpdateCommand = PostUpdateText; + o.AbortOnPreUpdateFail = AbortUpdate; + o.PreUninstallCommand = PreUninstallText; + o.PostUninstallCommand = PostUninstallText; + o.AbortOnPreUninstallFail = AbortUninstall; + return o; + } + + private void ApplyToOptions() + { + var s = SnapshotOptions(); + _options.RunAsAdministrator = s.RunAsAdministrator; + _options.InteractiveInstallation = s.InteractiveInstallation; + _options.SkipHashCheck = s.SkipHashCheck; + _options.UninstallPreviousVersionsOnUpdate= s.UninstallPreviousVersionsOnUpdate; + _options.AutoUpdatePackage = s.AutoUpdatePackage; + _options.SkipMinorUpdates = s.SkipMinorUpdates; + _options.OverridesNextLevelOpts = s.OverridesNextLevelOpts; + _options.PreRelease = s.PreRelease; + _options.Version = s.Version; + _options.Architecture = s.Architecture; + _options.InstallationScope = s.InstallationScope; + _options.CustomInstallLocation = s.CustomInstallLocation; + _options.CustomParameters_Install = s.CustomParameters_Install; + _options.CustomParameters_Update = s.CustomParameters_Update; + _options.CustomParameters_Uninstall = s.CustomParameters_Uninstall; + _options.PreInstallCommand = s.PreInstallCommand; + _options.PostInstallCommand = s.PostInstallCommand; + _options.AbortOnPreInstallFail = s.AbortOnPreInstallFail; + _options.PreUpdateCommand = s.PreUpdateCommand; + _options.PostUpdateCommand = s.PostUpdateCommand; + _options.AbortOnPreUpdateFail = s.AbortOnPreUpdateFail; + _options.PreUninstallCommand = s.PreUninstallCommand; + _options.PostUninstallCommand = s.PostUninstallCommand; + _options.AbortOnPreUninstallFail = s.AbortOnPreUninstallFail; + } + + private string ScopeToString(string? selected) + { + if (selected == CoreTools.Translate(CommonTranslations.ScopeNames[PackageScope.Local])) return "Local"; + if (selected == CoreTools.Translate(CommonTranslations.ScopeNames[PackageScope.Global])) return "Global"; + return ""; + } + + private static List Split(string text) + => text.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); +} diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageIgnoredUpdatesViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageIgnoredUpdatesViewModel.cs new file mode 100644 index 0000000000..8efbf13e26 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageIgnoredUpdatesViewModel.cs @@ -0,0 +1,189 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UniGetUI.Core.Tools; +using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine; +using UniGetUI.PackageEngine.Classes.Packages.Classes; +using UniGetUI.PackageEngine.PackageLoader; + +namespace UniGetUI.Avalonia.ViewModels; + +public partial class ManageIgnoredUpdatesViewModel : ObservableObject +{ + public event EventHandler? CloseRequested; + + public string Title { get; } = CoreTools.Translate("Manage ignored updates"); + public string Description { get; } = CoreTools.Translate("The packages listed here won't be taken in account when checking for updates. Click the button on their right to stop ignoring their updates."); + public string ResetLabel { get; } = CoreTools.Translate("Reset list"); + public string ResetConfirm { get; } = CoreTools.Translate("Do you really want to reset the ignored updates list? This action cannot be reverted"); + public string ResetYes { get; } = CoreTools.Translate("Yes"); + public string ResetNo { get; } = CoreTools.Translate("No"); + public string EmptyLabel { get; } = CoreTools.Translate("No ignored updates"); + public string ColName { get; } = CoreTools.Translate("Package name"); + public string ColId { get; } = CoreTools.Translate("Package ID"); + public string ColVersion { get; } = CoreTools.Translate("Ignored version"); + public string ColNewVersion { get; } = CoreTools.Translate("Available update"); + public string ColManager { get; } = CoreTools.Translate("Source"); + + public ObservableCollection Entries { get; } = []; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowEmptyLabel))] + private bool _hasEntries; + + public bool ShowEmptyLabel => !HasEntries; + + public ManageIgnoredUpdatesViewModel() + { + LoadEntries(); + } + + private void LoadEntries() + { + Entries.Clear(); + + var db = IgnoredUpdatesDatabase.GetDatabase(); + var managerMap = PEInterface.Managers + .ToDictionary(m => m.Properties.Name.ToLower(), m => m); + + foreach (var (ignoredId, version) in db.OrderBy(x => x.Key)) + { + var parts = ignoredId.Split('\\'); + var managerKey = parts[0]; + var packageId = parts.Length > 1 ? parts[^1] : ignoredId; + + string managerDisplay = managerMap.TryGetValue(managerKey, out var mgr) + ? mgr.DisplayName + : managerKey; + string managerIconPath = ResolveManagerIcon(managerKey); + string packageName = CoreTools.FormatAsName(packageId); + + string versionDisplay = version == "*" + ? CoreTools.Translate("All versions") + : version; + + // Compute the "new version" column like WinUI does + string currentVersion = + InstalledPackagesLoader.Instance.GetPackageForId(packageId)?.VersionString + ?? CoreTools.Translate("Unknown"); + + string newVersion; + if (UpgradablePackagesLoader.Instance.IgnoredPackages + .TryGetValue(packageId, out var upgradable) + && upgradable.NewVersionString != upgradable.VersionString) + { + newVersion = currentVersion + " \u27a4 " + upgradable.NewVersionString; + } + else if (currentVersion != CoreTools.Translate("Unknown")) + { + newVersion = CoreTools.Translate("Up to date") + $" ({currentVersion})"; + } + else + { + newVersion = CoreTools.Translate("Unknown"); + } + + var entry = new IgnoredPackageEntryViewModel( + ignoredId, packageId, packageName, managerDisplay, + managerIconPath, versionDisplay, newVersion); + entry.Removed += OnEntryRemoved; + Entries.Add(entry); + } + + HasEntries = Entries.Count > 0; + } + + private void OnEntryRemoved(object? sender, EventArgs e) + { + if (sender is IgnoredPackageEntryViewModel entry) + Entries.Remove(entry); + HasEntries = Entries.Count > 0; + } + + [RelayCommand] + private async Task ResetAll() + { + foreach (var entry in Entries.ToList()) + await entry.RemoveAsync(); + } + + private static string ResolveManagerIcon(string managerKey) => + (managerKey switch + { + "winget" => "winget", + "scoop" => "scoop", + "chocolatey" => "choco", + "dotnet" => "dotnet", + "npm" => "node", + "pip" => "python", + "powershell" => "powershell", + "cargo" => "rust", + "vcpkg" => "vcpkg", + "steam" => "steam", + "gog" => "gog", + "uplay" => "uplay", + _ => "ms_store", + }) is var name + ? $"avares://UniGetUI.Avalonia/Assets/Symbols/{name}.svg" + : $"avares://UniGetUI.Avalonia/Assets/Symbols/ms_store.svg"; +} + +public partial class IgnoredPackageEntryViewModel : ObservableObject +{ + public event EventHandler? Removed; + + public string Id { get; } + public string Name { get; } + public string Manager { get; } + public string ManagerIconPath { get; } + public string VersionDisplay { get; } + public string NewVersion { get; } + + private readonly string _ignoredId; + + public IgnoredPackageEntryViewModel( + string ignoredId, string id, string name, + string manager, string managerIconPath, + string versionDisplay, string newVersion) + { + _ignoredId = ignoredId; + Id = id; + Name = name; + Manager = manager; + ManagerIconPath = managerIconPath; + VersionDisplay = versionDisplay; + NewVersion = newVersion; + } + + [RelayCommand] + public async Task Remove() => await RemoveAsync(); + + public async Task RemoveAsync() + { + await Task.Run(() => IgnoredUpdatesDatabase.Remove(_ignoredId)); + await RestoreToUpdatesAsync(); + Removed?.Invoke(this, EventArgs.Empty); + } + + private async Task RestoreToUpdatesAsync() + { + var parts = _ignoredId.Split('\\'); + var packageId = parts.Length > 1 ? parts[^1] : _ignoredId; + + if (UpgradablePackagesLoader.Instance.IgnoredPackages.TryRemove(packageId, out var pkg) + && pkg.NewVersionString != pkg.VersionString) + { + await UpgradablePackagesLoader.Instance.AddForeign(pkg); + } + + foreach (var installed in InstalledPackagesLoader.Instance.Packages) + { + if (installed.Id == packageId) + { + installed.SetTag(PackageTag.Default); + break; + } + } + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs new file mode 100644 index 0000000000..91fff51c56 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs @@ -0,0 +1,189 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Media; +using Avalonia.Threading; +using UniGetUI.Avalonia.Infrastructure; +using UniGetUI.Avalonia.Views; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageOperations; + +namespace UniGetUI.Avalonia.ViewModels; + +public sealed class OperationViewModel : INotifyPropertyChanged +{ + public AbstractOperation Operation { get; } + + /// Short badge labels shown next to the progress bar (Admin, Interactive, …). + public ObservableCollection Badges { get; } = []; + + public ICommand ButtonCommand { get; } + public ICommand ShowDetailsCommand { get; } + + public OperationViewModel(AbstractOperation operation) + { + Operation = operation; + ButtonCommand = new SyncCommand(ButtonClick); + ShowDetailsCommand = new SyncCommand(ShowDetails); + + _title = operation.Metadata.Title; + _liveLine = operation.GetOutput().Any() + ? operation.GetOutput()[^1].Item1 + : CoreTools.Translate("Please wait..."); + _buttonText = CoreTools.Translate("Cancel"); + _progressBrush = new SolidColorBrush(Color.Parse("#888888")); + _backgroundBrush = Brushes.Transparent; + + // Route all background-thread events to the UI thread + operation.LogLineAdded += (_, ev) => + Dispatcher.UIThread.Post(() => LiveLine = ev.Item1); + + operation.StatusChanged += (_, status) => + Dispatcher.UIThread.Post(() => ApplyStatus(status)); + + operation.BadgesChanged += (_, badges) => + Dispatcher.UIThread.Post(() => + { + Badges.Clear(); + if (badges.AsAdministrator) Badges.Add(CoreTools.Translate("Administrator")); + if (badges.Interactive) Badges.Add(CoreTools.Translate("Interactive")); + if (badges.SkipHashCheck) Badges.Add(CoreTools.Translate("Skip hash check")); + }); + + // Sync with current status in case the operation already started + ApplyStatus(operation.Status); + } + + // ── Status → visual properties ──────────────────────────────────────────── + private void ApplyStatus(OperationStatus status) + { + switch (status) + { + case OperationStatus.InQueue: + ProgressIndeterminate = false; + ProgressValue = 0; + ProgressBrush = new SolidColorBrush(Color.Parse("#888888")); + BackgroundBrush = Brushes.Transparent; + ButtonText = CoreTools.Translate("Cancel"); + break; + + case OperationStatus.Running: + ProgressIndeterminate = true; + ProgressBrush = new SolidColorBrush(Color.Parse("#F0A500")); + BackgroundBrush = new SolidColorBrush(Color.FromArgb(30, 240, 165, 0)); + ButtonText = CoreTools.Translate("Cancel"); + break; + + case OperationStatus.Succeeded: + ProgressIndeterminate = false; + ProgressValue = 100; + ProgressBrush = new SolidColorBrush(Color.Parse("#0F7B0F")); + BackgroundBrush = new SolidColorBrush(Color.FromArgb(30, 15, 123, 15)); + ButtonText = CoreTools.Translate("Close"); + break; + + case OperationStatus.Failed: + ProgressIndeterminate = false; + ProgressValue = 100; + ProgressBrush = new SolidColorBrush(Color.Parse("#BC0000")); + BackgroundBrush = new SolidColorBrush(Color.FromArgb(40, 188, 0, 0)); + ButtonText = CoreTools.Translate("Close"); + break; + + case OperationStatus.Canceled: + ProgressIndeterminate = false; + ProgressValue = 100; + ProgressBrush = new SolidColorBrush(Color.Parse("#9D5D00")); + BackgroundBrush = Brushes.Transparent; + ButtonText = CoreTools.Translate("Close"); + break; + } + } + + // ── Button / details actions ────────────────────────────────────────────── + private void ButtonClick() + { + if (Operation.Status is OperationStatus.Running or OperationStatus.InQueue) + Operation.Cancel(); + else + AvaloniaOperationRegistry.Remove(this); + } + + private void ShowDetails() + { + if (Application.Current?.ApplicationLifetime + is IClassicDesktopStyleApplicationLifetime { MainWindow: Window mainWindow }) + { + var win = new OperationOutputWindow(Operation); + _ = win.ShowDialog(mainWindow); + } + } + + // ── Bindable properties ─────────────────────────────────────────────────── + private string _title; + public string Title + { + get => _title; + set { _title = value; OnPropertyChanged(); } + } + + private string _liveLine; + public string LiveLine + { + get => _liveLine; + set { _liveLine = value; OnPropertyChanged(); } + } + + private string _buttonText; + public string ButtonText + { + get => _buttonText; + set { _buttonText = value; OnPropertyChanged(); } + } + + private bool _progressIndeterminate; + public bool ProgressIndeterminate + { + get => _progressIndeterminate; + set { _progressIndeterminate = value; OnPropertyChanged(); } + } + + private double _progressValue; + public double ProgressValue + { + get => _progressValue; + set { _progressValue = value; OnPropertyChanged(); } + } + + private IBrush _progressBrush; + public IBrush ProgressBrush + { + get => _progressBrush; + set { _progressBrush = value; OnPropertyChanged(); } + } + + private IBrush _backgroundBrush; + public IBrush BackgroundBrush + { + get => _backgroundBrush; + set { _backgroundBrush = value; OnPropertyChanged(); } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + + // ── Minimal ICommand implementation ─────────────────────────────────────── + private sealed class SyncCommand(Action action) : ICommand + { + public event EventHandler? CanExecuteChanged { add { } remove { } } + public bool CanExecute(object? parameter) => true; + public void Execute(object? parameter) => action(); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/PackageDetailsViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/PackageDetailsViewModel.cs new file mode 100644 index 0000000000..546d1eca1c --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/PackageDetailsViewModel.cs @@ -0,0 +1,317 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Net.Http; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; + +namespace UniGetUI.Avalonia.ViewModels; + +public partial class PackageDetailsViewModel : ObservableObject +{ + public event EventHandler? CloseRequested; + + public readonly IPackage Package; + public readonly OperationType OperationRole; + + // ── Header ───────────────────────────────────────────────────────────────── + public string PackageName { get; } + public string SourceDisplay { get; } + + [ObservableProperty] + private Bitmap? _packageIcon; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsLoaded))] + private bool _isLoading = true; + + public bool IsLoaded => !IsLoading; + + // ── Tags ─────────────────────────────────────────────────────────────────── + public ObservableCollection Tags { get; } = []; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasTags))] + private int _tagCount; + + public bool HasTags => TagCount > 0; + + // ── Description ──────────────────────────────────────────────────────────── + [ObservableProperty] + private string _description = CoreTools.Translate("Loading..."); + + // ── Basic info ───────────────────────────────────────────────────────────── + [ObservableProperty] + private string _versionDisplay = ""; + + [ObservableProperty] + private string _homepageText = CoreTools.Translate("Loading..."); + [ObservableProperty] + private bool _hasHomepageUrl; + + [ObservableProperty] + private string _author = CoreTools.Translate("Loading..."); + [ObservableProperty] + private string _publisher = CoreTools.Translate("Loading..."); + + [ObservableProperty] + private string _licenseText = CoreTools.Translate("Loading..."); + [ObservableProperty] + private bool _hasLicenseUrl; + + // ── Actions ──────────────────────────────────────────────────────────────── + public string MainActionLabel { get; } + public string AsAdminLabel { get; } + public string InteractiveLabel { get; } + public string SkipHashOrRemoveDataLabel { get; } + public bool CanRunAsAdmin { get; } + public bool CanRunInteractively { get; } + public bool CanSkipHashOrRemoveData { get; } + + // ── Extended details ─────────────────────────────────────────────────────── + public string PackageId { get; } + + [ObservableProperty] + private string _manifestText = CoreTools.Translate("Loading..."); + [ObservableProperty] + private bool _hasManifestUrl; + + [ObservableProperty] + private string _installerHashLabel = CoreTools.Translate("Installer SHA256") + ":"; + [ObservableProperty] + private string _installerHash = CoreTools.Translate("Loading..."); + [ObservableProperty] + private string _installerType = CoreTools.Translate("Loading..."); + [ObservableProperty] + private string _installerUrlText = CoreTools.Translate("Loading..."); + [ObservableProperty] + private bool _hasInstallerUrl; + [ObservableProperty] + private string _installerSize = ""; + + public bool CanDownloadInstaller { get; } + + [ObservableProperty] + private string _updateDate = CoreTools.Translate("Loading..."); + + // ── Dependencies ─────────────────────────────────────────────────────────── + public bool CanListDependencies { get; } + public ObservableCollection Dependencies { get; } = []; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasDependenciesList))] + private bool _hasDependencyNote = true; + + public bool HasDependenciesList => !HasDependencyNote; + + [ObservableProperty] + private string _dependencyNote = ""; + + // ── Release notes ────────────────────────────────────────────────────────── + [ObservableProperty] + private string _releaseNotes = CoreTools.Translate("Loading..."); + [ObservableProperty] + private string _releaseNotesUrlText = CoreTools.Translate("Loading..."); + [ObservableProperty] + private bool _hasReleaseNotesUrl; + + // ── Translated labels ────────────────────────────────────────────────────── + public string LabelVersion { get; } + public string LabelHomepage { get; } = CoreTools.Translate("Homepage") + ":"; + public string LabelAuthor { get; } = CoreTools.Translate("Author") + ":"; + public string LabelPublisher { get; } = CoreTools.Translate("Publisher") + ":"; + public string LabelLicense { get; } = CoreTools.Translate("License") + ":"; + public string LabelPackageId { get; } = CoreTools.Translate("Package ID") + ":"; + public string LabelManifest { get; } = CoreTools.Translate("Manifest") + ":"; + public string LabelInstallerType { get; } = CoreTools.Translate("Installer type") + ":"; + public string LabelInstallerSize { get; } = CoreTools.Translate("Installer size") + ":"; + public string LabelInstallerUrl { get; } = CoreTools.Translate("Installer URL") + ":"; + public string LabelUpdateDate { get; } = CoreTools.Translate("Last updated") + ":"; + public string LabelReleaseNotesUrl { get; } = CoreTools.Translate("Release notes URL") + ":"; + public string LabelOpen { get; } = CoreTools.Translate("Open"); + public string LabelClose { get; } = CoreTools.Translate("Close"); + public string HeaderDetails { get; } = CoreTools.Translate("Details"); + public string HeaderDeps { get; } = CoreTools.Translate("Dependencies"); + public string HeaderReleaseNotes { get; } = CoreTools.Translate("Release notes"); + + public PackageDetailsViewModel(IPackage package, OperationType role) + { + if (role == OperationType.None) role = OperationType.Install; + + Package = package; + OperationRole = role; + PackageName = package.Name; + PackageId = package.Id; + SourceDisplay = package.Source.AsString_DisplayName; + + CanDownloadInstaller = package.Manager.Capabilities.CanDownloadInstaller; + CanListDependencies = package.Manager.Capabilities.CanListDependencies; + + var caps = package.Manager.Capabilities; + CanRunAsAdmin = caps.CanRunAsAdmin; + CanRunInteractively = caps.CanRunInteractively; + + var available = package.GetAvailablePackage(); + var upgradable = package.GetUpgradablePackage(); + var installed = upgradable?.GetInstalledPackages().FirstOrDefault(); + + if (role == OperationType.Install) + { + MainActionLabel = CoreTools.Translate("Install"); + LabelVersion = CoreTools.Translate("Version") + ":"; + VersionDisplay = available?.VersionString ?? package.VersionString; + AsAdminLabel = CoreTools.Translate("Install as administrator"); + InteractiveLabel = CoreTools.Translate("Interactive installation"); + SkipHashOrRemoveDataLabel = CoreTools.Translate("Skip hash check"); + CanSkipHashOrRemoveData = caps.CanSkipIntegrityChecks; + } + else if (role == OperationType.Update) + { + MainActionLabel = CoreTools.Translate( + "Update to version {0}", upgradable?.NewVersionString ?? package.NewVersionString); + LabelVersion = CoreTools.Translate("Installed Version") + ":"; + VersionDisplay = (upgradable?.VersionString ?? package.VersionString) + + " \u27a4 " + + (upgradable?.NewVersionString ?? package.NewVersionString); + AsAdminLabel = CoreTools.Translate("Update as administrator"); + InteractiveLabel = CoreTools.Translate("Interactive update"); + SkipHashOrRemoveDataLabel = CoreTools.Translate("Skip hash check"); + CanSkipHashOrRemoveData = caps.CanSkipIntegrityChecks; + } + else + { + MainActionLabel = CoreTools.Translate("Uninstall"); + LabelVersion = CoreTools.Translate("Installed Version") + ":"; + VersionDisplay = installed?.VersionString ?? package.VersionString; + AsAdminLabel = CoreTools.Translate("Uninstall as administrator"); + InteractiveLabel = CoreTools.Translate("Interactive uninstall"); + SkipHashOrRemoveDataLabel = CoreTools.Translate("Uninstall and remove data"); + CanSkipHashOrRemoveData = caps.CanRemoveDataOnUninstall; + } + } + + public async Task LoadDetailsAsync() + { + _ = LoadIconAsync(); + + var details = Package.Details; + if (!details.IsPopulated) + await details.Load(); + + IsLoading = false; + + Description = details.Description ?? CoreTools.Translate("Not available"); + HomepageText = details.HomepageUrl?.ToString() ?? CoreTools.Translate("Not available"); + HasHomepageUrl = details.HomepageUrl is not null; + Author = details.Author ?? CoreTools.Translate("Not available"); + Publisher = details.Publisher ?? CoreTools.Translate("Not available"); + + if (details.License is not null && details.LicenseUrl is not null) + LicenseText = $"{details.License} ({details.LicenseUrl})"; + else if (details.License is not null) + LicenseText = details.License; + else if (details.LicenseUrl is not null) + LicenseText = details.LicenseUrl.ToString(); + else + LicenseText = CoreTools.Translate("Not available"); + HasLicenseUrl = details.LicenseUrl is not null; + + ManifestText = details.ManifestUrl?.ToString() ?? CoreTools.Translate("Not available"); + HasManifestUrl = details.ManifestUrl is not null; + + if (Package.Manager.Properties.Name.Equals("chocolatey", StringComparison.OrdinalIgnoreCase)) + InstallerHashLabel = CoreTools.Translate("Installer SHA512") + ":"; + + InstallerHash = details.InstallerHash ?? CoreTools.Translate("Not available"); + InstallerType = details.InstallerType ?? CoreTools.Translate("Not available"); + InstallerUrlText = details.InstallerUrl?.ToString() ?? CoreTools.Translate("Not available"); + HasInstallerUrl = details.InstallerUrl is not null; + InstallerSize = details.InstallerSize > 0 + ? CoreTools.FormatAsSize(details.InstallerSize, 2) + : CoreTools.Translate("Unknown size"); + UpdateDate = details.UpdateDate ?? CoreTools.Translate("Not available"); + + ReleaseNotes = details.ReleaseNotes ?? CoreTools.Translate("Not available"); + ReleaseNotesUrlText = details.ReleaseNotesUrl?.ToString() ?? CoreTools.Translate("Not available"); + HasReleaseNotesUrl = details.ReleaseNotesUrl is not null; + + if (!CanListDependencies) + { + DependencyNote = CoreTools.Translate("Not available"); + HasDependencyNote = true; + } + else if (details.Dependencies.Any()) + { + HasDependencyNote = false; + Dependencies.Clear(); + foreach (var dep in details.Dependencies) + Dependencies.Add(new DependencyViewModel(dep)); + } + else + { + DependencyNote = CoreTools.Translate("No dependencies specified"); + HasDependencyNote = true; + } + + Tags.Clear(); + foreach (var tag in details.Tags) + Tags.Add(tag); + TagCount = Tags.Count; + } + + private async Task LoadIconAsync() + { + try + { + var iconUrl = await Task.Run(Package.GetIconUrl); + if (iconUrl is not null) + { + using var http = new HttpClient(); + var bytes = await http.GetByteArrayAsync(iconUrl); + using var ms = new MemoryStream(bytes); + PackageIcon = new Bitmap(ms); + return; + } + } + catch { /* icon is optional */ } + + try + { + using var stream = AssetLoader.Open( + new Uri("avares://UniGetUI.Avalonia/Assets/package_color.png")); + PackageIcon = new Bitmap(stream); + } + catch { } + } + + [RelayCommand] + private void OpenUrl(string? url) + { + if (string.IsNullOrEmpty(url) || !url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + return; + try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } + catch { } + } + + public void RequestClose() => CloseRequested?.Invoke(this, EventArgs.Empty); +} + +public class DependencyViewModel +{ + public string DisplayText { get; } + + public DependencyViewModel(IPackageDetails.Dependency dep) + { + var text = $" \u2022 {dep.Name}"; + if (!string.IsNullOrEmpty(dep.Version)) + text += $" v{dep.Version}"; + text += dep.Mandatory + ? $" ({CoreTools.Translate("mandatory")})" + : $" ({CoreTools.Translate("optional")})"; + DisplayText = text; + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000000..8c66fcde45 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,346 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using UniGetUI.Avalonia.Infrastructure; +using UniGetUI.Avalonia.ViewModels.Pages; +using UniGetUI.Avalonia.Views; +using UniGetUI.Avalonia.Views.Pages; +using UniGetUI.Avalonia.Views.Pages.SettingsPages; +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.PackageLoader; + +namespace UniGetUI.Avalonia.ViewModels; + +public partial class MainWindowViewModel : ViewModelBase +{ + // ─── Pages ─────────────────────────────────────────────────────────────── + private readonly DiscoverSoftwarePage DiscoverPage; + private readonly SoftwareUpdatesPage UpdatesPage; + private readonly InstalledPackagesPage InstalledPage; + private readonly PackageBundlesPage BundlesPage; + private SettingsBasePage? SettingsPage; + private SettingsBasePage? ManagersPage; + private UniGetUILogPage? UniGetUILogPage; + private ManagerLogsPage? ManagerLogPage; + private OperationHistoryPage? OperationHistoryPage; + private HelpPage? HelpPage; + + // ─── Navigation state ──────────────────────────────────────────────────── + private PageType _oldPage = PageType.Null; + private PageType _currentPage = PageType.Null; + public PageType CurrentPage_t => _currentPage; + private readonly List NavigationHistory = new(); + + [ObservableProperty] + private object? _currentPageContent; + + public event EventHandler? CanGoBackChanged; + public event EventHandler? CurrentPageChanged; + + // ─── Operations panel ───────────────────────────────────────────────────── + public ObservableCollection Operations + => AvaloniaOperationRegistry.OperationViewModels; + + [ObservableProperty] + private bool _operationsPanelVisible; + + // ─── Sidebar ───────────────────────────────────────────────────────────── + public SidebarViewModel Sidebar { get; } = new(); + + // ─── Global search ─────────────────────────────────────────────────────── + [ObservableProperty] + private string _globalSearchText = ""; + + [ObservableProperty] + private bool _globalSearchEnabled; + + [ObservableProperty] + private string _globalSearchPlaceholder = ""; + + // When search text changes, notify the current page + private PackagesPageViewModel? _subscribedPageViewModel; + private bool _syncingSearch; + + partial void OnGlobalSearchTextChanged(string value) + { + if (_syncingSearch) return; + if (CurrentPageContent is AbstractPackagesPage page) + page.ViewModel.GlobalQueryText = value; + } + + private void SubscribeToPageViewModel(AbstractPackagesPage? page) + { + if (_subscribedPageViewModel is not null) + _subscribedPageViewModel.PropertyChanged -= OnPageViewModelPropertyChanged; + + _subscribedPageViewModel = page?.ViewModel; + + if (_subscribedPageViewModel is not null) + _subscribedPageViewModel.PropertyChanged += OnPageViewModelPropertyChanged; + } + + private void OnPageViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(PackagesPageViewModel.GlobalQueryText) && sender is PackagesPageViewModel vm) + { + _syncingSearch = true; + GlobalSearchText = vm.GlobalQueryText; + _syncingSearch = false; + } + } + + // ─── Banners ───────────────────────────────────────────────────────────── + [ObservableProperty] + private bool _updatesBannerVisible; + + [ObservableProperty] + private string _updatesBannerText = ""; + + [ObservableProperty] + private bool _errorBannerVisible; + + [ObservableProperty] + private string _errorBannerText = ""; + + [ObservableProperty] + private bool _winGetWarningBannerVisible; + + [ObservableProperty] + private string _winGetWarningBannerText = ""; + + [ObservableProperty] + private bool _telemetryWarnerVisible; + + // ─── Constructor ───────────────────────────────────────────────────────── + public MainWindowViewModel() + { + DiscoverPage = new DiscoverSoftwarePage(); + UpdatesPage = new SoftwareUpdatesPage(); + InstalledPage = new InstalledPackagesPage(); + BundlesPage = new PackageBundlesPage(); + + // Wire loader status → sidebar badges (loaders are null until package engine initializes) + foreach (var (pageType, loader) in new (PageType, AbstractPackageLoader?)[] + { + (PageType.Discover, DiscoverablePackagesLoader.Instance), + (PageType.Updates, UpgradablePackagesLoader.Instance), + (PageType.Installed, InstalledPackagesLoader.Instance), + }) + { + if (loader is null) continue; + var pt = pageType; + loader.FinishedLoading += (_, _) => + Dispatcher.UIThread.Post(() => Sidebar.SetNavItemLoading(pt, false)); + loader.StartedLoading += (_, _) => + Dispatcher.UIThread.Post(() => Sidebar.SetNavItemLoading(pt, true)); + Sidebar.SetNavItemLoading(pt, loader.IsLoading); + } + + if (UpgradablePackagesLoader.Instance is { } upgLoader) + { + upgLoader.PackagesChanged += (_, _) => + Dispatcher.UIThread.Post(() => + Sidebar.UpdatesBadgeCount = upgLoader.Count()); + Sidebar.UpdatesBadgeCount = upgLoader.Count(); + + upgLoader.FinishedLoading += (_, _) => + { + var upgradable = upgLoader.Packages.ToList(); + if (upgradable.Count == 0) return; + WindowsAppNotificationBridge.ShowUpdatesAvailableNotification(upgradable); + MacOsNotificationBridge.ShowUpdatesAvailableNotification(upgradable); + }; + } + + BundlesPage.UnsavedChangesStateChanged += (_, _) => + Dispatcher.UIThread.Post(() => + Sidebar.BundlesBadgeVisible = BundlesPage.HasUnsavedChanges); + Sidebar.BundlesBadgeVisible = BundlesPage.HasUnsavedChanges; + + Sidebar.NavigationRequested += (_, pageType) => NavigateTo(pageType); + + // Keep OperationsPanelVisible in sync with the live operations list + Operations.CollectionChanged += (_, _) => + OperationsPanelVisible = Operations.Count > 0; + + if (CoreTools.IsAdministrator() && !Settings.Get(Settings.K.AlreadyWarnedAboutAdmin)) + { + Settings.Set(Settings.K.AlreadyWarnedAboutAdmin, true); + // TODO: _ = DialogHelper.WarnAboutAdminRights(); + } + + if (!Settings.Get(Settings.K.ShownTelemetryBanner)) + { + // TODO: DialogHelper.ShowTelemetryBanner(); + } + + LoadDefaultPage(); + } + + // ─── Navigation ────────────────────────────────────────────────────────── + public void LoadDefaultPage() + { + PageType type = Settings.GetValue(Settings.K.StartupPage) switch + { + "discover" => PageType.Discover, + "updates" => PageType.Updates, + "installed" => PageType.Installed, + "bundles" => PageType.Bundles, + "settings" => PageType.Settings, + _ => UpgradablePackagesLoader.Instance?.Count() > 0 ? PageType.Updates : PageType.Discover, + }; + NavigateTo(type); + } + + private Control GetPageForType(PageType type) => + type switch + { + PageType.Discover => DiscoverPage, + PageType.Updates => UpdatesPage, + PageType.Installed => InstalledPage, + PageType.Bundles => BundlesPage, + PageType.Settings => SettingsPage ??= new SettingsBasePage(false), + PageType.Managers => ManagersPage ??= new SettingsBasePage(true), + PageType.OwnLog => UniGetUILogPage ??= new UniGetUILogPage(), + PageType.ManagerLog => ManagerLogPage ??= new ManagerLogsPage(), + PageType.OperationHistory => OperationHistoryPage ??= new OperationHistoryPage(), + PageType.Help => HelpPage ??= new HelpPage(), + PageType.Null => throw new InvalidOperationException("Page type is Null"), + _ => throw new InvalidDataException($"Unknown page type {type}"), + }; + + public static PageType GetNextPage(PageType type) => + type switch + { + PageType.Discover => PageType.Updates, + PageType.Updates => PageType.Installed, + PageType.Installed => PageType.Bundles, + PageType.Bundles => PageType.Settings, + PageType.Settings => PageType.Managers, + PageType.Managers => PageType.Discover, + _ => PageType.Discover, + }; + + public static PageType GetPreviousPage(PageType type) => + type switch + { + PageType.Discover => PageType.Managers, + PageType.Updates => PageType.Discover, + PageType.Installed => PageType.Updates, + PageType.Bundles => PageType.Installed, + PageType.Settings => PageType.Bundles, + PageType.Managers => PageType.Settings, + _ => PageType.Discover, + }; + + public void NavigateTo(PageType newPage_t, bool toHistory = true) + { + if (newPage_t is PageType.About) { _ = ShowAboutDialog(); return; } + if (newPage_t is PageType.Quit) { (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); return; } + if (newPage_t is PageType.ReleaseNotes) { /* TODO: DialogHelper.ShowReleaseNotes(); */ return; } + + Sidebar.SelectNavButtonForPage(newPage_t); + + if (_currentPage == newPage_t) return; + + var newPage = GetPageForType(newPage_t); + var oldPage = CurrentPageContent as Control; + + if (oldPage is ISearchBoxPage oldSPage) + oldSPage.QueryBackup = GlobalSearchText; + (oldPage as IEnterLeaveListener)?.OnLeave(); + + CurrentPageContent = newPage; + _oldPage = _currentPage; + _currentPage = newPage_t; + + if (toHistory && _oldPage is not PageType.Null) + { + NavigationHistory.Add(_oldPage); + CanGoBackChanged?.Invoke(this, true); + } + + (newPage as AbstractPackagesPage)?.FocusPackageList(); + (newPage as AbstractPackagesPage)?.FilterPackages(); + (newPage as IEnterLeaveListener)?.OnEnter(); + + if (newPage is ISearchBoxPage newSPage) + { + SubscribeToPageViewModel(newPage as AbstractPackagesPage); + GlobalSearchText = newSPage.QueryBackup; + GlobalSearchPlaceholder = newSPage.SearchBoxPlaceholder; + GlobalSearchEnabled = true; + } + else + { + SubscribeToPageViewModel(null); + GlobalSearchText = ""; + GlobalSearchPlaceholder = ""; + GlobalSearchEnabled = false; + } + + CurrentPageChanged?.Invoke(this, newPage_t); + } + + public void NavigateBack() + { + if (CurrentPageContent is IInnerNavigationPage navPage && navPage.CanGoBack()) + { + navPage.GoBack(); + } + else if (NavigationHistory.Count > 0) + { + NavigateTo(NavigationHistory.Last(), toHistory: false); + NavigationHistory.RemoveAt(NavigationHistory.Count - 1); + CanGoBackChanged?.Invoke(this, + NavigationHistory.Count > 0 + || ((CurrentPageContent as IInnerNavigationPage)?.CanGoBack() ?? false)); + } + } + + public void OpenManagerLogs(IPackageManager? manager = null) + { + NavigateTo(PageType.ManagerLog); + if (manager is not null) ManagerLogPage?.LoadForManager(manager); + } + + public void OpenManagerSettings(IPackageManager? manager = null) + { + NavigateTo(PageType.Managers); + if (manager is not null) ManagersPage?.NavigateTo(manager); + } + + public void OpenSettingsPage(Type page) + { + NavigateTo(PageType.Settings); + SettingsPage?.NavigateTo(page); + } + + public void ShowHelp(string uriAttachment = "") + { + NavigateTo(PageType.Help); + HelpPage?.NavigateTo(uriAttachment); + } + + private async Task ShowAboutDialog() + { + Sidebar.SelectNavButtonForPage(PageType.Null); + // TODO: await DialogHelper.ShowAboutUniGetUI(); + Sidebar.SelectNavButtonForPage(_currentPage); + } + + // ─── Search box ────────────────────────────────────────────────────────── + public void SubmitGlobalSearch() + { + if (CurrentPageContent is ISearchBoxPage page) + page.SearchBox_QuerySubmitted(this, EventArgs.Empty); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AdministratorViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AdministratorViewModel.cs new file mode 100644 index 0000000000..1bdafbb405 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/AdministratorViewModel.cs @@ -0,0 +1,12 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using UniGetUI.Avalonia.ViewModels; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; + +public partial class AdministratorViewModel : ViewModelBase +{ + /// + /// True when elevation is NOT prohibited — controls enabled-state of the cache-admin-rights cards. + /// + [ObservableProperty] private bool _isElevationEnabled = true; +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/BackupViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/BackupViewModel.cs new file mode 100644 index 0000000000..354b7ca9ad --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/BackupViewModel.cs @@ -0,0 +1,46 @@ +using global::Avalonia; +using global::Avalonia.Controls; +using global::Avalonia.Platform.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Core.Data; +using CoreSettings = global::UniGetUI.Core.SettingsEngine.Settings; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; + +public partial class BackupViewModel : ViewModelBase +{ + [ObservableProperty] private bool _isLocalBackupEnabled; + [ObservableProperty] private string _backupDirectoryLabel = ""; + + public BackupViewModel() => RefreshDirectoryLabel(); + + private void RefreshDirectoryLabel() + { + string dir = CoreSettings.GetValue(CoreSettings.K.ChangeBackupOutputDirectory); + BackupDirectoryLabel = string.IsNullOrEmpty(dir) ? CoreData.UniGetUI_DefaultBackupDirectory : dir; + } + + [RelayCommand] + private async Task PickBackupDirectory(Visual? visual) + { + if (visual is null || TopLevel.GetTopLevel(visual) is not { } topLevel) return; + var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + AllowMultiple = false, + }); + if (folders is not [{ } folder]) return; + var path = folder.TryGetLocalPath(); + if (path is null) return; + CoreSettings.SetValue(CoreSettings.K.ChangeBackupOutputDirectory, path); + RefreshDirectoryLabel(); + } + + [RelayCommand] + private static async Task DoLocalBackup(Visual? _) + { + // TODO: wire up to InstalledPackagesPage.BackupPackages_LOCAL() when available + await Task.CompletedTask; + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/ExperimentalViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/ExperimentalViewModel.cs new file mode 100644 index 0000000000..b7bb36ce7d --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/ExperimentalViewModel.cs @@ -0,0 +1,7 @@ +using UniGetUI.Avalonia.ViewModels; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; + +public partial class ExperimentalViewModel : ViewModelBase +{ +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/GeneralViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/GeneralViewModel.cs new file mode 100644 index 0000000000..454b11427e --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/GeneralViewModel.cs @@ -0,0 +1,68 @@ +using global::Avalonia; +using global::Avalonia.Controls; +using global::Avalonia.Platform.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Avalonia.Views.DialogPages; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; +using CoreSettings = global::UniGetUI.Core.SettingsEngine.Settings; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; + +public partial class GeneralViewModel : ViewModelBase +{ + [RelayCommand] + private async Task ShowTelemetryDialog(Visual? visual) + { + if (visual is null || TopLevel.GetTopLevel(visual) is not Window owner) return; + var dialog = new TelemetryDialog(); + await dialog.ShowDialog(owner); + if (dialog.Result.HasValue) + CoreSettings.Set(CoreSettings.K.DisableTelemetry, !dialog.Result.Value); + } + + [RelayCommand] + private async Task ImportSettings(Visual? visual) + { + if (visual is null || TopLevel.GetTopLevel(visual) is not { } topLevel) return; + var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + AllowMultiple = false, + FileTypeFilter = [new FilePickerFileType("Settings JSON") { Patterns = ["*.json"] }], + }); + if (files is not [{ } file]) return; + var path = file.TryGetLocalPath(); + if (path is null) return; + await Task.Run(() => CoreSettings.ImportFromFile_JSON(path)); + OnRestartRequired(); + } + + [RelayCommand] + private async Task ExportSettings(Visual? visual) + { + if (visual is null || TopLevel.GetTopLevel(visual) is not { } topLevel) return; + var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + SuggestedFileName = CoreTools.Translate("WingetUI Settings") + ".json", + FileTypeChoices = [new FilePickerFileType("Settings JSON") { Patterns = ["*.json"] }], + }); + if (file is null) return; + var path = file.TryGetLocalPath(); + if (path is null) return; + try { await Task.Run(() => CoreSettings.ExportToFile_JSON(path)); } + catch (Exception ex) { Logger.Error(ex); } + } + + [RelayCommand] + private void ResetSettings(Visual? _) + { + try { CoreSettings.ResetSettings(); } + catch (Exception ex) { Logger.Error(ex); } + OnRestartRequired(); + } + + public event EventHandler? RestartRequired; + private void OnRestartRequired() => RestartRequired?.Invoke(this, EventArgs.Empty); +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/Interface_PViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/Interface_PViewModel.cs new file mode 100644 index 0000000000..2a7f9e274e --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/Interface_PViewModel.cs @@ -0,0 +1,39 @@ +using global::Avalonia; +using global::Avalonia.Controls; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; + +public partial class Interface_PViewModel : ViewModelBase +{ + [ObservableProperty] private string _iconCacheSizeText = ""; + + public event EventHandler? RestartRequired; + + [RelayCommand] + private void EditAutostartSettings() + => CoreTools.Launch("ms-settings:startupapps"); + + [RelayCommand] + private async Task ResetIconCache(Visual? _) + { + try { Directory.Delete(CoreData.UniGetUICacheDirectory_Icons, true); } + catch (Exception ex) { Logger.Error(ex); } + RestartRequired?.Invoke(this, EventArgs.Empty); + await LoadIconCacheSize(); + } + + public async Task LoadIconCacheSize() + { + double realSize = (await Task.Run(() => + Directory.GetFiles(CoreData.UniGetUICacheDirectory_Icons, "*", SearchOption.AllDirectories) + .Sum(f => new FileInfo(f).Length))) / 1048576d; + double rounded = ((int)(realSize * 100)) / 100d; + IconCacheSizeText = CoreTools.Translate("The local icon cache currently takes {0} MB", rounded); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/InternetViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/InternetViewModel.cs new file mode 100644 index 0000000000..c9ee454a1b --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/InternetViewModel.cs @@ -0,0 +1,36 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using UniGetUI.Avalonia.ViewModels; +using CoreSettings = global::UniGetUI.Core.SettingsEngine.Settings; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; + +public partial class InternetViewModel : ViewModelBase +{ + [ObservableProperty] private bool _isProxyEnabled; + [ObservableProperty] private bool _isProxyAuthEnabled; + + public static void ApplyProxyToProcess() + { + var proxyUri = CoreSettings.GetProxyUrl(); + if (proxyUri is null || !CoreSettings.Get(CoreSettings.K.EnableProxy)) + { + Environment.SetEnvironmentVariable("HTTP_PROXY", "", EnvironmentVariableTarget.Process); + Environment.SetEnvironmentVariable("HTTPS_PROXY", "", EnvironmentVariableTarget.Process); + return; + } + string content; + if (!CoreSettings.Get(CoreSettings.K.EnableProxyAuth)) + { + content = proxyUri.ToString(); + } + else + { + var creds = CoreSettings.GetProxyCredentials(); + content = creds is not null + ? $"{proxyUri.Scheme}://{creds.UserName}:{creds.Password}@{proxyUri.Host}:{proxyUri.Port}" + : proxyUri.ToString(); + } + Environment.SetEnvironmentVariable("HTTP_PROXY", content, EnvironmentVariableTarget.Process); + Environment.SetEnvironmentVariable("HTTPS_PROXY", content, EnvironmentVariableTarget.Process); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/ManagersHomepageViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/ManagersHomepageViewModel.cs new file mode 100644 index 0000000000..1c05c6caad --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/ManagersHomepageViewModel.cs @@ -0,0 +1,11 @@ +using System.Collections.ObjectModel; +using UniGetUI.Avalonia.ViewModels; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; + +public record ManagerButtonInfo(string DisplayName, string StatusText); + +public partial class ManagersHomepageViewModel : ViewModelBase +{ + public ObservableCollection Managers { get; } = new(); +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/NotificationsViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/NotificationsViewModel.cs new file mode 100644 index 0000000000..2e56f1003d --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/NotificationsViewModel.cs @@ -0,0 +1,10 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using UniGetUI.Avalonia.ViewModels; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; + +public partial class NotificationsViewModel : ViewModelBase +{ + [ObservableProperty] private bool _isSystemTrayEnabled = true; + [ObservableProperty] private bool _isNotificationsEnabled; +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/OperationsViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/OperationsViewModel.cs new file mode 100644 index 0000000000..21162b9236 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/OperationsViewModel.cs @@ -0,0 +1,7 @@ +using UniGetUI.Avalonia.ViewModels; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; + +public partial class OperationsViewModel : ViewModelBase +{ +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/SettingsBasePageViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/SettingsBasePageViewModel.cs new file mode 100644 index 0000000000..e47bc0e402 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/SettingsBasePageViewModel.cs @@ -0,0 +1,14 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; + +public partial class SettingsBasePageViewModel : ViewModelBase +{ + [ObservableProperty] private string _title = ""; + [ObservableProperty] private bool _isRestartBannerVisible; + + public string RestartBannerText => CoreTools.Translate("Restart UniGetUI to fully apply changes"); + public string RestartButtonText => CoreTools.Translate("Restart UniGetUI"); +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/SettingsHomepageViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/SettingsHomepageViewModel.cs new file mode 100644 index 0000000000..b7ac975278 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/SettingsHomepageViewModel.cs @@ -0,0 +1,7 @@ +using UniGetUI.Avalonia.ViewModels; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; + +public partial class SettingsHomepageViewModel : ViewModelBase +{ +} diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/UpdatesViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/UpdatesViewModel.cs new file mode 100644 index 0000000000..f9f90ab567 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/UpdatesViewModel.cs @@ -0,0 +1,9 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using UniGetUI.Avalonia.ViewModels; + +namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; + +public partial class UpdatesViewModel : ViewModelBase +{ + [ObservableProperty] private bool _isAutoCheckEnabled; +} diff --git a/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs new file mode 100644 index 0000000000..57c0b37d4f --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs @@ -0,0 +1,54 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using UniGetUI.Avalonia.Views; + +namespace UniGetUI.Avalonia.ViewModels; + +public partial class SidebarViewModel : ViewModelBase +{ + // ─── Badge properties ───────────────────────────────────────────────────── + [ObservableProperty] + private int _updatesBadgeCount; + + [ObservableProperty] + private bool _updatesBadgeVisible; + + [ObservableProperty] + private bool _bundlesBadgeVisible; + + // When the count changes, sync the badge visibility + partial void OnUpdatesBadgeCountChanged(int value) => + UpdatesBadgeVisible = value > 0; + + // ─── Loading indicators ─────────────────────────────────────────────────── + [ObservableProperty] + private bool _discoverIsLoading; + + [ObservableProperty] + private bool _updatesIsLoading; + + [ObservableProperty] + private bool _installedIsLoading; + + // ─── Selected page ──────────────────────────────────────────────────────── + [ObservableProperty] + private PageType _selectedPageType = PageType.Null; + + // ─── Navigation ────────────────────────────────────────────────────────── + public event EventHandler? NavigationRequested; + + public void RequestNavigation(PageType page) => + NavigationRequested?.Invoke(this, page); + + public void SelectNavButtonForPage(PageType page) => + SelectedPageType = page; + + public void SetNavItemLoading(PageType page, bool isLoading) + { + switch (page) + { + case PageType.Discover: DiscoverIsLoading = isLoading; break; + case PageType.Updates: UpdatesIsLoading = isLoading; break; + case PageType.Installed: InstalledIsLoading = isLoading; break; + } + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs new file mode 100644 index 0000000000..567499bb15 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs @@ -0,0 +1,657 @@ +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Collections.ObjectModel; +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.PackageLoader; +using UniGetUI.PackageEngine.Operations; +using Avalonia.Threading; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Avalonia.Infrastructure; + +namespace UniGetUI.Avalonia.ViewModels.Pages; + +public enum SearchMode { Both, Name, Id, Exact, Similar } + +public enum ReloadReason +{ + FirstRun, + Automated, + Manual, + External, +} + +public struct PackagesPageData +{ + public bool DisableAutomaticPackageLoadOnStart; + public bool MegaQueryBlockEnabled; + public bool PackagesAreCheckedByDefault; + public bool ShowLastLoadTime; + public bool DisableSuggestedResultsRadio; + public bool DisableFilterOnQueryChange; + public bool DisableReload; + + public OperationType PageRole; + public AbstractPackageLoader Loader; + + public string PageName; + public string PageTitle; + public string IconName; // SVG filename without extension, e.g. "search" + + public string NoPackages_BackgroundText; + public string NoPackages_SourcesText; + public string NoPackages_SubtitleText_Base; + public string MainSubtitle_StillLoading; + public string NoMatches_BackgroundText; +} + +/// +/// Represents a node in the sources tree (replaces WinUI TreeViewNode). +/// +public class SourceTreeNode : INotifyPropertyChanged +{ + public string PackageName { get; set; } + public string PackageID { get; init; } + public string Version { get; init; } + public string Source { get; init; } + public List Children { get; } = []; + + public event PropertyChangedEventHandler? PropertyChanged; + + private bool _isSelected; + public bool IsSelected + { + get => _isSelected; + set { _isSelected = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSelected))); } + } + + private bool _isExpanded; + public bool IsExpanded + { + get => _isExpanded; + set { _isExpanded = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsExpanded))); } + } +} + +public partial class PackagesPageViewModel : ViewModelBase +{ + public double FilterPaneColumnWidth => IsFilterPaneOpen ? 220.0 : 0.0; + partial void OnIsFilterPaneOpenChanged(bool _) => OnPropertyChanged(nameof(FilterPaneColumnWidth)); + + // ─── Static config (set once in constructor) ────────────────────────────── + public readonly string PageName; + public readonly bool MegaQueryBoxEnabled; + public readonly bool DisableFilterOnQueryChange; + public readonly bool DisableReload; + public readonly bool RoleIsUpdateLike; + public bool SimilarSearchEnabled { get; private set; } + public readonly string NoPackagesText; + public readonly string NoMatchesText; + public readonly string SearchBoxPlaceholder; + private readonly string _noPackagesSubtitleBase; + private readonly string _stillLoadingSubtitle; + private readonly bool _showLastCheckedTime; + private DateTime _lastLoadTime = DateTime.Now; + + protected AbstractPackageLoader Loader; + + // ─── Observable properties ──────────────────────────────────────────────── + [ObservableProperty] private string _pageTitle = ""; + [ObservableProperty] private string _pageIconPath = ""; + [ObservableProperty] private string _subtitle = ""; + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private bool _backgroundTextVisible; + [ObservableProperty] private string _backgroundText = ""; + [ObservableProperty] private bool _sourcesPlaceholderVisible = true; + [ObservableProperty] private bool _sourcesTreeVisible; + [ObservableProperty] private bool _megaQueryVisible; + [ObservableProperty] private string _megaQueryText = ""; + [ObservableProperty] private string _globalQueryText = ""; + [ObservableProperty] private bool _newVersionHeaderVisible; + [ObservableProperty] private bool _reloadButtonVisible; + [ObservableProperty] private bool _isFilterPaneOpen; + [ObservableProperty] private int _viewMode; + [ObservableProperty] private int _sortFieldIndex; + [ObservableProperty] private bool _sortAscending = true; + [ObservableProperty] private bool _instantSearch = true; + [ObservableProperty] private bool _upperLowerCase; + [ObservableProperty] private bool _ignoreSpecialChars = true; + [ObservableProperty] private SearchMode _searchMode = SearchMode.Both; + [ObservableProperty] private bool? _allPackagesChecked; + [ObservableProperty] private string _nameHeaderText = ""; + [ObservableProperty] private string _idHeaderText = ""; + [ObservableProperty] private string _versionHeaderText = ""; + [ObservableProperty] private string _newVersionHeaderText = ""; + [ObservableProperty] private string _sourceHeaderText = ""; + + // ─── Collections ────────────────────────────────────────────────────────── + public ObservablePackageCollection FilteredPackages { get; } = new(); + public ObservableCollection SourceNodes { get; } = new(); + public ObservableCollection ToolBarItems { get; } = new(); + + + // ─── Internal state ─────────────────────────────────────────────────────── + private string _searchQuery = ""; + public string QueryBackup { get; set; } = ""; + + private readonly ObservableCollection _wrappedPackages = new(); + protected List UsedManagers = []; + protected ConcurrentDictionary> UsedSourcesForManager = new(); + protected ConcurrentDictionary RootNodeForManager = new(); + protected ConcurrentDictionary NodesForSources = new(); + private readonly SourceTreeNode _localPackagesNode = new(){PackageName = "local"}; + + // ─── Events (replace abstract methods) ─────────────────────────────────── + public event Action? PackagesLoaded; + public event Action? PackageCountUpdated; + public event Action? ShowingContextMenu; + public event Action? FocusListRequested; + + // ─── Constructor ───────────────────────────────────────────────────────── + public PackagesPageViewModel(PackagesPageData data) + { + PageName = data.PageName; + PageTitle = data.PageTitle; + PageIconPath = $"avares://UniGetUI.Avalonia/Assets/Symbols/{data.IconName}.svg"; + DisableFilterOnQueryChange = data.DisableFilterOnQueryChange; + MegaQueryBoxEnabled = data.MegaQueryBlockEnabled; + DisableReload = data.DisableReload; + _showLastCheckedTime = data.ShowLastLoadTime; + NoPackagesText = data.NoPackages_BackgroundText; + NoMatchesText = data.NoMatches_BackgroundText; + _noPackagesSubtitleBase = data.NoPackages_SubtitleText_Base; + _stillLoadingSubtitle = data.MainSubtitle_StillLoading; + SimilarSearchEnabled = !data.DisableSuggestedResultsRadio; + RoleIsUpdateLike = data.PageRole == OperationType.Update; + NewVersionHeaderVisible = RoleIsUpdateLike; + ReloadButtonVisible = !DisableReload; + SearchBoxPlaceholder = CoreTools.Translate("Search for packages"); + + AllPackagesChecked = data.PackagesAreCheckedByDefault; + + Loader = data.Loader; + Loader.StartedLoading += Loader_StartedLoading; + Loader.FinishedLoading += Loader_FinishedLoading; + Loader.PackagesChanged += Loader_PackagesChanged; + + _wrappedPackages.CollectionChanged += (_, _) => { /* invalidate query cache if needed */ }; + + InstantSearch = !Settings.GetDictionaryItem(Settings.K.DisableInstantSearch, PageName); + + ViewMode = Settings.GetDictionaryItem(Settings.K.PackageListViewMode, PageName); + if (ViewMode < 0 || ViewMode > 2) ViewMode = 0; + + _localPackagesNode.PackageName = CoreTools.Translate("Local"); + + if (Loader.IsLoading) + Loader_StartedLoading(this, EventArgs.Empty); + else + { + Loader_FinishedLoading(this, EventArgs.Empty); + FilterPackages(); + } + Loader_PackagesChanged(this, new(false, [], [])); + + UpdateHeaderTexts(); + + if (MegaQueryBoxEnabled) + { + MegaQueryVisible = true; + BackgroundTextVisible = false; + } + + // Toolbar is generated by the View after construction (see AbstractPackagesPage ctor) + } + + // ─── Loader events ──────────────────────────────────────────────────────── + private void Loader_PackagesChanged(object? sender, PackagesChangedEvent e) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(() => Loader_PackagesChanged(sender, e)); + return; + } + + if (e.ProceduralChange) + { + foreach (var pkg in e.AddedPackages) + { + if (_wrappedPackages.Any(w => w.Package.Equals(pkg))) continue; + _wrappedPackages.Add(new PackageWrapper(pkg, this)); + AddPackageToSourcesList(pkg); + } + var toRemove = _wrappedPackages.Where(w => e.RemovedPackages.Contains(w.Package)).ToList(); + foreach (var wrapper in toRemove) { wrapper.Dispose(); _wrappedPackages.Remove(wrapper); } + } + else + { + foreach (var w in _wrappedPackages) w.Dispose(); + _wrappedPackages.Clear(); + ClearSourcesList(); + foreach (var pkg in Loader.Packages) + { + _wrappedPackages.Add(new PackageWrapper(pkg, this)); + AddPackageToSourcesList(pkg); + } + } + FilterPackages(); + } + + private void Loader_FinishedLoading(object? sender, EventArgs e) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(() => Loader_FinishedLoading(sender, e)); + return; + } + IsLoading = false; + _lastLoadTime = DateTime.Now; + FilterPackages(); + PackagesLoaded?.Invoke(ReloadReason.External); + } + + private void Loader_StartedLoading(object? sender, EventArgs e) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(() => Loader_StartedLoading(sender, e)); + return; + } + IsLoading = true; + UpdateSubtitle(); + } + + // ─── Search & filter ────────────────────────────────────────────────────── + partial void OnGlobalQueryTextChanged(string value) + { + _searchQuery = value; + if (MegaQueryBoxEnabled) + { + if (string.IsNullOrEmpty(value)) + { + MegaQueryText = ""; + MegaQueryVisible = true; + Loader?.ClearPackages(emitFinishSignal: false); + } + else + { + MegaQueryText = value; + MegaQueryVisible = false; + } + return; + } + if (!DisableFilterOnQueryChange && InstantSearch) + FilterPackages(); + } + + [RelayCommand] + public void SubmitSearch() + { + string query = _searchQuery = GlobalQueryText = MegaQueryText.Trim(); + MegaQueryVisible = false; + + if (Loader is DiscoverablePackagesLoader discoverLoader) + { + Loader.ClearPackages(emitFinishSignal: false); + _ = discoverLoader.ReloadPackages(query); + } + else + { + FilterPackages(fromQuery: true); + } + } + + public void FilterPackages(bool fromQuery = false) + { + var filters = new List>(); + if (!UpperLowerCase) filters.Add(FilterHelpers.NormalizeCase); + if (IgnoreSpecialChars) filters.Add(FilterHelpers.NormalizeSpecialCharacters); + + string query = _searchQuery; + foreach (var f in filters) query = f(query); + + Func matchFunc = SearchMode switch + { + SearchMode.Name => pkg => FilterHelpers.NameContains(pkg, query, filters), + SearchMode.Id => pkg => FilterHelpers.IdContains(pkg, query, filters), + SearchMode.Exact => pkg => FilterHelpers.NameOrIdExactMatch(pkg, query, filters), + SearchMode.Similar => _ => true, + _ => pkg => FilterHelpers.NameOrIdContains(pkg, query, filters), + }; + + var selectedSources = GetSelectedSourceNodes(); + Func sourceFilter = SourceNodes.Count == 0 + ? _ => true // sources not yet loaded — show everything + : selectedSources.Count == 0 + ? _ => false // sources loaded but none selected — show nothing + : pkg => selectedSources.Any(n => + n.PackageName.TrimEnd('.', ' ') == pkg.Source.Manager.DisplayName + || n.PackageName.TrimEnd('.', ' ') == pkg.Source.Name); + + var results = FilteredPackages.ApplyToList( + _wrappedPackages.Where(w => matchFunc(w.Package) && sourceFilter(w.Package)) + ).ToList(); + + FilteredPackages.Clear(); + foreach (var w in results) FilteredPackages.Add(w); + + UpdateSubtitle(); + PackageCountUpdated?.Invoke(); + + if (FilteredPackages.Count == 0) + { + BackgroundText = string.IsNullOrWhiteSpace(query) ? NoPackagesText : NoMatchesText; + BackgroundTextVisible = !MegaQueryBoxEnabled || !string.IsNullOrWhiteSpace(query); + } + else + { + BackgroundTextVisible = false; + } + } + + // ─── Package loading ────────────────────────────────────────────────────── + public async Task LoadPackages(ReloadReason reason = ReloadReason.External) + { + if (!Loader.IsLoading && (!Loader.IsLoaded + || reason is ReloadReason.External or ReloadReason.Manual or ReloadReason.Automated)) + { + Loader.ClearPackages(emitFinishSignal: false); + await Loader.ReloadPackages(); + } + } + + // ─── Sorting ────────────────────────────────────────────────────────────── + public string SortFieldName => SortFieldIndex switch + { + 1 => "Id", + 2 => "Version", + 3 => "New version", + 4 => "Source", + _ => "Name", + }; + + partial void OnSortFieldIndexChanged(int value) + { + FilteredPackages.SortBy(value switch + { + 1 => ObservablePackageCollection.Sorter.Id, + 2 => ObservablePackageCollection.Sorter.Version, + 3 => ObservablePackageCollection.Sorter.NewVersion, + 4 => ObservablePackageCollection.Sorter.Source, + _ => ObservablePackageCollection.Sorter.Name, + }); + OnPropertyChanged(nameof(SortFieldName)); + FilterPackages(); + } + + partial void OnSortAscendingChanged(bool value) + { + FilteredPackages.SetSortDirection(value); + FilterPackages(); + } + + + // ─── Selection ──────────────────────────────────────────────────────────── + partial void OnInstantSearchChanged(bool value) + => Settings.SetDictionaryItem(Settings.K.DisableInstantSearch, PageName, !value); + + partial void OnUpperLowerCaseChanged(bool value) => FilterPackages(); + partial void OnIgnoreSpecialCharsChanged(bool value) => FilterPackages(); + partial void OnSearchModeChanged(SearchMode value) => FilterPackages(); + + partial void OnAllPackagesCheckedChanged(bool? value) + { + if (value == true) FilteredPackages.SelectAll(); + else if (value == false) FilteredPackages.ClearSelection(); + } + + // ─── Sources ────────────────────────────────────────────────────────────── + public void AddPackageToSourcesList(IPackage package) + { + IManagerSource source = package.Source; + if (!UsedManagers.Contains(source.Manager)) + { + UsedManagers.Add(source.Manager); + var node = new SourceTreeNode + { + PackageName = source.Manager.DisplayName, + PackageID = package.Id, + Version = package.VersionString, + Source = package.Source.Name + }; + + var existing = GetAllSourceNodes(); + if (existing.Count == 0 || existing.Count(n => n.IsSelected) >= existing.Count / 2) + node.IsSelected = true; + + AddRootSourceNode(node); + RootNodeForManager.TryAdd(source.Manager, node); + UsedSourcesForManager.TryAdd(source.Manager, []); + SourcesPlaceholderVisible = false; + SourcesTreeVisible = true; + } + + if ((!UsedSourcesForManager.ContainsKey(source.Manager) + || !UsedSourcesForManager[source.Manager].Contains(source)) + && source.Manager.Capabilities.SupportsCustomSources) + { + UsedSourcesForManager[source.Manager].Add(source); + var item = new SourceTreeNode + { + PackageName = source.Manager.DisplayName, + PackageID = package.Id, + Version = package.VersionString, + Source = package.Source.Name + }; + NodesForSources.TryAdd(source, item); + + if (source.IsVirtualManager) + { + _localPackagesNode.Children.Add(item); + if (!GetAllSourceNodes().Contains(_localPackagesNode)) + { + AddRootSourceNode(_localPackagesNode); + _localPackagesNode.IsSelected = true; + } + } + else + { + RootNodeForManager[source.Manager].Children.Add(item); + } + } + } + + public void ClearSourcesList() + { + foreach (var node in SourceNodes) + node.PropertyChanged -= OnRootSourceNodePropertyChanged; + UsedManagers.Clear(); + SourceNodes.Clear(); + UsedSourcesForManager.Clear(); + RootNodeForManager.Clear(); + NodesForSources.Clear(); + _localPackagesNode.Children.Clear(); + } + + private void AddRootSourceNode(SourceTreeNode node) + { + node.PropertyChanged += OnRootSourceNodePropertyChanged; + SourceNodes.Add(node); + } + + private void OnRootSourceNodePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SourceTreeNode.IsSelected)) + FilterPackages(); + } + private List GetAllSourceNodes() => SourceNodes.ToList(); + private List GetSelectedSourceNodes() => SourceNodes.Where(n => n.IsSelected).ToList(); + + public void SetSourceNodeSelected(SourceTreeNode node, bool selected) => node.IsSelected = selected; + public void ClearSourceSelection() { foreach (var n in SourceNodes) n.IsSelected = false; } + public void SelectAllSources() { foreach (var n in SourceNodes) n.IsSelected = true; } + + // ─── Header texts ───────────────────────────────────────────────────────── + public void UpdateHeaderTexts() + { + bool isList = ViewMode == 0; + NameHeaderText = isList ? CoreTools.Translate("Package Name") : ""; + IdHeaderText = isList ? CoreTools.Translate("Package ID") : ""; + VersionHeaderText = isList ? CoreTools.Translate("Version") : ""; + NewVersionHeaderText = isList ? CoreTools.Translate("New version") : ""; + SourceHeaderText = isList ? CoreTools.Translate("Source") : ""; + } + + public bool IsListViewMode => ViewMode == 0; + public bool IsGridViewMode => ViewMode == 1; + public bool IsIconsViewMode => ViewMode == 2; + + partial void OnViewModeChanged(int value) + { + UpdateHeaderTexts(); + Settings.SetDictionaryItem(Settings.K.PackageListViewMode, PageName, value); + OnPropertyChanged(nameof(IsListViewMode)); + OnPropertyChanged(nameof(IsGridViewMode)); + OnPropertyChanged(nameof(IsIconsViewMode)); + } + + // ─── Package count (called by PackageWrapper.IsChecked setter) ──────────── + public void UpdatePackageCount() + { + UpdateSubtitle(); + PackageCountUpdated?.Invoke(); + } + + // ─── Subtitle ───────────────────────────────────────────────────────────── + public void UpdateSubtitle() + { + if (Loader.IsLoading) + { + Subtitle = _stillLoadingSubtitle; + return; + } + + if (Loader.Any()) + { + int selected = FilteredPackages.GetCheckedPackages().Count; + string r = CoreTools.Translate( + "{0} packages were found, {1} of which match the specified filters.", + FilteredPackages.Count, + _wrappedPackages.Count + ) + " (" + CoreTools.Translate("{0} selected", selected) + ")"; + + if (_showLastCheckedTime) + r += " " + CoreTools.Translate("(Last checked: {0})", _lastLoadTime.ToString(CultureInfo.CurrentCulture)); + + Subtitle = r; + } + else + { + Subtitle = _noPackagesSubtitleBase + (_showLastCheckedTime + ? " " + CoreTools.Translate("(Last checked: {0})", _lastLoadTime.ToString(CultureInfo.CurrentCulture)) + : ""); + } + } + + // ─── Commands ───────────────────────────────────────────────────────────── + [RelayCommand] private async Task Reload() => await LoadPackages(ReloadReason.Manual); + [RelayCommand] private void SelectAllSources_Cmd() { SelectAllSources(); FilterPackages(); } + [RelayCommand] private void ClearSourceSelection_Cmd(){ ClearSourceSelection(); FilterPackages(); } + + + [RelayCommand] + private void SubmitMegaQuery(string query) + { + MegaQueryVisible = false; + _searchQuery = query?.Trim() ?? ""; + FilterPackages(fromQuery: true); + } + + // ─── Keyboard / search-box actions (called by the View's interface impls) ── + public void TriggerReload() + { + if (!DisableReload) + _ = LoadPackages(ReloadReason.Manual); + } + + public void ToggleSelectAll() + { + if (AllPackagesChecked != true) + { + AllPackagesChecked = true; + FilteredPackages.SelectAll(); + } + else + { + AllPackagesChecked = false; + FilteredPackages.ClearSelection(); + } + } + + public void HandleSearchSubmitted() + { + if (MegaQueryBoxEnabled) SubmitSearch(); + else FilterPackages(fromQuery: true); + } + + // ─── Operation launchers ───────────────────────────────────────────────── + public static async Task LaunchInstall( + IEnumerable packages, + bool? elevated = null, + bool? interactive = null, + bool? no_integrity = null) + { + foreach (var pkg in packages) + { + var opts = await InstallOptionsFactory.LoadApplicableAsync( + pkg, elevated: elevated, interactive: interactive, no_integrity: no_integrity); + var op = new InstallPackageOperation(pkg, opts); + AvaloniaOperationRegistry.Add(op); + _ = op.MainThread(); + } + } + + // ─── Focus (triggers view to focus the list) ────────────────────────────── + public void RequestFocusList() => FocusListRequested?.Invoke(); + + // ─── FilterHelpers (inner static class) ────────────────────────────────── + internal static class FilterHelpers + { + public static string NormalizeCase(string input) => input.ToLower(); + + public static string NormalizeSpecialCharacters(string input) + { + input = input.Replace("-","").Replace("_","").Replace(" ","") + .Replace("@","").Replace("\t","").Replace(".","") + .Replace(",","").Replace(":",""); + foreach (var (replacement, chars) in new (char, string)[] + { + ('a',"àáäâ"),('e',"èéëê"),('i',"ìíïî"),('o',"òóöô"), + ('u',"ùúüû"),('y',"ýÿ"),('c',"ç"),('ñ',"n"), + }) + foreach (char c in chars) input = input.Replace(c, replacement); + return input; + } + + public static bool NameContains(IPackage pkg, string q, List> f) + { var n = pkg.Name; foreach (var x in f) n = x(n); return n.Contains(q); } + + public static bool IdContains(IPackage pkg, string q, List> f) + { var id = pkg.Id; foreach (var x in f) id = x(id); return id.Contains(q); } + + public static bool NameOrIdContains(IPackage pkg, string q, List> f) + => NameContains(pkg, q, f) || IdContains(pkg, q, f); + + public static bool NameOrIdExactMatch(IPackage pkg, string q, List> f) + { + var id = pkg.Id; foreach (var x in f) id = x(id); if (q == id) return true; + var n = pkg.Name; foreach (var x in f) n = x(n); return q == n; + } + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/ViewModelBase.cs b/src/UniGetUI.Avalonia/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000000..aca5b14a01 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/ViewModelBase.cs @@ -0,0 +1,11 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace UniGetUI.Avalonia.ViewModels; + +/// +/// Base class for all ViewModels. Inherits ObservableObject from CommunityToolkit.Mvvm, +/// which provides INotifyPropertyChanged, SetProperty, and [ObservableProperty] source-generator support. +/// +public abstract class ViewModelBase : ObservableObject +{ +} diff --git a/src/UniGetUI.Avalonia/Views/BaseView.cs b/src/UniGetUI.Avalonia/Views/BaseView.cs new file mode 100644 index 0000000000..7f0ab4b96e --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/BaseView.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using UniGetUI.Avalonia.ViewModels; + +namespace UniGetUI.Avalonia.Views; + +/// +/// Typed base class for all UserControl views. +/// Provides a strongly-typed ViewModel property that mirrors DataContext. +/// +public abstract class BaseView : UserControl where TViewModel : ViewModelBase +{ + public TViewModel? ViewModel => DataContext as TViewModel; +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/Settings/ButtonCard.cs b/src/UniGetUI.Avalonia/Views/Controls/Settings/ButtonCard.cs new file mode 100644 index 0000000000..6c71021412 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/Settings/ButtonCard.cs @@ -0,0 +1,58 @@ +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.Views.Controls.Settings; + +public sealed partial class ButtonCard : SettingsCard +{ + public static readonly StyledProperty CommandProperty = + AvaloniaProperty.Register(nameof(Command)); + + public static readonly StyledProperty CommandParameterProperty = + AvaloniaProperty.Register(nameof(CommandParameter)); + + private readonly Button _button = new(); + + public string ButtonText + { + set => _button.Content = CoreTools.Translate(value); + } + + public string Text + { + set => Header = CoreTools.Translate(value); + } + + public ICommand? Command + { + get => GetValue(CommandProperty); + set => SetValue(CommandProperty, value); + } + + public object? CommandParameter + { + get => GetValue(CommandParameterProperty); + set => SetValue(CommandParameterProperty, value); + } + + public new event EventHandler? Click; + + public ButtonCard() + { + _button.MinWidth = 200; + _button.Click += (_, _) => Click?.Invoke(this, EventArgs.Empty); + Content = _button; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == CommandProperty) + _button.Command = (ICommand?)change.NewValue; + else if (change.Property == CommandParameterProperty) + _button.CommandParameter = change.NewValue; + } +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/Settings/CheckboxButtonCard.cs b/src/UniGetUI.Avalonia/Views/Controls/Settings/CheckboxButtonCard.cs new file mode 100644 index 0000000000..ce10af13a5 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/Settings/CheckboxButtonCard.cs @@ -0,0 +1,88 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using UniGetUI.Core.Tools; +using CoreSettings = global::UniGetUI.Core.SettingsEngine.Settings; + +namespace UniGetUI.Avalonia.Views.Controls.Settings; + +public sealed partial class CheckboxButtonCard : SettingsCard +{ + public ToggleSwitch _checkbox; + public TextBlock _textblock; + public Button Button; + private bool IS_INVERTED; + + private CoreSettings.K setting_name = CoreSettings.K.Unset; + public CoreSettings.K SettingName + { + set + { + setting_name = value; + IS_INVERTED = CoreSettings.ResolveKey(value).StartsWith("Disable"); + _checkbox.IsChecked = CoreSettings.Get(setting_name) ^ IS_INVERTED ^ ForceInversion; + _textblock.Opacity = (_checkbox.IsChecked ?? false) ? 1 : 0.7; + Button.IsEnabled = (_checkbox.IsChecked ?? false) || _buttonAlwaysOn; + } + } + + public bool ForceInversion { get; set; } + public bool Checked => _checkbox.IsChecked ?? false; + + public event EventHandler? StateChanged; + public new event EventHandler? Click; + + public string CheckboxText + { + set => _textblock.Text = CoreTools.Translate(value); + } + + public string ButtonText + { + set => Button.Content = CoreTools.Translate(value); + } + + private bool _buttonAlwaysOn; + public bool ButtonAlwaysOn + { + set + { + _buttonAlwaysOn = value; + Button.IsEnabled = (_checkbox.IsChecked ?? false) || _buttonAlwaysOn; + } + } + + public CheckboxButtonCard() + { + Button = new Button { Margin = new Thickness(0, 8, 0, 0) }; + _checkbox = new ToggleSwitch + { + Margin = new Thickness(0, 0, 8, 0), + OnContent = new TextBlock { Text = CoreTools.Translate("Enabled") }, + OffContent = new TextBlock { Text = CoreTools.Translate("Disabled") }, + }; + _textblock = new TextBlock + { + Margin = new Thickness(2, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + FontWeight = FontWeight.Medium, + }; + IS_INVERTED = false; + + Content = _checkbox; + Header = _textblock; + Description = Button; + + _checkbox.IsCheckedChanged += (_, _) => + { + CoreSettings.Set(setting_name, (_checkbox.IsChecked ?? false) ^ IS_INVERTED ^ ForceInversion); + StateChanged?.Invoke(this, EventArgs.Empty); + Button.IsEnabled = (_checkbox.IsChecked ?? false) ? true : _buttonAlwaysOn; + _textblock.Opacity = (_checkbox.IsChecked ?? false) ? 1 : 0.7; + }; + Button.Click += (s, e) => Click?.Invoke(s, e); + } +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/Settings/CheckboxCard.cs b/src/UniGetUI.Avalonia/Views/Controls/Settings/CheckboxCard.cs new file mode 100644 index 0000000000..aa95206c9b --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/Settings/CheckboxCard.cs @@ -0,0 +1,156 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using UniGetUI.Core.Tools; +using CoreSettings = global::UniGetUI.Core.SettingsEngine.Settings; + +namespace UniGetUI.Avalonia.Views.Controls.Settings; + +public partial class CheckboxCard : SettingsCard +{ + public ToggleSwitch _checkbox; + public TextBlock _textblock; + public TextBlock _warningBlock; + protected bool IS_INVERTED; + + private CoreSettings.K setting_name = CoreSettings.K.Unset; + public CoreSettings.K SettingName + { + set + { + _checkbox.IsCheckedChanged -= _checkbox_Toggled; + setting_name = value; + IS_INVERTED = CoreSettings.ResolveKey(value).StartsWith("Disable"); + _checkbox.IsChecked = CoreSettings.Get(setting_name) ^ IS_INVERTED ^ ForceInversion; + _textblock.Opacity = (_checkbox.IsChecked ?? false) ? 1 : 0.7; + _checkbox.IsCheckedChanged += _checkbox_Toggled; + } + } + + public bool ForceInversion { get; set; } + + public bool Checked => _checkbox.IsChecked ?? false; + + public virtual event EventHandler? StateChanged; + + public string Text + { + set => _textblock.Text = CoreTools.Translate(value); + } + + public string WarningText + { + set + { + _warningBlock.Text = CoreTools.Translate(value); + _warningBlock.IsVisible = value.Any(); + } + } + + public double WarningOpacity + { + set => _warningBlock.Opacity = value; + } + + public CheckboxCard() + { + _checkbox = new ToggleSwitch + { + Margin = new Thickness(0, 0, 8, 0), + OnContent = new TextBlock { Text = CoreTools.Translate("Enabled") }, + OffContent = new TextBlock { Text = CoreTools.Translate("Disabled") }, + }; + _textblock = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + }; + _warningBlock = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + FontSize = 12, + Opacity = 0.7, + IsVisible = false, + }; + _warningBlock.Classes.Add("setting-warning-text"); + IS_INVERTED = false; + + Content = _checkbox; + Header = new StackPanel + { + Spacing = 4, + Orientation = Orientation.Vertical, + Children = { _textblock, _warningBlock }, + }; + + _checkbox.IsCheckedChanged += _checkbox_Toggled; + } + + protected virtual void _checkbox_Toggled(object? sender, RoutedEventArgs e) + { + CoreSettings.Set(setting_name, (_checkbox.IsChecked ?? false) ^ IS_INVERTED ^ ForceInversion); + StateChanged?.Invoke(this, EventArgs.Empty); + _textblock.Opacity = (_checkbox.IsChecked ?? false) ? 1 : 0.7; + } +} + +public partial class CheckboxCard_Dict : CheckboxCard +{ + public override event EventHandler? StateChanged; + + private CoreSettings.K _dictName = CoreSettings.K.Unset; + private bool _disableStateChangedEvent; + + private string _keyName = ""; + public string KeyName + { + set + { + _keyName = value; + if (_dictName != CoreSettings.K.Unset && _keyName.Any()) + { + _disableStateChangedEvent = true; + _checkbox.IsChecked = + CoreSettings.GetDictionaryItem(_dictName, _keyName) + ^ IS_INVERTED + ^ ForceInversion; + _textblock.Opacity = (_checkbox.IsChecked ?? false) ? 1 : 0.7; + _disableStateChangedEvent = false; + } + } + } + + public CoreSettings.K DictionaryName + { + set + { + _dictName = value; + IS_INVERTED = CoreSettings.ResolveKey(value).StartsWith("Disable"); + if (_dictName != CoreSettings.K.Unset && _keyName.Any()) + { + _checkbox.IsChecked = + CoreSettings.GetDictionaryItem(_dictName, _keyName) + ^ IS_INVERTED + ^ ForceInversion; + _textblock.Opacity = (_checkbox.IsChecked ?? false) ? 1 : 0.7; + } + } + } + + public CheckboxCard_Dict() : base() { } + + protected override void _checkbox_Toggled(object? sender, RoutedEventArgs e) + { + if (_disableStateChangedEvent) return; + CoreSettings.SetDictionaryItem( + _dictName, + _keyName, + (_checkbox.IsChecked ?? false) ^ IS_INVERTED ^ ForceInversion + ); + StateChanged?.Invoke(this, EventArgs.Empty); + _textblock.Opacity = (_checkbox.IsChecked ?? false) ? 1 : 0.7; + } +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/Settings/ComboboxCard.cs b/src/UniGetUI.Avalonia/Views/Controls/Settings/ComboboxCard.cs new file mode 100644 index 0000000000..66a92a26ec --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/Settings/ComboboxCard.cs @@ -0,0 +1,78 @@ +using System.Collections.ObjectModel; +using Avalonia.Controls; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; +using CoreSettings = global::UniGetUI.Core.SettingsEngine.Settings; + +namespace UniGetUI.Avalonia.Views.Controls.Settings; + +public sealed partial class ComboboxCard : SettingsCard +{ + private readonly ComboBox _combobox = new(); + private readonly ObservableCollection _elements = []; + private readonly Dictionary _values_ref = []; + private readonly Dictionary _inverted_val_ref = []; + + private CoreSettings.K settings_name = CoreSettings.K.Unset; + public CoreSettings.K SettingName + { + set => settings_name = value; + } + + public string Text + { + set => Header = CoreTools.Translate(value); + } + + public event EventHandler? ValueChanged; + + public ComboboxCard() + { + _combobox.MinWidth = 200; + _combobox.ItemsSource = _elements; + Content = _combobox; + } + + public void AddItem(string name, string value) => AddItem(name, value, true); + + public void AddItem(string name, string value, bool translate) + { + if (translate) name = CoreTools.Translate(name); + _elements.Add(name); + _values_ref.Add(name, value); + _inverted_val_ref.Add(value, name); + } + + public void ShowAddedItems() + { + try + { + string savedItem = CoreSettings.GetValue(settings_name); + _combobox.SelectedIndex = _elements.IndexOf(_inverted_val_ref[savedItem]); + } + catch + { + _combobox.SelectedIndex = 0; + } + _combobox.SelectionChanged += (_, _) => + { + try + { + CoreSettings.SetValue( + settings_name, + _values_ref[_combobox.SelectedItem?.ToString() ?? ""] + ); + ValueChanged?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + Logger.Warn(ex); + } + }; + } + + public string SelectedValue() => + _combobox.SelectedItem?.ToString() ?? throw new InvalidCastException(); + + public void SelectIndex(int index) => _combobox.SelectedIndex = index; +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/Settings/SecureCheckboxCard.cs b/src/UniGetUI.Avalonia/Views/Controls/Settings/SecureCheckboxCard.cs new file mode 100644 index 0000000000..36b44e9247 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/Settings/SecureCheckboxCard.cs @@ -0,0 +1,135 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using UniGetUI.Core.Logging; +using UniGetUI.Core.SettingsEngine.SecureSettings; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.Views.Controls.Settings; + +public partial class SecureCheckboxCard : SettingsCard +{ + public ToggleSwitch _checkbox; + public TextBlock _textblock; + public TextBlock _warningBlock; + public ProgressBar _loading; // Avalonia has no ProgressRing; use indeterminate ProgressBar + private bool IS_INVERTED; + + private SecureSettings.K setting_name = SecureSettings.K.Unset; + public SecureSettings.K SettingName + { + set + { + _checkbox.IsEnabled = false; + setting_name = value; + IS_INVERTED = SecureSettings.ResolveKey(value).StartsWith("Disable"); + _checkbox.IsChecked = SecureSettings.Get(setting_name) ^ IS_INVERTED ^ ForceInversion; + _textblock.Opacity = (_checkbox.IsChecked ?? false) ? 1 : 0.7; + _checkbox.IsEnabled = true; + } + } + + public new bool IsEnabled + { + set + { + base.IsEnabled = value; + _warningBlock.Opacity = value ? 1 : 0.2; + } + get => base.IsEnabled; + } + + public bool ForceInversion { get; set; } + public bool Checked => _checkbox.IsChecked ?? false; + + public virtual event EventHandler? StateChanged; + + public string Text + { + set => _textblock.Text = CoreTools.Translate(value); + } + + public string WarningText + { + set + { + _warningBlock.Text = CoreTools.Translate(value); + _warningBlock.IsVisible = value.Any(); + } + } + + public SecureCheckboxCard() + { + _checkbox = new ToggleSwitch + { + Margin = new Thickness(0, 0, 8, 0), + OnContent = new TextBlock { Text = CoreTools.Translate("Enabled") }, + OffContent = new TextBlock { Text = CoreTools.Translate("Disabled") }, + }; + _loading = new ProgressBar + { + IsIndeterminate = true, + IsVisible = false, + Width = 20, + Height = 20, + Margin = new Thickness(0, 0, 4, 0), + }; + _textblock = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + }; + _warningBlock = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + FontSize = 12, + IsVisible = false, + }; + _warningBlock.Classes.Add("setting-warning-text"); + IS_INVERTED = false; + + Content = new StackPanel + { + Spacing = 4, + Orientation = Orientation.Horizontal, + Children = { _loading, _checkbox }, + }; + Header = new StackPanel + { + Spacing = 4, + Orientation = Orientation.Vertical, + Children = { _textblock, _warningBlock }, + }; + + _checkbox.IsCheckedChanged += (s, e) => _ = _checkbox_Toggled(); + } + + protected virtual async Task _checkbox_Toggled() + { + try + { + if (_checkbox.IsEnabled is false) return; + + _loading.IsVisible = true; + _checkbox.IsEnabled = false; + await SecureSettings.TrySet( + setting_name, + (_checkbox.IsChecked ?? false) ^ IS_INVERTED ^ ForceInversion + ); + StateChanged?.Invoke(this, EventArgs.Empty); + _textblock.Opacity = (_checkbox.IsChecked ?? false) ? 1 : 0.7; + _checkbox.IsChecked = SecureSettings.Get(setting_name) ^ IS_INVERTED ^ ForceInversion; + _loading.IsVisible = false; + _checkbox.IsEnabled = true; + } + catch (Exception ex) + { + Logger.Warn(ex); + _checkbox.IsChecked = SecureSettings.Get(setting_name) ^ IS_INVERTED ^ ForceInversion; + _loading.IsVisible = false; + _checkbox.IsEnabled = true; + } + } +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/Settings/SettingsCard.cs b/src/UniGetUI.Avalonia/Views/Controls/Settings/SettingsCard.cs new file mode 100644 index 0000000000..62c9150ded --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/Settings/SettingsCard.cs @@ -0,0 +1,208 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; + +namespace UniGetUI.Avalonia.Views.Controls.Settings; + +/// +/// Avalonia equivalent of CommunityToolkit.WinUI.Controls.SettingsCard. +/// Layout: [icon][header / description stack] [content] +/// +public class SettingsCard : UserControl +{ + // ── Internal layout elements ─────────────────────────────────────────── + private readonly Border _border; + private readonly ContentControl _iconPresenter; + private readonly ContentControl _headerPresenter; + private readonly ContentControl _descriptionPresenter; + private readonly ContentControl _contentPresenter; + private readonly StackPanel _descriptionRow; + + // ── Backing stores ───────────────────────────────────────────────────── + private object? _header; + private object? _description; + private Control? _headerIcon; + private object? _rightContent; + private bool _isClickEnabled; + + // ── Events ───────────────────────────────────────────────────────────── + public new event EventHandler? Click; + + // ── Properties ──────────────────────────────────────────────────────── + + public new object? Content + { + get => _rightContent; + set + { + _rightContent = value; + _contentPresenter.Content = value is string s + ? new TextBlock { Text = s, VerticalAlignment = VerticalAlignment.Center } + : value; + } + } + + public object? Header + { + get => _header; + set + { + _header = value; + _headerPresenter.Content = value is string s + ? new TextBlock + { + Text = s, + TextWrapping = TextWrapping.Wrap, + VerticalAlignment = VerticalAlignment.Center, + } + : value; + } + } + + public object? Description + { + get => _description; + set + { + _description = value; + if (value is null) + { + _descriptionRow.IsVisible = false; + return; + } + _descriptionPresenter.Content = value is string s + ? new TextBlock + { + Text = s, + TextWrapping = TextWrapping.Wrap, + FontSize = 12, + Opacity = 0.7, + } + : value; + _descriptionRow.IsVisible = true; + } + } + + public Control? HeaderIcon + { + get => _headerIcon; + set + { + _headerIcon = value; + _iconPresenter.Content = value; + _iconPresenter.IsVisible = value is not null; + } + } + + public bool IsClickEnabled + { + get => _isClickEnabled; + set + { + _isClickEnabled = value; + Cursor = value ? new Cursor(StandardCursorType.Hand) : Cursor.Default; + if (value) + _border.Classes.Add("settings-card-clickable"); + else + _border.Classes.Remove("settings-card-clickable"); + } + } + + public new CornerRadius CornerRadius + { + get => _border.CornerRadius; + set => _border.CornerRadius = value; + } + + public new Thickness BorderThickness + { + get => _border.BorderThickness; + set => _border.BorderThickness = value; + } + + // ── Constructor ──────────────────────────────────────────────────────── + + public SettingsCard() + { + _iconPresenter = new ContentControl + { + IsVisible = false, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 12, 0), + Width = 24, + Height = 24, + }; + + _headerPresenter = new ContentControl + { + VerticalAlignment = VerticalAlignment.Center, + }; + + _descriptionPresenter = new ContentControl + { + VerticalAlignment = VerticalAlignment.Center, + }; + + _descriptionRow = new StackPanel + { + Orientation = Orientation.Vertical, + IsVisible = false, + }; + _descriptionRow.Children.Add(_descriptionPresenter); + + var leftStack = new StackPanel + { + Orientation = Orientation.Vertical, + VerticalAlignment = VerticalAlignment.Center, + }; + leftStack.Children.Add(_headerPresenter); + leftStack.Children.Add(_descriptionRow); + + var leftRow = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center, + }; + leftRow.Children.Add(_iconPresenter); + leftRow.Children.Add(leftStack); + + _contentPresenter = new ContentControl + { + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(16, 0, 0, 0), + }; + + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,Auto"), + MinHeight = 60, + Margin = new Thickness(16, 8, 16, 8), + }; + Grid.SetColumn(leftRow, 0); + Grid.SetColumn(_contentPresenter, 1); + grid.Children.Add(leftRow); + grid.Children.Add(_contentPresenter); + + _border = new Border + { + CornerRadius = new CornerRadius(8), + BorderThickness = new Thickness(1), + Child = grid, + }; + _border.Classes.Add("settings-card"); + + base.Content = _border; + + PointerPressed += OnPointerPressed; + } + + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (_isClickEnabled) + Click?.Invoke(this, new RoutedEventArgs()); + } +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/Settings/SettingsPageButton.cs b/src/UniGetUI.Avalonia/Views/Controls/Settings/SettingsPageButton.cs new file mode 100644 index 0000000000..12078962f0 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/Settings/SettingsPageButton.cs @@ -0,0 +1,62 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using UniGetUI.Avalonia.Views.Controls; +using UniGetUI.Core.Tools; +using UniGetUI.Interface.Enums; + +namespace UniGetUI.Avalonia.Views.Controls.Settings; + +public partial class SettingsPageButton : SettingsCard +{ + public string Text + { + set => Header = CoreTools.Translate(value); + } + + public string UnderText + { + set => Description = CoreTools.Translate(value); + } + + public IconType Icon + { + set => HeaderIcon = new SvgIcon + { + Path = $"avares://UniGetUI.Avalonia/Assets/Symbols/{IconTypeToName(value)}.svg", + Width = 24, + Height = 24, + }; + } + + public SettingsPageButton() + { + CornerRadius = new CornerRadius(8); + IsClickEnabled = true; + + Content = new TextBlock + { + Text = "›", + FontSize = 20, + VerticalAlignment = VerticalAlignment.Center, + Opacity = 0.6, + }; + } + + private static string IconTypeToName(IconType icon) => icon switch + { + IconType.Package => "package", + IconType.UAC => "uac", + IconType.Update => "update", + IconType.Help => "help", + IconType.Console => "console", + IconType.Checksum => "checksum", + IconType.Download => "download", + IconType.Settings => "settings", + IconType.SaveAs => "save_as", + IconType.OpenFolder => "open_folder", + IconType.Experimental => "experimental", + _ => icon.ToString().ToLower(), + }; +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/Settings/TextboxCard.cs b/src/UniGetUI.Avalonia/Views/Controls/Settings/TextboxCard.cs new file mode 100644 index 0000000000..c610b1bf79 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/Settings/TextboxCard.cs @@ -0,0 +1,86 @@ +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using UniGetUI.Core.Tools; +using CoreSettings = global::UniGetUI.Core.SettingsEngine.Settings; + +namespace UniGetUI.Avalonia.Views.Controls.Settings; + +public sealed partial class TextboxCard : SettingsCard +{ + private readonly TextBox _textbox; + private readonly Button _helpbutton; // WinUI HyperlinkButton → plain Button + Process.Start + + private CoreSettings.K setting_name = CoreSettings.K.Unset; + private Uri? _helpUri; + + public CoreSettings.K SettingName + { + set + { + setting_name = value; + _textbox.Text = CoreSettings.GetValue(setting_name); + _textbox.TextChanged += (_, _) => SaveValue(); + } + } + + public string Placeholder + { + set => _textbox.Watermark = CoreTools.Translate(value); + } + + public string Text + { + set => Header = CoreTools.Translate(value); + } + + public Uri HelpUrl + { + set + { + _helpUri = value; + _helpbutton.IsVisible = true; + _helpbutton.Content = CoreTools.Translate("More info"); + } + } + + public event EventHandler? ValueChanged; + + public TextboxCard() + { + _helpbutton = new Button + { + IsVisible = false, + Margin = new Thickness(0, 0, 8, 0), + }; + _helpbutton.Click += (_, _) => + { + if (_helpUri is not null) + Process.Start(new ProcessStartInfo(_helpUri.ToString()) { UseShellExecute = true }); + }; + + _textbox = new TextBox { MinWidth = 200, MaxWidth = 300 }; + + var s = new StackPanel { Orientation = Orientation.Horizontal }; + s.Children.Add(_helpbutton); + s.Children.Add(_textbox); + + Content = s; + } + + public void SaveValue() + { + string sanitizedText = _textbox.Text ?? ""; + + if (CoreSettings.ResolveKey(setting_name).Contains("File")) + sanitizedText = CoreTools.MakeValidFileName(sanitizedText); + + if (sanitizedText != "") + CoreSettings.SetValue(setting_name, sanitizedText); + else + CoreSettings.Set(setting_name, false); + + ValueChanged?.Invoke(this, EventArgs.Empty); + } +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/Settings/TranslatedTextBlock.cs b/src/UniGetUI.Avalonia/Views/Controls/Settings/TranslatedTextBlock.cs new file mode 100644 index 0000000000..de821ab4d6 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/Settings/TranslatedTextBlock.cs @@ -0,0 +1,17 @@ +using Avalonia.Controls; +using Avalonia.Media; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia.Views.Controls.Settings; + +/// +/// A TextBlock whose Text property is automatically translated via CoreTools.Translate. +/// +public class TranslatedTextBlock : TextBlock +{ + public new string Text + { + get => base.Text ?? ""; + set => base.Text = CoreTools.Translate(value); + } +} diff --git a/src/UniGetUI.Avalonia/Views/Controls/SvgIcon.cs b/src/UniGetUI.Avalonia/Views/Controls/SvgIcon.cs new file mode 100644 index 0000000000..702f9a833e --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/SvgIcon.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Xml.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Platform; + +namespace UniGetUI.Avalonia.Views.Controls; + +/// +/// Lightweight SVG icon renderer that uses Avalonia's native geometry engine +/// instead of SkiaSharp's SVG module (which is broken on some macOS configurations). +/// Supports single-path and multi-path SVGs with a uniform viewBox. +/// +public class SvgIcon : Control +{ + public static readonly StyledProperty PathProperty = + AvaloniaProperty.Register(nameof(Path)); + + public static readonly StyledProperty ForegroundProperty = + AvaloniaProperty.Register(nameof(Foreground), + defaultValue: null, inherits: true); + + public string? Path + { + get => GetValue(PathProperty); + set => SetValue(PathProperty, value); + } + + public IBrush? Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + private readonly List _geometries = new(); + private double _viewBoxWidth = 24, _viewBoxHeight = 24; + + static SvgIcon() + { + AffectsRender(ForegroundProperty); + AffectsMeasure(PathProperty); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == PathProperty) + LoadSvg(change.NewValue as string); + } + + private void LoadSvg(string? uri) + { + _geometries.Clear(); + _viewBoxWidth = 24; + _viewBoxHeight = 24; + + if (string.IsNullOrEmpty(uri)) + { + InvalidateVisual(); + return; + } + + try + { + using Stream stream = AssetLoader.Open(new Uri(uri)); + XDocument doc = XDocument.Load(stream); + XElement? svg = doc.Root; + if (svg is null) return; + + string? vb = svg.Attribute("viewBox")?.Value; + if (vb != null) + { + var parts = vb.Split(' ', System.StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 4 && + double.TryParse(parts[2], System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out double w) && + double.TryParse(parts[3], System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out double h)) + { + _viewBoxWidth = w; + _viewBoxHeight = h; + } + } + + XNamespace ns = "http://www.w3.org/2000/svg"; + foreach (XElement el in doc.Descendants(ns + "path")) + { + string? d = el.Attribute("d")?.Value; + if (!string.IsNullOrEmpty(d)) + { + try { _geometries.Add(Geometry.Parse(d)); } + catch { /* skip malformed path data */ } + } + } + } + catch + { + // Silently ignore missing or unreadable assets + } + + InvalidateMeasure(); + InvalidateVisual(); + } + + protected override Size MeasureOverride(Size availableSize) + { + double w = double.IsInfinity(availableSize.Width) ? _viewBoxWidth : availableSize.Width; + double h = double.IsInfinity(availableSize.Height) ? _viewBoxHeight : availableSize.Height; + return new Size(w, h); + } + + public override void Render(DrawingContext context) + { + if (_geometries.Count == 0) return; + + IBrush brush = Foreground ?? Brushes.Black; + + double scaleX = Bounds.Width / _viewBoxWidth; + double scaleY = Bounds.Height / _viewBoxHeight; + double scale = Math.Min(scaleX, scaleY); + + double offsetX = (Bounds.Width - _viewBoxWidth * scale) / 2; + double offsetY = (Bounds.Height - _viewBoxHeight * scale) / 2; + + using var _ = context.PushTransform( + Matrix.CreateTranslation(offsetX, offsetY) * Matrix.CreateScale(scale, scale)); + + foreach (Geometry geo in _geometries) + context.DrawGeometry(brush, null, geo); + } +} diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml b/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml new file mode 100644 index 0000000000..affbb208ec --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsWindow.axaml @@ -0,0 +1,296 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/ManageIgnoredUpdatesWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/DialogPages/ManageIgnoredUpdatesWindow.axaml.cs new file mode 100644 index 0000000000..bfb8754a3a --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/DialogPages/ManageIgnoredUpdatesWindow.axaml.cs @@ -0,0 +1,15 @@ +using Avalonia.Controls; +using UniGetUI.Avalonia.ViewModels; + +namespace UniGetUI.Avalonia.Views; + +public partial class ManageIgnoredUpdatesWindow : Window +{ + public ManageIgnoredUpdatesWindow() + { + var vm = new ManageIgnoredUpdatesViewModel(); + DataContext = vm; + InitializeComponent(); + vm.CloseRequested += (_, _) => Close(); + } +} diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/OperationOutputWindow.cs b/src/UniGetUI.Avalonia/Views/DialogPages/OperationOutputWindow.cs new file mode 100644 index 0000000000..d7484cf4ce --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/DialogPages/OperationOutputWindow.cs @@ -0,0 +1,53 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using UniGetUI.PackageOperations; + +namespace UniGetUI.Avalonia.Views; + +/// +/// Simple window that shows the full output log of a completed or failed operation. +/// Created in code so no AXAML file is required. +/// +public sealed class OperationOutputWindow : Window +{ + public OperationOutputWindow(AbstractOperation operation) + { + Title = operation.Metadata.Title; + Width = 700; + Height = 500; + MinWidth = 400; + MinHeight = 300; + Background = (Application.Current?.FindResource("AppDialogBackground") as IBrush) ?? new SolidColorBrush(Color.Parse("#1e2025")); + + var lines = string.Join("\n", operation.GetOutput().Select(x => x.Item1)); + + var textBox = new TextBox + { + Text = lines, + IsReadOnly = true, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + FontFamily = new FontFamily("Cascadia Mono,Consolas,Menlo,monospace"), + FontSize = 12, + Foreground = (Application.Current?.FindResource("SystemControlForegroundBaseHighBrush") as IBrush) ?? new SolidColorBrush(Color.Parse("#d4d4d4")), + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + VerticalAlignment = VerticalAlignment.Stretch, + }; + + var scroll = new ScrollViewer + { + Content = textBox, + HorizontalScrollBarVisibility = global::Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = global::Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, + Margin = new Thickness(8), + }; + + Content = scroll; + + // Scroll to bottom once shown + Opened += (_, _) => scroll.ScrollToEnd(); + } +} diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml b/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml new file mode 100644 index 0000000000..8a3ac91ad3 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/DialogPages/PackageDetailsWindow.axaml @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs new file mode 100644 index 0000000000..025b5fb88b --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -0,0 +1,123 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Avalonia.Views.Pages; + +namespace UniGetUI.Avalonia.Views; + +public enum PageType +{ + Discover, + Updates, + Installed, + Bundles, + Settings, + Managers, + OwnLog, + ManagerLog, + OperationHistory, + Help, + ReleaseNotes, + About, + Quit, + Null, // Used for initializers +} + +public partial class MainWindow : Window +{ + public enum RuntimeNotificationLevel + { + Progress, + Success, + Error, + } + + private MainWindowViewModel ViewModel => (MainWindowViewModel)DataContext!; + + public MainWindow() + { + DataContext = new MainWindowViewModel(); + InitializeComponent(); + + KeyDown += Window_KeyDown; + } + + private void Window_KeyDown(object? sender, KeyEventArgs e) + { + bool isCtrl = e.KeyModifiers.HasFlag(KeyModifiers.Control); + bool isShift = e.KeyModifiers.HasFlag(KeyModifiers.Shift); + + if (e.Key == Key.Tab && isCtrl) + { + ViewModel.NavigateTo(isShift + ? MainWindowViewModel.GetPreviousPage(ViewModel.CurrentPage_t) + : MainWindowViewModel.GetNextPage(ViewModel.CurrentPage_t)); + } + else if (!isCtrl && !isShift && e.Key == Key.F1) + { + ViewModel.NavigateTo(PageType.Help); + } + else if ((e.Key is Key.Q or Key.W) && isCtrl) + { + Close(); + } + else if (e.Key == Key.F5 || (e.Key == Key.R && isCtrl)) + { + (ViewModel.CurrentPageContent as IKeyboardShortcutListener)?.ReloadTriggered(); + } + else if (e.Key == Key.F && isCtrl) + { + (ViewModel.CurrentPageContent as IKeyboardShortcutListener)?.SearchTriggered(); + } + else if (e.Key == Key.A && isCtrl) + { + (ViewModel.CurrentPageContent as IKeyboardShortcutListener)?.SelectAllTriggered(); + } + } + + private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + BeginMoveDrag(e); + } + + private void SearchBox_KeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + ViewModel.SubmitGlobalSearch(); + } + + private void SearchButton_Click(object? sender, RoutedEventArgs e) => + ViewModel.SubmitGlobalSearch(); + + // ─── Banner close handlers ──────────────────────────────────────────────── + private void UpdatesBannerCloseButton_Click(object? sender, RoutedEventArgs e) => + ViewModel.UpdatesBannerVisible = false; + + private void ErrorBannerCloseButton_Click(object? sender, RoutedEventArgs e) => + ViewModel.ErrorBannerVisible = false; + + private void WinGetWarningBannerCloseButton_Click(object? sender, RoutedEventArgs e) => + ViewModel.WinGetWarningBannerVisible = false; + + private void TelemetryWarnerCloseButton_Click(object? sender, RoutedEventArgs e) => + ViewModel.TelemetryWarnerVisible = false; + + // ─── Public navigation API ──────────────────────────────────────────────── + public void Navigate(PageType type) => ViewModel.NavigateTo(type); + + // ─── Public API (legacy compat) ─────────────────────────────────────────── + public void ShowBanner(string title, string message, RuntimeNotificationLevel level) + { + // TODO: implement in-app notification display + } + + public void UpdateSystemTrayStatus() + { + // TODO: implement tray status update + } + + public void ShowRuntimeNotification(string title, string message, RuntimeNotificationLevel level) => + ShowBanner(title, message, level); +} diff --git a/src/UniGetUI.Avalonia/Views/Pages/AboutPageWindow.axaml b/src/UniGetUI.Avalonia/Views/Pages/AboutPageWindow.axaml deleted file mode 100644 index 35104abfc9..0000000000 --- a/src/UniGetUI.Avalonia/Views/Pages/AboutPageWindow.axaml +++ /dev/null @@ -1,163 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/UniGetUI.Avalonia/Views/Pages/InstallOptionsWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/InstallOptionsWindow.axaml.cs deleted file mode 100644 index 9bfc52ce66..0000000000 --- a/src/UniGetUI.Avalonia/Views/Pages/InstallOptionsWindow.axaml.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Interactivity; -using Avalonia.Markup.Xaml; -using UniGetUI.Avalonia.Models; -using UniGetUI.Core.Tools; -using UniGetUI.PackageEngine.Interfaces; - -namespace UniGetUI.Avalonia.Views.Pages; - -public partial class InstallOptionsWindow : Window -{ - private readonly InstallOptionsEditorView? _editorView; - - public InstallOptionsWindow() - { - InitializeComponent(); - ApplyTranslations(); - SaveBtn.IsEnabled = false; - } - - public InstallOptionsWindow(IPackage package, PackagePageMode pageMode) - { - _editorView = new InstallOptionsEditorView(package); - InitializeComponent(); - ApplyTranslations(); - EditorHost.Content = _editorView; - } - - private void ApplyTranslations() - { - SaveBtn.Content = CoreTools.Translate("Save"); - CancelBtn.Content = CoreTools.Translate("Cancel"); - Title = CoreTools.Translate("Install options"); - - SaveBtn.Click += SaveBtn_OnClick; - CancelBtn.Click += CancelBtn_OnClick; - } - - private async void SaveBtn_OnClick(object? sender, RoutedEventArgs e) - { - if (_editorView is null) - { - Close(); - return; - } - - await _editorView.SaveAsync(); - Close(); - } - - private void CancelBtn_OnClick(object? sender, RoutedEventArgs e) - { - Close(); - } - -} diff --git a/src/UniGetUI.Avalonia/Views/Pages/LogsPageView.axaml b/src/UniGetUI.Avalonia/Views/Pages/LogsPageView.axaml deleted file mode 100644 index 0c68dea1c2..0000000000 --- a/src/UniGetUI.Avalonia/Views/Pages/LogsPageView.axaml +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/UniGetUI.Avalonia/Views/Pages/Managers/ManagerRowView.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/Managers/ManagerRowView.axaml.cs deleted file mode 100644 index 7655865426..0000000000 --- a/src/UniGetUI.Avalonia/Views/Pages/Managers/ManagerRowView.axaml.cs +++ /dev/null @@ -1,154 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Interactivity; -using Avalonia.Markup.Xaml; -using UniGetUI.Core.SettingsEngine; -using UniGetUI.Core.Tools; -using UniGetUI.PackageEngine.Interfaces; - -namespace UniGetUI.Avalonia.Views.Pages.ManagersPages; - -public partial class ManagerRowView : UserControl -{ - private bool _isLoading; - private IPackageManager? _manager; - - private TextBlock AvatarText => GetControl("AvatarTextBlock"); - - private TextBlock TitleText => GetControl("ManagerTitleBlock"); - - private TextBlock DescriptionText => GetControl("ManagerDescriptionBlock"); - - private Border StatusBadge => GetControl("StatusBadgeBorder"); - - private TextBlock StatusText => GetControl("StatusTextBlock"); - - private TextBlock ExecutablePathText => GetControl("ExecutablePathBlock"); - - private CheckBox EnabledToggle => GetControl("EnabledCheckBox"); - - private TextBlock ToggleCaptionText => GetControl("ToggleCaptionBlock"); - - private Button OpenManagerAction => GetControl