/*
┌──────────────────────────────────────────────────────────────────┐
│ Author: Ivan Murzak (https://github.com/IvanMurzak) │
│ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
│ Copyright (c) 2025 Ivan Murzak │
│ Licensed under the Apache License, Version 2.0. │
│ See the LICENSE file in the project root for more information. │
└──────────────────────────────────────────────────────────────────┘
*/
#nullable enable
using System;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using com.IvanMurzak.Unity.MCP.Editor.UI;
using Microsoft.Extensions.Logging;
using UnityEditor;
using UnityEngine;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace com.IvanMurzak.Unity.MCP.Editor.Utils
{
///
/// Utility class for checking if a new version of the package is available on OpenUPM.
///
///
/// OpenUPM is the source of truth for the version that Unity Package Manager will actually
/// install for end users. GitHub releases are published before the OpenUPM build pipeline
/// finishes, so polling GitHub releases would prompt users to update to a version that is
/// not yet installable. See https://github.com/IvanMurzak/Unity-MCP/issues/694.
///
/// Two opt-out layers gate the popup, in this order:
/// 1. — team-shared, stored in
/// ProjectSettings/AI-Game-Developer-UpdateSettings.asset via
/// . Set this once and commit it to
/// disable the popup for every team member who clones the project. See
/// https://github.com/IvanMurzak/Unity-MCP/issues/768.
/// 2. — per-user, stored in
/// (NOT — clearing in-game PlayerPrefs must not
/// wipe the editor flag). See https://github.com/IvanMurzak/Unity-MCP/issues/755.
///
public static class UpdateChecker
{
private const string PackageId = "com.ivanmurzak.unity.mcp";
// package.openupm.com is the npm-style metadata registry (machine-readable JSON);
// openupm.com is the human-readable package page the popup links users to.
private const string OpenUpmPackageMetadataUrl = "https://package.openupm.com/" + PackageId;
private const string OpenUpmPackageUrl = "https://openupm.com/packages/" + PackageId + "/";
// Anchored at both ends: accepts only complete "N.N" or "N.N.N" strings. Without the
// trailing anchor, "1.0.0-preview" would match and CompareVersions would silently
// treat the "0-preview" segment as 0, making pre-release tags look equal to the
// final release. See https://github.com/IvanMurzak/Unity-MCP/issues/694 review.
private static readonly Regex VersionPattern = new(@"^\d+\.\d+(\.\d+)?$", RegexOptions.Compiled);
// Hoisted to a single static instance to avoid socket exhaustion (TIME_WAIT) under
// repeated update checks. Same pattern used by NuGetDownloader and DeviceAuthService
// elsewhere in this package. The 10s timeout covers OpenUPM's slowest realistic responses.
private static readonly HttpClient HttpClient = CreateHttpClient();
// EditorPrefs is global to the Unity install, so the keys are namespaced by package id
// AND by a stable per-project token (FNV-1a hash of Application.dataPath) to keep parallel
// projects from stomping on each other's state. See #755 for the migration rationale.
//
// FNV-1a (not string.GetHashCode()) because Unity is migrating to CoreCLR (preview in 6.x),
// and on CoreCLR string.GetHashCode() is randomized per process — keys would change on
// every editor restart and per-user state (DoNotShowAgain, cooldown, skipped version)
// would be silently lost. FNV-1a is byte-stable across runtimes and across machines.
//
// Caveat: two distinct Unity projects that happen to live at the same Application.dataPath
// (e.g. user copies a project folder, deletes the original, and opens the copy at the same
// location) will share EditorPrefs state. Acceptable for a "do not show again" flag.
private static readonly string ProjectKeyPrefix =
$"{PackageId}.{Fnv1a32(Application.dataPath):X8}.UpdateChecker.";
private static readonly string KeyDoNotShowAgain = ProjectKeyPrefix + "DoNotShowAgain";
private static readonly string KeyNextCheckTime = ProjectKeyPrefix + "NextCheckTime";
private static readonly string KeySkippedVersion = ProjectKeyPrefix + "SkippedVersion";
///
/// Deterministic 32-bit FNV-1a hash of a UTF-16 string. Stable across .NET runtimes
/// (Mono and CoreCLR) and across machines — unlike ,
/// which is randomized per process under CoreCLR.
///
private static uint Fnv1a32(string input)
{
const uint offsetBasis = 2166136261u;
const uint prime = 16777619u;
var hash = offsetBasis;
for (int i = 0; i < input.Length; i++)
{
hash ^= (byte)(input[i] & 0xFF);
hash *= prime;
hash ^= (byte)((input[i] >> 8) & 0xFF);
hash *= prime;
}
return hash;
}
private static bool isChecking = false;
private static string? latestVersion = null;
private static ILogger? logger = null;
private static HttpClient CreateHttpClient()
{
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
client.DefaultRequestHeaders.Add("User-Agent", "AI-Game-Developer-UpdateChecker");
return client;
}
///
/// Gets whether the team-wide kill-switch is enabled for this project. When true,
/// the popup is suppressed for every team member who has the
/// ProjectSettings/AI-Game-Developer-UpdateSettings.asset file in their checkout,
/// regardless of their per-user state.
///
///
/// Stored in — a
/// persisted under ProjectSettings/. The asset is intended to be committed to VCS.
///
public static bool IsDisabledForProject
{
get => UnityMcpUpdateProjectSettings.instance.DisableUpdateNotificationsForTeam;
set => UnityMcpUpdateProjectSettings.instance.DisableUpdateNotificationsForTeam = value;
}
///
/// Gets whether the user has chosen to never show the update popup again on this machine.
///
///
/// Stored in (per-user, per-Unity-install) — not in
/// . Clearing in-game PlayerPrefs must not silently
/// reset the editor flag. See https://github.com/IvanMurzak/Unity-MCP/issues/755.
///
public static bool IsDoNotShowAgain
{
get => EditorPrefs.GetBool(KeyDoNotShowAgain, false);
set => EditorPrefs.SetBool(KeyDoNotShowAgain, value);
}
///
/// Gets the latest version that was found during the last check.
///
public static string? LatestVersion => latestVersion;
///
/// Gets the OpenUPM package URL for the user to manually view available versions.
///
///
/// Points at OpenUPM rather than GitHub releases because OpenUPM is the registry
/// Unity Package Manager actually pulls from — the version visible there is the
/// version users can actually install at this moment.
///
public static string ReleasesUrl => OpenUpmPackageUrl;
public static void Init(ILogger? initLogger = null)
{
logger = initLogger;
// Check for updates after Unity finishes loading
EditorApplication.delayCall += CheckForUpdatesOnStartup;
}
private static void CheckForUpdatesOnStartup()
{
EditorApplication.delayCall -= CheckForUpdatesOnStartup;
if (!ShouldCheckForUpdates())
return;
_ = CheckForUpdatesAsync();
}
///
/// Determines if we should check for updates based on the team-shared project flag,
/// the per-user opt-out, and the cooldown timer.
///
///
/// Precedence (in order):
/// 1. — team-wide kill-switch wins over everything.
/// 2. — per-user opt-out.
/// 3. The cooldown timestamp stored in .
///
public static bool ShouldCheckForUpdates()
{
// 1. Team-wide kill-switch — short-circuits before any per-user state is consulted.
if (IsDisabledForProject)
return false;
// 2. Per-user opt-out.
if (IsDoNotShowAgain)
return false;
// 3. Cooldown.
var nextCheckTimeStr = EditorPrefs.GetString(KeyNextCheckTime, string.Empty);
if (!string.IsNullOrEmpty(nextCheckTimeStr))
{
if (DateTime.TryParse(nextCheckTimeStr, out var nextCheckDateTime))
{
if (DateTime.UtcNow < nextCheckDateTime)
return false;
}
}
return true;
}
///
/// Skips a specific version (user doesn't want to be notified about it again).
///
public static void SkipVersion(string version)
{
EditorPrefs.SetString(KeySkippedVersion, version);
}
///
/// Clears all per-user update-checker preferences (useful for testing and for the
/// "Reset Update Preferences" debug menu).
///
///
/// This intentionally does NOT reset — that flag is
/// team-shared via ProjectSettings/, and clearing it from a debug menu would
/// produce a spurious diff in a committed asset (potentially surprising other team
/// members). The team flag is reset only through the Project Settings UI or
/// Tools ▸ AI Game Developer ▸ Updates ▸ Disable Update Notifications (Team).
///
public static void ClearPreferences()
{
EditorPrefs.DeleteKey(KeyDoNotShowAgain);
EditorPrefs.DeleteKey(KeyNextCheckTime);
EditorPrefs.DeleteKey(KeySkippedVersion);
}
///
/// Asynchronously checks for updates from OpenUPM.
///
/// If true, ignores cooldown and skipped version settings.
public static async Task CheckForUpdatesAsync(bool forceCheck = false)
{
if (isChecking)
{
if (forceCheck)
logger?.LogWarning("Already checking for updates...");
return;
}
if (!forceCheck && !ShouldCheckForUpdates())
return;
isChecking = true;
try
{
var fetched = await FetchLatestVersionAsync();
if (string.IsNullOrEmpty(fetched))
{
if (forceCheck)
logger?.LogWarning("Unable to check for updates. Please check your internet connection.");
return;
}
latestVersion = fetched;
// Check if this version was skipped
var skippedVersion = EditorPrefs.GetString(KeySkippedVersion, string.Empty);
if (!string.IsNullOrEmpty(skippedVersion) && skippedVersion == fetched && !forceCheck)
{
return;
}
// Compare versions. The `!` is required because the Unity Editor C# compiler
// does not honor `[NotNullWhen(false)]` on `string.IsNullOrEmpty`, so it does
// not narrow `fetched` (declared `string?`) to non-null after the early-return
// guard above and would otherwise emit CS8604 here. The runtime check above
// already establishes `fetched` is non-null and non-empty at this point.
var currentVersion = UnityMcpPlugin.Version;
if (IsNewerVersion(fetched!, currentVersion))
{
// Show the update popup on the main thread
EditorApplication.delayCall += () =>
{
UpdatePopupWindow.ShowWindow(currentVersion, fetched!);
};
}
else if (forceCheck)
{
// User manually checked - inform them they're up to date
logger?.LogDebug("You are using the latest version ({currentVersion}).", currentVersion);
}
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Failed to check for updates");
}
finally
{
// Set next allowed check time to enforce cooldown (only for automatic checks)
if (!forceCheck)
{
EditorPrefs.SetString(KeyNextCheckTime, DateTime.UtcNow.AddHours(1).ToString("O"));
}
isChecking = false;
}
}
///
/// Fetches the latest version from the OpenUPM registry.
///
///
/// Uses the npm-style registry endpoint https://package.openupm.com/{packageId},
/// which returns a JSON document whose dist-tags.latest field is the version
/// currently installable via Unity Package Manager. On any network or parsing failure
/// the method returns null so callers fall back gracefully without prompting.
///
private static async Task FetchLatestVersionAsync()
{
try
{
var json = await HttpClient.GetStringAsync(OpenUpmPackageMetadataUrl);
return ParseLatestVersionFromJson(json);
}
catch (HttpRequestException ex)
{
// Use the exception-preserving overload so stack trace and inner exceptions
// survive into structured logs — same pattern as the catch-all below.
logger?.LogWarning(ex, "Failed to fetch package metadata");
return null;
}
catch (TaskCanceledException ex)
{
logger?.LogWarning(ex, "OpenUPM request timed out");
return null;
}
catch (Exception ex)
{
// ParseLatestVersionFromJson swallows JsonException internally, so anything
// reaching here is an unexpected transport / URI / IO failure rather than a
// parse error — message must reflect that.
logger?.LogWarning(ex, "Unexpected error during OpenUPM update check");
return null;
}
}
///
/// Parses the latest version from an OpenUPM registry JSON response.
///
///
/// The OpenUPM registry follows the npm registry shape; the latest published version
/// is at dist-tags.latest. Returns null if the JSON is empty, malformed,
/// or does not contain a dist-tags.latest string. Also returns null for
/// non-numeric or pre-release version strings (e.g. "1.0.0-preview") — the
/// parser only accepts strict N.N / N.N.N shapes so the popup never
/// surfaces a tag that would silently misorder.
///
internal static string? ParseLatestVersionFromJson(string json)
{
if (string.IsNullOrEmpty(json))
return null;
try
{
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("dist-tags", out var distTags))
return null;
if (distTags.ValueKind != JsonValueKind.Object)
return null;
if (!distTags.TryGetProperty("latest", out var latest))
return null;
if (latest.ValueKind != JsonValueKind.String)
return null;
var version = latest.GetString();
if (string.IsNullOrEmpty(version))
return null;
// Defensive: only accept numeric semver-shaped strings. Without this guard
// CompareVersions would silently treat non-numeric parts as 0, producing a
// misleading "version" in the popup if the registry ever returns garbage.
return VersionPattern.IsMatch(version) ? version : null;
}
catch (JsonException)
{
return null;
}
}
///
/// Compares two version strings.
///
internal static int CompareVersions(string v1, string v2)
{
var parts1 = v1.Split('.');
var parts2 = v2.Split('.');
var maxLength = Math.Max(parts1.Length, parts2.Length);
for (int i = 0; i < maxLength; i++)
{
var num1 = i < parts1.Length && int.TryParse(parts1[i], out var n1) ? n1 : 0;
var num2 = i < parts2.Length && int.TryParse(parts2[i], out var n2) ? n2 : 0;
if (num1 != num2)
return num1.CompareTo(num2);
}
return 0;
}
///
/// Determines if the remote version is newer than the current version.
///
public static bool IsNewerVersion(string remoteVersion, string currentVersion)
{
return CompareVersions(remoteVersion, currentVersion) > 0;
}
}
}