diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index ec5f9c7249..b8dd1edf07 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -113,6 +113,12 @@ "Enable Software Catalog in Experimental settings and developer options, then restart UniGetUI.": "Enable Software Catalog in Experimental settings and developer options, then restart UniGetUI.", "The software catalog could not be loaded. Ask an administrator to check {0}, then try again.": "The software catalog could not be loaded. Ask an administrator to check {0}, then try again.", "Edit software catalog": "Edit software catalog", + "Back to {0}": "Back to {0}", + "Create a catalog": "Create a catalog", + "Create a new catalog or open a catalog file": "Create a new catalog or open a catalog file", + "Enable the Catalog Editor": "Enable the Catalog Editor", + "Catalog Editor is disabled": "Catalog Editor is disabled", + "Enable the Catalog Editor in Experimental settings and developer options, then restart UniGetUI.": "Enable the Catalog Editor in Experimental settings and developer options, then restart UniGetUI.", "Software Catalog": "Software Catalog", "Software selected for you, ready to install.": "Software selected for you, ready to install.", "Refresh software catalog": "Refresh software catalog", diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs index 953c751455..1025d5e04f 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs @@ -257,9 +257,14 @@ private static IpcAppInfo GetAppInfo() CanNavigate = window is not null, CanQuit = true, CurrentPage = window is null ? "" : IpcAppPages.ToPageName(window.CurrentPage.ToString()), - SupportedPages = window is { SoftwareCatalogEnabled: true } - ? IpcAppPages.SupportedPages - : IpcAppPages.SupportedPages.Where(page => page is not ("catalog" or "catalog-editor")).ToArray(), + SupportedPages = IpcAppPages.SupportedPages + .Where(page => page switch + { + "catalog" => window is { SoftwareCatalogEnabled: true }, + "catalog-editor" => window is { CatalogEditorEnabled: true }, + _ => true, + }) + .ToArray(), SystemRestartPending = PendingRebootStore.HasPending, SystemRestartPendingPackages = PendingRebootStore.PendingCount, }; @@ -298,6 +303,8 @@ private static IpcCommandResult NavigateApp(IpcAppNavigateRequest request) case "catalog-editor": if (!window.SoftwareCatalogEnabled) throw new InvalidOperationException("Software Catalog is disabled. Enable it in Experimental settings and developer options, then restart UniGetUI."); + if (page == "catalog-editor" && !window.CatalogEditorEnabled) + throw new InvalidOperationException("Catalog Editor is disabled. Enable it in Experimental settings and developer options, then restart UniGetUI."); window.Navigate(page == "catalog-editor" ? PageType.CatalogEditor : PageType.Catalog); break; case "settings": diff --git a/src/UniGetUI.Avalonia/Infrastructure/NavigationPageOrder.cs b/src/UniGetUI.Avalonia/Infrastructure/NavigationPageOrder.cs index acdb1901cf..7c376e7906 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/NavigationPageOrder.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/NavigationPageOrder.cs @@ -6,7 +6,7 @@ namespace UniGetUI.Avalonia.Infrastructure; internal static class NavigationPageOrder { public static PageType[] DefaultPages(bool catalogEnabled) => catalogEnabled - ? [PageType.Catalog, PageType.Discover, PageType.Updates, PageType.Installed, PageType.Bundles, PageType.CatalogEditor] + ? [PageType.Catalog, PageType.Discover, PageType.Updates, PageType.Installed, PageType.Bundles] : [PageType.Discover, PageType.Updates, PageType.Installed, PageType.Bundles]; public static PageType[] Get(bool catalogEnabled) diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs index 58a4667d95..3db59f35c9 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs @@ -55,7 +55,7 @@ public partial class CatalogEditorViewModel : ViewModelBase private string _query = ""; [ObservableProperty] - private string _filePath = SoftwareCatalog.FilePath; + private string _filePath = ""; private string _message = ""; @@ -110,14 +110,15 @@ public Task EnsureLoadedAsync() return _initialLoad ??= LoadOrStartAsync(FilePath); } - // No catalog deployed yet: start an empty document that saves to that path, so a first - // catalog can be created there instead of the editor opening disabled. + // The editor starts on an untitled, empty document rather than the deployed catalog, so nobody + // edits what this device installs from by accident; New catalog or Open file is the first step. + // A path set beforehand that does not exist yet also starts empty, and saves there. private Task LoadOrStartAsync(string path) { - if (!IsMissing(path)) return LoadAsync(path); + if (path.Length > 0 && !IsMissing(path)) return LoadAsync(path); SelectedCatalog = null; Catalogs.Clear(); - FilePath = Path.GetFullPath(path); + FilePath = path.Length > 0 ? Path.GetFullPath(path) : ""; _savedCatalogs = []; IsLoaded = true; IsDirty = false; diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index f90b016d11..e350b0e42c 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -433,7 +433,7 @@ public MainWindowViewModel() RegisterBannerToast(TelemetryWarner); RegisterBannerToast(PortableImportBanner); - CatalogEditor = Sidebar.SoftwareCatalogEnabled ? new CatalogEditorViewModel() : null; + CatalogEditor = Sidebar.CatalogEditorEnabled ? new CatalogEditorViewModel() : null; DiscoverPage = new DiscoverSoftwarePage(CatalogEditor); UpdatesPage = new SoftwareUpdatesPage(); InstalledPage = new InstalledPackagesPage(); @@ -669,7 +669,7 @@ public void LoadDefaultPage() "installed" => PageType.Installed, "bundles" => PageType.Bundles, "catalog" when Sidebar.SoftwareCatalogEnabled => PageType.Catalog, - "catalog-editor" when Sidebar.SoftwareCatalogEnabled => PageType.CatalogEditor, + "catalog-editor" when Sidebar.CatalogEditorEnabled => PageType.CatalogEditor, "settings" => PageType.Settings, // A usable admin-curated catalog is the simplest starting point, so it wins over the default pages. _ when Sidebar.HasUsableCatalog => PageType.Catalog, @@ -743,6 +743,15 @@ public async Task NavigateToAsync( MainWindow.RuntimeNotificationLevel.Error); return false; } + if (newPage_t is PageType.CatalogEditor && !Sidebar.CatalogEditorEnabled) + { + Logger.Warn("Catalog Editor navigation was rejected because the editor is disabled."); + MainWindow.Instance?.ShowBanner( + CoreTools.Translate("Catalog Editor is disabled"), + CoreTools.Translate("Enable the Catalog Editor in Experimental settings and developer options, then restart UniGetUI."), + MainWindow.RuntimeNotificationLevel.Error); + return false; + } if (newPage_t is PageType.About) { _ = ShowAboutDialog(); return true; } if (newPage_t is PageType.Quit) { MainWindow.Instance?.QuitApplication(); return true; } @@ -840,6 +849,13 @@ public async Task NavigateToAsync( _ => CoreTools.Translate("UniGetUI"), }; + /// Opens the Catalog Editor on a new catalog file, for the Software Catalog's New catalog actions. + public async Task StartNewCatalogAsync() + { + if (!await NavigateToAsync(PageType.CatalogEditor)) return; + if (_catalogEditorPage is { } editor) await editor.StartNewCatalogAsync(); + } + public void NavigateBack() => _ = NavigateBackAsync(); public async Task NavigateBackAsync(CancellationToken cancellationToken = default) diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/ExperimentalViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/ExperimentalViewModel.cs index 47f103135c..113c7f120f 100644 --- a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/ExperimentalViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/ExperimentalViewModel.cs @@ -1,5 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using UniGetUI.Avalonia.ViewModels; +using CoreSettings = global::UniGetUI.Core.SettingsEngine.Settings; namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; @@ -7,8 +9,19 @@ public partial class ExperimentalViewModel : ViewModelBase { public bool IsWindows { get; } = OperatingSystem.IsWindows(); + // The Catalog Editor is a sub-option of the Software Catalog. + [ObservableProperty] + private bool _isSoftwareCatalogEnabled = CoreSettings.Get(CoreSettings.K.EnableSoftwareCatalog); + public event EventHandler? RestartRequired; [RelayCommand] private void ShowRestartRequired() => RestartRequired?.Invoke(this, EventArgs.Empty); + + [RelayCommand] + private void SoftwareCatalogChanged() + { + IsSoftwareCatalogEnabled = CoreSettings.Get(CoreSettings.K.EnableSoftwareCatalog); + ShowRestartRequired(); + } } diff --git a/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs index 2c7bad65ab..31da33e069 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs @@ -13,6 +13,10 @@ public partial class SidebarViewModel : ViewModelBase { public bool SoftwareCatalogEnabled { get; } = Settings.Get(Settings.K.EnableSoftwareCatalog); + // An administrator tool, opened from the catalog page rather than the sidebar; off unless asked for. + public bool CatalogEditorEnabled { get; } = + Settings.Get(Settings.K.EnableSoftwareCatalog) && Settings.Get(Settings.K.EnableCatalogEditor); + // Only a catalog with packages to offer becomes the startup page; an unset or broken one would // open the app on an empty page. Checked once at startup, like the setting itself. public bool HasUsableCatalog { get; } = diff --git a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs index d217a93dfa..df137752ae 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs @@ -154,7 +154,8 @@ partial void OnIsFilterPaneOpenChanged(bool value) public bool SimilarSearchEnabled { get; private set; } public bool InstallerHostColumnVisible { get; } public bool DownloadSizeColumnVisible { get; } - public readonly string NoPackagesText; + // Settable so a page can say why it is empty; takes effect on the next list update. + public string NoPackagesText { get; set; } public readonly string NoMatchesText; public readonly string SearchBoxPlaceholder; private readonly string _noPackagesSubtitleBase; @@ -170,6 +171,19 @@ partial void OnIsFilterPaneOpenChanged(bool value) [ObservableProperty] private string _pageIconPath = ""; [ObservableProperty] private string _subtitle = ""; + // A page opened from another one (the Catalog Editor from the Software Catalog) links back to it above its title. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasParentPage))] + [NotifyPropertyChangedFor(nameof(ParentPageLinkName))] + private string _parentPageTitle = ""; + + public bool HasParentPage => ParentPageTitle.Length > 0; + public string ParentPageLinkName => CoreTools.Translate("Back to {0}", ParentPageTitle); + public Action? ParentPageRequested { get; set; } + + [RelayCommand] + private void GoToParentPage() => ParentPageRequested?.Invoke(); + // Shown after the subtitle in a secondary tone: when the list was last checked, then the page suffix. [ObservableProperty] private string _subtitleDetail = ""; diff --git a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs index 4e3ea3322f..598ec5c619 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs @@ -168,6 +168,7 @@ private void NotifyBackgroundTextChanged() OnPropertyChanged(nameof(BackgroundText)); OnPropertyChanged(nameof(HasBackgroundText)); OnPropertyChanged(nameof(HasBackgroundDetail)); + OnPropertyChanged(nameof(ShowCreateCatalog)); } private bool IsSelectedByFilters(CatalogTileViewModel tile) => @@ -267,22 +268,21 @@ partial void OnErrorMessageChanged(string value) value, global::Avalonia.Automation.AutomationLiveSetting.Assertive); } + // Administrators who enabled the Catalog Editor can start a catalog from here; everyone else only installs. + public bool IsEditorEnabled { get; } = + Settings.Get(Settings.K.EnableSoftwareCatalog) && Settings.Get(Settings.K.EnableCatalogEditor); + + // On a device with no catalog yet, the empty page offers to create one. + public bool ShowCreateCatalog => IsEditorEnabled && _notConfigured && IsEmpty; + + [RelayCommand] + private void OpenEditor() => MainWindow.Instance?.Navigate(PageType.CatalogEditor); + [RelayCommand] - private async Task EditAsync() + private async Task NewCatalogAsync() { - try - { - var owner = MainWindow.Instance - ?? throw new InvalidOperationException("The catalog editor requires an application window."); - owner.Navigate(PageType.CatalogEditor); - await Task.CompletedTask; - } - catch (Exception ex) - { - Logger.Error("Could not open the software catalog editor."); - Logger.Error(ex); - ErrorMessage = CoreTools.Translate("The catalog editor could not be opened: {0}", ex.Message); - } + if (MainWindow.Instance?.DataContext is MainWindowViewModel main) + await main.StartNewCatalogAsync(); } private bool _waitingForManagers; diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index b70066f19c..db4e357eb8 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -194,6 +194,7 @@ public enum RuntimeNotificationLevel private MainWindowViewModel ViewModel => (MainWindowViewModel)DataContext!; public PageType CurrentPage => ViewModel.CurrentPage_t; public bool SoftwareCatalogEnabled => ViewModel.Sidebar.SoftwareCatalogEnabled; + public bool CatalogEditorEnabled => ViewModel.Sidebar.CatalogEditorEnabled; public MainWindow() { diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Experimental.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Experimental.axaml index 08b4a9d0fa..5b41963dbd 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Experimental.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Experimental.axaml @@ -84,8 +84,15 @@ + + + IsEnabled="{Binding IsSoftwareCatalogEnabled}" + BorderThickness="1,0,1,1" + CornerRadius="0,0,8,8"/> diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml.cs index 27f310621a..6fbd56ed29 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml.cs @@ -46,7 +46,7 @@ public Interface_P() StartupPageSelector.AddItem(CoreTools.Translate("Software Updates"), "updates"); StartupPageSelector.AddItem(CoreTools.Translate("Installed Packages"), "installed"); StartupPageSelector.AddItem(CoreTools.Translate("Package Bundles"), "bundles"); - if (catalogEnabled) + if (catalogEnabled && CoreSettings.Get(CoreSettings.K.EnableCatalogEditor)) StartupPageSelector.AddItem(CoreTools.Translate("Catalog Editor"), "catalog-editor"); StartupPageSelector.AddItem(CoreTools.Translate("Settings"), "settings"); StartupPageSelector.SettingName = CoreSettings.K.StartupPage; diff --git a/src/UniGetUI.Avalonia/Views/SidebarView.axaml b/src/UniGetUI.Avalonia/Views/SidebarView.axaml index e97bf88a5f..f961777e5d 100644 --- a/src/UniGetUI.Avalonia/Views/SidebarView.axaml +++ b/src/UniGetUI.Avalonia/Views/SidebarView.axaml @@ -160,18 +160,6 @@ - - - - - - - diff --git a/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs b/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs index 30c3ea8a3b..aa6b340027 100644 --- a/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/SidebarView.axaml.cs @@ -426,7 +426,6 @@ private void AssignShortcutKeys(PageType[] pages) PageType.Updates => UpdatesNavBtn, PageType.Installed => InstalledNavBtn, PageType.Bundles => BundlesNavBtn, - PageType.CatalogEditor => CatalogEditorNavBtn, PageType.Settings => SettingsNavBtn, PageType.Managers => ManagersNavBtn, _ => null, @@ -446,7 +445,6 @@ private void SyncListBoxSelection(PageType page) PageType.Installed => InstalledNavBtn, PageType.Bundles => BundlesNavBtn, PageType.Catalog => CatalogNavBtn, - PageType.CatalogEditor => CatalogEditorNavBtn, _ => null, }; FooterNavListBox.SelectedItem = page switch diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml index 811880c1d2..7a5efccf66 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml @@ -262,6 +262,22 @@ Path="{Binding PageIconPath}"/> + + + { + if (GetMainWindow()?.DataContext is MainWindowViewModel main) main.NavigateTo(PageType.Catalog); + }; + document.PropertyChanged += DocumentChanged; document.ErrorRaised += message => Dispatcher.UIThread.Post(() => { @@ -148,6 +155,9 @@ private void SyncCatalog() _observedCatalog?.Packages.CollectionChanged -= PackagesChanged; _observedCatalog = _document.SelectedCatalog; _observedCatalog?.Packages.CollectionChanged += PackagesChanged; + ViewModel.NoPackagesText = _observedCatalog is null + ? CoreTools.Translate("Create a new catalog or open a catalog file") + : CoreTools.Translate("This catalog contains no packages."); _loader.Replace(_observedCatalog?.Packages ?? []); } @@ -349,6 +359,13 @@ public async Task ConfirmDiscardAsync() return dialog.Result is true; } + /// Starts a new catalog file, as New file does, once the document has loaded. + public async Task StartNewCatalogAsync() + { + await _document.EnsureLoadedAsync(); + await NewAsync(); + } + private async Task NewAsync() { if (_document.IsBusy || _pickingFile) return; @@ -374,11 +391,17 @@ private async Task OpenAsync() SyncStatus(); try { - var files = await MainWindow.Instance!.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + var storage = MainWindow.Instance!.StorageProvider; + string deployedFolder = Path.GetDirectoryName(SoftwareCatalog.FilePath)!; + var files = await storage.OpenFilePickerAsync(new FilePickerOpenOptions { Title = CoreTools.Translate("Open catalog file"), AllowMultiple = false, FileTypeFilter = [new FilePickerFileType("JSON") { Patterns = ["*.json"] }], + // Start where the deployed catalog lives, so opening it is one click away. + SuggestedStartLocation = Directory.Exists(deployedFolder) + ? await storage.TryGetFolderFromPathAsync(deployedFolder) + : null, }); if (files.Count > 0) await _document.LoadAsync(files[0].TryGetLocalPath() @@ -398,9 +421,15 @@ private async Task SaveAsync(bool saveAs) string path = _document.FilePath; if (saveAs || string.IsNullOrEmpty(path)) { - var file = await MainWindow.Instance!.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + var storage = MainWindow.Instance!.StorageProvider; + string deployedFolder = Path.GetDirectoryName(SoftwareCatalog.FilePath)!; + var file = await storage.SaveFilePickerAsync(new FilePickerSaveOptions { Title = CoreTools.Translate("Save catalog as"), + // An untitled catalog starts where it would be deployed. + SuggestedStartLocation = string.IsNullOrEmpty(path) && Directory.Exists(deployedFolder) + ? await storage.TryGetFolderFromPathAsync(deployedFolder) + : null, SuggestedFileName = string.IsNullOrEmpty(path) ? "SoftwareCatalog.json" : Path.GetFileName(path), DefaultExtension = "json", ShowOverwritePrompt = true, diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareCatalogPage.axaml b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareCatalogPage.axaml index a256d85b99..1649f86a37 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareCatalogPage.axaml +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareCatalogPage.axaml @@ -217,6 +217,17 @@ + + @@ -572,6 +583,9 @@ TextAlignment="Center" TextWrapping="Wrap" Text="{Binding BackgroundDetail}" IsVisible="{Binding HasBackgroundDetail}"/> +