Skip to content

Commit 9c4f4da

Browse files
committed
Packages: support multiple unofficial catalog URLs
1 parent 65e7ca8 commit 9c4f4da

9 files changed

Lines changed: 171 additions & 16 deletions

File tree

src/BasisPM.App/Localization/Languages/en.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,10 @@
490490
"key": "packages.badge.github",
491491
"value": "github"
492492
},
493+
{
494+
"key": "packages.badge.unofficial",
495+
"value": "UNOFFICIAL"
496+
},
493497
{
494498
"key": "packages.bundle.basisLine",
495499
"value": "Basis: {0} · Unity {1}"
@@ -1210,6 +1214,26 @@
12101214
"key": "settings.catalog.title",
12111215
"value": "Package Catalog URL"
12121216
},
1217+
{
1218+
"key": "settings.extraCatalogs.add",
1219+
"value": "Add"
1220+
},
1221+
{
1222+
"key": "settings.extraCatalogs.description",
1223+
"value": "Add package catalogs from other sources. These are UNOFFICIAL — not reviewed or vetted by BasisVR — so install from them at your own risk. Official Basis packages always take priority over an unofficial one with the same id."
1224+
},
1225+
{
1226+
"key": "settings.extraCatalogs.remove",
1227+
"value": "Remove this catalog"
1228+
},
1229+
{
1230+
"key": "settings.extraCatalogs.title",
1231+
"value": "Additional Catalogs (Unofficial)"
1232+
},
1233+
{
1234+
"key": "settings.extraCatalogs.watermark",
1235+
"value": "https://example.com/catalog.json"
1236+
},
12131237
{
12141238
"key": "settings.clone.description",
12151239
"value": "Where new Basis clones are created by default."

src/BasisPM.App/Styles/BasisTheme.axaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
<Color x:Key="BasisDanger">#EF1237</Color>
3535
<Color x:Key="BasisSuccess">#22C55E</Color>
36+
<Color x:Key="BasisWarning">#F59E0B</Color>
3637

3738
<SolidColorBrush x:Key="BasisBackgroundBrush" Color="{StaticResource BasisBackground}"/>
3839
<SolidColorBrush x:Key="BasisPanelBrush" Color="{StaticResource BasisPanel}"/>
@@ -58,6 +59,7 @@
5859
<SolidColorBrush x:Key="BasisPinkBrush" Color="{StaticResource BasisPink}"/>
5960
<SolidColorBrush x:Key="BasisDangerBrush" Color="{StaticResource BasisDanger}"/>
6061
<SolidColorBrush x:Key="BasisSuccessBrush" Color="{StaticResource BasisSuccess}"/>
62+
<SolidColorBrush x:Key="BasisWarningBrush" Color="{StaticResource BasisWarning}"/>
6163

6264
<SolidColorBrush x:Key="BasisAccentBrush" Color="{StaticResource BasisBrand}"/>
6365

@@ -378,6 +380,15 @@
378380
<Setter Property="Padding" Value="10,3"/>
379381
</Style>
380382

383+
<!-- Caution pill for packages from an unofficial (user-added) catalog. -->
384+
<Style Selector="Border.unofficialBadge">
385+
<Setter Property="Background" Value="#26F59E0B"/>
386+
<Setter Property="BorderBrush" Value="#80F59E0B"/>
387+
<Setter Property="BorderThickness" Value="1"/>
388+
<Setter Property="CornerRadius" Value="999"/>
389+
<Setter Property="Padding" Value="8,2"/>
390+
</Style>
391+
381392
<Style Selector="Border.versionPill">
382393
<Setter Property="Background" Value="{StaticResource BasisSurfaceMutedBrush}"/>
383394
<Setter Property="BorderBrush" Value="{StaticResource BasisBorderBrush}"/>

src/BasisPM.App/ViewModels/MainWindowViewModel.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ public async Task InitializeAsync()
292292
ShowDevelopTab = settings.DeveloperMode;
293293
PackagesVM.SetInitialGridView(settings.PackagesGridView);
294294
await InstallsVM.LoadAsync(settings);
295-
await PackagesVM.LoadCatalogAsync(settings.CatalogUrl);
295+
await PackagesVM.LoadCatalogAsync(settings.CatalogUrl, settings.ExtraCatalogUrls);
296296

297297
// Handle a launch-time deep link now (active install + packages are ready) — don't wait on the slower Unity refresh.
298298
if (DeepLinkDispatcher.Pending is { } pending)

src/BasisPM.App/ViewModels/PackagesViewModel.cs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ public sealed class PackagesViewModel : ObservableObject
2929
private readonly List<InstalledPackageRow> _allInstalled = new();
3030
private BasisInstall? _selectedInstall;
3131
private bool _syncingSelection;
32+
// Configured catalog sources, remembered so Refresh reloads exactly what's configured.
33+
private string? _officialCatalogUrl;
34+
private IReadOnlyList<string> _extraCatalogUrls = Array.Empty<string>();
35+
// Package ids that came only from an unofficial (extra) catalog — drives the "Unofficial" badge.
36+
private readonly HashSet<string> _unofficialIds = new(StringComparer.OrdinalIgnoreCase);
3237

3338
private static string AllOwnersLabel => L.Tr("packages.filter.allOwners");
3439

@@ -162,20 +167,43 @@ public void SetInstallOptions(IReadOnlyList<BasisInstall> installs)
162167
_syncingSelection = false;
163168
}
164169

