Skip to content
2 changes: 1 addition & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Related environment variables:
| --- | --- | --- | --- |
| `app status` | None | None | Returns app/session state such as headless mode, page, and supported UI actions. |
| `app show` | None | None | Shows and focuses the window when a GUI session exists. |
| `app navigate` | `--page <page>` | `--manager <id>`, `--help-attachment <path>` | Valid pages include `discover`, `updates`, `installed`, `bundles`, `settings`, `managers`, `own-log`, `manager-log`, `operation-history`, `help`, `release-notes`, and `about`. |
| `app navigate` | `--page <page>` | `--manager <id>`, `--help-attachment <path>` | Valid pages include `discover`, `updates`, `installed`, `bundles`, `catalog`, `catalog-editor`, `settings`, `managers`, `own-log`, `manager-log`, `operation-history`, `help`, `release-notes`, and `about`. `catalog` and `catalog-editor` require Enable Software Catalog in Experimental settings and developer options, followed by an application restart. |
| `app quit` | None | None | Gracefully shuts down the selected session, including headless daemons. |

### Operations
Expand Down
73 changes: 73 additions & 0 deletions src/Languages/lang_en.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,77 @@
{
"Catalog Editor": "Catalog Editor",
"Tiles": "Tiles",
"Rename catalog": "Rename catalog",
"Rename": "Rename",
"Enter a catalog name.": "Enter a catalog name.",
"Package unavailable": "Package unavailable",
"Enable this package's manager and source to view its details.": "Enable this package's manager and source to view its details.",
"Catalog filters": "Catalog filters",
"Hide packages with unavailable sources": "Hide packages with unavailable sources",
"Packages with unavailable sources are hidden. Change Filters to show them.": "Packages with unavailable sources are hidden. Change Filters to show them.",
"No packages are available with the current filters.": "No packages are available with the current filters.",
"Open Catalog Editor": "Open Catalog Editor",
"Select a catalog": "Select a catalog",
"Catalog ID": "Catalog ID",
"Add selection to catalog": "Add selection to catalog",
"Remove selection from catalog": "Remove selection from catalog",
"Add packages from Discover using Add selection to catalog.": "Add packages from Discover using Add selection to catalog.",
"No packages in this catalog": "No packages in this catalog",
"Loading catalog": "Loading catalog",
"No packages match your filters": "No packages match your filters",
"Open or create a catalog before adding packages.": "Open or create a catalog before adding packages.",
"{0} packages added to catalog {1}. Save the catalog to keep your changes.": "{0} packages added to catalog {1}. Save the catalog to keep your changes.",
"Could not add packages to the catalog": "Could not add packages to the catalog",
"Could not add packages to the catalog: {0}": "Could not add packages to the catalog: {0}",
"Catalog editor": "Catalog editor",
"Open file": "Open file",
"Open catalog file": "Open catalog file",
"Save catalog": "Save catalog",
"Save catalog as": "Save catalog as",
"Select catalog to edit": "Select catalog to edit",
"Catalog identifier": "Catalog identifier",
"Catalog name": "Catalog name",
"Catalog packages": "Catalog packages",
"Search package manager": "Search package manager",
"Catalog package search results": "Catalog package search results",
"Search catalog packages": "Search catalog packages",
"Search packages to add": "Search packages to add",
"New catalog": "New catalog",
"Add package": "Add package",
"Remove package": "Remove package",
"No package managers are ready. Enable a package manager before searching.": "No package managers are ready. Enable a package manager before searching.",
"Could not open catalog file {0}: {1}": "Could not open catalog file {0}: {1}",
"Catalog saved to {0}.": "Catalog saved to {0}.",
"Could not save catalog file {0}: {1}": "Could not save catalog file {0}: {1}",
"This package is already in the selected catalog.": "This package is already in the selected catalog.",
"The selected package manager is not ready.": "The selected package manager is not ready.",
"Catalog package search failed: {0}": "Catalog package search failed: {0}",
"Discard unsaved catalog changes?": "Discard unsaved catalog changes?",
"Select a local catalog file.": "Select a local catalog file.",
"Could not select a catalog file: {0}": "Could not select a catalog file: {0}",
"The catalog editor could not be opened: {0}": "The catalog editor could not be opened: {0}",
"Open the catalog editor to search, add or remove packages and save a catalog file.": "Open the catalog editor to search, add or remove packages and save a catalog file.",
"Catalog": "Catalog",
"Select software catalog": "Select software catalog",
"This catalog contains no packages.": "This catalog contains no packages.",
"Enable Software Catalog": "Enable Software Catalog",
"Requires a machine-wide SoftwareCatalog.json file and an application restart.": "Requires a machine-wide SoftwareCatalog.json file and an application restart.",
"Software Catalog is disabled": "Software Catalog is disabled",
"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",
"Software Catalog": "Software Catalog",
"Software selected for you, ready to install.": "Software selected for you, ready to install.",
"Refresh software catalog": "Refresh software catalog",
"Installed": "Installed",
"The software catalog could not be loaded. Check SoftwareCatalog.json and try again.": "The software catalog could not be loaded. Check SoftwareCatalog.json and try again.",
"The package manager {0} is not available on this platform.": "The package manager {0} is not available on this platform.",
"The package manager {0} is disabled. Enable it in Package Managers.": "The package manager {0} is disabled. Enable it in Package Managers.",
"The package manager {0} is not available or is still initializing.": "The package manager {0} is not available or is still initializing.",
"The source {0} is not enabled for {1}. Add it in Package Managers.": "The source {0} is not enabled for {1}. Add it in Package Managers.",
"Waiting for installed package information. Refresh Installed Packages if this persists.": "Waiting for installed package information. Refresh Installed Packages if this persists.",
"Unable to install {0}": "Unable to install {0}",
"The catalog package could not be found or installed. Check that its identifier and source are correct, then try again.": "The catalog package could not be found or installed. Check that its identifier and source are correct, then try again.",
"Operation": "Operation",
"Status": "Status",
"Date": "Date",
Expand Down
3 changes: 3 additions & 0 deletions src/SharedAssets/Assets/Symbols/catalog.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 9 additions & 1 deletion src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,9 @@ private static IpcAppInfo GetAppInfo()
CanNavigate = window is not null,
CanQuit = true,
CurrentPage = window is null ? "" : IpcAppPages.ToPageName(window.CurrentPage.ToString()),
SupportedPages = IpcAppPages.SupportedPages,
SupportedPages = window is { SoftwareCatalogEnabled: true }
? IpcAppPages.SupportedPages
: IpcAppPages.SupportedPages.Where(page => page is not ("catalog" or "catalog-editor")).ToArray(),
SystemRestartPending = PendingRebootStore.HasPending,
SystemRestartPendingPackages = PendingRebootStore.PendingCount,
};
Expand Down Expand Up @@ -290,6 +292,12 @@ private static IpcCommandResult NavigateApp(IpcAppNavigateRequest request)
case "bundles":
window.Navigate(PageType.Bundles);
break;
case "catalog":
case "catalog-editor":
if (!window.SoftwareCatalogEnabled)
throw new InvalidOperationException("Software Catalog 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":
window.Navigate(PageType.Settings);
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ private sealed record Entry(
new("Administrator rights and other dangerous settings", ["administrator", "dangerous"], typeof(Administrator), null),

// ── Experimental ─────────────────────────────────────────────────────
new("Enable Software Catalog", ["catalog", "software catalog"], typeof(Experimental), "SoftwareCatalogCard"),
new("Show UniGetUI's version and build number on the titlebar.", ["version number", "titlebar", "build number"], typeof(Experimental), "ShowVersionNumberOnTitlebar"),
new("Enable background api (UniGetUI Widgets and Sharing, port 7058)", ["api", "widgets", "sharing", "port"], typeof(Experimental), "BackgroundApiCard"),
new("Disable the 1-minute timeout for package-related operations", ["timeout"], typeof(Experimental), "DisableTimeoutCard"),
Expand Down
48 changes: 48 additions & 0 deletions src/UniGetUI.Avalonia/Models/CatalogPackageIdentitySet.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using UniGetUI.PackageEngine.Interfaces;

namespace UniGetUI.Avalonia.Models;

/// <summary>
/// A per-refresh snapshot of package identities, using the same aliases and case rules as CatalogEntry.
/// Construction takes expected O(packages) time and space; each tile membership check takes expected O(1).
/// Rebuild on each refresh so inventory removals and completed operations cannot leave stale identities.
/// </summary>
internal sealed class CatalogPackageIdentitySet
{
private readonly HashSet<Identity> _identities = new(new IdentityComparer());

public CatalogPackageIdentitySet(IEnumerable<IPackage> packages, bool installedInventory = false)
{
foreach (var package in packages)
{
var manager = package.Manager;
// Only installed Chocolatey inventory loses feed provenance. Pending operations remain source-specific.
string? source = installedInventory
&& string.Equals(manager.Id, "chocolatey", StringComparison.OrdinalIgnoreCase)
? null
: package.Source.Name;
_identities.Add(new Identity(manager.Id, package.Id, source));
_identities.Add(new Identity(manager.Name, package.Id, source));
_identities.Add(new Identity(manager.DisplayName, package.Id, source));
}
}

public bool Contains(CatalogEntry entry) =>
_identities.Contains(new Identity(entry.ManagerName, entry.Id, entry.Source))
|| _identities.Contains(new Identity(entry.ManagerName, entry.Id, null));

private readonly record struct Identity(string Manager, string Id, string? Source);

private sealed class IdentityComparer : IEqualityComparer<Identity>
{
public bool Equals(Identity x, Identity y) =>
StringComparer.OrdinalIgnoreCase.Equals(x.Manager, y.Manager)
&& StringComparer.OrdinalIgnoreCase.Equals(x.Id, y.Id)
&& StringComparer.OrdinalIgnoreCase.Equals(x.Source, y.Source);

public int GetHashCode(Identity identity) => HashCode.Combine(
StringComparer.OrdinalIgnoreCase.GetHashCode(identity.Manager),
StringComparer.OrdinalIgnoreCase.GetHashCode(identity.Id),
identity.Source is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(identity.Source));
}
}
147 changes: 147 additions & 0 deletions src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using UniGetUI.Core.Data;
using UniGetUI.PackageEngine.Classes.Serializable;
using UniGetUI.PackageEngine.Interfaces;

namespace UniGetUI.Avalonia.Models;

public sealed class CatalogDocument
{
[JsonPropertyName("version")]
public required int Version { get; init; }

[JsonPropertyName("catalogs")]
public required CatalogDefinition[] Catalogs { get; init; }
}

public sealed class CatalogDefinition
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("packages")]
public required CatalogEntry[] Packages { get; init; }
}

