Skip to content

Commit 17184c4

Browse files
authored
Merge pull request #4622 from puk06/fix/core-plugin-version-check-semver
Improve semantic version comparison logic for plugin versions
2 parents da59fe6 + f8c4f76 commit 17184c4

6 files changed

Lines changed: 234 additions & 28 deletions

File tree

Flow.Launcher.Core/Plugin/PluginConfig.cs

Lines changed: 85 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Text.Json;
88
using Flow.Launcher.Infrastructure.UserSettings;
99
using Flow.Launcher.Plugin.SharedCommands;
10+
using Version = SemanticVersioning.Version;
1011

1112
namespace Flow.Launcher.Core.Plugin
1213
{
@@ -79,37 +80,101 @@ internal static (List<PluginMetadata>, List<PluginMetadata>) GetUniqueLatestPlug
7980

8081
var duplicateGroups = allPluginMetadata.GroupBy(x => x.ID).Where(g => g.Count() > 1).Select(y => y).ToList();
8182

82-
foreach (var metadata in allPluginMetadata)
83+
foreach (var group in duplicateGroups)
8384
{
84-
var duplicatesExist = false;
85-
foreach (var group in duplicateGroups)
85+
// Use a single consistent comparison strategy for the entire group
86+
// to avoid cycles when mixing semantic and non-semantic versions.
87+
var allSemantic = group.All(x => TryParseSemanticVersion(x.Version, out _));
88+
89+
// Use the same comparison strategy for both the sort and the tie check so
90+
// that equal semantic precedence expressed with different text (e.g.
91+
// "1.0" vs "1.0.0") is detected as a tie.
92+
IOrderedEnumerable<PluginMetadata> sorted;
93+
if (allSemantic)
8694
{
87-
if (metadata.ID == group.Key)
95+
sorted = group.OrderByDescending(x =>
8896
{
89-
duplicatesExist = true;
97+
TryParseSemanticVersion(x.Version, out var v);
98+
return v;
99+
});
100+
}
101+
else
102+
{
103+
sorted = group.OrderByDescending(x => x.Version, StringComparer.InvariantCulture);
104+
}
90105

91-
// If metadata's version greater than each duplicate's version, CompareTo > 0
92-
var count = group.Where(x => metadata.Version.CompareTo(x.Version) > 0).Count();
93-
94-
// Only add if the meatadata's version is the highest of all duplicates in the group
95-
if (count == group.Count() - 1)
96-
{
97-
unique_list.Add(metadata);
98-
}
99-
else
100-
{
101-
duplicate_list.Add(metadata);
102-
}
103-
}
106+
var ordered = sorted.ToList();
107+
108+
// If the top two versions are tied, no single copy is uniquely highest,
109+
// so treat all as duplicates (preserves original behavior).
110+
bool isTie;
111+
if (ordered.Count < 2)
112+
{
113+
isTie = false;
114+
}
115+
else if (allSemantic)
116+
{
117+
TryParseSemanticVersion(ordered[0].Version, out var v0);
118+
TryParseSemanticVersion(ordered[1].Version, out var v1);
119+
isTie = v0.Equals(v1);
120+
}
121+
else
122+
{
123+
isTie = StringComparer.InvariantCulture.Equals(ordered[0].Version, ordered[1].Version);
104124
}
105-
106-
if (!duplicatesExist)
125+
126+
if (!isTie)
127+
{
128+
unique_list.Add(ordered[0]);
129+
duplicate_list.AddRange(ordered.Skip(1));
130+
}
131+
else
132+
{
133+
duplicate_list.AddRange(ordered);
134+
}
135+
}
136+
137+
// Add plugins that have no duplicates
138+
foreach (var metadata in allPluginMetadata)
139+
{
140+
if (!duplicateGroups.Any(g => g.Key == metadata.ID))
141+
{
107142
unique_list.Add(metadata);
143+
}
108144
}
109145

110146
return (unique_list, duplicate_list);
111147
}
112148

