Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Editor/UpdateNotification.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

197 changes: 197 additions & 0 deletions Editor/UpdateNotification/ReleaseChecker.cs
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +193 to +194

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle AsyncOperation yields in editor coroutine runner

EditorCoroutine.Update only pauses for yielded values that implement IEnumerator, but GetLatestVersionCoroutine yields request.SendWebRequest() (a UnityWebRequestAsyncOperation). Because that yield is ignored, _routine.MoveNext() runs again on the next editor tick before the request completes, so request.result is often still InProgress and the code reports a failed update check even when the network call would succeed. This makes automatic/manual update checks unreliable in normal latency conditions.

Useful? React with 👍 / 👎.

}
}
}
11 changes: 11 additions & 0 deletions Editor/UpdateNotification/ReleaseChecker.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 58 additions & 0 deletions Editor/UpdateNotification/VersionUtility.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
11 changes: 11 additions & 0 deletions Editor/UpdateNotification/VersionUtility.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 63 additions & 0 deletions Editor/UpdateNotification/VpmApiClient.cs
Original file line number Diff line number Diff line change
@@ -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<string> onComplete, Action<string> 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();
}
}
}
11 changes: 11 additions & 0 deletions Editor/UpdateNotification/VpmApiClient.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.