Skip to content

Commit c6b9906

Browse files
committed
Cross-platform hardening (Platform/DiskSpace/services) + project-card layout & UI polish
1 parent 67cdcd5 commit c6b9906

29 files changed

Lines changed: 736 additions & 129 deletions

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,10 @@
566566
"key": "packages.button.gridView",
567567
"value": "Grid view"
568568
},
569+
{
570+
"key": "packages.button.installBundleFile",
571+
"value": "Install bundle from file…"
572+
},
569573
{
570574
"key": "packages.button.install",
571575
"value": "Install"
@@ -670,6 +674,14 @@
670674
"key": "packages.label.project",
671675
"value": "Project"
672676
},
677+
{
678+
"key": "packages.picker.openBundle",
679+
"value": "Open bundle file"
680+
},
681+
{
682+
"key": "packages.picker.saveBundle",
683+
"value": "Save bundle"
684+
},
673685
{
674686
"key": "packages.recommended.name",
675687
"value": "Basis Recommended"
@@ -690,10 +702,22 @@
690702
"key": "packages.status.addedFromGitHub",
691703
"value": "{0} {1}{2} from GitHub."
692704
},
705+
{
706+
"key": "packages.status.bundleFileEmpty",
707+
"value": "That bundle file has no packages."
708+
},
709+
{
710+
"key": "packages.status.bundleFileInvalid",
711+
"value": "That file isn’t a valid bundle: {0}"
712+
},
693713
{
694714
"key": "packages.status.bundleInstallFailed",
695715
"value": "Bundle install failed: {0}"
696716
},
717+
{
718+
"key": "packages.status.bundleSaved",
719+
"value": "Saved bundle “{0}” ({1} package(s)) → {2}"
720+
},
697721
{
698722
"key": "packages.status.chooseProject",
699723
"value": "Choose a project first."
@@ -1672,7 +1696,7 @@
16721696
},
16731697
{
16741698
"key": "dialog.createBundle.create",
1675-
"value": "Create submission"
1699+
"value": "Submit"
16761700
},
16771701
{
16781702
"key": "dialog.createBundle.descriptionLabel",
@@ -1692,7 +1716,7 @@
16921716
},
16931717
{
16941718
"key": "dialog.createBundle.intro",
1695-
"value": "Share your Basis + packages as an installable bundle. It opens a GitHub issue to submit."
1719+
"value": "Bundle your Basis + packages. Save it to a file to keep or share locally, or submit it to the public registry."
16961720
},
16971721
{
16981722
"key": "dialog.createBundle.nameLabel",
@@ -1702,6 +1726,10 @@
17021726
"key": "dialog.createBundle.packagesLabel",
17031727
"value": "Packages to include"
17041728
},
1729+
{
1730+
"key": "dialog.createBundle.saveLocal",
1731+
"value": "Save to file"
1732+
},
17051733
{
17061734
"key": "dialog.createBundle.title",
17071735
"value": "Create bundle"

src/BasisPM.App/Services/DeepLink.cs

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Diagnostics;
2-
using System.Runtime.InteropServices;
32

43
namespace BasisPM.App.Services;
54

@@ -61,16 +60,25 @@ private static bool TryParse(string? uri, string expectedHost, out Func<string,
6160
}
6261

6362
/// <summary>
64-
/// Registers the <c>basispm://</c> scheme so the OS routes links to this app.
65-
/// Only for installed (Velopack) Windows builds — dev runs are tested by launching
66-
/// the exe with the URI argument directly.
63+
/// Registers the <c>basispm://</c> scheme so the OS routes links to this app. Only for installed
64+
/// (Velopack) builds — dev runs are tested by launching the exe with the URI argument directly.
65+
/// Windows writes the HKCU class; Linux writes an XDG <c>.desktop</c> handler; macOS declares the
66+
/// scheme via <c>CFBundleURLTypes</c> in the app bundle's Info.plist at packaging time (LaunchServices
67+
/// reads it on install), so there is nothing to register at runtime there.
6768
/// </summary>
6869
public static void RegisterProtocolIfPackaged(bool isPackaged)
6970
{
70-
if (!isPackaged || !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
71+
if (!isPackaged) return;
7172
var exe = Environment.ProcessPath;
7273
if (string.IsNullOrEmpty(exe)) return;
7374

75+
if (OperatingSystem.IsWindows()) RegisterWindows(exe);
76+
else if (OperatingSystem.IsLinux()) RegisterLinux(exe);
77+
// macOS handled at packaging time (Info.plist), see summary.
78+
}
79+
80+
private static void RegisterWindows(string exe)
81+
{
7482
var root = $@"HKCU\Software\Classes\{Scheme}";
7583
try
7684
{
@@ -90,4 +98,44 @@ private static void Reg(string key, params string[] rest)
9098
psi.ArgumentList.Add("/f");
9199
Process.Start(psi)?.WaitForExit(3000);
92100
}
101+
102+
/// <summary>The XDG desktop-entry filename that owns the scheme (also the id passed to xdg-mime).</summary>
103+
public const string LinuxDesktopFile = "basispm-url-handler.desktop";
104+
105+
/// <summary>The <c>.desktop</c> file body registering this app as the handler for <c>basispm://</c>. Pure, so it's testable.</summary>
106+
public static string LinuxDesktopEntry(string exe) =>
107+
"[Desktop Entry]\n" +
108+
"Type=Application\n" +
109+
"Name=Basis Package Manager\n" +
110+
$"Exec=\"{exe}\" %u\n" +
111+
"NoDisplay=true\n" +
112+
"StartupNotify=false\n" +
113+
"Terminal=false\n" +
114+
$"MimeType=x-scheme-handler/{Scheme};\n";
115+
116+
private static void RegisterLinux(string exe)
117+
{
118+
try
119+
{
120+
var appsDir = Path.Combine(
121+
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "applications");
122+
Directory.CreateDirectory(appsDir);
123+
File.WriteAllText(Path.Combine(appsDir, LinuxDesktopFile), LinuxDesktopEntry(exe));
124+
// Make this desktop entry the default handler for the scheme, then refresh the DB (both best-effort).
125+
RunQuiet("xdg-mime", "default", LinuxDesktopFile, $"x-scheme-handler/{Scheme}");
126+
RunQuiet("update-desktop-database", appsDir);
127+
}
128+
catch { /* best effort — deep links simply won't route until next successful run */ }
129+
}
130+
131+
private static void RunQuiet(string exe, params string[] args)
132+
{
133+
try
134+
{
135+
var psi = new ProcessStartInfo(exe) { UseShellExecute = false, CreateNoWindow = true };
136+
foreach (var a in args) psi.ArgumentList.Add(a);
137+
Process.Start(psi)?.WaitForExit(3000);
138+
}
139+
catch { }
140+
}
93141
}

src/BasisPM.App/Services/Dialogs.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using Avalonia.Controls;
22
using Avalonia.Controls.ApplicationLifetimes;
3+
using Avalonia.Platform.Storage;
4+
using BasisPM.App.Localization;
35
using BasisPM.App.Views;
46
using BasisPM.Core.Models;
57
using BasisPM.Core.Services;
@@ -12,6 +14,12 @@ public static class Dialogs
1214
private static Window? Owner =>
1315
Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime d ? d.MainWindow : null;
1416

17+
private static FilePickerFileType BundleFileType => new("Basis bundle")
18+
{
19+
Patterns = new[] { "*.json" },
20+
MimeTypes = new[] { "application/json" },
21+
};
22+
1523
/// <summary>Asks for a display name; returns the trimmed value, or null if cancelled/empty.</summary>
1624
public static async Task<string?> PromptAliasAsync(string title, string path, string suggested)
1725
{
@@ -44,6 +52,38 @@ public static async Task<bool> ConfirmAsync(string title, string message)
4452
return await new CreateBundleWindow(suggestedName, basisLine, candidates).ShowDialog<BundleDraft?>(owner);
4553
}
4654

55+
/// <summary>Prompts for a save location and writes the bundle JSON there; returns the saved path, or null if cancelled.</summary>
56+
public static async Task<string?> SaveBundleFileAsync(string suggestedFileName, string json)
57+
{
58+
var owner = Owner;
59+
if (owner is null) return null;
60+
var file = await owner.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
61+
{
62+
Title = L.Tr("packages.picker.saveBundle"),
63+
SuggestedFileName = suggestedFileName,
64+
DefaultExtension = "json",
65+
FileTypeChoices = new[] { BundleFileType },
66+
});
67+
var path = file?.TryGetLocalPath();
68+
if (string.IsNullOrEmpty(path)) return null;
69+
await File.WriteAllTextAsync(path, json);
70+
return path;
71+
}
72+
73+
/// <summary>Prompts the user to pick a local bundle file; returns its path, or null if cancelled.</summary>
74+
public static async Task<string?> OpenBundleFileAsync()
75+
{
76+
var owner = Owner;
77+
if (owner is null) return null;
78+
var files = await owner.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
79+
{
80+
Title = L.Tr("packages.picker.openBundle"),
81+
AllowMultiple = false,
82+
FileTypeFilter = new[] { BundleFileType },
83+
});
84+
return files?.FirstOrDefault()?.TryGetLocalPath();
85+
}
86+
4787
/// <summary>Collects pull-request details for a mounted package; returns the request, or null if cancelled.</summary>
4888
public static async Task<PrRequest?> SubmitPrAsync(string packageId)
4989
{

src/BasisPM.App/Styles/BasisTheme.axaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@
6363

6464
<SolidColorBrush x:Key="BasisAccentBrush" Color="{StaticResource BasisBrand}"/>
6565

66+
<!-- Monospace stack for code / logs / git URLs / diffs. Avalonia doesn't resolve the CSS
67+
generic "monospace", so real family names for all three desktop OSes are listed: Windows
68+
(Cascadia/Consolas), macOS (Menlo/Monaco), Linux (DejaVu/Liberation/Ubuntu/Noto). Without
69+
this, those fields fall back to the proportional UI font on Linux. -->
70+
<FontFamily x:Key="MonoFontFamily">Cascadia Mono, Cascadia Code, Consolas, Menlo, Monaco, DejaVu Sans Mono, Liberation Mono, Ubuntu Mono, Noto Sans Mono, Courier New, monospace</FontFamily>
71+
6672
<LinearGradientBrush x:Key="BasisAnimatedGradient" StartPoint="100%,0%" EndPoint="0%,100%">
6773
<GradientStop Offset="0" Color="#EF1237"/>
6874
<GradientStop Offset="0.33" Color="#9333EA"/>

src/BasisPM.App/ViewModels/PackagesViewModel.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ public PackageRow? SelectedPackage
9595
public RelayCommand RefreshCommand { get; }
9696
public RelayCommand AddFromGitHubCommand { get; }
9797
public RelayCommand CreateBundleCommand { get; }
98+
public RelayCommand InstallBundleFromFileCommand { get; }
9899
public RelayCommand<CatalogPackageVersion> ChooseVersionCommand { get; }
99100
public RelayCommand ToggleLayoutCommand { get; }
100101
public RelayCommand<string> OpenLinkCommand { get; }
@@ -114,6 +115,7 @@ public PackagesViewModel(UserSettingsService settingsService, CatalogService cat
114115
RefreshCommand = new RelayCommand(RefreshAsync);
115116
AddFromGitHubCommand = new RelayCommand(AddFromGitHubAsync);
116117
CreateBundleCommand = new RelayCommand(CreateBundleAsync);
118+
InstallBundleFromFileCommand = new RelayCommand(InstallBundleFromFileAsync);
117119
ChooseVersionCommand = new RelayCommand<CatalogPackageVersion>(ChooseVersionAsync);
118120
ToggleLayoutCommand = new RelayCommand(() => IsGridView = !IsGridView);
119121
OpenLinkCommand = new RelayCommand<string>(url => { if (!string.IsNullOrWhiteSpace(url)) ExternalLink.Open(url!); });
@@ -594,6 +596,16 @@ private async Task CreateBundleAsync()
594596
WriteIndented = true,
595597
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
596598
});
599+
600+
// Local: write the bundle JSON to a file the user keeps — no submission.
601+
if (draft.Destination == BundleDestination.SaveToFile)
602+
{
603+
var savedPath = await Dialogs.SaveBundleFileAsync(bundle.Id, json);
604+
if (savedPath is not null)
605+
_shell.SetStatus(L.Tr("packages.status.bundleSaved", draft.Name, draft.Packages.Count, savedPath), StatusKind.Success);
606+
return;
607+
}
608+
597609
var body = "### Bundle submission\n\nAdd this entry to `src/BasisPM.Server/seed/bundles.json`:\n\n```json\n" + json + "\n```\n";
598610
var url = "https://github.com/BasisVR/BasisPackageManager/issues/new?labels=bundle-submission"
599611
+ "&title=" + Uri.EscapeDataString("Add bundle: " + draft.Name)
@@ -602,6 +614,37 @@ private async Task CreateBundleAsync()
602614
_shell.SetStatus(L.Tr("packages.status.openingIssue", draft.Name, draft.Packages.Count), StatusKind.Success);
603615
}
604616