public sealed class CatalogEntry
{
public required string Id { get; init; }
public required string Name { get; init; }
public string Version { get; set; } = "";
public required string Source { get; init; }
public required string ManagerName { get; init; }

public static CatalogEntry FromPackage(IPackage package) => new()
{
Id = package.Id,
Name = package.Name,
Version = "",
Source = package.Source.Name,
ManagerName = package.Manager.DisplayName,
};

public SerializablePackage AsSerializable() => new()
{
Id = Id,
Name = Name,
Version = Version,
Source = Source,
ManagerName = ManagerName,
};

public bool Matches(string id, string manager, string source) =>
string.Equals(Id, id, StringComparison.OrdinalIgnoreCase)
&& string.Equals(ManagerName, manager, StringComparison.OrdinalIgnoreCase)
&& string.Equals(Source, source, StringComparison.OrdinalIgnoreCase);

public bool MatchesManager(IPackageManager manager) =>
string.Equals(ManagerName, manager.Id, StringComparison.OrdinalIgnoreCase)
|| string.Equals(ManagerName, manager.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(ManagerName, manager.DisplayName, StringComparison.OrdinalIgnoreCase);

public bool Matches(IPackage package) =>
string.Equals(Id, package.Id, StringComparison.OrdinalIgnoreCase)
&& string.Equals(Source, package.Source.Name, StringComparison.OrdinalIgnoreCase)
&& MatchesManager(package.Manager);

public bool MatchesInstalled(IPackage package) =>
string.Equals(Id, package.Id, StringComparison.OrdinalIgnoreCase)
&& MatchesManager(package.Manager)
// Chocolatey's installed inventory assigns DefaultSource and does not retain feed provenance.
&& (string.Equals(package.Manager.Id, "chocolatey", StringComparison.OrdinalIgnoreCase)
|| string.Equals(Source, package.Source.Name, StringComparison.OrdinalIgnoreCase));
}

internal static class SoftwareCatalog
{
public static string FilePath => Path.Combine(CoreData.UniGetUIGlobalDirectory, "SoftwareCatalog.json");

internal static async Task SaveAsync(string filePath, CatalogDefinition[] catalogs)
{
var document = new CatalogDocument { Version = 1, Catalogs = catalogs };
string json = JsonSerializer.Serialize(document, CatalogJsonContext.Default.CatalogDocument);
Parse(json);
string destination = Path.GetFullPath(filePath);
string temporary = Path.Combine(Path.GetDirectoryName(destination)!, $".catalog-{Guid.NewGuid():N}.tmp");
try
{
await File.WriteAllTextAsync(temporary, json + Environment.NewLine);
if (File.Exists(destination))
File.Replace(temporary, destination, null);
else
File.Move(temporary, destination);
}
finally
{
if (File.Exists(temporary))
File.Delete(temporary);
}
}

public static Task<CatalogDefinition[]> LoadAsync() => LoadAsync(FilePath);

internal static async Task<CatalogDefinition[]> LoadAsync(string filePath)
{
await using var stream = File.OpenRead(filePath);
using var reader = new StreamReader(stream);
return Parse(await reader.ReadToEndAsync());
}

internal static CatalogDefinition[] Parse(string json)
{
var document = JsonSerializer.Deserialize(json, CatalogJsonContext.Default.CatalogDocument)
?? throw new JsonException("The software catalog must be a versioned JSON object.");
if (document.Version != 1)
throw new JsonException($"Unsupported software catalog version: {document.Version}.");
var catalogs = document.Catalogs
?? throw new JsonException("The software catalog requires a catalogs array.");
var catalogIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var catalog in catalogs)
{
if (catalog is null || string.IsNullOrWhiteSpace(catalog.Id)
|| string.IsNullOrWhiteSpace(catalog.Name) || catalog.Packages is null)
throw new JsonException("Every catalog requires an id, name and packages array.");
if (!catalogIds.Add(catalog.Id))
throw new JsonException($"Duplicate catalog identifier: {catalog.Id}.");

var identities = new HashSet<(string, string, string)>();
foreach (var entry in catalog.Packages)
{
if (entry is null || string.IsNullOrWhiteSpace(entry.Name)
|| string.IsNullOrWhiteSpace(entry.Id) || string.IsNullOrWhiteSpace(entry.ManagerName)
|| string.IsNullOrWhiteSpace(entry.Source) || entry.Version is null)
throw new JsonException("Every catalog entry requires Name, Id, ManagerName and Source, with a non-null Version.");

if (!identities.Add((entry.Id.ToUpperInvariant(), entry.ManagerName.ToUpperInvariant(), entry.Source.ToUpperInvariant())))
throw new JsonException($"Duplicate package in catalog {catalog.Id}: {entry.ManagerName}/{entry.Source}/{entry.Id}.");
}
}
return catalogs;
}
}

[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(CatalogDocument))]
internal partial class CatalogJsonContext : JsonSerializerContext;
Loading
Loading