Notify users when there's a new version (#329)

* feat: add package update service with version check and GitHub integration

* feat: add migration warning banner and dialog for legacy package users

* test: remove redundant cache expiration and clearing tests from PackageUpdateService

* test: add package update service tests for expired cache and asset store installations
main
Marcus Sanatan 2025-10-18 20:42:18 -04:00 committed by GitHub
parent 350337813b
commit 673456b701
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 644 additions and 1 deletions

View File

@ -0,0 +1,60 @@
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for checking package updates and version information
/// </summary>
public interface IPackageUpdateService
{
/// <summary>
/// Checks if a newer version of the package is available
/// </summary>
/// <param name="currentVersion">The current package version</param>
/// <returns>Update check result containing availability and latest version info</returns>
UpdateCheckResult CheckForUpdate(string currentVersion);
/// <summary>
/// Compares two version strings to determine if the first is newer than the second
/// </summary>
/// <param name="version1">First version string</param>
/// <param name="version2">Second version string</param>
/// <returns>True if version1 is newer than version2</returns>
bool IsNewerVersion(string version1, string version2);
/// <summary>
/// Determines if the package was installed via Git or Asset Store
/// </summary>
/// <returns>True if installed via Git, false if Asset Store or unknown</returns>
bool IsGitInstallation();
/// <summary>
/// Clears the cached update check data, forcing a fresh check on next request
/// </summary>
void ClearCache();
}
/// <summary>
/// Result of an update check operation
/// </summary>
public class UpdateCheckResult
{
/// <summary>
/// Whether an update is available
/// </summary>
public bool UpdateAvailable { get; set; }
/// <summary>
/// The latest version available (null if check failed or no update)
/// </summary>
public string LatestVersion { get; set; }
/// <summary>
/// Whether the check was successful (false if network error, etc.)
/// </summary>
public bool CheckSucceeded { get; set; }
/// <summary>
/// Optional message about the check result
/// </summary>
public string Message { get; set; }
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e94ae28f193184e4fb5068f62f4f00c6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -13,6 +13,7 @@ namespace MCPForUnity.Editor.Services
private static IPythonToolRegistryService _pythonToolRegistryService;
private static ITestRunnerService _testRunnerService;
private static IToolSyncService _toolSyncService;
private static IPackageUpdateService _packageUpdateService;
public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService();
public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService();
@ -20,6 +21,7 @@ namespace MCPForUnity.Editor.Services
public static IPythonToolRegistryService PythonToolRegistry => _pythonToolRegistryService ??= new PythonToolRegistryService();
public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService();
public static IToolSyncService ToolSync => _toolSyncService ??= new ToolSyncService();
public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService();
/// <summary>
/// Registers a custom implementation for a service (useful for testing)
@ -40,6 +42,8 @@ namespace MCPForUnity.Editor.Services
_testRunnerService = t;
else if (implementation is IToolSyncService ts)
_toolSyncService = ts;
else if (implementation is IPackageUpdateService pu)
_packageUpdateService = pu;
}
/// <summary>
@ -53,6 +57,7 @@ namespace MCPForUnity.Editor.Services
(_pythonToolRegistryService as IDisposable)?.Dispose();
(_testRunnerService as IDisposable)?.Dispose();
(_toolSyncService as IDisposable)?.Dispose();
(_packageUpdateService as IDisposable)?.Dispose();
_bridgeService = null;
_clientService = null;
@ -60,6 +65,7 @@ namespace MCPForUnity.Editor.Services
_pythonToolRegistryService = null;
_testRunnerService = null;
_toolSyncService = null;
_packageUpdateService = null;
}
}
}

View File