149+
private static bool TryParseSemanticVersion(string value, out Version version)
150+
{
151+
if (Version.TryParse(value, out version))
152+
{
153+
return true;
154+
}
155+
156+
if (string.IsNullOrEmpty(value))
157+
{
158+
return false;
159+
}
160+
161+
var suffixIndex = value.IndexOfAny(new[] { '-', '+' });
162+
var coreLength = suffixIndex >= 0 ? suffixIndex : value.Length;
163+
var componentCount = value[..coreLength].Split('.').Length;
164+
165+
if (componentCount is not (1 or 2))
166+
{
167+
return false;
168+
}
169+
170+
var missingComponents = componentCount == 1 ? ".0.0" : ".0";
171+
var normalized = suffixIndex >= 0
172+
? value.Insert(suffixIndex, missingComponents)
173+
: value + missingComponents;
174+
175+
return Version.TryParse(normalized, out version);
176+
}
177+
113178
private static PluginMetadata GetPluginMetadata(string pluginDirectory)
114179
{
115180
string configPath = Path.Combine(pluginDirectory, Constant.PluginMetadataFileName);

Flow.Launcher.Core/Plugin/PluginInstaller.cs

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using CommunityToolkit.Mvvm.DependencyInjection;
1111
using Flow.Launcher.Infrastructure.UserSettings;
1212
using Flow.Launcher.Plugin;
13+
using Version = SemanticVersioning.Version;
1314

1415
namespace Flow.Launcher.Core.Plugin;
1516

@@ -280,9 +281,7 @@ public static async Task CheckForPluginUpdatesAsync(Func<List<PluginUpdateInfo>,
280281
from existingPlugin in PublicApi.Instance.GetAllPlugins()
281282
join pluginUpdateSource in PublicApi.Instance.GetPluginManifest()
282283
on existingPlugin.Metadata.ID equals pluginUpdateSource.ID
283-
where string.Compare(existingPlugin.Metadata.Version, pluginUpdateSource.Version,
284-
StringComparison.InvariantCulture) <
285-
0 // if current version precedes version of the plugin from update source (e.g. PluginsManifest)
284+
where IsUpdateAvailable(existingPlugin.Metadata.Version, pluginUpdateSource.Version)
286285
&& !PublicApi.Instance.PluginModified(existingPlugin.Metadata.ID)
287286
select
288287
new PluginUpdateInfo()
@@ -489,6 +488,54 @@ private static bool InstallSourceKnown(string url)
489488
x.Metadata.Website.StartsWith(constructedUrlPart)
490489
);
491490
}
491+
492+
/// <summary>
493+
/// Determines if an update is available by comparing semantic versions, with invariant string comparison as a fallback.
494+
/// </summary>
495+
/// <param name="currentVersion">The currently installed version string.</param>
496+
/// <param name="latestVersion">The latest available version string from the manifest.</param>
497+
/// <returns>True if latestVersion is greater than currentVersion; otherwise false.</returns>
498+
internal static bool IsUpdateAvailable(string currentVersion, string latestVersion)
499+
{
500+
if (TryParseSemanticVersion(currentVersion, out var current) &&
501+
TryParseSemanticVersion(latestVersion, out var latest))
502+
{
503+
return current < latest;
504+
}
505+
506+
// Third-party plugins may use version formats that are not valid semantic versions.
507+
// Preserve the previous comparison behavior so those plugins are not silently omitted.
508+
return string.Compare(currentVersion, latestVersion, StringComparison.InvariantCulture) < 0;
509+
}
510+
511+
private static bool TryParseSemanticVersion(string value, out Version version)
512+
{
513+
if (Version.TryParse(value, out version))
514+
{
515+
return true;
516+
}
517+
518+
if (string.IsNullOrEmpty(value))
519+
{
520+
return false;
521+
}
522+
523+
var suffixIndex = value.IndexOfAny(new[] { '-', '+' });
524+
var coreLength = suffixIndex >= 0 ? suffixIndex : value.Length;
525+
var componentCount = value[..coreLength].Split('.').Length;
526+
527+
if (componentCount is not (1 or 2))
528+
{
529+
return false;
530+
}
531+
532+
var missingComponents = componentCount == 1 ? ".0.0" : ".0";
533+
var normalized = suffixIndex >= 0
534+
? value.Insert(suffixIndex, missingComponents)
535+
: value + missingComponents;
536+
537+
return Version.TryParse(normalized, out version);
538+
}
492539
}
493540

