diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index 0ff69b169d..8dbc7fade6 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -1075,5 +1075,9 @@ "Could not import settings": "Could not import settings", "The installer for {package} does not match the hash in its manifest": "The installer for {package} does not match the hash in its manifest", "The package manifest is likely out of date. WinGet cannot skip this check while running as administrator.": "The package manifest is likely out of date. WinGet cannot skip this check while running as administrator.", - "The package manifest is likely out of date. Skipping this check requires WinGet's InstallerHashOverride administrator setting.": "The package manifest is likely out of date. Skipping this check requires WinGet's InstallerHashOverride administrator setting." + "The package manifest is likely out of date. Skipping this check requires WinGet's InstallerHashOverride administrator setting.": "The package manifest is likely out of date. Skipping this check requires WinGet's InstallerHashOverride administrator setting.", + "Subfolder for each package:": "Subfolder for each package:", + "%PACKAGE% is replaced with the package ID, and %NAME% with the package name.": "%PACKAGE% is replaced with the package ID, and %NAME% with the package name.", + "Package name": "Package name", + "No subfolder": "No subfolder" } diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs index a8b1ac4d16..4048fc823e 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/InstallOptionsViewModel.cs @@ -48,6 +48,7 @@ public partial class InstallOptionsViewModel : ObservableObject public string ParamsUpdateLabel { get; } = CoreTools.Translate("Custom update arguments:"); public string ParamsUninstallLabel { get; } = CoreTools.Translate("Custom uninstall arguments:"); public string CliArgsHintLabel { get; } = CoreTools.Translate("These fields are independent: an argument set for Install won't apply to Update or Uninstall, and vice versa."); + public string LocationPlaceholderHintLabel { get; } = CoreTools.Translate("%PACKAGE% is replaced with the package ID, and %NAME% with the package name."); public string EnvVarSyntaxHintLabel { get; } = Settings.Get(Settings.K.ExpandEnvVarsWithPercentSyntax) ? CoreTools.Translate("Environment variables use %VARIABLE% syntax.") : CoreTools.Translate("Environment variables use syntax."); diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs index a5690fd5d8..d883a7f52d 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/OperationViewModel.cs @@ -308,7 +308,7 @@ private static void ShowPackageDetails(PackageOperation packageOp) private static async Task ShowInstallOptionsAsync(PackageOperation packageOp) { if (GetMainWindow() is not { } mainWindow) return; - var opts = await InstallOptionsFactory.LoadApplicableAsync(packageOp.Package); + var opts = await InstallOptionsFactory.LoadForPackageAsync(packageOp.Package); var win = new InstallOptionsWindow(packageOp.Package, OperationType.None, opts); await win.ShowDialog(mainWindow); await InstallOptionsFactory.SaveForPackageAsync(opts, packageOp.Package); diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/InstallOptionsPanelViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/InstallOptionsPanelViewModel.cs index 9f145aa667..52bc03f42b 100644 --- a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/InstallOptionsPanelViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/InstallOptionsPanelViewModel.cs @@ -18,6 +18,9 @@ public partial class InstallOptionsPanelViewModel : ViewModelBase { private readonly IPackageManager _manager; private readonly string _defaultLocationLabel; + private readonly string _subfolderIdLabel = CoreTools.Translate("Package ID"); + private readonly string _subfolderNameLabel = CoreTools.Translate("Package name"); + private readonly string _subfolderNoneLabel = CoreTools.Translate("No subfolder"); public event EventHandler? NavigateToAdministratorRequested; @@ -55,6 +58,8 @@ public partial class InstallOptionsPanelViewModel : ViewModelBase [ObservableProperty] private bool _locationSelectEnabled; [ObservableProperty] private bool _locationResetEnabled; [ObservableProperty] private string _locationText = ""; + [ObservableProperty] private ObservableCollection _subfolderItems = []; + [ObservableProperty] private string? _selectedSubfolder; // ── CLI args ────────────────────────────────────────────────────────────── [ObservableProperty] private bool _cliSectionEnabled; @@ -74,6 +79,8 @@ public partial class InstallOptionsPanelViewModel : ViewModelBase public string LocationLabel { get; } = CoreTools.Translate("Install location:"); public string SelectDirLabel { get; } = CoreTools.Translate("Select"); public string ResetDirLabel { get; } = CoreTools.Translate("Reset"); + public string SubfolderLabel { get; } = CoreTools.Translate("Subfolder for each package:"); + public string LocationPlaceholderHintLabel { get; } = CoreTools.Translate("%PACKAGE% is replaced with the package ID, and %NAME% with the package name."); public string InstallArgsLabel { get; } = CoreTools.Translate("Custom install arguments:"); public string UpdateArgsLabel { get; } = CoreTools.Translate("Custom update arguments:"); public string UninstallArgsLabel { get; } = CoreTools.Translate("Custom uninstall arguments:"); @@ -92,11 +99,18 @@ public partial class InstallOptionsPanelViewModel : ViewModelBase public double ArchOpacity => ArchitectureEnabled ? 1.0 : 0.5; public double ScopeOpacity => ScopeEnabled ? 1.0 : 0.5; public double LocationOpacity => LocationSelectEnabled ? 1.0 : 0.5; + public bool SubfolderEnabled => LocationSelectEnabled && LocationResetEnabled; partial void OnCliSectionEnabledChanged(bool value) => OnPropertyChanged(nameof(CliOpacity)); partial void OnArchitectureEnabledChanged(bool value) => OnPropertyChanged(nameof(ArchOpacity)); partial void OnScopeEnabledChanged(bool value) => OnPropertyChanged(nameof(ScopeOpacity)); - partial void OnLocationSelectEnabledChanged(bool value) => OnPropertyChanged(nameof(LocationOpacity)); + partial void OnLocationResetEnabledChanged(bool value) => OnPropertyChanged(nameof(SubfolderEnabled)); + + partial void OnLocationSelectEnabledChanged(bool value) + { + OnPropertyChanged(nameof(LocationOpacity)); + OnPropertyChanged(nameof(SubfolderEnabled)); + } // Mark HasChanges when user edits options (guards against firing during load) partial void OnAdminCheckedChanged(bool value) => HasChanges = !IsLoading; @@ -107,6 +121,13 @@ public partial class InstallOptionsPanelViewModel : ViewModelBase partial void OnSelectedArchitectureChanged(string? value) => HasChanges = !IsLoading; partial void OnSelectedScopeChanged(string? value) => HasChanges = !IsLoading; + partial void OnSelectedSubfolderChanged(string? value) + { + if (IsLoading || value is null || !LocationResetEnabled) return; + LocationText = _withSubfolder(LocationText, value); + HasChanges = true; + } + public InstallOptionsPanelViewModel(IPackageManager manager) { _manager = manager; @@ -122,6 +143,11 @@ public InstallOptionsPanelViewModel(IPackageManager manager) _scopeItems.Add(CoreTools.Translate(CommonTranslations.ScopeNames[PackageScope.Local])); _scopeItems.Add(CoreTools.Translate(CommonTranslations.ScopeNames[PackageScope.Global])); + _subfolderItems.Add(_subfolderIdLabel); + _subfolderItems.Add(_subfolderNameLabel); + _subfolderItems.Add(_subfolderNoneLabel); + _selectedSubfolder = _subfolderIdLabel; + _ = DoLoadOptions(); } @@ -236,6 +262,7 @@ private async Task DoLoadOptions() { LocationText = options.CustomInstallLocation; LocationResetEnabled = true; + SelectedSubfolder = _subfolderLabelFor(LocationText); } else { @@ -243,6 +270,7 @@ private async Task DoLoadOptions() ? _defaultLocationLabel : CoreTools.Translate("Install location can't be changed for {0} packages", _manager.DisplayName); LocationResetEnabled = false; + SelectedSubfolder = _subfolderIdLabel; } // CLI @@ -267,7 +295,7 @@ private async Task SelectLocation(Visual? visual) if (folders is not [{ } folder]) return; var path = folder.TryGetLocalPath(); if (string.IsNullOrEmpty(path)) return; - LocationText = path.TrimEnd('/').TrimEnd('\\') + "/%PACKAGE%"; + LocationText = _withSubfolder(path, SelectedSubfolder); LocationResetEnabled = true; HasChanges = true; } @@ -275,11 +303,74 @@ private async Task SelectLocation(Visual? visual) [RelayCommand] private void ResetLocation() { - LocationText = _defaultLocationLabel; LocationResetEnabled = false; + LocationText = _defaultLocationLabel; + SelectedSubfolder = _subfolderIdLabel; HasChanges = true; } + private string _subfolderLabelFor(string location) + { + string trimmed = location.TrimEnd('/', '\\'); + + if (trimmed.EndsWith(InstallOptionsFactory.PackageNamePlaceholder, StringComparison.OrdinalIgnoreCase)) + return _subfolderNameLabel; + + if (trimmed.EndsWith(InstallOptionsFactory.PackageIdPlaceholder, StringComparison.OrdinalIgnoreCase)) + return _subfolderIdLabel; + + return _subfolderNoneLabel; + } + + private string _withSubfolder(string location, string? subfolderLabel) + { + string[] placeholders = + [ + InstallOptionsFactory.PackageIdPlaceholder, + InstallOptionsFactory.PackageNamePlaceholder, + ]; + + string basePath = location.TrimEnd('/', '\\'); + + foreach (var placeholder in placeholders) + { + if (basePath.EndsWith(placeholder, StringComparison.OrdinalIgnoreCase)) + { + basePath = basePath[..^placeholder.Length]; + break; + } + } + + basePath = _asDirectoryPath(basePath); + + string subfolder = + subfolderLabel == _subfolderNameLabel ? InstallOptionsFactory.PackageNamePlaceholder + : subfolderLabel == _subfolderIdLabel ? InstallOptionsFactory.PackageIdPlaceholder + : ""; + + if (basePath.Length is 0) + return subfolder; + + if (subfolder.Length is 0) + return basePath; + + return basePath[^1] is '/' or '\\' + ? basePath + subfolder + : basePath + Path.DirectorySeparatorChar + subfolder; + } + + private static string _asDirectoryPath(string path) + { + string trimmed = path.TrimEnd('/', '\\'); + + if (trimmed.Length is 0) + return path.Length is 0 ? path : path[..1]; + + return trimmed.Length is 2 && trimmed[1] is ':' + ? trimmed + Path.DirectorySeparatorChar + : trimmed; + } + // ── Navigation ──────────────────────────────────────────────────────────── [RelayCommand] diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsControl.axaml b/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsControl.axaml index eb3768bf03..10fbb0fc29 100644 --- a/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsControl.axaml +++ b/src/UniGetUI.Avalonia/Views/DialogPages/InstallOptionsControl.axaml @@ -343,6 +343,12 @@ FontSize="14" HorizontalAlignment="Stretch"/> + + + + + + + + param; try { + var exported = pkg.installation_options.Copy(); + exported.CustomInstallLocation = InstallOptionsFactory.ExpandPackagePlaceholders( + exported.CustomInstallLocation, pkg); param = pkg.Manager.OperationHelper.GetStandaloneParameters( - pkg, pkg.installation_options, OperationType.Install); + pkg, exported, OperationType.Install); } catch (InvalidOperationException ex) { diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/InstallOptionsFactory.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/InstallOptionsFactory.cs index 06903147ef..682d4706f5 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/InstallOptionsFactory.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/InstallOptionsFactory.cs @@ -18,6 +18,9 @@ namespace UniGetUI.PackageEngine.PackageClasses /// public static class InstallOptionsFactory { + public const string PackageIdPlaceholder = "%PACKAGE%"; + public const string PackageNamePlaceholder = "%NAME%"; + public static bool IsIdentityScopedOptionsFile(string fileName) => StoragePath.IsIdentityScoped(fileName); @@ -162,14 +165,12 @@ public static InstallOptions LoadApplicable( $"Package {package.Id} does not override options, will use package manager's default..." ); instance = LoadForManager(package.Manager); - - var legalizedId = CoreTools.MakeValidFileName(package.Id); - instance.CustomInstallLocation = instance.CustomInstallLocation.Replace( - "%PACKAGE%", - legalizedId - ); } + instance.CustomInstallLocation = ExpandPackagePlaceholders( + instance.CustomInstallLocation, + package + ); instance.CustomInstallLocationIsExplicit = locationIsExplicit; if (elevated is not null) @@ -399,6 +400,25 @@ private static void _expandAndSanitizeCliArguments(List parameters) } } + public static string ExpandPackagePlaceholders(string location, IPackage package) + { + if (!location.Contains('%')) + return location; + + string legalizedId = _legalizeFolderName(package.Id); + string legalizedName = _legalizeFolderName(package.Name); + + if (legalizedName.Length is 0) + legalizedName = legalizedId; + + return location + .Replace(PackageIdPlaceholder, legalizedId, StringComparison.OrdinalIgnoreCase) + .Replace(PackageNamePlaceholder, legalizedName, StringComparison.OrdinalIgnoreCase); + } + + private static string _legalizeFolderName(string value) => + CoreTools.MakeValidFileName(value.Replace("%", "")); + private static string _expandEnvironmentVariables(string value) { if (string.IsNullOrEmpty(value)) diff --git a/src/UniGetUI.PackageEngine.Tests/InstallOptionsFactoryTests.cs b/src/UniGetUI.PackageEngine.Tests/InstallOptionsFactoryTests.cs index c8af25c80d..07dec27718 100644 --- a/src/UniGetUI.PackageEngine.Tests/InstallOptionsFactoryTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/InstallOptionsFactoryTests.cs @@ -259,6 +259,165 @@ public void LoadApplicable_UsesManagerDefaultsAndExpandsPackageToken() Assert.False(resolved.CustomInstallLocationIsExplicit); } + [Fact] + public void LoadApplicable_ExpandsNameTokenFromManagerDefaults() + { + var manager = new PackageManagerBuilder().WithName($"Manager{Guid.NewGuid():N}").Build(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("Notepad++.Notepad++") + .WithName("Notepad++") + .Build(); + + InstallOptionsFactory.SaveForManager( + new InstallOptions { CustomInstallLocation = @"D:\Programs\%NAME%" }, + manager + ); + InstallOptionsFactory.SaveForPackage(new InstallOptions(), package); + + var resolved = InstallOptionsFactory.LoadApplicable(package); + + Assert.Equal(@"D:\Programs\Notepad++", resolved.CustomInstallLocation); + Assert.False(resolved.CustomInstallLocationIsExplicit); + } + + [Fact] + public void LoadApplicable_ExpandsTokensInExplicitPackageLocation() + { + var manager = new PackageManagerBuilder().WithName($"Manager{Guid.NewGuid():N}").Build(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Tool") + .WithName("Contoso Tool") + .Build(); + + InstallOptionsFactory.SaveForPackage( + new InstallOptions + { + OverridesNextLevelOpts = true, + CustomInstallLocation = @"D:\Programs\%name%\%package%", + }, + package + ); + + var resolved = InstallOptionsFactory.LoadApplicable(package); + + Assert.Equal(@"D:\Programs\Contoso Tool\Contoso.Tool", resolved.CustomInstallLocation); + Assert.True(resolved.CustomInstallLocationIsExplicit); + } + + [Fact] + public void LoadApplicable_SanitizesNameTokenForUseAsAFolderName() + { + var manager = new PackageManagerBuilder().WithName($"Manager{Guid.NewGuid():N}").Build(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId($"Pkg{Guid.NewGuid():N}") + .WithName("Contoso: Tool*") + .Build(); + + InstallOptionsFactory.SaveForManager( + new InstallOptions { CustomInstallLocation = @"D:\Programs\%NAME%" }, + manager + ); + InstallOptionsFactory.SaveForPackage(new InstallOptions(), package); + + var resolved = InstallOptionsFactory.LoadApplicable(package); + + Assert.Equal(@"D:\Programs\Contoso Tool", resolved.CustomInstallLocation); + } + + [Fact] + public void LoadApplicable_DoesNotLetPackageMetadataIntroduceEnvironmentVariables() + { + var varName = $"UNIGETUI_TEST_{Guid.NewGuid():N}"; + Environment.SetEnvironmentVariable(varName, @"C:\Expanded"); + try + { + Settings.Set(Settings.K.ExpandEnvVarsWithPercentSyntax, true); + var manager = new PackageManagerBuilder().WithName($"Manager{Guid.NewGuid():N}").Build(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId($"Contoso.%{varName}%") + .WithName($"Contoso %{varName}% Tool") + .Build(); + + InstallOptionsFactory.SaveForManager( + new InstallOptions { CustomInstallLocation = @"D:\Programs\%NAME%\%PACKAGE%" }, + manager + ); + InstallOptionsFactory.SaveForPackage(new InstallOptions(), package); + + var resolved = InstallOptionsFactory.LoadApplicable(package); + + Assert.Equal( + $@"D:\Programs\Contoso {varName} Tool\Contoso.{varName}", + resolved.CustomInstallLocation + ); + Assert.DoesNotContain( + @"C:\Expanded", + resolved.CustomInstallLocation, + StringComparison.Ordinal + ); + } + finally + { + Settings.Set(Settings.K.ExpandEnvVarsWithPercentSyntax, false); + Environment.SetEnvironmentVariable(varName, null); + } + } + + [Theory] + [InlineData("..")] + [InlineData(@"..\..\Windows\System32")] + [InlineData("../../etc")] + [InlineData(" ")] + [InlineData("CON")] + public void LoadApplicable_KeepsTheNameTokenInsideTheConfiguredDirectory(string name) + { + var manager = new PackageManagerBuilder().WithName($"Manager{Guid.NewGuid():N}").Build(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId($"Pkg{Guid.NewGuid():N}") + .WithName(name) + .Build(); + + InstallOptionsFactory.SaveForManager( + new InstallOptions { CustomInstallLocation = @"D:\Programs\%NAME%" }, + manager + ); + InstallOptionsFactory.SaveForPackage(new InstallOptions(), package); + + var resolved = InstallOptionsFactory.LoadApplicable(package); + + Assert.StartsWith(@"D:\Programs\", resolved.CustomInstallLocation, StringComparison.Ordinal); + Assert.Equal( + @"D:\Programs", + Path.GetDirectoryName(Path.GetFullPath(resolved.CustomInstallLocation)) + ); + } + + [Fact] + public void LoadApplicable_FallsBackToPackageIdWhenNameCannotBeAFolderName() + { + var manager = new PackageManagerBuilder().WithName($"Manager{Guid.NewGuid():N}").Build(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Tool") + .WithName("?*|") + .Build(); + + InstallOptionsFactory.SaveForManager( + new InstallOptions { CustomInstallLocation = @"D:\Programs\%NAME%" }, + manager + ); + InstallOptionsFactory.SaveForPackage(new InstallOptions(), package); + + var resolved = InstallOptionsFactory.LoadApplicable(package); + + Assert.Equal(@"D:\Programs\Contoso.Tool", resolved.CustomInstallLocation); + } + [Fact] public void LoadApplicable_MarksLocationExplicitOnlyForPerPackageOverrides() {