@ -0,0 +1,161 @@
using System;
using System.Net;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Service for checking package updates from GitHub
/// </summary>
public class PackageUpdateService : IPackageUpdateService
{
private const string LastCheckDateKey = "MCPForUnity.LastUpdateCheck";
private const string CachedVersionKey = "MCPForUnity.LatestKnownVersion";
private const string PackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/main/MCPForUnity/package.json";
/// <inheritdoc/>
public UpdateCheckResult CheckForUpdate(string currentVersion)
{
// Check cache first - only check once per day
string lastCheckDate = EditorPrefs.GetString(LastCheckDateKey, "");
string cachedLatestVersion = EditorPrefs.GetString(CachedVersionKey, "");
if (lastCheckDate == DateTime.Now.ToString("yyyy-MM-dd") && !string.IsNullOrEmpty(cachedLatestVersion))
{
return new UpdateCheckResult
{
CheckSucceeded = true,
LatestVersion = cachedLatestVersion,
UpdateAvailable = IsNewerVersion(cachedLatestVersion, currentVersion),
Message = "Using cached version check"
};
}
// Don't check for Asset Store installations
if (!IsGitInstallation())
{
return new UpdateCheckResult
{
CheckSucceeded = false,
UpdateAvailable = false,
Message = "Asset Store installations are updated via Unity Asset Store"
};
}
// Fetch latest version from GitHub
string latestVersion = FetchLatestVersionFromGitHub();
if (!string.IsNullOrEmpty(latestVersion))
{
// Cache the result
EditorPrefs.SetString(LastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd"));
EditorPrefs.SetString(CachedVersionKey, latestVersion);
return new UpdateCheckResult
{
CheckSucceeded = true,
LatestVersion = latestVersion,
UpdateAvailable = IsNewerVersion(latestVersion, currentVersion),
Message = "Successfully checked for updates"
};
}
return new UpdateCheckResult
{
CheckSucceeded = false,
UpdateAvailable = false,
Message = "Failed to check for updates (network issue or offline)"
};
}
/// <inheritdoc/>
public bool IsNewerVersion(string version1, string version2)
{
try
{
// Remove any "v" prefix
version1 = version1.TrimStart('v', 'V');
version2 = version2.TrimStart('v', 'V');
var version1Parts = version1.Split('.');
var version2Parts = version2.Split('.');
for (int i = 0; i < Math.Min(version1Parts.Length, version2Parts.Length); i++)
{
if (int.TryParse(version1Parts[i], out int v1Num) &&
int.TryParse(version2Parts[i], out int v2Num))
{
if (v1Num > v2Num) return true;
if (v1Num < v2Num) return false;
}
}
return false;
}
catch
{
return false;
}
}
/// <inheritdoc/>
public bool IsGitInstallation()
{
// Git packages are installed via Package Manager and have a package.json in Packages/
// Asset Store packages are in Assets/
string packageRoot = AssetPathUtility.GetMcpPackageRootPath();
if (string.IsNullOrEmpty(packageRoot))
{
return false;
}
// If the package is in Packages/ it's a PM install (likely Git)
// If it's in Assets/ it's an Asset Store install
return packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase);
}
/// <inheritdoc/>
public void ClearCache()
{
EditorPrefs.DeleteKey(LastCheckDateKey);
EditorPrefs.DeleteKey(CachedVersionKey);
}
/// <summary>
/// Fetches the latest version from GitHub's main branch package.json
/// </summary>
private string FetchLatestVersionFromGitHub()
{
try
{
// GitHub API endpoint (Option 1 - has rate limits):
// https://api.github.com/repos/CoplayDev/unity-mcp/releases/latest
//
// We use Option 2 (package.json directly) because:
// - No API rate limits (GitHub serves raw files freely)
// - Simpler - just parse JSON for version field
// - More reliable - doesn't require releases to be published
// - Direct source of truth from the main branch
using (var client = new WebClient())
{
client.Headers.Add("User-Agent", "Unity-MCPForUnity-UpdateChecker");
string jsonContent = client.DownloadString(PackageJsonUrl);
var packageJson = JObject.Parse(jsonContent);
string version = packageJson["version"]?.ToString();
return string.IsNullOrEmpty(version) ? null : version;
}
}
catch (Exception ex)
{
// Silent fail - don't interrupt the user if network is unavailable
McpLog.Info($"Update check failed (this is normal if offline): {ex.Message}");
return null;
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7c3c2304b14e9485ca54182fad73b035
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -242,7 +242,7 @@ namespace MCPForUnity.Editor.Windows
private void InitializeUI()
{
// Settings Section
versionLabel.text = AssetPathUtility.GetPackageVersion();
UpdateVersionLabel();
debugLogsToggle.value = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false);
validationLevelField.Init(ValidationLevel.Standard);
@ -833,5 +833,28 @@ namespace MCPForUnity.Editor.Windows
EditorGUIUtility.systemCopyBuffer = configJsonField.value;
McpLog.Info("Configuration copied to clipboard");
}
private void UpdateVersionLabel()
{
string currentVersion = AssetPathUtility.GetPackageVersion();
versionLabel.text = $"v{currentVersion}";
// Check for updates using the service
var updateCheck = MCPServiceLocator.Updates.CheckForUpdate(currentVersion);
if (updateCheck.UpdateAvailable && !string.IsNullOrEmpty(updateCheck.LatestVersion))
{
// Update available - enhance the label
versionLabel.text = $"\u2191 v{currentVersion} (Update available: v{updateCheck.LatestVersion})";
versionLabel.style.color = new Color(1f, 0.7f, 0f); // Orange
versionLabel.tooltip = $"Version {updateCheck.LatestVersion} is available. Update via Package Manager.\n\nGit URL: https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity";
}
else
{
versionLabel.style.color = StyleKeyword.Null; // Default color
versionLabel.tooltip = $"Current version: {currentVersion}";
}
}
}
}