494541
public record PluginUpdateInfo

Flow.Launcher.Test/Flow.Launcher.Test.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
<ProjectReference Include="..\Flow.Launcher\Flow.Launcher.csproj" />
4242
<ProjectReference Include="..\Plugins\Flow.Launcher.Plugin.Calculator\Flow.Launcher.Plugin.Calculator.csproj" />
4343
<ProjectReference Include="..\Plugins\Flow.Launcher.Plugin.Explorer\Flow.Launcher.Plugin.Explorer.csproj" />
44+
<ProjectReference Include="..\Plugins\Flow.Launcher.Plugin.PluginsManager\Flow.Launcher.Plugin.PluginsManager.csproj" />
4445
<ProjectReference Include="..\Plugins\Flow.Launcher.Plugin.Program\Flow.Launcher.Plugin.Program.csproj" />
4546
<ProjectReference Include="..\Plugins\Flow.Launcher.Plugin.Shell\Flow.Launcher.Plugin.Shell.csproj" />
4647
<ProjectReference Include="..\Plugins\Flow.Launcher.Plugin.Url\Flow.Launcher.Plugin.Url.csproj" />
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using Flow.Launcher.Core.Plugin;
2+
using NUnit.Framework;
3+
4+
namespace Flow.Launcher.Test;
5+
6+
public class PluginInstallerTest
7+
{
8+
[TestCase("1.9.0", "1.10.0", true)]
9+
[TestCase("1.10.0", "1.9.0", false)]
10+
[TestCase("1.0.0", "1.0.0", false)]
11+
[TestCase("1", "1.0.0", false)]
12+
[TestCase("1.0", "1.0.0", false)]
13+
[TestCase("1.9", "1.10", true)]
14+
[TestCase("1.0.0-beta", "1.0", true)]
15+
[TestCase("1.0+build.1", "1.0.0+build.2", false)]
16+
[TestCase("1.0.0.0", "2", true)]
17+
[TestCase("custom-1", "custom-2", true)]
18+
[TestCase("custom-2", "custom-1", false)]
19+
[TestCase("custom", "custom", false)]
20+
[TestCase("!custom", "2.0.0", true)]
21+
[TestCase("1.0.0", "custom", true)]
22+
public void IsUpdateAvailableComparesSemanticAndNonSemanticVersionsFlowCore(
23+
string currentVersion,
24+
string latestVersion,
25+
bool expected)
26+
{
27+
Assert.That(PluginInstaller.IsUpdateAvailable(currentVersion, latestVersion), Is.EqualTo(expected));
28+
}
29+
30+
[TestCase("1.9.0", "1.10.0", true)]
31+
[TestCase("1.10.0", "1.9.0", false)]
32+
[TestCase("1.0.0", "1.0.0", false)]
33+
[TestCase("1", "1.0.0", false)]
34+
[TestCase("1.0", "1.0.0", false)]
35+
[TestCase("1.9", "1.10", true)]
36+
[TestCase("1.0.0-beta", "1.0", true)]
37+
[TestCase("1.0+build.1", "1.0.0+build.2", false)]
38+
[TestCase("1.0.0.0", "2", true)]
39+
[TestCase("custom-1", "custom-2", true)]
40+
[TestCase("custom-2", "custom-1", false)]
41+
[TestCase("custom", "custom", false)]
42+
[TestCase("!custom", "2.0.0", true)]
43+
[TestCase("1.0.0", "custom", true)]
44+
public void IsUpdateAvailableComparesSemanticAndNonSemanticVersionsPluginManager(
45+
string currentVersion,
46+
string latestVersion,
47+
bool expected)
48+
{
49+
Assert.That(Plugin.PluginsManager.PluginsManager.IsUpdateAvailable(currentVersion, latestVersion), Is.EqualTo(expected));
50+
}
51+
}

