diff --git a/Editor/UpdateNotification.meta b/Editor/UpdateNotification.meta new file mode 100644 index 0000000..d6b105b --- /dev/null +++ b/Editor/UpdateNotification.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 67ceeee4bf6e463f9fbdeeb6bc96eb84 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/UpdateNotification/ReleaseChecker.cs b/Editor/UpdateNotification/ReleaseChecker.cs new file mode 100644 index 0000000..21532cc --- /dev/null +++ b/Editor/UpdateNotification/ReleaseChecker.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections; +using System.Security.Cryptography; +using System.Text; +using UnityEditor; +using UnityEngine; + +namespace Net._32ba.GitTool.UpdateNotification +{ + [InitializeOnLoad] + internal static class ReleaseChecker + { + private const string PackageId = "net.32ba.git-tool"; + private const string PackageDisplayName = "Git Tool"; + private const string ReleasePageUrl = "https://github.com/32ba/VRC-git-tool/releases"; + private const string LastCheckKeyPrefix = "net.32ba.git.tool.LastVersionCheck"; + private const double CheckIntervalHours = 24.0; + + private static readonly VpmApiClient Api = new VpmApiClient(PackageId); + + private static string LatestVersion { get; set; } + private static bool HasNewVersion { get; set; } + private static bool IsChecking { get; set; } + private static string CheckError { get; set; } + + static ReleaseChecker() + { + EditorApplication.delayCall += () => CheckForUpdates(false); + } + + private static void CheckForUpdates(bool forceCheck) + { + if (IsChecking) + return; + + if (!forceCheck && !ShouldCheckForUpdates()) + return; + + IsChecking = true; + HasNewVersion = false; + CheckError = null; + + EditorCoroutine.Start(CheckRoutine(forceCheck)); + } + + private static string GetCurrentVersion() + { + try + { + var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(ReleaseChecker).Assembly); + if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.version)) + return packageInfo.version; + } + catch (Exception ex) + { + Debug.LogWarning($"[{PackageDisplayName}] Failed to read package version: {ex.Message}"); + } + + return "0.0.0"; + } + + private static IEnumerator CheckRoutine(bool forceCheck) + { + yield return Api.GetLatestVersionCoroutine( + latest => HandleSuccess(latest, forceCheck), + error => HandleError(error, forceCheck)); + } + + private static void HandleSuccess(string latest, bool forceCheck) + { + IsChecking = false; + + if (string.IsNullOrEmpty(latest)) + { + CheckError = "Empty version response"; + if (forceCheck) + EditorUtility.DisplayDialog($"{PackageDisplayName} Update Check", CheckError, "OK"); + return; + } + + LatestVersion = latest; + var current = GetCurrentVersion(); + EditorPrefs.SetString(GetLastCheckKey(), DateTime.Now.ToBinary().ToString()); + + if (VersionUtility.IsNewerVersion(current, latest)) + { + HasNewVersion = true; + Debug.Log($"[{PackageDisplayName}] New version available: {current} -> {latest}"); + ShowUpdateDialog(current, latest); + } + else if (forceCheck) + { + EditorUtility.DisplayDialog( + $"{PackageDisplayName} Update Check", + $"{PackageDisplayName} is up to date.\n\nCurrent: {VersionUtility.FormatVersion(current)}", + "OK"); + } + else + { + Debug.Log($"[{PackageDisplayName}] Package is up to date: {current}"); + } + } + + private static void HandleError(string error, bool forceCheck) + { + IsChecking = false; + CheckError = error; + Debug.LogWarning($"[{PackageDisplayName}] Update check failed: {error}"); + + if (forceCheck) + EditorUtility.DisplayDialog($"{PackageDisplayName} Update Check", $"Update check failed.\n\n{error}", "OK"); + } + + private static void ShowUpdateDialog(string current, string latest) + { + if (EditorUtility.DisplayDialog( + $"{PackageDisplayName} Update Available", + $"A new version of {PackageDisplayName} is available.\n\n{VersionUtility.FormatVersion(current)} -> {VersionUtility.FormatVersion(latest)}", + "Open Release Page", + "Later")) + { + Application.OpenURL(ReleasePageUrl); + } + } + + private static bool ShouldCheckForUpdates() + { + var stored = EditorPrefs.GetString(GetLastCheckKey(), ""); + if (string.IsNullOrEmpty(stored)) + return true; + + if (long.TryParse(stored, out var binary)) + { + var last = DateTime.FromBinary(binary); + return (DateTime.Now - last).TotalHours >= CheckIntervalHours; + } + + return true; + } + + private static string GetLastCheckKey() + { + return $"{LastCheckKeyPrefix}.{GetProjectScopeSuffix()}"; + } + + private static string GetProjectScopeSuffix() + { + var projectPath = Application.dataPath; + if (string.IsNullOrEmpty(projectPath)) + return "unknown"; + + using (var sha1 = SHA1.Create()) + { + var bytes = Encoding.UTF8.GetBytes(projectPath); + var hash = sha1.ComputeHash(bytes); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + } + } + + internal sealed class EditorCoroutine + { + private readonly IEnumerator _routine; + private IEnumerator _nested; + + public static EditorCoroutine Start(IEnumerator routine) + { + var coroutine = new EditorCoroutine(routine); + EditorApplication.update += coroutine.Update; + return coroutine; + } + + private EditorCoroutine(IEnumerator routine) + { + _routine = routine; + } + + private void Update() + { + if (_nested != null) + { + if (_nested.MoveNext()) + return; + _nested = null; + } + + if (!_routine.MoveNext()) + { + EditorApplication.update -= Update; + return; + } + + if (_routine.Current is IEnumerator nestedRoutine) + _nested = nestedRoutine; + } + } +} diff --git a/Editor/UpdateNotification/ReleaseChecker.cs.meta b/Editor/UpdateNotification/ReleaseChecker.cs.meta new file mode 100644 index 0000000..217f4a9 --- /dev/null +++ b/Editor/UpdateNotification/ReleaseChecker.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d7f7a242e0a48588285d5f0c249c10d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/UpdateNotification/VersionUtility.cs b/Editor/UpdateNotification/VersionUtility.cs new file mode 100644 index 0000000..c91b8fb --- /dev/null +++ b/Editor/UpdateNotification/VersionUtility.cs @@ -0,0 +1,58 @@ +using System; +using System.Text.RegularExpressions; +using UnityEngine; + +namespace Net._32ba.GitTool.UpdateNotification +{ + internal static class VersionUtility + { + public static bool IsNewerVersion(string currentVersion, string latestVersion) + { + if (string.IsNullOrEmpty(currentVersion) || string.IsNullOrEmpty(latestVersion)) + return false; + + try + { + return ParseVersion(latestVersion) > ParseVersion(currentVersion); + } + catch (Exception ex) + { + Debug.LogWarning($"[Git Tool] Failed to compare versions '{currentVersion}' and '{latestVersion}': {ex.Message}"); + return false; + } + } + + public static string FormatVersion(string version) + { + if (string.IsNullOrEmpty(version)) + return "Unknown"; + + return version.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? version : "v" + version; + } + + private static Version ParseVersion(string versionString) + { + var clean = versionString.TrimStart('v', 'V'); + + var match = Regex.Match(clean, @"^(\d+)\.(\d+)\.(\d+)"); + if (match.Success) + { + return new Version( + int.Parse(match.Groups[1].Value), + int.Parse(match.Groups[2].Value), + int.Parse(match.Groups[3].Value)); + } + + match = Regex.Match(clean, @"^(\d+)\.(\d+)"); + if (match.Success) + { + return new Version( + int.Parse(match.Groups[1].Value), + int.Parse(match.Groups[2].Value), + 0); + } + + return new Version(clean); + } + } +} diff --git a/Editor/UpdateNotification/VersionUtility.cs.meta b/Editor/UpdateNotification/VersionUtility.cs.meta new file mode 100644 index 0000000..a468201 --- /dev/null +++ b/Editor/UpdateNotification/VersionUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 960880228afc4c90b2ec15ccb51d9fc0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/UpdateNotification/VpmApiClient.cs b/Editor/UpdateNotification/VpmApiClient.cs new file mode 100644 index 0000000..f221baf --- /dev/null +++ b/Editor/UpdateNotification/VpmApiClient.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections; +using System.Text; +using UnityEngine; +using UnityEngine.Networking; + +namespace Net._32ba.GitTool.UpdateNotification +{ + internal sealed class VpmApiClient + { + private const string ApiBaseUrl = "https://vpm.32ba.net/api/packages"; + private const int RequestTimeoutSeconds = 10; + private readonly string _packageId; + + public VpmApiClient(string packageId) + { + _packageId = packageId; + } + + public IEnumerator GetLatestVersionCoroutine(Action onComplete, Action onError = null) + { + var url = $"{ApiBaseUrl}/{_packageId}/latest/version"; + + using (var request = UnityWebRequest.Get(url)) + { + request.timeout = RequestTimeoutSeconds; + request.SetRequestHeader("User-Agent", $"32ba-UnityUpdateChecker/{Application.unityVersion}"); + yield return request.SendWebRequest(); + + if (request.result == UnityWebRequest.Result.Success) + onComplete?.Invoke(request.downloadHandler.text.Trim()); + else + onError?.Invoke(BuildErrorMessage(request, url)); + } + } + + private static string BuildErrorMessage(UnityWebRequest request, string url) + { + var parts = new StringBuilder(); + parts.Append("VPM API request failed"); + parts.Append($" ({request.result})"); + + if (!string.IsNullOrEmpty(request.error)) + parts.Append($": {request.error}"); + + if (request.responseCode > 0) + parts.Append($" [HTTP {request.responseCode}]"); + + parts.Append($" URL={url}"); + + var responseText = request.downloadHandler != null ? request.downloadHandler.text : null; + if (!string.IsNullOrWhiteSpace(responseText)) + { + responseText = responseText.Trim(); + if (responseText.Length > 200) + responseText = responseText.Substring(0, 200) + "..."; + parts.Append($" Response={responseText}"); + } + + return parts.ToString(); + } + } +} diff --git a/Editor/UpdateNotification/VpmApiClient.cs.meta b/Editor/UpdateNotification/VpmApiClient.cs.meta new file mode 100644 index 0000000..0e15d13 --- /dev/null +++ b/Editor/UpdateNotification/VpmApiClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8518386e7f724631a984770cc98b047d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: