diff --git a/src/Languages/Data/TranslatedPercentages.json b/src/Languages/Data/TranslatedPercentages.json
index 7138fe2c73..e02ee13c5d 100644
--- a/src/Languages/Data/TranslatedPercentages.json
+++ b/src/Languages/Data/TranslatedPercentages.json
@@ -1,61 +1,61 @@
{
- "af": "100%",
- "ar": "100%",
- "be": "100%",
- "bg": "100%",
- "bn": "100%",
- "da": "100%",
- "el": "100%",
- "eo": "100%",
- "es": "100%",
- "es-MX": "100%",
- "et": "100%",
- "fa": "100%",
- "fil": "100%",
- "gl": "100%",
- "gu": "100%",
- "he": "100%",
- "hi": "100%",
- "hr": "100%",
- "hu": "100%",
- "id": "100%",
- "ja": "100%",
- "ka": "100%",
- "kn": "100%",
- "ko": "100%",
- "ku": "100%",
- "lt": "100%",
- "mk": "100%",
- "mr": "100%",
- "nb": "100%",
- "nn": "100%",
- "pt_PT": "100%",
- "ru": "100%",
- "sa": "100%",
- "si": "100%",
- "sk": "100%",
- "sl": "100%",
- "sr": "100%",
- "ta": "100%",
- "tl": "100%",
- "th": "100%",
- "tr": "100%",
- "uk": "100%",
- "ur": "100%",
- "ca": "100%",
- "cs": "100%",
- "de": "100%",
- "fi": "100%",
- "fr": "100%",
- "it": "100%",
- "nl": "100%",
- "pl": "100%",
- "pt_BR": "100%",
- "ro": "100%",
- "sq": "100%",
- "sv": "100%",
- "vi": "100%",
- "zh_CN": "100%",
- "zh_TW": "100%",
+ "af": "99%",
+ "ar": "99%",
+ "be": "99%",
+ "bg": "99%",
+ "bn": "99%",
+ "da": "99%",
+ "el": "99%",
+ "eo": "99%",
+ "es": "99%",
+ "es-MX": "99%",
+ "et": "99%",
+ "fa": "99%",
+ "fil": "99%",
+ "gl": "99%",
+ "gu": "99%",
+ "he": "99%",
+ "hi": "99%",
+ "hr": "99%",
+ "hu": "99%",
+ "id": "99%",
+ "ja": "99%",
+ "ka": "99%",
+ "kn": "99%",
+ "ko": "99%",
+ "ku": "99%",
+ "lt": "99%",
+ "mk": "99%",
+ "mr": "99%",
+ "nb": "99%",
+ "nn": "99%",
+ "pt_PT": "99%",
+ "ru": "99%",
+ "sa": "99%",
+ "si": "99%",
+ "sk": "99%",
+ "sl": "99%",
+ "sr": "99%",
+ "ta": "99%",
+ "tl": "99%",
+ "th": "99%",
+ "tr": "99%",
+ "uk": "99%",
+ "ur": "99%",
+ "ca": "99%",
+ "cs": "99%",
+ "de": "99%",
+ "fi": "99%",
+ "fr": "99%",
+ "it": "99%",
+ "nl": "99%",
+ "pl": "99%",
+ "pt_BR": "99%",
+ "ro": "99%",
+ "sq": "99%",
+ "sv": "99%",
+ "vi": "99%",
+ "zh_CN": "99%",
+ "zh_TW": "99%",
"en": "100%"
}
diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json
index 3dde6bf7e8..741f306617 100644
--- a/src/Languages/lang_en.json
+++ b/src/Languages/lang_en.json
@@ -808,5 +808,7 @@
"Error": "Error",
"Log in failed: ": "Log in failed: ",
"Log out failed: ": "Log out failed: ",
- "Package backup settings": "Package backup settings"
+ "Package backup settings": "Package backup settings",
+ "Warn me when the installer URL host changes between the installed version and the new version (WinGet only)": "Warn me when the installer URL host changes between the installed version and the new version (WinGet only)",
+ "Installer host changed since the installed version.\nOld: {0}\nNew: {1}\n\nThis is usually harmless (the publisher moved hosting), but can also indicate a hijacked package manifest. Verify the new source before upgrading.": "Installer host changed since the installed version.\nOld: {0}\nNew: {1}\n\nThis is usually harmless (the publisher moved hosting), but can also indicate a hijacked package manifest. Verify the new source before upgrading."
}
diff --git a/src/UniGetUI.Avalonia/Models/PackageCollections.cs b/src/UniGetUI.Avalonia/Models/PackageCollections.cs
index 0a7401217c..66e18c2655 100644
--- a/src/UniGetUI.Avalonia/Models/PackageCollections.cs
+++ b/src/UniGetUI.Avalonia/Models/PackageCollections.cs
@@ -6,10 +6,14 @@
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using UniGetUI.Avalonia.ViewModels.Pages;
+using UniGetUI.Core.Logging;
using UniGetUI.Core.SettingsEngine;
using UniGetUI.Core.Tools;
using UniGetUI.Interface.Enums;
using UniGetUI.PackageEngine.Interfaces;
+#if WINDOWS
+using UniGetUI.PackageEngine.Managers.WingetManager;
+#endif
// ReSharper disable once CheckNamespace
namespace UniGetUI.PackageEngine.PackageClasses;
@@ -64,6 +68,11 @@ public bool IsChecked
public string TagIconPath { get; private set; } = "";
public bool TagIconVisible { get; private set; }
+ public bool InstallerHostChanged { get; private set; }
+ public string InstallerHostChangeTooltip { get; private set; } = "";
+
+ private CancellationTokenSource? _installerHostCheckCts;
+
public string SourceIconPath => IconTypeToSvgPath(Package.Source.IconId);
private static string IconTypeToSvgPath(IconType icon)
@@ -94,6 +103,81 @@ public PackageWrapper(IPackage package, PackagesPageViewModel page)
if (!Settings.Get(Settings.K.DisableIconsOnPackageLists))
_ = LoadIconAsync();
+
+ MaybeStartInstallerHostCheck();
+ }
+
+ ///
+ /// For upgradable WinGet packages, asynchronously fetches the installer URL host for
+ /// both the installed and the new version, and flags the row when the hosts differ.
+ /// See issue #4617 — defense-in-depth signal that an upgrade may be redirecting the
+ /// download to a different domain than the user originally trusted.
+ ///
+ private void MaybeStartInstallerHostCheck()
+ {
+#if WINDOWS
+ if (!Package.IsUpgradable) return;
+ if (Package.Manager is not WinGet) return;
+ if (Settings.Get(Settings.K.DisableInstallerHostChangeWarning)) return;
+
+ string installedVersion = Package.VersionString;
+ string newVersion = Package.NewVersionString;
+ if (string.IsNullOrWhiteSpace(installedVersion) || string.IsNullOrWhiteSpace(newVersion))
+ return;
+ if (installedVersion == newVersion) return;
+
+ _installerHostCheckCts?.Cancel();
+ _installerHostCheckCts = new CancellationTokenSource();
+ CancellationToken token = _installerHostCheckCts.Token;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ if (token.IsCancellationRequested) return;
+ var oldHosts = WinGet.TryGetInstallerHostsForVersion(Package, installedVersion);
+ if (token.IsCancellationRequested) return;
+ var newHosts = WinGet.TryGetInstallerHostsForVersion(Package, newVersion);
+ if (token.IsCancellationRequested) return;
+
+ if (oldHosts is null || newHosts is null) return;
+ // Only flag when the two host sets are fully disjoint. If they share even
+ // one host, the publisher hasn't moved hosting — adding/removing CDN mirrors
+ // or architectures shouldn't trigger the warning.
+ if (oldHosts.Overlaps(newHosts)) return;
+
+ string tooltip = CoreTools.Translate(
+ "Installer host changed since the installed version.\n"
+ + "Old: {0}\n"
+ + "New: {1}\n\n"
+ + "This is usually harmless (the publisher moved hosting), "
+ + "but can also indicate a hijacked package manifest. "
+ + "Verify the new source before upgrading.",
+ string.Join(", ", oldHosts),
+ string.Join(", ", newHosts)
+ );
+
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ if (token.IsCancellationRequested) return;
+ InstallerHostChanged = true;
+ InstallerHostChangeTooltip = tooltip;
+ PropertyChanged?.Invoke(
+ this,
+ new PropertyChangedEventArgs(nameof(InstallerHostChanged))
+ );
+ PropertyChanged?.Invoke(
+ this,
+ new PropertyChangedEventArgs(nameof(InstallerHostChangeTooltip))
+ );
+ });
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Installer-host check failed for {Package.Id}: {ex.Message}");
+ }
+ }, token);
+#endif
}
private async Task LoadIconAsync()
@@ -187,6 +271,9 @@ private void UpdateDisplayState()
public void Dispose()
{
Package.PropertyChanged -= Package_PropertyChanged;
+ _installerHostCheckCts?.Cancel();
+ _installerHostCheckCts?.Dispose();
+ _installerHostCheckCts = null;
}
}
diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml
index f2d6fd0640..1cdd3d3f99 100644
--- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml
+++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml
@@ -81,6 +81,11 @@
BorderThickness="1,0,1,1"
CornerRadius="0,0,8,8"/>
+
+
diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml
index 3a67da096c..6c46a45413 100644
--- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml
+++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml
@@ -469,7 +469,13 @@
+ Width="16" Height="16" VerticalAlignment="Center"
+ IsVisible="{Binding !InstallerHostChanged}"/>
+
@@ -528,6 +534,13 @@
IsVisible="{Binding TagIconVisible}"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"/>
+
+
"WinGetCliToolPreference",
K.WinGetComApiPolicy => "WinGetComApiPolicy",
K.DisableClassicMode => "DisableClassicMode",
+ K.DisableInstallerHostChangeWarning => "DisableInstallerHostChangeWarning",
K.Test1 => "TestSetting1",
K.Test2 => "TestSetting2",
diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs
index e3944879d4..4fde3193eb 100644
--- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs
+++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/PingetPackageDetailsProvider.cs
@@ -136,6 +136,12 @@ private static IReadOnlyList BuildShowArguments(PackageQuery query, stri
arguments.Add(source);
}
+ if (!string.IsNullOrWhiteSpace(query.Version))
+ {
+ arguments.Add("--version");
+ arguments.Add(query.Version);
+ }
+
arguments.Add("--accept-source-agreements");
arguments.Add("--output");
arguments.Add("json");
@@ -347,7 +353,7 @@ public bool LoadPackageDetails(IPackageDetails details, INativeTaskLogger logger
}
}
- internal static PackageQuery CreateQuery(IPackage package)
+ internal static PackageQuery CreateQuery(IPackage package, string? version = null)
{
string id = package.Id.TrimEnd('…');
string name = package.Name.TrimEnd('…');
@@ -379,6 +385,11 @@ internal static PackageQuery CreateQuery(IPackage package)
query = query with { Source = package.Source.Name };
}
+ if (!string.IsNullOrWhiteSpace(version))
+ {
+ query = query with { Version = version };
+ }
+
return query;
}
@@ -446,6 +457,61 @@ private static ShowResult ShowWithRepository(PackageQuery query)
return repository.ShowFirstMatchAcrossSources(query);
}
+ ///
+ /// Fetches the set of installer URL hosts for a specific version of a package.
+ /// Returns null if the manifest can't be loaded, OR a non-empty set otherwise.
+ /// Used to detect installer-host changes between installed and upgrade versions (issue #4617).
+ /// Returns the SET (not just one) because manifests typically have multiple installers
+ /// (per arch / locale / scope) — flagging on a single-installer comparison can produce
+ /// false positives when a publisher legitimately uses different CDNs per architecture
+ /// or adds/removes architectures across versions.
+ ///
+ internal static IReadOnlySet? TryGetInstallerHostsForVersion(
+ IPackage package,
+ string version
+ )
+ {
+ try
+ {
+ PackageQuery query = CreateQuery(package, version);
+ ShowResult result;
+ using (Repository repository = OpenRepository())
+ {
+ result = repository.ShowFirstMatchAcrossSources(query);
+ }
+
+ // Pinget silently falls back to the latest manifest when the requested version
+ // isn't in the index (yanked / expired / never indexed). That fallback would
+ // make the host-change check return false-negatives, so reject any result whose
+ // manifest version doesn't match what we asked for.
+ string returnedVersion = result.Manifest.Version ?? "";
+ if (!string.Equals(returnedVersion, version, StringComparison.OrdinalIgnoreCase))
+ {
+ Logger.Info(
+ $"Pinget returned manifest version '{returnedVersion}' when '{version}' "
+ + $"was requested for {package.Id}; treating as not found"
+ );
+ return null;
+ }
+
+ HashSet hosts = new(StringComparer.OrdinalIgnoreCase);
+ foreach (Installer installer in result.Manifest.Installers)
+ {
+ if (TryCreateUri(installer.Url, out Uri? uri) && uri is not null)
+ hosts.Add(uri.Host);
+ }
+
+ return hosts.Count > 0 ? hosts : null;
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn(
+ $"Could not resolve installer hosts for {package.Id} version {version}: {ex.Message}"
+ );
+ return null;
+ }
+ }
+
private static Repository OpenRepository()
{
return Repository.Open(CreateRepositoryOptions());
diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs
index ab77f9e8a7..fa4f4a4c6e 100644
--- a/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs
+++ b/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs
@@ -180,6 +180,21 @@ public static string GetProxyArgument()
return $"--proxy {proxyUri.ToString().TrimEnd('/')}";
}
+ ///
+ /// Returns the set of installer URL hosts from the WinGet manifest for a specific
+ /// version of the given package, or null if it can't be resolved. Used for the
+ /// installer-host-change warning on the Updates page (issue #4617).
+ /// Returns a set (not a single host) so callers can do set-overlap comparison —
+ /// see PingetPackageDetailsProvider.TryGetInstallerHostsForVersion for rationale.
+ ///
+ public static IReadOnlySet? TryGetInstallerHostsForVersion(
+ UniGetUI.PackageEngine.Interfaces.IPackage package,
+ string version
+ )
+ {
+ return PingetPackageDetailsProvider.TryGetInstallerHostsForVersion(package, version);
+ }
+
protected override IReadOnlyList FindPackages_UnSafe(string query)
{
return WinGetHelper.Instance.FindPackages_UnSafe(query);
diff --git a/src/UniGetUI/Controls/PackageWrapper.cs b/src/UniGetUI/Controls/PackageWrapper.cs
index 659bb579b1..8761fc4f09 100644
--- a/src/UniGetUI/Controls/PackageWrapper.cs
+++ b/src/UniGetUI/Controls/PackageWrapper.cs
@@ -1,14 +1,18 @@
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Runtime.InteropServices;
+using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using UniGetUI.Core.Classes;
+using UniGetUI.Core.Logging;
+using UniGetUI.Core.SettingsEngine;
using UniGetUI.Core.Tools;
using UniGetUI.Interface;
using UniGetUI.Interface.Enums;
using UniGetUI.PackageEngine.Interfaces;
+using UniGetUI.PackageEngine.Managers.WingetManager;
namespace UniGetUI.PackageEngine.PackageClasses
{
@@ -60,6 +64,13 @@ public Uri? PackageIcon
public readonly string ExtendedTooltip = "";
public float ListedOpacity = 1.0f;
+ public bool InstallerHostChanged { get; private set; }
+ public string InstallerHostChangeTooltip { get; private set; } = "";
+
+ public bool ShowUpgradeDownloadIcon => Package.IsUpgradable && !InstallerHostChanged;
+
+ private CancellationTokenSource? _installerHostCheckCts;
+
public int NewVersionLabelWidth
{
get => Package.IsUpgradable ? 125 : 0;
@@ -94,6 +105,89 @@ public PackageWrapper(IPackage package, AbstractPackagesPage page)
$"{package.Name} ({package.Id} from {package.Source.AsString_DisplayName})";
else
ExtendedTooltip = $"{package.Name} (from {package.Source.AsString_DisplayName})";
+
+ MaybeStartInstallerHostCheck();
+ }
+
+ ///
+ /// For upgradable WinGet packages, asynchronously fetches the installer URL host for
+ /// both the installed and the new version, and flags the row when the hosts differ.
+ /// See issue #4617 — defense-in-depth signal that an upgrade may be redirecting the
+ /// download to a different domain than the user originally trusted.
+ ///
+ private void MaybeStartInstallerHostCheck()
+ {
+ if (!Package.IsUpgradable) return;
+ if (Package.Manager is not WinGet) return;
+ if (Settings.Get(Settings.K.DisableInstallerHostChangeWarning)) return;
+
+ string installedVersion = Package.VersionString;
+ string newVersion = Package.NewVersionString;
+ if (string.IsNullOrWhiteSpace(installedVersion) || string.IsNullOrWhiteSpace(newVersion))
+ return;
+ if (installedVersion == newVersion) return;
+
+ DispatcherQueue? dispatcher = _page.DispatcherQueue
+ ?? DispatcherQueue.GetForCurrentThread();
+ if (dispatcher is null) return;
+
+ _installerHostCheckCts?.Cancel();
+ _installerHostCheckCts = new CancellationTokenSource();
+ CancellationToken token = _installerHostCheckCts.Token;
+
+ Task.Run(() =>
+ {
+ try
+ {
+ if (token.IsCancellationRequested) return;
+ var oldHosts = WinGet.TryGetInstallerHostsForVersion(Package, installedVersion);
+ if (token.IsCancellationRequested) return;
+ var newHosts = WinGet.TryGetInstallerHostsForVersion(Package, newVersion);
+ if (token.IsCancellationRequested) return;
+
+ if (oldHosts is null || newHosts is null) return;
+ // Only flag when the two host sets are fully disjoint. If they share even
+ // one host, the publisher hasn't moved hosting — adding/removing CDN mirrors
+ // or architectures shouldn't trigger the warning.
+ if (oldHosts.Overlaps(newHosts)) return;
+
+ string tooltip = CoreTools.Translate(
+ "Installer host changed since the installed version.\n"
+ + "Old: {0}\n"
+ + "New: {1}\n\n"
+ + "This is usually harmless (the publisher moved hosting), "
+ + "but can also indicate a hijacked package manifest. "
+ + "Verify the new source before upgrading.",
+ string.Join(", ", oldHosts),
+ string.Join(", ", newHosts)
+ );
+
+ dispatcher.TryEnqueue(() =>
+ {
+ if (token.IsCancellationRequested) return;
+ InstallerHostChanged = true;
+ InstallerHostChangeTooltip = tooltip;
+ PropertyChanged?.Invoke(
+ this,
+ new PropertyChangedEventArgs(nameof(InstallerHostChanged))
+ );
+ PropertyChanged?.Invoke(
+ this,
+ new PropertyChangedEventArgs(nameof(InstallerHostChangeTooltip))
+ );
+ PropertyChanged?.Invoke(
+ this,
+ new PropertyChangedEventArgs(nameof(ShowUpgradeDownloadIcon))
+ );
+ });
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn(
+ $"Installer-host check failed for {Package.Id}: {ex.Message}"
+ );
+ }
+ }, token);
}
public void PackageItemContainer_DoubleTapped(
@@ -157,6 +251,9 @@ public void Package_PropertyChanged(object? sender, PropertyChangedEventArgs e)
public void Dispose()
{
Package.PropertyChanged -= Package_PropertyChanged;
+ _installerHostCheckCts?.Cancel();
+ _installerHostCheckCts?.Dispose();
+ _installerHostCheckCts = null;
}
///
diff --git a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Updates.xaml b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Updates.xaml
index 0863133e97..17f81a0957 100644
--- a/src/UniGetUI/Pages/SettingsPages/GeneralPages/Updates.xaml
+++ b/src/UniGetUI/Pages/SettingsPages/GeneralPages/Updates.xaml
@@ -88,15 +88,15 @@
@@ -107,6 +107,13 @@
+
+
+
+
+
+
+
+