617+
/// <summary>Reads a local bundle file (as saved by "Save to file") and installs its packages into a chosen project.</summary>
618+
private async Task InstallBundleFromFileAsync()
619+
{
620+
var path = await Dialogs.OpenBundleFileAsync();
621+
if (string.IsNullOrEmpty(path)) return;
622+
623+
Bundle? bundle;
624+
try
625+
{
626+
var json = await File.ReadAllTextAsync(path);
627+
bundle = JsonSerializer.Deserialize<Bundle>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
628+
}
629+
catch (Exception ex)
630+
{
631+
_shell.SetStatus(L.Tr("packages.status.bundleFileInvalid", ex.Message), StatusKind.Error);
632+
return;
633+
}
634+
635+
if (bundle is null || bundle.Packages.Count == 0)
636+
{
637+
_shell.SetStatus(L.Tr("packages.status.bundleFileEmpty"), StatusKind.Error);
638+
return;
639+
}
640+
641+
var target = await _shell.ChooseInstallTargetAsync(L.Tr("shell.bundle.pickerLabel", bundle.Name, bundle.Packages.Count));
642+
if (target is null) return;
643+
644+
_shell.SetActiveInstall(target);
645+
await AddBundleAsync(bundle, target);
646+
}
647+
605648
/// <summary>Adds every package in a bundle to the target project's manifest (used by the bundle deep link).</summary>
606649
public async Task AddBundleAsync(Bundle bundle, BasisInstall target)
607650
{

src/BasisPM.App/Views/AnnouncementsView.axaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
1515
<StackPanel Grid.Column="0" Spacing="6">
1616
<TextBlock Text="{loc:Tr announcements.hero.tagline}" Classes="tagline"/>
17-
<TextBlock Text="{loc:Tr announcements.hero.title}" Classes="h1"/>
17+
<TextBlock Text="{loc:Tr announcements.hero.title}" Classes="h1" TextWrapping="Wrap"/>
1818
<TextBlock Text="{loc:Tr announcements.hero.subtitle}"
19-
Classes="muted" FontSize="14"/>
19+
Classes="muted" FontSize="14" TextWrapping="Wrap"/>
2020
</StackPanel>
2121
<Button Grid.Column="1" Classes="iconBtn" VerticalAlignment="Center"
2222
Command="{Binding RefreshCommand}" ToolTip.Tip="{loc:Tr announcements.button.refresh}">
@@ -49,7 +49,7 @@
4949
<TextBlock Text="{Binding DateDisplay}" Classes="dim" FontSize="12"
5050
VerticalAlignment="Center" IsVisible="{Binding HasDate}"/>
5151
</StackPanel>
52-
<TextBlock Text="{Binding Title}" Classes="h3" FontSize="17"/>
52+
<TextBlock Text="{Binding Title}" Classes="h3" FontSize="17" TextWrapping="Wrap"/>
5353
<TextBlock Text="{Binding Body}" Classes="muted" FontSize="13" TextWrapping="Wrap"/>
5454
<Button Content="{Binding LinkText}" HorizontalAlignment="Left"
5555
IsVisible="{Binding HasLink}"

src/BasisPM.App/Views/ChangesView.axaml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
1616
<StackPanel Grid.Column="0" Spacing="6">
1717
<TextBlock Text="{loc:Tr changes.hero.tagline}" Classes="tagline"/>
18-
<TextBlock Text="{Binding InstallName}" Classes="h1"/>
18+
<TextBlock Text="{Binding InstallName}" Classes="h1" TextWrapping="Wrap"/>
1919
<TextBlock Text="{loc:Tr changes.hero.subtitle}"
20-
Classes="muted" FontSize="14"/>
20+
Classes="muted" FontSize="14" TextWrapping="Wrap"/>
2121
</StackPanel>
2222
<Button Grid.Column="1" Classes="iconBtn" Command="{Binding RefreshCommand}" ToolTip.Tip="{loc:Tr changes.button.refresh}">
2323
<PathIcon Width="16" Height="16" Data="{StaticResource IconRefresh}"/>
@@ -63,7 +63,7 @@
6363
<ScrollViewer Grid.Row="1" Padding="16,12"
6464
HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
6565
<SelectableTextBlock Text="{Binding DiffText}"
66-
FontFamily="Cascadia Mono, Consolas, Menlo, monospace"
66+
FontFamily="{StaticResource MonoFontFamily}"
6767
FontSize="12" Foreground="{StaticResource BasisTextMutedBrush}"/>
6868
</ScrollViewer>
6969
</Grid>
@@ -74,7 +74,8 @@
7474
<StackPanel Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="0,60">
7575
<TextBlock Text="{loc:Tr changes.empty.title}" Classes="h3" HorizontalAlignment="Center"/>
7676
<TextBlock Text="{loc:Tr changes.empty.description}"
77-
Classes="muted" HorizontalAlignment="Center"/>
77+
Classes="muted" HorizontalAlignment="Center" TextAlignment="Center"
78+
MaxWidth="480" TextWrapping="Wrap"/>
7879
</StackPanel>
7980
</Border>
8081
</Grid>

0 commit comments

Comments
 (0)