Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,14 @@ protected override IReadOnlyList<Uri> GetScreenshots_UnSafe(IPackage package)

protected override string? GetInstallLocation_UnSafe(IPackage package)
{
return Path.Join(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cargo",
"bin"
);
// Cargo installs binaries into $CARGO_HOME/bin (default ~/.cargo/bin).
var cargoHome = Environment.GetEnvironmentVariable("CARGO_HOME");
if (string.IsNullOrEmpty(cargoHome))
cargoHome = Path.Join(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cargo"
);
return Path.Join(cargoHome, "bin");
}

protected override IReadOnlyList<string> GetInstallableVersions_UnSafe(IPackage package)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,21 @@ var base_path in new[]
return path_with_id;
}

return Path.Join(
Path.GetDirectoryName(Manager.Status.ExecutablePath),
"lib",
package.Id
);
// Chocolatey keeps each package under <ChocolateyInstall>\lib\<id>. Resolve the root
// from the environment variable, falling back to the executable's folder (stepping out
// of the \bin shim folder when choco.exe is found there).
string? chocoRoot = Environment.GetEnvironmentVariable("ChocolateyInstall");
if (string.IsNullOrEmpty(chocoRoot))
{
var exeDir = Path.GetDirectoryName(Manager.Status.ExecutablePath);
chocoRoot =
exeDir is not null
&& Path.GetFileName(exeDir).Equals("bin", StringComparison.OrdinalIgnoreCase)
? Path.GetDirectoryName(exeDir)
: exeDir;
}

return chocoRoot is null ? null : Path.Join(chocoRoot, "lib", package.Id);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,23 @@ protected override IReadOnlyList<Uri> GetScreenshots_UnSafe(IPackage package)
=> Array.Empty<Uri>();

protected override string? GetInstallLocation_UnSafe(IPackage package)
=> $"/var/lib/flatpak/app/{package.Id}";
{
// System-wide installs live under /var/lib/flatpak, per-user installs under
// ~/.local/share/flatpak. Return whichever actually contains the app.
foreach (var baseDir in new[]
{
"/var/lib/flatpak",
Path.Join(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".local", "share", "flatpak"),
})
{
var path = Path.Join(baseDir, "app", package.Id);
if (Directory.Exists(path))
return path;
}
return null;
}

protected override IReadOnlyList<string> GetInstallableVersions_UnSafe(IPackage package)
=> throw new InvalidOperationException("Flatpak does not support installing arbitrary versions");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,16 @@ protected override IReadOnlyList<Uri> GetScreenshots_UnSafe(IPackage package)

protected override string? GetInstallLocation_UnSafe(IPackage package)
{
// Formulae: /opt/homebrew/Cellar/<name> (Apple Silicon)
// /usr/local/Cellar/<name> (Intel)
foreach (var prefix in new[] { "/opt/homebrew", "/usr/local" })
// Apple Silicon prefix is /opt/homebrew, Intel/Linux is /usr/local (or /home/linuxbrew).
// Formulae live under Cellar/<name>, casks under Caskroom/<token>.
foreach (var prefix in new[] { "/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew" })
{
var path = Path.Join(prefix, "Cellar", package.Id);
if (Directory.Exists(path))
return path;
foreach (var kind in new[] { "Cellar", "Caskroom" })
{
var path = Path.Join(prefix, kind, package.Id);
if (Directory.Exists(path))
return path;
}
}
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,10 @@ protected override IReadOnlyList<Uri> GetScreenshots_UnSafe(IPackage package)
"node_modules",
package.Id
);
// ApplicationData already resolves to the Roaming folder; npm's default global prefix
// is %AppData%\npm, so global modules live under %AppData%\npm\node_modules.
return Path.Join(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Roaming",
"npm",
"node_modules",
package.Id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,13 +289,29 @@ protected override IReadOnlyList<Uri> GetScreenshots_UnSafe(IPackage package)

protected override string? GetInstallLocation_UnSafe(IPackage package)
{
return Path.Join(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"scoop",
"apps",
package.Id,
"current"
);
// Honor custom Scoop roots (SCOOP / SCOOP_GLOBAL) and both user and global installs,
// returning the first that actually exists.
string?[] roots =
[
Environment.GetEnvironmentVariable("SCOOP"),
Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "scoop"),
Environment.GetEnvironmentVariable("SCOOP_GLOBAL"),
Path.Join(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"scoop"
),
];

foreach (var root in roots)
{
if (string.IsNullOrEmpty(root))
continue;
var path = Path.Join(root, "apps", package.Id, "current");
if (Directory.Exists(path))
return path;
}

return null;
}

protected override IReadOnlyList<string> GetInstallableVersions_UnSafe(IPackage package)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,9 @@ protected override IReadOnlyList<Uri> GetScreenshots_UnSafe(IPackage package)
var VcpkgInstalledPath = Path.Join(rootPath, "installed", package.Id.Split(":")[1]);
return Directory.Exists(PackagePath)
? PackagePath
: (
Directory.Exists(VcpkgInstalledPath)
? VcpkgInstalledPath
: Path.GetDirectoryName(PackageId)
);
: Directory.Exists(VcpkgInstalledPath)
? VcpkgInstalledPath
: null;
}

protected override IReadOnlyList<string> GetInstallableVersions_UnSafe(IPackage package)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using Microsoft.Management.Deployment;
using Microsoft.Win32;
using UniGetUI.Core.IconEngine;
Expand Down Expand Up @@ -208,4 +210,156 @@ string ending in new[]

return null;
}

private const long MsixCacheTtlMs = 30_000;
private static readonly Lock _msixLock = new();
private static Dictionary<string, string>? _msixIndex; // normalized name -> install path
private static long _msixBuiltAt = long.MinValue;