165-
public async Task LoadCatalogAsync(string? url)
170+
public Task LoadCatalogAsync(string? officialUrl, IReadOnlyList<string>? extraUrls = null)
171+
{
172+
_officialCatalogUrl = officialUrl;
173+
_extraCatalogUrls = extraUrls ?? Array.Empty<string>();
174+
return ReloadCatalogAsync();
175+
}
176+
177+
/// <summary>Reloads the official Basis catalog plus any configured unofficial extras, merged into
178+
/// one list. The official catalog always wins a package-id conflict; extras contribute only ids it
179+
/// doesn't already define, tracked in <see cref="_unofficialIds"/> so they can be badged.</summary>
180+
private async Task ReloadCatalogAsync()
166181
{
167182
IsBusy = true;
168183
try
169184
{
170-
_catalog = await _catalogService.LoadAsync(url);
185+
var merged = await _catalogService.LoadAsync(_officialCatalogUrl);
186+
_unofficialIds.Clear();
187+
foreach (var extraUrl in _extraCatalogUrls)
188+
{
189+
var extra = await _catalogService.TryLoadAsync(extraUrl?.Trim() ?? "");
190+
if (extra is null) continue;
191+
foreach (var kv in extra.Packages)
192+
{
193+
if (merged.Packages.ContainsKey(kv.Key)) continue; // official / earlier extra wins
194+
merged.Packages[kv.Key] = kv.Value;
195+
_unofficialIds.Add(kv.Key);
196+
}
197+
}
198+
_catalog = merged;
171199
Refilter();
172200
}
173201
finally { IsBusy = false; }
174202
}
175203

176204
private async Task RefreshAsync()
177205
{
178-
await LoadCatalogAsync(null);
206+
await ReloadCatalogAsync();
179207
if (_install is not null && _install.HasUnityProject)
180208
{
181209
try
@@ -200,7 +228,7 @@ private void Refilter()
200228
continue;
201229

202230
var installedVersion = _install?.Manifest.Dependencies.GetValueOrDefault(v.Name);
203-
Available.Add(new PackageRow(v, installedVersion));
231+
Available.Add(new PackageRow(v, installedVersion, _unofficialIds.Contains(v.Name)));
204232
}
205233

206234
// Keep an open detail panel pointed at the refreshed row so its installed-state stays current.
@@ -643,7 +671,7 @@ private static string Slugify(string s)
643671
private static void OpenUrl(string url) => ExternalLink.Open(url);
644672
}
645673