Plugins/Flow.Launcher.Plugin.PluginsManager/Flow.Launcher.Plugin.PluginsManager.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
</ItemGroup>
3737

3838
<ItemGroup>
39+
<PackageReference Include="SemanticVersioning" Version="3.0.0" />
3940
<PackageReference Include="SharpZipLib" Version="1.4.2" />
4041
</ItemGroup>
4142
</Project>

Plugins/Flow.Launcher.Plugin.PluginsManager/PluginsManager.cs

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
using System.Threading.Tasks;
88
using System.Windows;
99
using Flow.Launcher.Plugin.SharedCommands;
10+
using Version = SemanticVersioning.Version;
1011

1112
namespace Flow.Launcher.Plugin.PluginsManager
1213
{
13-
internal class PluginsManager
14+
public class PluginsManager
1415
{
1516
private const string ZipSuffix = "zip";
1617

@@ -92,7 +93,7 @@ internal async Task InstallOrUpdateAsync(UserPlugin plugin)
9293
if (PluginExists(plugin.ID))
9394
{
9495
if (Context.API.GetAllPlugins()
95-
.Any(x => x.Metadata.ID == plugin.ID && x.Metadata.Version.CompareTo(plugin.Version) < 0))
96+
.Any(x => x.Metadata.ID == plugin.ID && IsUpdateAvailable(x.Metadata.Version, plugin.Version)))
9697
{
9798
var updateDetail = !plugin.IsFromLocalInstallPath ? plugin.Name : plugin.LocalInstallPath;
9899

@@ -275,9 +276,7 @@ internal async ValueTask<List<Result>> RequestUpdateAsync(string search, Cancell
275276
from existingPlugin in Context.API.GetAllPlugins()
276277
join pluginUpdateSource in updateSource
277278
on existingPlugin.Metadata.ID equals pluginUpdateSource.ID
278-
where string.Compare(existingPlugin.Metadata.Version, pluginUpdateSource.Version,
279-
StringComparison.InvariantCulture) <
280-
0 // if current version precedes version of the plugin from update source (e.g. PluginsManifest)
279+
where IsUpdateAvailable(existingPlugin.Metadata.Version, pluginUpdateSource.Version)
281280
&& !Context.API.PluginModified(existingPlugin.Metadata.ID)
282281
select
283282
new
@@ -840,5 +839,47 @@ private async Task<bool> UninstallAsync(PluginMetadata plugin)
840839
return false;
841840
}
842841
}
842+
843+
public static bool IsUpdateAvailable(string currentVersion, string latestVersion)
844+
{
845+
if (TryParseSemanticVersion(currentVersion, out var current) &&
846+
TryParseSemanticVersion(latestVersion, out var latest))
847+
{
848+
return current < latest;
849+
}
850+
851+
// Third-party plugins may use version formats that are not valid semantic versions.
852+
// Preserve the previous comparison behavior so those plugins are not silently omitted.
853+
return string.Compare(currentVersion, latestVersion, StringComparison.InvariantCulture) < 0;
854+
}
855+
856+
private static bool TryParseSemanticVersion(string value, out Version version)
857+
{
858+
if (Version.TryParse(value, out version))
859+
{
860+
return true;
861+
}
862+
863+
if (string.IsNullOrEmpty(value))
864+
{
865+
return false;
866+
}
867+
868+
var suffixIndex = value.IndexOfAny(new[] { '-', '+' });
869+
var coreLength = suffixIndex >= 0 ? suffixIndex : value.Length;
870+
var componentCount = value[..coreLength].Split('.').Length;
871+
872+
if (componentCount is not (1 or 2))
873+
{
874+
return false;
875+
}
876+
877+
var missingComponents = componentCount == 1 ? ".0.0" : ".0";
878+
var normalized = suffixIndex >= 0
879+
? value.Insert(suffixIndex, missingComponents)
880+
: value + missingComponents;
881+
882+
return Version.TryParse(normalized, out version);
883+
}
843884
}
844885
}

0 commit comments

Comments
 (0)