From 2abf367f368b58a2bc4f3c0a49cdb97e486f4bb2 Mon Sep 17 00:00:00 2001 From: cneicy Date: Sat, 5 Sep 2026 02:33:59 +0800 Subject: [PATCH] fix: compare installed package versions semantically --- CHANGELOG.md | 7 ++ Editor/ShrinkPackageVersion.cs | 117 ++++++++++++++++++ Editor/ShrinkPackageVersion.cs.meta | 11 ++ Editor/ShrinkSdkInstallerWindow.cs | 52 +++++--- Editor/ShrinkSdkPackageCatalog.cs | 39 +++--- Tests.meta | 8 ++ Tests/Editor.meta | 8 ++ .../ShrinkInstaller.Editor.Tests.asmdef | 22 ++++ .../ShrinkInstaller.Editor.Tests.asmdef.meta | 7 ++ Tests/Editor/ShrinkPackageVersionTests.cs | 22 ++++ .../Editor/ShrinkPackageVersionTests.cs.meta | 11 ++ package.json | 2 +- 12 files changed, 270 insertions(+), 36 deletions(-) create mode 100644 Editor/ShrinkPackageVersion.cs create mode 100644 Editor/ShrinkPackageVersion.cs.meta create mode 100644 Tests.meta create mode 100644 Tests/Editor.meta create mode 100644 Tests/Editor/ShrinkInstaller.Editor.Tests.asmdef create mode 100644 Tests/Editor/ShrinkInstaller.Editor.Tests.asmdef.meta create mode 100644 Tests/Editor/ShrinkPackageVersionTests.cs create mode 100644 Tests/Editor/ShrinkPackageVersionTests.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc0181..9447d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 本文件记录 `ShrinkInstaller` 的包内变更。 +## [0.2.5] - 2026-09-05 + +### Fixed + +- 按 SemVer 比较已安装版本与目录版本;已安装较新版本不再被误判为旧版本,也不会提供降级操作。 +- 更新内置包目录,使版本与当前 ShrinkSDK 模块清单一致。 + ## [0.2.4] - 2026-08-28 ### Changed diff --git a/Editor/ShrinkPackageVersion.cs b/Editor/ShrinkPackageVersion.cs new file mode 100644 index 0000000..afba6ff --- /dev/null +++ b/Editor/ShrinkPackageVersion.cs @@ -0,0 +1,117 @@ +#nullable enable + +using System; + +namespace ShrinkSDK.Installer +{ + public enum ShrinkPackageVersionRelation + { + Unknown, + Older, + Equal, + Newer + } + + public static class ShrinkPackageVersion + { + public static ShrinkPackageVersionRelation Classify(string? installedVersion, string? targetVersion) + { + return TryCompare(installedVersion, targetVersion, out var comparison) + ? comparison < 0 + ? ShrinkPackageVersionRelation.Older + : comparison > 0 + ? ShrinkPackageVersionRelation.Newer + : ShrinkPackageVersionRelation.Equal + : ShrinkPackageVersionRelation.Unknown; + } + + public static bool TryCompare(string? left, string? right, out int comparison) + { + comparison = 0; + if (!TryParse(left, out var leftVersion) || !TryParse(right, out var rightVersion)) + return false; + + for (var index = 0; index < 3; index++) + { + comparison = leftVersion.Core[index].CompareTo(rightVersion.Core[index]); + if (comparison != 0) + return true; + } + + comparison = ComparePrerelease(leftVersion.Prerelease, rightVersion.Prerelease); + return true; + } + + private static bool TryParse(string? raw, out ParsedVersion version) + { + version = default; + if (string.IsNullOrWhiteSpace(raw)) + return false; + + var value = raw.Trim(); + if (value.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + value = value.Substring(1); + var buildIndex = value.IndexOf('+'); + if (buildIndex >= 0) + value = value.Substring(0, buildIndex); + var prereleaseIndex = value.IndexOf('-'); + var coreText = prereleaseIndex >= 0 ? value.Substring(0, prereleaseIndex) : value; + var prerelease = prereleaseIndex >= 0 ? value.Substring(prereleaseIndex + 1) : string.Empty; + var coreParts = coreText.Split('.'); + if (coreParts.Length is < 1 or > 3) + return false; + + var core = new int[3]; + for (var index = 0; index < coreParts.Length; index++) + { + if (!int.TryParse(coreParts[index], out core[index]) || core[index] < 0) + return false; + } + + if (prereleaseIndex >= 0 && string.IsNullOrWhiteSpace(prerelease)) + return false; + version = new ParsedVersion(core, prerelease); + return true; + } + + private static int ComparePrerelease(string left, string right) + { + var leftEmpty = string.IsNullOrEmpty(left); + var rightEmpty = string.IsNullOrEmpty(right); + if (leftEmpty || rightEmpty) + return leftEmpty == rightEmpty ? 0 : leftEmpty ? 1 : -1; + + var leftParts = left.Split('.'); + var rightParts = right.Split('.'); + var count = Math.Min(leftParts.Length, rightParts.Length); + for (var index = 0; index < count; index++) + { + var leftNumeric = int.TryParse(leftParts[index], out var leftValue); + var rightNumeric = int.TryParse(rightParts[index], out var rightValue); + int comparison; + if (leftNumeric && rightNumeric) + comparison = leftValue.CompareTo(rightValue); + else if (leftNumeric != rightNumeric) + comparison = leftNumeric ? -1 : 1; + else + comparison = string.Compare(leftParts[index], rightParts[index], StringComparison.Ordinal); + if (comparison != 0) + return comparison; + } + + return leftParts.Length.CompareTo(rightParts.Length); + } + + private readonly struct ParsedVersion + { + public ParsedVersion(int[] core, string prerelease) + { + Core = core; + Prerelease = prerelease; + } + + public int[] Core { get; } + public string Prerelease { get; } + } + } +} diff --git a/Editor/ShrinkPackageVersion.cs.meta b/Editor/ShrinkPackageVersion.cs.meta new file mode 100644 index 0000000..b3d241c --- /dev/null +++ b/Editor/ShrinkPackageVersion.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6e04e7ab0ec4fbe87bb30cb04f7ce18 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/ShrinkSdkInstallerWindow.cs b/Editor/ShrinkSdkInstallerWindow.cs index 37c61a2..41465c7 100644 --- a/Editor/ShrinkSdkInstallerWindow.cs +++ b/Editor/ShrinkSdkInstallerWindow.cs @@ -172,16 +172,16 @@ namespace ShrinkSDK.Installer title.style.flexGrow = 1f; titleRow.Add(title); - var exact = bundle.Packages.Count(IsExactInstalled); + var ready = bundle.Packages.Count(IsInstalledAtLeast); var button = new Button(() => BeginInstall(bundle.InstallRoots)); - if (exact == bundle.Packages.Count) + if (ready == bundle.Packages.Count) { button.text = "已完成"; button.SetEnabled(false); } else { - button.text = exact > 0 ? "补齐组合" : "安装组合"; + button.text = ready > 0 ? "补齐组合" : "安装组合"; button.SetEnabled(!IsBusy); } button.style.width = 100f; @@ -193,9 +193,9 @@ namespace ShrinkSDK.Installer summary.style.marginTop = 5f; card.Add(summary); - var progress = new Label($"已就绪 {exact}/{bundle.Packages.Count}"); + var progress = new Label($"已就绪 {ready}/{bundle.Packages.Count}"); progress.style.marginTop = 8f; - progress.style.color = exact == bundle.Packages.Count + progress.style.color = ready == bundle.Packages.Count ? new Color(0.35f, 0.75f, 0.45f) : new Color(0.65f, 0.65f, 0.65f); card.Add(progress); @@ -266,7 +266,7 @@ namespace ShrinkSDK.Installer row.Add(name); var status = new Label(GetInstalledStatus(package)); status.style.fontSize = 10f; - status.style.color = IsExactInstalled(package) + status.style.color = IsInstalledAtLeast(package) ? new Color(0.35f, 0.75f, 0.45f) : new Color(0.65f, 0.65f, 0.65f); row.Add(status); @@ -278,13 +278,26 @@ namespace ShrinkSDK.Installer var button = new Button(() => BeginInstall(new[] { package })); if (!_installed.TryGetValue(package.PackageName, out var installed)) button.text = "安装 " + package.Version; - else if (string.Equals(installed.version, package.Version, StringComparison.Ordinal)) - { - button.text = "已安装 " + installed.version; - button.SetEnabled(false); - } else - button.text = installed.version + " → " + package.Version; + { + switch (ShrinkPackageVersion.Classify(installed.version, package.Version)) + { + case ShrinkPackageVersionRelation.Equal: + button.text = "已安装 " + installed.version; + button.SetEnabled(false); + break; + case ShrinkPackageVersionRelation.Newer: + button.text = "较新版本 " + installed.version; + button.SetEnabled(false); + break; + case ShrinkPackageVersionRelation.Older: + button.text = "升级 " + installed.version + " → " + package.Version; + break; + default: + button.text = installed.version + " → " + package.Version; + break; + } + } if (IsBusy) button.SetEnabled(false); @@ -326,17 +339,22 @@ namespace ShrinkSDK.Installer package.Summary.IndexOf(_searchText, StringComparison.OrdinalIgnoreCase) >= 0 || package.PackageName.IndexOf(_searchText, StringComparison.OrdinalIgnoreCase) >= 0; - private bool IsExactInstalled(ShrinkSdkPackageEntry package) => + private bool IsInstalledAtLeast(ShrinkSdkPackageEntry package) => _installed.TryGetValue(package.PackageName, out var installed) && - string.Equals(installed.version, package.Version, StringComparison.Ordinal); + ShrinkPackageVersion.Classify(installed.version, package.Version) is + ShrinkPackageVersionRelation.Equal or ShrinkPackageVersionRelation.Newer; private string GetInstalledStatus(ShrinkSdkPackageEntry package) { if (!_installed.TryGetValue(package.PackageName, out var installed)) return "未安装 · " + package.Version; - return string.Equals(installed.version, package.Version, StringComparison.Ordinal) - ? "已安装 " + installed.version - : "已安装 " + installed.version + " · 固定 " + package.Version; + return ShrinkPackageVersion.Classify(installed.version, package.Version) switch + { + ShrinkPackageVersionRelation.Equal => "已安装 " + installed.version, + ShrinkPackageVersionRelation.Newer => "已安装较新版本 " + installed.version, + ShrinkPackageVersionRelation.Older => "可升级 " + installed.version + " → " + package.Version, + _ => "已安装 " + installed.version + " · 目标 " + package.Version + }; } private bool IsBusy => _listRequest != null || _resolvePending || _listRetryPending; diff --git a/Editor/ShrinkSdkPackageCatalog.cs b/Editor/ShrinkSdkPackageCatalog.cs index e53bc33..02dc959 100644 --- a/Editor/ShrinkSdkPackageCatalog.cs +++ b/Editor/ShrinkSdkPackageCatalog.cs @@ -67,67 +67,70 @@ namespace ShrinkSDK.Installer internal static readonly ShrinkSdkPackageEntry ContextCore = Package( "Context 核心", "可逆效应、响应式依赖与组件生命周期,是框架的组合基础。", - "com.cneicy.shrink-context-core", "0.1.0", ShrinkSdkPackageLayer.Context, true); + "com.cneicy.shrink-context-core", "0.2.0", ShrinkSdkPackageLayer.Context, true); internal static readonly ShrinkSdkPackageEntry ContextAppAdapter = Package( "Context 应用适配", "用 ContextLoader 承载 ShrinkApp 组合根和运行时诊断。", - "com.cneicy.shrink-context-app-adapter", "0.1.5", ShrinkSdkPackageLayer.Context, true); + "com.cneicy.shrink-context-app-adapter", "0.2.0", ShrinkSdkPackageLayer.Context, true); internal static readonly ShrinkSdkPackageEntry ContextEventBusAdapter = Package( "Context 事件适配", "把事件订阅纳入 Context 的安装与自动撤回。", "com.cneicy.shrink-context-eventbus-adapter", "0.1.1", ShrinkSdkPackageLayer.Context, true); internal static readonly ShrinkSdkPackageEntry AppCore = Package( "应用宿主", "统一启动、服务门面与应用生命周期。", - "com.cneicy.shrink-app-core", "0.1.4", ShrinkSdkPackageLayer.Host, true); + "com.cneicy.shrink-app-core", "0.2.0", ShrinkSdkPackageLayer.Host, true); internal static readonly ShrinkSdkPackageEntry StarterBasic = Package( "基础应用起步包", "默认 Context 组合根,包含命令、存档、网络及其集成。", - "com.cneicy.shrink-app-starter-basic", "0.2.2", ShrinkSdkPackageLayer.Host, true); + "com.cneicy.shrink-app-starter-basic", "0.3.0", ShrinkSdkPackageLayer.Host, true); internal static readonly ShrinkSdkPackageEntry Command = Package( "命令系统", "命令路由、参数、权限与自动注册。", - "com.cneicy.shrink-command", "0.2.0", ShrinkSdkPackageLayer.Feature, true); + "com.cneicy.shrink-command", "0.3.0", ShrinkSdkPackageLayer.Feature, true); internal static readonly ShrinkSdkPackageEntry DataSaver = Package( "存档系统", "异步存档、设置、备份恢复与数据观察。", - "com.cneicy.shrink-datasaver", "2.2.2", ShrinkSdkPackageLayer.Feature, true); + "com.cneicy.shrink-datasaver", "2.3.0", ShrinkSdkPackageLayer.Feature, true); internal static readonly ShrinkSdkPackageEntry EventBus = Package( "事件总线", "类型安全事件、请求结果、调度与代码生成。", - "com.cneicy.shrink-eventbus", "2.0.1", ShrinkSdkPackageLayer.Feature, true); + "com.cneicy.shrink-eventbus", "2.1.0", ShrinkSdkPackageLayer.Feature, true); internal static readonly ShrinkSdkPackageEntry EventBusEntities = Package( "Entities 事件适配", "将事件总线接入 Unity Entities。", "com.cneicy.shrink-eventbus-entities", "0.1.1", ShrinkSdkPackageLayer.Integration, true); internal static readonly ShrinkSdkPackageEntry ModFramework = Package( "模组框架", "Context 托管的模组生命周期、热替换与外部 DLL 加载。", - "com.cneicy.shrink-mod-framework", "0.2.4", ShrinkSdkPackageLayer.Feature, true); + "com.cneicy.shrink-mod-framework", "0.3.0", ShrinkSdkPackageLayer.Feature, true); internal static readonly ShrinkSdkPackageEntry Network = Package( "网络系统", "消息、RPC、权限、序列化与多种传输层。", - "com.cneicy.shrink-network", "0.2.1", ShrinkSdkPackageLayer.Feature, true); + "com.cneicy.shrink-network", "0.3.0", ShrinkSdkPackageLayer.Feature, true); internal static readonly ShrinkSdkPackageEntry Tutorial = Package( "引导系统", "可复用的步骤式引导、遮罩、锚点与进度存储。", - "com.cneicy.shrink-tutorial", "0.1.2", ShrinkSdkPackageLayer.Feature); + "com.cneicy.shrink-tutorial", "0.2.0", ShrinkSdkPackageLayer.Feature); internal static readonly ShrinkSdkPackageEntry CommandApp = Package( "命令 - 应用集成", "以原生 Context 组件向应用宿主发布命令服务。", - "com.cneicy.shrink-command-integration-app", "0.1.3", ShrinkSdkPackageLayer.Integration, true); + "com.cneicy.shrink-command-integration-app", "0.2.0", ShrinkSdkPackageLayer.Integration, true); internal static readonly ShrinkSdkPackageEntry CommandEventBus = Package( "命令 - 事件集成", "通过事件总线执行命令并发布执行状态。", - "com.cneicy.shrink-command-integration-eventbus", "0.1.2", ShrinkSdkPackageLayer.Integration, true); + "com.cneicy.shrink-command-integration-eventbus", "0.2.0", ShrinkSdkPackageLayer.Integration, true); internal static readonly ShrinkSdkPackageEntry CommandNetwork = Package( "命令 - 网络集成", "通过网络 RPC 执行远程命令。", - "com.cneicy.shrink-command-integration-network", "0.1.1", ShrinkSdkPackageLayer.Integration, true); + "com.cneicy.shrink-command-integration-network", "0.2.0", ShrinkSdkPackageLayer.Integration, true); internal static readonly ShrinkSdkPackageEntry DataSaverApp = Package( "存档 - 应用集成", "以原生 Context 组件向应用宿主发布存档服务。", - "com.cneicy.shrink-datasaver-integration-app", "0.1.3", ShrinkSdkPackageLayer.Integration, true); + "com.cneicy.shrink-datasaver-integration-app", "0.2.0", ShrinkSdkPackageLayer.Integration, true); internal static readonly ShrinkSdkPackageEntry DataSaverEventBus = Package( "存档 - 事件集成", "通过事件总线请求读写并观察存档变化。", - "com.cneicy.shrink-datasaver-integration-eventbus", "2.1.3", ShrinkSdkPackageLayer.Integration, true); + "com.cneicy.shrink-datasaver-integration-eventbus", "2.2.0", ShrinkSdkPackageLayer.Integration, true); internal static readonly ShrinkSdkPackageEntry NetworkApp = Package( "网络 - 应用集成", "以原生 Context 组件向应用宿主发布网络服务。", - "com.cneicy.shrink-network-integration-app", "0.1.3", ShrinkSdkPackageLayer.Integration, true); + "com.cneicy.shrink-network-integration-app", "0.2.0", ShrinkSdkPackageLayer.Integration, true); internal static readonly ShrinkSdkPackageEntry NetworkEventBus = Package( "网络 - 事件集成", "把网络消息与事件请求桥接为可撤回的 Context 组件。", - "com.cneicy.shrink-network-integration-eventbus", "0.1.3", ShrinkSdkPackageLayer.Integration, true); + "com.cneicy.shrink-network-integration-eventbus", "0.2.0", ShrinkSdkPackageLayer.Integration, true); internal static readonly ShrinkSdkPackageEntry SharedCodeGen = Package( "共享代码生成", "各功能包使用的编译期注册表基础设施。", "com.cneicy.shrink-shared-codegen", "0.1.0", ShrinkSdkPackageLayer.Tooling); + internal static readonly ShrinkSdkPackageEntry RuntimeAbstractions = Package( + "运行时抽象", "Unity 与其他 .NET 宿主共用的平台服务合同。", + "com.cneicy.shrink-runtime-abstractions", "0.1.0", ShrinkSdkPackageLayer.Tooling); internal static readonly IReadOnlyList Packages = new[] { @@ -137,7 +140,7 @@ namespace ShrinkSDK.Installer CommandApp, CommandEventBus, CommandNetwork, DataSaverApp, DataSaverEventBus, NetworkApp, NetworkEventBus, EventBusEntities, - SharedCodeGen + SharedCodeGen, RuntimeAbstractions }; internal static readonly IReadOnlyList Bundles = new[] diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 0000000..db242e4 --- /dev/null +++ b/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 238fc591f33a45dca54d074857959f87 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor.meta b/Tests/Editor.meta new file mode 100644 index 0000000..69fcffa --- /dev/null +++ b/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a5027bfa9e76434983f82652539bc42a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/ShrinkInstaller.Editor.Tests.asmdef b/Tests/Editor/ShrinkInstaller.Editor.Tests.asmdef new file mode 100644 index 0000000..650069c --- /dev/null +++ b/Tests/Editor/ShrinkInstaller.Editor.Tests.asmdef @@ -0,0 +1,22 @@ +{ + "name": "ShrinkInstaller.Editor.Tests", + "rootNamespace": "ShrinkSDK.Installer.Tests", + "references": [ + "ShrinkInstaller.Editor" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false, + "optionalUnityReferences": [ + "TestAssemblies" + ] +} diff --git a/Tests/Editor/ShrinkInstaller.Editor.Tests.asmdef.meta b/Tests/Editor/ShrinkInstaller.Editor.Tests.asmdef.meta new file mode 100644 index 0000000..36789df --- /dev/null +++ b/Tests/Editor/ShrinkInstaller.Editor.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 00a17f283465481592e764466d9c980d +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/ShrinkPackageVersionTests.cs b/Tests/Editor/ShrinkPackageVersionTests.cs new file mode 100644 index 0000000..232725d --- /dev/null +++ b/Tests/Editor/ShrinkPackageVersionTests.cs @@ -0,0 +1,22 @@ +#nullable enable + +using NUnit.Framework; + +namespace ShrinkSDK.Installer.Tests +{ + public sealed class ShrinkPackageVersionTests + { + [TestCase("1.10.0", "1.9.0", ShrinkPackageVersionRelation.Newer)] + [TestCase("2.1.0", "2.1.0", ShrinkPackageVersionRelation.Equal)] + [TestCase("2.0.9", "2.1.0", ShrinkPackageVersionRelation.Older)] + [TestCase("1.0.0", "1.0.0-preview.9", ShrinkPackageVersionRelation.Newer)] + [TestCase("1.0.0-preview.10", "1.0.0-preview.2", ShrinkPackageVersionRelation.Newer)] + [TestCase("1.0.0+build.2", "1.0.0+build.1", ShrinkPackageVersionRelation.Equal)] + [TestCase("git+https://example.invalid/package", "1.0.0", ShrinkPackageVersionRelation.Unknown)] + public void Classify_UsesSemanticVersionOrdering(string installed, string target, + ShrinkPackageVersionRelation expected) + { + Assert.AreEqual(expected, ShrinkPackageVersion.Classify(installed, target)); + } + } +} diff --git a/Tests/Editor/ShrinkPackageVersionTests.cs.meta b/Tests/Editor/ShrinkPackageVersionTests.cs.meta new file mode 100644 index 0000000..3321cea --- /dev/null +++ b/Tests/Editor/ShrinkPackageVersionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9152d73a7f3141429d048528d92b1ecf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json index f9efd7f..3d3ea71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.cneicy.shrink-installer", - "version": "0.2.4", + "version": "0.2.5", "displayName": "ShrinkSDK 包管理", "description": "基于 UI Toolkit 的 ShrinkSDK 固定版本包管理器,提供安装状态、Context 分层与推荐组合。", "unity": "2022.3",