/// <summary>
/// Resolves an MSIX/Store package whose full name is encoded in the Id (Microsoft Store
/// source). Cheap — no package enumeration.
/// </summary>
public static string? GetMsixLocationFromId(IPackage package)
{
if (!package.Id.StartsWith("MSIX\\", StringComparison.OrdinalIgnoreCase))
return null;

var fullName = package.Id["MSIX\\".Length..];
foreach (
var root in new[]
{
Path.Join(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
"WindowsApps",
fullName
),
Path.Join(
Environment.GetFolderPath(Environment.SpecialFolder.Windows),
"SystemApps",
fullName
),
}
)
if (Directory.Exists(root))
return root;

return null;
}

/// <summary>
/// Resolves an MSIX/Store package by matching it against the installed AppX packages. These do
/// not appear in the "Add/Remove programs" registry, so this enumerates them via the platform
/// API (cached). Used as a last resort because the enumeration has a cost.
/// </summary>
public static string? GetMsixLocationByName(IPackage package)
{
var index = GetMsixIndex();
if (index.Count == 0)
return null;

string[] candidates = [NormalizeMsix(package.Id), NormalizeMsix(package.Name)];

// Exact match first (most reliable).
foreach (var candidate in candidates)
if (candidate.Length >= 3 && index.TryGetValue(candidate, out var location))
return location;

// Then a length-guarded fuzzy match (prefix or containment) to bridge identity/display-name
// differences, e.g. winget "Microsoft.AppInstaller" / "App Installer" vs the MSIX identity
// "Microsoft.DesktopAppInstaller". Only reached as a last resort, over the small MSIX set.
foreach (var candidate in candidates)
{
if (candidate.Length < 6)
continue;
foreach (var (key, location) in index)
if (
key.StartsWith(candidate, StringComparison.Ordinal)
|| candidate.StartsWith(key, StringComparison.Ordinal)
|| key.Contains(candidate, StringComparison.Ordinal)
)
return location;
}

return null;
}

private static Dictionary<string, string> GetMsixIndex()
{
lock (_msixLock)
{
var now = Environment.TickCount64;
if (_msixIndex is null || now - _msixBuiltAt > MsixCacheTtlMs)
{
_msixIndex = BuildMsixIndex();
_msixBuiltAt = now;
}
return _msixIndex;
}
}

private const string AppxRepositoryKey =
@"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";

private static Dictionary<string, string> BuildMsixIndex()
{
// Read MSIX install locations straight from the AppModel repository registry rather than the
// WinRT PackageManager API: the latter fails to activate inside this process (the WinGet COM
// interop reconfigures process-wide COM security), whereas the registry is always readable.
var map = new Dictionary<string, string>(StringComparer.Ordinal);
try
{
using var root = Registry.CurrentUser.OpenSubKey(AppxRepositoryKey);
if (root is null)
return map;

foreach (var packageFullName in root.GetSubKeyNames())
{
try
{
using var entry = root.OpenSubKey(packageFullName);
if (entry?.GetValue("PackageRootFolder") is not string path)
continue;
if (string.IsNullOrEmpty(path) || !Directory.Exists(path))
continue;

// Full name is "<Name>_<version>_<arch>__<publisherHash>".
AddMsixKey(map, packageFullName.Split('_')[0], path);

if (
entry.GetValue("DisplayName") is string displayName
&& !displayName.StartsWith("ms-resource:", StringComparison.OrdinalIgnoreCase)
)
AddMsixKey(map, displayName, path);
}
catch
{
// Skip unreadable entries.
}
}
}
catch (Exception ex)
{
Logger.Debug($"Could not read installed MSIX packages from the registry: {ex.Message}");
}
return map;
}

private static void AddMsixKey(Dictionary<string, string> map, string? key, string path)
{
var normalized = NormalizeMsix(key);
if (normalized.Length >= 3 && !map.ContainsKey(normalized))
map[normalized] = path;
}

private static string NormalizeMsix(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return "";
var sb = new StringBuilder(value.Length);
foreach (var c in value)
if (char.IsLetterOrDigit(c))
sb.Append(char.ToLowerInvariant(c));
return sb.ToString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,26 @@ protected override void GetDetails_UnSafe(IPackageDetails details)

protected override string? GetInstallLocation_UnSafe(IPackage package)
{
// Packages from the local "Add/Remove programs" source encode their exact registry key
// in the Id, so their install location can be read straight from that ARP entry. The
// generic name-based ARP lookup is handled by the base helper as a fallback.
if (package.Source is LocalWinGetSource { Type: LocalWinGetSource.Type_t.LocalPC })
{
var encodedLocation = ArpRegistryHelper.GetLocationFromEncodedId(package.Id);
if (encodedLocation is not null)
return encodedLocation;
}

// MSIX/Store packages from the Microsoft Store source encode their full name in the Id.
var msixFromId = WinGetIconsHelper.GetMsixLocationFromId(package);
if (msixFromId is not null)
return msixFromId;

// Match against Add/Remove programs by name before the heavier lookups below.
var arpByName = ArpRegistryHelper.ResolveByName(package.Name, package.Id);
if (arpByName is not null)
return arpByName;

foreach (
var base_path in new[]
{
Expand Down Expand Up @@ -84,7 +104,9 @@ var base_path in new[]
return path_with_source;
}

return null;
// Last resort: enumerate installed MSIX packages (has a cost, so only reached when
// nothing cheaper matched — e.g. Store apps surfaced through the winget catalog).
return WinGetIconsHelper.GetMsixLocationByName(package);
}

protected override IReadOnlyList<Uri> GetScreenshots_UnSafe(IPackage package)
Expand Down
Loading
Loading