646-
public sealed record PackageRow(CatalogPackageVersion Entry, string? InstalledVersion)
674+
public sealed record PackageRow(CatalogPackageVersion Entry, string? InstalledVersion, bool IsUnofficial = false)
647675
{
648676
public string DisplayName => Entry.DisplayName;
649677
public string Name => Entry.Name;

src/BasisPM.App/ViewModels/SettingsViewModel.cs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.ObjectModel;
12
using BasisPM.App.Localization;
23
using BasisPM.Core.Models;
34
using BasisPM.Core.Services;
@@ -13,6 +14,7 @@ public sealed class SettingsViewModel : ObservableObject
1314

1415
private string _clonePath = "";
1516
private string _catalogUrl = "";
17+
private string _newCatalogUrl = "";
1618
private string _unityHubPath = "";
1719
private bool _showLocalChanges;
1820
private bool _developerMode;
@@ -23,6 +25,10 @@ public sealed class SettingsViewModel : ObservableObject
2325

2426
public string ClonePath { get => _clonePath; set => SetField(ref _clonePath, value); }
2527
public string CatalogUrl { get => _catalogUrl; set => SetField(ref _catalogUrl, value); }
28+
// Extra, user-added catalog URLs — all UNOFFICIAL (not vetted by BasisVR).
29+
public ObservableCollection<CatalogUrlItem> ExtraCatalogs { get; } = new();
30+
// URL typed into the "add" field before it's committed to the list.
31+
public string NewCatalogUrl { get => _newCatalogUrl; set => SetField(ref _newCatalogUrl, value); }
2632
public string UnityHubPath { get => _unityHubPath; set => SetField(ref _unityHubPath, value); }
2733
public bool ShowLocalChanges { get => _showLocalChanges; set => SetField(ref _showLocalChanges, value); }
2834
public bool DeveloperMode { get => _developerMode; set => SetField(ref _developerMode, value); }
@@ -47,6 +53,8 @@ public LanguageInfo? SelectedLanguage
4753
public LogsViewModel Logs { get; }
4854

4955
public RelayCommand SaveCommand { get; }
56+
public RelayCommand AddCatalogCommand { get; }
57+
public RelayCommand<CatalogUrlItem> RemoveCatalogCommand { get; }
5058
public RelayCommand CheckForUpdatesCommand => _shell.CheckForUpdatesCommand;
5159

5260
public SettingsViewModel(UserSettingsService settingsService, GitService gitService, UnityHubService hubService, MainWindowViewModel shell, LogsViewModel logs)
@@ -58,6 +66,8 @@ public SettingsViewModel(UserSettingsService settingsService, GitService gitServ
5866
Logs = logs;
5967
SettingsPath = settingsService.SettingsPath;
6068
SaveCommand = new RelayCommand(SaveAsync);
69+
AddCatalogCommand = new RelayCommand(AddCatalog);
70+
RemoveCatalogCommand = new RelayCommand<CatalogUrlItem>(item => { if (item is not null) ExtraCatalogs.Remove(item); });
6171
_selectedLanguage = FindLanguage(Localizer.Instance.CurrentCode);
6272
// Re-pull the localized "detected tooling" fallbacks when the language changes.
6373
Localizer.Instance.LanguageChanged += _ => RefreshDetected();
@@ -68,6 +78,9 @@ public void Apply(UserSettings settings)
6878
{
6979
ClonePath = settings.ClonePath ?? "";
7080
CatalogUrl = settings.CatalogUrl;
81+
ExtraCatalogs.Clear();
82+
foreach (var u in settings.ExtraCatalogUrls)
83+
if (!string.IsNullOrWhiteSpace(u)) ExtraCatalogs.Add(new CatalogUrlItem(u));
7184
UnityHubPath = settings.UnityHubPath ?? "";
7285
ShowLocalChanges = settings.ShowLocalChanges;
7386
DeveloperMode = settings.DeveloperMode;
@@ -102,17 +115,39 @@ private async Task SaveAsync()
102115
var settings = await _settingsService.LoadAsync();
103116
settings.ClonePath = string.IsNullOrWhiteSpace(ClonePath) ? null : ClonePath.Trim();
104117
settings.CatalogUrl = CatalogUrl?.Trim() ?? "";
118+
settings.ExtraCatalogUrls = ExtraCatalogs
119+
.Select(c => c.Url?.Trim() ?? "")
120+
.Where(u => u.Length > 0)
121+
.Distinct(StringComparer.OrdinalIgnoreCase)
122+
.ToList();
105123
settings.UnityHubPath = string.IsNullOrWhiteSpace(UnityHubPath) ? null : UnityHubPath.Trim();
106124
settings.ShowLocalChanges = ShowLocalChanges;
107125
settings.DeveloperMode = DeveloperMode;
108126
settings.PrereleaseUpdates = PrereleaseUpdates;
109127
await _settingsService.SaveAsync(settings);
110128

111-
await _shell.PackagesVM.LoadCatalogAsync(settings.CatalogUrl);
129+
await _shell.PackagesVM.LoadCatalogAsync(settings.CatalogUrl, settings.ExtraCatalogUrls);
112130
_shell.ShowChangesTab = settings.ShowLocalChanges;
113131
_shell.ShowDevelopTab = settings.DeveloperMode;
114132
_shell.ApplyPrerelease(settings.PrereleaseUpdates);
115133
RefreshDetected();
116134
_shell.SetStatus(L.Tr("settings.status.saved"), StatusKind.Success);
117135
}
136+
137+
private void AddCatalog()
138+
{
139+
var url = NewCatalogUrl?.Trim();
140+
if (string.IsNullOrWhiteSpace(url)) return;
141+
if (!ExtraCatalogs.Any(c => string.Equals(c.Url?.Trim(), url, StringComparison.OrdinalIgnoreCase)))
142+
ExtraCatalogs.Add(new CatalogUrlItem(url));
143+
NewCatalogUrl = "";
144+
}
145+
}
146+
147+
/// <summary>One user-added (unofficial) catalog URL row in Settings; editable in place.</summary>
148+
public sealed class CatalogUrlItem : ObservableObject
149+
{
150+
private string _url;
151+
public CatalogUrlItem(string url) => _url = url;
152+
public string Url { get => _url; set => SetField(ref _url, value); }
118153
}

src/BasisPM.App/Views/PackagesView.axaml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@
103103
<Border Background="{StaticResource BasisSurfaceHoverBrush}" CornerRadius="999" Padding="8,2" VerticalAlignment="Center">
104104
<TextBlock Text="{Binding Owner}" FontSize="10" FontWeight="Bold" Classes="muted"/>
105105
</Border>
106+
<Border Classes="unofficialBadge" VerticalAlignment="Center" IsVisible="{Binding IsUnofficial}">
107+
<TextBlock Text="{loc:Tr packages.badge.unofficial}" FontSize="9" FontWeight="Bold"
108+
Foreground="{StaticResource BasisWarningBrush}"/>
109+
</Border>
106110
</StackPanel>
107111
<TextBlock Text="{Binding Name}" Classes="dim" FontSize="11"/>
108112
<TextBlock Text="{Binding Description}" Classes="muted" FontSize="12" TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis" Margin="0,3,0,0"/>
@@ -116,7 +120,7 @@
116120
</Border>
117121
<TextBlock Text="{Binding Author, StringFormat='by {0}'}" Classes="dim" FontSize="11" VerticalAlignment="Center" IsVisible="{Binding HasAuthor}"/>
118122
<TextBlock Text="{Binding Unity, StringFormat='Unity {0}'}" Classes="dim" FontSize="11" VerticalAlignment="Center" IsVisible="{Binding HasUnity}"/>
119-
<Button Content="Website ↗" Classes="link" VerticalAlignment="Center" IsVisible="{Binding HasLink}"
123+
<Button Content="🔗" FontSize="14" Classes="link" VerticalAlignment="Center" IsVisible="{Binding HasLink}"
120124
ToolTip.Tip="{Binding Link}"
121125
Command="{Binding #Root.((vm:PackagesViewModel)DataContext).OpenLinkCommand}"
122126
CommandParameter="{Binding Link}"/>
@@ -153,6 +157,10 @@
153157
<TextBlock Text="{Binding DisplayName}" FontWeight="SemiBold" FontSize="13"
154158
TextTrimming="CharacterEllipsis" MaxWidth="150"/>
155159
<TextBlock Text="{Binding Owner}" Classes="dim" FontSize="10"/>
160+
<Border Classes="unofficialBadge" HorizontalAlignment="Left" Margin="0,2,0,0" IsVisible="{Binding IsUnofficial}">
161+
<TextBlock Text="{loc:Tr packages.badge.unofficial}" FontSize="9" FontWeight="Bold"
162+
Foreground="{StaticResource BasisWarningBrush}"/>
163+
</Border>
156164
</StackPanel>
157165
</StackPanel>
158166
<TextBlock Text="{Binding Description}" Classes="muted" FontSize="11" TextWrapping="Wrap"
@@ -165,7 +173,7 @@
165173
ToolTip.Tip="{loc:Tr packages.label.license}">
166174
<TextBlock Text="{Binding License, StringFormat='⚖ {0}'}" FontSize="10" Classes="muted"/>
167175
</Border>
168-
<Button Content="Website ↗" Classes="link" VerticalAlignment="Center" IsVisible="{Binding HasLink}"
176+
<Button Content="🔗" FontSize="14" Classes="link" VerticalAlignment="Center" IsVisible="{Binding HasLink}"
169177
ToolTip.Tip="{Binding Link}"
170178
Command="{Binding #Root.((vm:PackagesViewModel)DataContext).OpenLinkCommand}"
171179
CommandParameter="{Binding Link}"/>
@@ -256,6 +264,10 @@
256264
<StackPanel Orientation="Horizontal" Spacing="10" Margin="0,3,0,0">
257265
<TextBlock Text="{Binding Author, StringFormat='by {0}'}" Classes="muted" FontSize="12" IsVisible="{Binding HasAuthor}"/>
258266
<TextBlock Text="{Binding Owner}" Classes="dim" FontSize="12"/>
267+
<Border Classes="unofficialBadge" VerticalAlignment="Center" IsVisible="{Binding IsUnofficial}">
268+
<TextBlock Text="{loc:Tr packages.badge.unofficial}" FontSize="9" FontWeight="Bold"
269+
Foreground="{StaticResource BasisWarningBrush}"/>
270+
</Border>
259271
</StackPanel>
260272
</StackPanel>
261273
<Button Grid.Column="2" Content="" FontSize="15" Padding="11,6" VerticalAlignment="Top"

src/BasisPM.App/Views/SettingsView.axaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,31 @@
4949
<TextBox Text="{Binding CatalogUrl}" Watermark="https://basisvr.org/packages/catalog.json"/>
5050
</StackPanel>
5151

52+
<StackPanel Spacing="6">
53+
<TextBlock Text="{loc:Tr settings.extraCatalogs.title}" Classes="h3"/>
54+
<TextBlock Text="{loc:Tr settings.extraCatalogs.description}"
55+
Classes="muted" FontSize="12" TextWrapping="Wrap"/>
56+
<ItemsControl ItemsSource="{Binding ExtraCatalogs}">
57+
<ItemsControl.ItemTemplate>
58+
<DataTemplate DataType="vm:CatalogUrlItem">
59+
<Grid ColumnDefinitions="*,Auto" Margin="0,0,0,6">
60+
<TextBox Grid.Column="0" Text="{Binding Url}"/>
61+
<Button Grid.Column="1" Content="" Margin="8,0,0,0" Classes="danger"
62+
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveCatalogCommand}"
63+
CommandParameter="{Binding}"
64+
ToolTip.Tip="{loc:Tr settings.extraCatalogs.remove}"/>
65+
</Grid>
66+
</DataTemplate>
67+
</ItemsControl.ItemTemplate>
68+
</ItemsControl>
69+
<Grid ColumnDefinitions="*,Auto">
70+
<TextBox Grid.Column="0" Text="{Binding NewCatalogUrl}"
71+
Watermark="{loc:Tr settings.extraCatalogs.watermark}"/>
72+
<Button Grid.Column="1" Content="{loc:Tr settings.extraCatalogs.add}" Margin="8,0,0,0"
73+
Classes="primary" Command="{Binding AddCatalogCommand}"/>
74+
</Grid>
75+
</StackPanel>
76+
5277
<StackPanel Spacing="6">
5378
<TextBlock Text="{loc:Tr settings.unityHub.title}" Classes="h3"/>
5479
<TextBlock Text="{loc:Tr settings.unityHub.description}"

src/BasisPM.Core/Models/UserSettings.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ public sealed class UserSettings
1717
[JsonPropertyName("catalogUrl")]
1818
public string CatalogUrl { get; set; } = "";
1919

20+
// Additional, user-added catalog URLs. These are UNOFFICIAL (not vetted by BasisVR): their
21+
// packages are merged into the list and badged as such, and can never override an official
22+
// package id (the Basis catalog above always wins a conflict).
23+
[JsonPropertyName("extraCatalogUrls")]
24+
public List<string> ExtraCatalogUrls { get; set; } = new();
25+
2026
[JsonPropertyName("unityHubPath")]
2127
public string? UnityHubPath { get; set; }
2228

0 commit comments

Comments
 (0)