diff --git a/src/OneWare.Essentials/Controls/SegmentedControl.axaml b/src/OneWare.Essentials/Controls/SegmentedControl.axaml
new file mode 100644
index 000000000..da14375d4
--- /dev/null
+++ b/src/OneWare.Essentials/Controls/SegmentedControl.axaml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OneWare.Essentials/Controls/SegmentedControl.cs b/src/OneWare.Essentials/Controls/SegmentedControl.cs
new file mode 100644
index 000000000..b19430b06
--- /dev/null
+++ b/src/OneWare.Essentials/Controls/SegmentedControl.cs
@@ -0,0 +1,70 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Mixins;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
+using Avalonia.Input;
+using Avalonia.Layout;
+
+namespace OneWare.Essentials.Controls;
+
+///
+/// A generic single-selection segmented control that lays its items out horizontally.
+/// Items can be plain objects or instances.
+///
+public class SegmentedControl : SelectingItemsControl
+{
+ private static readonly FuncTemplate DefaultPanel =
+ new(() => new StackPanel { Orientation = Orientation.Horizontal });
+
+ static SegmentedControl()
+ {
+ SelectionModeProperty.OverrideDefaultValue(
+ SelectionMode.Single | SelectionMode.AlwaysSelected);
+ ItemsPanelProperty.OverrideDefaultValue(DefaultPanel);
+ }
+
+ protected override Type StyleKeyOverride => typeof(SegmentedControl);
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+
+ if (e.Source is Visual source && e.GetCurrentPoint(source).Properties.IsLeftButtonPressed)
+ e.Handled = UpdateSelectionFromEventSource(e.Source);
+ }
+
+ protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey)
+ {
+ return new SegmentedControlItem();
+ }
+
+ protected override bool NeedsContainerOverride(object? item, int index, out object? recycleKey)
+ {
+ return NeedsContainer(item, out recycleKey);
+ }
+}
+
+///
+/// Container for an item hosted inside a .
+///
+public class SegmentedControlItem : ContentControl, ISelectable
+{
+ public static readonly StyledProperty IsSelectedProperty =
+ SelectingItemsControl.IsSelectedProperty.AddOwner();
+
+ static SegmentedControlItem()
+ {
+ SelectableMixin.Attach(IsSelectedProperty);
+ PressedMixin.Attach();
+ FocusableProperty.OverrideDefaultValue(true);
+ }
+
+ public bool IsSelected
+ {
+ get => GetValue(IsSelectedProperty);
+ set => SetValue(IsSelectedProperty, value);
+ }
+
+ protected override Type StyleKeyOverride => typeof(SegmentedControlItem);
+}
diff --git a/src/OneWare.Essentials/Controls/SharedControls.axaml b/src/OneWare.Essentials/Controls/SharedControls.axaml
index 661ef1aba..40bd6c68f 100644
--- a/src/OneWare.Essentials/Controls/SharedControls.axaml
+++ b/src/OneWare.Essentials/Controls/SharedControls.axaml
@@ -6,5 +6,6 @@
+
\ No newline at end of file
diff --git a/src/OneWare.Essentials/Extensions/StringExtensions.cs b/src/OneWare.Essentials/Extensions/StringExtensions.cs
index 6b59f355f..53edebb6d 100644
--- a/src/OneWare.Essentials/Extensions/StringExtensions.cs
+++ b/src/OneWare.Essentials/Extensions/StringExtensions.cs
@@ -35,7 +35,7 @@ public static bool EqualUrls(this string url1, string url2)
StringComparison.OrdinalIgnoreCase)
&& string.Equals(uri1.Query, uri2.Query, StringComparison.Ordinal);
}
-
+
public static bool ContainsSubPath(this string pathToFile, string subPath)
{
pathToFile = Path.GetDirectoryName(pathToFile) + "\\";
diff --git a/src/OneWare.Essentials/PackageManager/Package.cs b/src/OneWare.Essentials/PackageManager/Package.cs
index 7c6a6d41f..406bb9ec5 100644
--- a/src/OneWare.Essentials/PackageManager/Package.cs
+++ b/src/OneWare.Essentials/PackageManager/Package.cs
@@ -19,6 +19,8 @@ public class Package
public string? UrlLaunchIds { get; init; }
public string? IconUrl { get; init; }
+
+ public string? Icon { get; set; }
public string? SourceUrl { get; init; }
diff --git a/src/OneWare.Essentials/PackageManager/PackageManifest.cs b/src/OneWare.Essentials/PackageManager/PackageManifest.cs
index acf71c79d..0cabd2bac 100644
--- a/src/OneWare.Essentials/PackageManager/PackageManifest.cs
+++ b/src/OneWare.Essentials/PackageManager/PackageManifest.cs
@@ -1,6 +1,12 @@
-namespace OneWare.Essentials.PackageManager;
+using System.Text.Json;
+
+namespace OneWare.Essentials.PackageManager;
public class PackageManifest
{
public string? ManifestUrl { get; init; }
+
+ public JsonElement? Content { get; init; }
+
+ public string? Icon { get; init; }
}
\ No newline at end of file
diff --git a/src/OneWare.Essentials/Services/IPackageService.cs b/src/OneWare.Essentials/Services/IPackageService.cs
index 4110e1047..cd869e84c 100644
--- a/src/OneWare.Essentials/Services/IPackageService.cs
+++ b/src/OneWare.Essentials/Services/IPackageService.cs
@@ -41,6 +41,13 @@ public interface IPackageService : INotifyPropertyChanged
///
void RegisterPackageRepository(string url);
+ ///
+ /// Registers a package repository URL with fallback URLs.
+ /// Uses the first URL of the array that works.
+ ///
+ ///
+ void RegisterPackageRepositoryWithFallback(string[] urls);
+
///
/// Registers a package installer for a package type.
///
diff --git a/src/OneWare.PackageManager/Services/IPackageCatalog.cs b/src/OneWare.PackageManager/Services/IPackageCatalog.cs
index 78a7b2815..41d25411b 100644
--- a/src/OneWare.PackageManager/Services/IPackageCatalog.cs
+++ b/src/OneWare.PackageManager/Services/IPackageCatalog.cs
@@ -6,7 +6,7 @@ public interface IPackageCatalog
{
IReadOnlyDictionary Manifests { get; }
- Task RefreshAsync(IEnumerable sources, CancellationToken cancellationToken = default);
+ Task RefreshAsync(IEnumerable repositories, CancellationToken cancellationToken = default);
void RegisterStandalone(Package package);
}
diff --git a/src/OneWare.PackageManager/Services/PackageCatalog.cs b/src/OneWare.PackageManager/Services/PackageCatalog.cs
index 0602863d5..12fd11e9f 100644
--- a/src/OneWare.PackageManager/Services/PackageCatalog.cs
+++ b/src/OneWare.PackageManager/Services/PackageCatalog.cs
@@ -26,27 +26,35 @@ public void RegisterStandalone(Package package)
_manifests[package.Id] = package;
}
- public async Task RefreshAsync(IEnumerable sources, CancellationToken cancellationToken = default)
+ public async Task RefreshAsync(IEnumerable repositories, CancellationToken cancellationToken = default)
{
var result = true;
var newPackages = new Dictionary();
- foreach (var source in sources)
+ foreach (var repository in repositories)
{
- IReadOnlyList loaded;
- try
+ IReadOnlyList loaded = new List();
+ foreach (var source in repository)
{
- loaded = await _repositoryClient.LoadRepositoryAsync(source, cancellationToken);
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception e)
- {
- _logger.Error($"Failed to refresh package source '{source}'.", e);
- result = false;
- continue;
+ try
+ {
+ loaded = await _repositoryClient.LoadRepositoryAsync(source, cancellationToken);
+ if(loaded.Count == 0)
+ {
+ continue;
+ }
+ break;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception e)
+ {
+ _logger.Error($"Failed to refresh package source '{source}'.", e);
+ result = false;
+ continue;
+ }
}
if (loaded.Count == 0)
diff --git a/src/OneWare.PackageManager/Services/PackageRepositoryClient.cs b/src/OneWare.PackageManager/Services/PackageRepositoryClient.cs
index d38bdfe61..06dffcdf9 100644
--- a/src/OneWare.PackageManager/Services/PackageRepositoryClient.cs
+++ b/src/OneWare.PackageManager/Services/PackageRepositoryClient.cs
@@ -1,5 +1,4 @@
using System.Text.Json;
-using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using OneWare.Essentials.PackageManager;
using OneWare.Essentials.Services;
@@ -29,13 +28,12 @@ public async Task> LoadRepositoryAsync(string url,
{
try
{
- var repositoryString = await _httpService.DownloadTextAsync(url, TimeSpan.FromSeconds(10));
+ var repositoryString = await _httpService.DownloadTextAsync(url, TimeSpan.FromSeconds(10), cancellationToken);
if (repositoryString == null) return Array.Empty();
- var trimmed = WhitespaceRegex().Replace(repositoryString, "");
var packages = new List();
- if (trimmed.StartsWith("{\"packages\":", StringComparison.OrdinalIgnoreCase))
+ if (IsRepositoryJson(repositoryString))
{
try
{
@@ -45,12 +43,7 @@ public async Task> LoadRepositoryAsync(string url,
foreach (var manifest in repository.Packages)
try
{
- if (manifest.ManifestUrl == null) continue;
-
- var downloadManifest =
- await _httpService.DownloadTextAsync(manifest.ManifestUrl);
-
- var package = JsonSerializer.Deserialize(downloadManifest!, SerializerOptions);
+ var package = await LoadPackageManifestAsync(manifest, cancellationToken);
if (package != null) packages.Add(package);
}
@@ -97,10 +90,57 @@ public async Task> LoadRepositoryAsync(string url,
catch (Exception e)
{
_logger.Error($"Failed to load package source '{url}'.", e);
- return Array.Empty();
+ return [];
}
}
- [GeneratedRegex(@"\s+")]
- private static partial Regex WhitespaceRegex();
+ private async Task LoadPackageManifestAsync(PackageManifest manifest,
+ CancellationToken cancellationToken)
+ {
+ var manifestContent = GetManifestContent(manifest.Content);
+
+ if (manifestContent == null)
+ {
+ if (string.IsNullOrWhiteSpace(manifest.ManifestUrl))
+ return null;
+
+ manifestContent = await _httpService.DownloadTextAsync(manifest.ManifestUrl, cancellationToken: cancellationToken);
+ }
+
+ if (string.IsNullOrWhiteSpace(manifestContent))
+ return null;
+
+ var package = JsonSerializer.Deserialize(manifestContent, SerializerOptions);
+
+ if (package != null && string.IsNullOrWhiteSpace(package.Icon) && !string.IsNullOrWhiteSpace(manifest.Icon))
+ package.Icon = manifest.Icon;
+
+ return package;
+ }
+
+ private static bool IsRepositoryJson(string json)
+ {
+ using var document = JsonDocument.Parse(json, new JsonDocumentOptions
+ {
+ AllowTrailingCommas = true
+ });
+
+ return document.RootElement.ValueKind == JsonValueKind.Object
+ && document.RootElement.TryGetProperty("packages", out var packages)
+ && packages.ValueKind == JsonValueKind.Array;
+ }
+
+ private static string? GetManifestContent(JsonElement? content)
+ {
+ if (content is not { } contentElement) return null;
+
+ return contentElement.ValueKind switch
+ {
+ JsonValueKind.String => string.IsNullOrWhiteSpace(contentElement.GetString())
+ ? null
+ : contentElement.GetString(),
+ JsonValueKind.Object => contentElement.GetRawText(),
+ _ => null
+ };
+ }
}
diff --git a/src/OneWare.PackageManager/Services/PackageService.cs b/src/OneWare.PackageManager/Services/PackageService.cs
index d38f40b98..9e322b025 100644
--- a/src/OneWare.PackageManager/Services/PackageService.cs
+++ b/src/OneWare.PackageManager/Services/PackageService.cs
@@ -1,7 +1,10 @@
using System;
using System.Collections.ObjectModel;
+using System.Text;
using System.Threading;
using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Svg.Skia;
using CommunityToolkit.Mvvm.ComponentModel;
using DynamicData;
using Microsoft.Extensions.Logging;
@@ -31,7 +34,7 @@ public class PackageService : ObservableObject, IPackageService
private readonly Dictionary _installersByType = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary> _activeInstalls = new();
private readonly Dictionary _installCancellation = new();
- private readonly List _repositoryUrls = [];
+ private readonly List _repositoryUrls = [];
private Task? _currentRefreshTask;
@@ -96,7 +99,12 @@ public void RegisterPackage(Package package)
public void RegisterPackageRepository(string url)
{
- _repositoryUrls.Add(url);
+ _repositoryUrls.Add([url]);
+ }
+
+ public void RegisterPackageRepositoryWithFallback(string[] urls)
+ {
+ _repositoryUrls.Add(urls);
}
public void RegisterInstaller(string packageType) where T : IPackageInstaller
@@ -285,11 +293,74 @@ public Task CheckCompatibilityAsync(string packageId, Packa
public async Task DownloadPackageIconAsync(Package package)
{
+ if (!string.IsNullOrWhiteSpace(package.Icon))
+ return CreateImageFromBase64(package.Icon, package.Id);
+
if (package.IconUrl == null) return null;
return await _httpService.DownloadImageAsync(package.IconUrl);
}
+ private IImage? CreateImageFromBase64(string icon, string? packageId)
+ {
+ try
+ {
+ var base64 = icon.Trim();
+ var mediaType = string.Empty;
+
+ if (base64.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
+ {
+ var separatorIndex = base64.IndexOf(',');
+ if (separatorIndex < 0)
+ {
+ _logger.Warning($"Invalid embedded icon data URI for package {packageId}.");
+ return null;
+ }
+
+ mediaType = base64[..separatorIndex];
+ base64 = base64[(separatorIndex + 1)..];
+ }
+
+ var bytes = Convert.FromBase64String(base64);
+ using var stream = new MemoryStream(bytes);
+
+ if (IsSvgIcon(bytes, mediaType))
+ {
+ var svg = SvgSource.LoadFromStream(stream);
+ return svg is null
+ ? null
+ : new SvgImage
+ {
+ Source = svg
+ };
+ }
+
+ return new Bitmap(stream);
+ }
+ catch (FormatException e)
+ {
+ _logger.Warning($"Invalid embedded icon base64 for package {packageId}: {e.Message}");
+ }
+ catch (ArgumentException e)
+ {
+ _logger.Warning($"Invalid embedded icon for package {packageId}: {e.Message}");
+ }
+ catch (NotSupportedException e)
+ {
+ _logger.Warning($"Unsupported embedded icon for package {packageId}: {e.Message}");
+ }
+
+ return null;
+ }
+
+ private static bool IsSvgIcon(byte[] bytes, string mediaType)
+ {
+ if (mediaType.Contains("svg", StringComparison.OrdinalIgnoreCase)) return true;
+
+ var prefix = Encoding.UTF8.GetString(bytes.AsSpan(0, Math.Min(bytes.Length, 512)));
+ return prefix.Contains("