View File

@ -0,0 +1,295 @@
using System;
using NUnit.Framework;
using UnityEditor;
using MCPForUnity.Editor.Services;
namespace MCPForUnityTests.Editor.Services
{
public class PackageUpdateServiceTests
{
private PackageUpdateService _service;
private const string TestLastCheckDateKey = "MCPForUnity.LastUpdateCheck";
private const string TestCachedVersionKey = "MCPForUnity.LatestKnownVersion";
[SetUp]
public void SetUp()
{
_service = new PackageUpdateService();
// Clean up any existing test data
CleanupEditorPrefs();
}
[TearDown]
public void TearDown()
{
// Clean up test data
CleanupEditorPrefs();
}
private void CleanupEditorPrefs()
{
if (EditorPrefs.HasKey(TestLastCheckDateKey))
{
EditorPrefs.DeleteKey(TestLastCheckDateKey);
}
if (EditorPrefs.HasKey(TestCachedVersionKey))
{
EditorPrefs.DeleteKey(TestCachedVersionKey);
}
}
[Test]
public void IsNewerVersion_ReturnsTrue_WhenMajorVersionIsNewer()
{
bool result = _service.IsNewerVersion("2.0.0", "1.0.0");
Assert.IsTrue(result, "2.0.0 should be newer than 1.0.0");
}
[Test]
public void IsNewerVersion_ReturnsTrue_WhenMinorVersionIsNewer()
{
bool result = _service.IsNewerVersion("1.2.0", "1.1.0");
Assert.IsTrue(result, "1.2.0 should be newer than 1.1.0");
}
[Test]
public void IsNewerVersion_ReturnsTrue_WhenPatchVersionIsNewer()
{
bool result = _service.IsNewerVersion("1.0.2", "1.0.1");
Assert.IsTrue(result, "1.0.2 should be newer than 1.0.1");
}
[Test]
public void IsNewerVersion_ReturnsFalse_WhenVersionsAreEqual()
{
bool result = _service.IsNewerVersion("1.0.0", "1.0.0");
Assert.IsFalse(result, "Same versions should return false");
}
[Test]
public void IsNewerVersion_ReturnsFalse_WhenVersionIsOlder()
{
bool result = _service.IsNewerVersion("1.0.0", "2.0.0");
Assert.IsFalse(result, "1.0.0 should not be newer than 2.0.0");
}
[Test]
public void IsNewerVersion_HandlesVersionPrefix_v()
{
bool result = _service.IsNewerVersion("v2.0.0", "v1.0.0");
Assert.IsTrue(result, "Should handle 'v' prefix correctly");
}
[Test]
public void IsNewerVersion_HandlesVersionPrefix_V()
{
bool result = _service.IsNewerVersion("V2.0.0", "V1.0.0");
Assert.IsTrue(result, "Should handle 'V' prefix correctly");
}
[Test]
public void IsNewerVersion_HandlesMixedPrefixes()
{
bool result = _service.IsNewerVersion("v2.0.0", "1.0.0");
Assert.IsTrue(result, "Should handle mixed prefixes correctly");
}
[Test]
public void IsNewerVersion_ComparesCorrectly_WhenMajorDiffers()
{
bool result1 = _service.IsNewerVersion("10.0.0", "9.0.0");
bool result2 = _service.IsNewerVersion("2.0.0", "10.0.0");
Assert.IsTrue(result1, "10.0.0 should be newer than 9.0.0");
Assert.IsFalse(result2, "2.0.0 should not be newer than 10.0.0");
}
[Test]
public void IsNewerVersion_ReturnsFalse_OnInvalidVersionFormat()
{
// Service should handle errors gracefully
bool result = _service.IsNewerVersion("invalid", "1.0.0");
Assert.IsFalse(result, "Should return false for invalid version format");
}
[Test]
public void CheckForUpdate_ReturnsCachedVersion_WhenCacheIsValid()
{
// Arrange: Set up valid cache
string today = DateTime.Now.ToString("yyyy-MM-dd");
string cachedVersion = "5.5.5";
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, cachedVersion);
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsTrue(result.CheckSucceeded, "Check should succeed with valid cache");
Assert.AreEqual(cachedVersion, result.LatestVersion, "Should return cached version");
Assert.IsTrue(result.UpdateAvailable, "Update should be available (5.5.5 > 5.0.0)");
}
[Test]
public void CheckForUpdate_DetectsUpdateAvailable_WhenNewerVersionCached()
{
// Arrange
string today = DateTime.Now.ToString("yyyy-MM-dd");
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, "6.0.0");
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsTrue(result.UpdateAvailable, "Should detect update is available");
Assert.AreEqual("6.0.0", result.LatestVersion);
}
[Test]
public void CheckForUpdate_DetectsNoUpdate_WhenVersionsMatch()
{
// Arrange
string today = DateTime.Now.ToString("yyyy-MM-dd");
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsFalse(result.UpdateAvailable, "Should detect no update needed");
Assert.AreEqual("5.0.0", result.LatestVersion);
}
[Test]
public void CheckForUpdate_DetectsNoUpdate_WhenCurrentVersionIsNewer()
{
// Arrange
string today = DateTime.Now.ToString("yyyy-MM-dd");
EditorPrefs.SetString(TestLastCheckDateKey, today);
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
// Act
var result = _service.CheckForUpdate("6.0.0");
// Assert
Assert.IsFalse(result.UpdateAvailable, "Should detect no update when current is newer");
Assert.AreEqual("5.0.0", result.LatestVersion);
}
[Test]
public void CheckForUpdate_IgnoresExpiredCache_AndAttemptsFreshFetch()
{
// Arrange: Set cache from yesterday (expired)
string yesterday = DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd");
string cachedVersion = "4.0.0";
EditorPrefs.SetString(TestLastCheckDateKey, yesterday);
EditorPrefs.SetString(TestCachedVersionKey, cachedVersion);
// Act
var result = _service.CheckForUpdate("5.0.0");
// Assert
Assert.IsNotNull(result, "Should return a result");
// If the check succeeded (network available), verify it didn't use the expired cache
if (result.CheckSucceeded)
{
Assert.AreNotEqual(cachedVersion, result.LatestVersion,
"Should not return expired cached version when fresh fetch succeeds");
Assert.IsNotNull(result.LatestVersion, "Should have fetched a new version");
}
else
{
// If offline, check should fail (not succeed with cached data)
Assert.IsFalse(result.UpdateAvailable,
"Should not report update available when fetch fails and cache is expired");
}
}
[Test]
public void CheckForUpdate_ReturnsAssetStoreMessage_ForNonGitInstallations()
{
// Note: This test verifies the service behavior when IsGitInstallation() returns false.
// Since the actual result depends on package installation method, we create a mock
// implementation to test this specific code path.
var mockService = new MockAssetStorePackageUpdateService();
// Act
var result = mockService.CheckForUpdate("5.0.0");
// Assert
Assert.IsFalse(result.CheckSucceeded, "Check should not succeed for Asset Store installs");
Assert.IsFalse(result.UpdateAvailable, "No update should be reported for Asset Store installs");
Assert.AreEqual("Asset Store installations are updated via Unity Asset Store", result.Message,
"Should return Asset Store update message");
Assert.IsNull(result.LatestVersion, "Latest version should be null for Asset Store installs");
}
[Test]
public void ClearCache_RemovesAllCachedData()
{
// Arrange: Set up cache
EditorPrefs.SetString(TestLastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd"));
EditorPrefs.SetString(TestCachedVersionKey, "5.0.0");
// Verify cache exists
Assert.IsTrue(EditorPrefs.HasKey(TestLastCheckDateKey), "Cache should exist before clearing");
Assert.IsTrue(EditorPrefs.HasKey(TestCachedVersionKey), "Cache should exist before clearing");
// Act
_service.ClearCache();
// Assert
Assert.IsFalse(EditorPrefs.HasKey(TestLastCheckDateKey), "Date cache should be cleared");
Assert.IsFalse(EditorPrefs.HasKey(TestCachedVersionKey), "Version cache should be cleared");
}
[Test]
public void ClearCache_DoesNotThrow_WhenNoCacheExists()
{
// Ensure no cache exists
CleanupEditorPrefs();
// Act & Assert - should not throw
Assert.DoesNotThrow(() => _service.ClearCache(), "Should not throw when clearing non-existent cache");
}
}
/// <summary>
/// Mock implementation of IPackageUpdateService that simulates Asset Store installation behavior
/// </summary>
internal class MockAssetStorePackageUpdateService : IPackageUpdateService
{
public UpdateCheckResult CheckForUpdate(string currentVersion)
{
// Simulate Asset Store installation (IsGitInstallation returns false)
return new UpdateCheckResult
{
CheckSucceeded = false,
UpdateAvailable = false,
Message = "Asset Store installations are updated via Unity Asset Store"
};
}
public bool IsNewerVersion(string version1, string version2)
{
// Not used in the Asset Store test, but required by interface
return false;
}
public bool IsGitInstallation()
{
// Simulate non-Git installation (Asset Store)
return false;
}
public void ClearCache()
{
// Not used in the Asset Store test, but required by interface
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 676c3849f71a84b17b14d813774d3f74
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -71,6 +71,9 @@ namespace MCPForUnity.Editor.Windows
// Load validation level setting
LoadValidationLevelSetting();
// Show one-time migration dialog
ShowMigrationDialogIfNeeded();
// First-run auto-setup only if Claude CLI is available
if (autoRegisterEnabled && !string.IsNullOrEmpty(ExecPath.ResolveClaude()))
{
@ -170,6 +173,9 @@ namespace MCPForUnity.Editor.Windows
{
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
// Migration warning banner (non-dismissible)
DrawMigrationWarningBanner();
// Header
DrawHeader();
@ -1573,6 +1579,65 @@ namespace MCPForUnity.Editor.Windows
}
}
private void ShowMigrationDialogIfNeeded()
{
const string dialogShownKey = "MCPForUnity.LegacyMigrationDialogShown";
if (EditorPrefs.GetBool(dialogShownKey, false))
{
return; // Already shown
}
int result = EditorUtility.DisplayDialogComplex(
"Migration Required",
"This is the legacy UnityMcpBridge package.\n\n" +
"Please migrate to the new MCPForUnity package to receive updates and support.\n\n" +
"Migration takes just a few minutes.",
"View Migration Guide",
"Remind Me Later",
"I'll Migrate Later"
);
if (result == 0) // View Migration Guide
{
Application.OpenURL("https://github.com/CoplayDev/unity-mcp/blob/main/docs/v5_MIGRATION.md");
EditorPrefs.SetBool(dialogShownKey, true);
}
else if (result == 2) // I'll Migrate Later
{
EditorPrefs.SetBool(dialogShownKey, true);
}
// result == 1 (Remind Me Later) - don't set the flag, show again next time
}
private void DrawMigrationWarningBanner()
{
// Warning banner - not dismissible, always visible
EditorGUILayout.Space(5);
Rect bannerRect = EditorGUILayout.GetControlRect(false, 50);
EditorGUI.DrawRect(bannerRect, new Color(1f, 0.6f, 0f, 0.3f)); // Orange background
GUIStyle warningStyle = new GUIStyle(EditorStyles.boldLabel)
{
fontSize = 13,
alignment = TextAnchor.MiddleLeft,
richText = true
};
// Use Unicode warning triangle (same as used elsewhere in codebase at line 647, 652)
string warningText = "\u26A0 <color=#FF8C00>LEGACY PACKAGE:</color> Please migrate to MCPForUnity for updates and support.";
Rect textRect = new Rect(bannerRect.x + 15, bannerRect.y + 8, bannerRect.width - 180, bannerRect.height - 16);
GUI.Label(textRect, warningText, warningStyle);
// Button on the right
Rect buttonRect = new Rect(bannerRect.xMax - 160, bannerRect.y + 10, 145, 30);
if (GUI.Button(buttonRect, "View Migration Guide"))
{
Application.OpenURL("https://github.com/CoplayDev/unity-mcp/blob/main/docs/v5_MIGRATION.md");
}
EditorGUILayout.Space(5);
}
private bool IsPythonDetected()
{
try