Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 b73bb5e5de Add client update notification UI (Unity)
Handle ClientUpdateAvailable messages from server and show notification UI.

Components:
- UpdateNotificationManager: Singleton that receives server messages
- UpdateNotificationPanel: Dismissible notification for optional updates
- UpdateRequiredModal: Blocking modal for required updates
- PersistentClientConnection: Dispatch messages to manager

When user clicks "Restart Now", the app quits. On Mac, Sparkle handles
the update on relaunch. On Windows, the launcher handles updates.

Depends on: #5820 (server-side)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 07:16:40 -08:00
4 changed files with 242 additions and 0 deletions
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using common;
using common.UpdateNotification;
using Grpc.Core;
using Net.Eagle0.Eagle.Api;
using static Net.Eagle0.Eagle.Api.UpdateStreamRequest.Types;
@@ -1213,6 +1214,19 @@ namespace eagle {
// UNKNOWN is benign (old servers that don't set status)
break;
case UpdateStreamResponse.ResponseDetailsOneofCase
.ClientUpdateAvailable:
var updateNotification = current.ClientUpdateAvailable;
_remoteEagleClientLogger.LogLine(
$"[UPDATE] Client update available: platform={updateNotification.Platform} " +
$"version={updateNotification.Version} required={updateNotification.Required}");
// Dispatch to main thread for UI handling
MainQueue.Q.Enqueue(() => {
UpdateNotificationManager.Instance?.OnClientUpdateAvailable(
updateNotification);
});
break;
}
sc = _streamingCall;
@@ -0,0 +1,114 @@
using Net.Eagle0.Eagle.Api;
using UnityEngine;
namespace common.UpdateNotification {
/// <summary>
/// Manages client update notifications received from the server.
/// Shows appropriate UI based on whether the update is required or optional.
/// Singleton that persists across scene loads.
/// </summary>
public class UpdateNotificationManager : MonoBehaviour {
public static UpdateNotificationManager Instance { get; private set; }
[SerializeField]
private UpdateNotificationPanel optionalPanel;
[SerializeField]
private UpdateRequiredModal requiredModal;
private string CurrentPlatform =>
Application.platform == RuntimePlatform.OSXPlayer ? "mac" : "windows";
void Awake() {
if (Instance != null && Instance != this) {
Debug.LogWarning("[UpdateNotificationManager] Duplicate instance destroyed");
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
void OnDestroy() {
if (Instance == this) { Instance = null; }
}
/// <summary>
/// Set the optional update panel reference.
/// </summary>
public void SetOptionalPanel(UpdateNotificationPanel panel) { optionalPanel = panel; }
/// <summary>
/// Set the required update modal reference.
/// </summary>
public void SetRequiredModal(UpdateRequiredModal modal) { requiredModal = modal; }
/// <summary>
/// Handle a ClientUpdateAvailable message from the server.
/// Shows the appropriate UI based on platform match and required flag.
/// </summary>
public void OnClientUpdateAvailable(ClientUpdateAvailable update) {
if (update == null) {
Debug.LogWarning("[UpdateNotificationManager] Received null update notification");
return;
}
// Only show if update is for our platform (or platform is empty = all platforms)
if (!string.IsNullOrEmpty(update.Platform) && update.Platform != CurrentPlatform) {
Debug.Log(
$"[UpdateNotificationManager] Ignoring update for platform '{update.Platform}' " +
$"(current: '{CurrentPlatform}')");
return;
}
Debug.Log(
$"[UpdateNotificationManager] Showing update notification: version={update.Version} " +
$"required={update.Required} message={update.Message}");
string message = string.IsNullOrEmpty(update.Message) ? "A new version is available!"
: update.Message;
if (update.Required) {
ShowRequiredUpdate(update.Version, message);
} else {
ShowOptionalUpdate(update.Version, message);
}
}
private void ShowOptionalUpdate(string version, string message) {
if (optionalPanel == null) {
Debug.LogWarning(
"[UpdateNotificationManager] No optional panel assigned, cannot show notification");
// Fall back to required modal if optional panel is not available
ShowRequiredUpdate(version, message);
return;
}
optionalPanel.Show(version, message);
}
private void ShowRequiredUpdate(string version, string message) {
if (requiredModal == null) {
Debug.LogWarning(
"[UpdateNotificationManager] No required modal assigned, showing log message only");
Debug.LogError(
$"[CRITICAL] Required client update to version {version}: {message}. Please restart the game.");
return;
}
requiredModal.Show(version, message);
}
/// <summary>
/// Quits the application to allow the user to get the update.
/// On Mac, Sparkle handles auto-update on relaunch.
/// On Windows, the launcher handles updates.
/// </summary>
public void RestartNow() {
Debug.Log("[UpdateNotificationManager] User requested restart for update");
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
}
}
}
@@ -0,0 +1,60 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace common.UpdateNotification {
/// <summary>
/// Dismissible notification panel for optional updates.
/// Shows a small notification bar/toast that the user can dismiss or act on.
/// </summary>
public class UpdateNotificationPanel : MonoBehaviour {
[Header("Panel Elements")]
public GameObject panel;
[Header("Content")]
public TextMeshProUGUI versionText;
public TextMeshProUGUI messageText;
[Header("Buttons")]
public Button restartButton;
public Button laterButton;
void Awake() {
if (panel != null) panel.SetActive(false);
if (restartButton != null) restartButton.onClick.AddListener(OnRestartClicked);
if (laterButton != null) laterButton.onClick.AddListener(OnLaterClicked);
}
/// <summary>
/// Show the optional update notification.
/// </summary>
public void Show(string version, string message) {
if (versionText != null) versionText.text = $"Version {version} available";
if (messageText != null) messageText.text = message;
if (panel != null) panel.SetActive(true);
Debug.Log($"[UpdateNotificationPanel] Showing optional update: {version}");
}
/// <summary>
/// Hide the notification panel.
/// </summary>
public void Hide() {
if (panel != null) panel.SetActive(false);
}
private void OnRestartClicked() {
Debug.Log("[UpdateNotificationPanel] Restart button clicked");
UpdateNotificationManager.Instance?.RestartNow();
}
private void OnLaterClicked() {
Debug.Log("[UpdateNotificationPanel] Later button clicked");
Hide();
}
}
}
@@ -0,0 +1,54 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace common.UpdateNotification {
/// <summary>
/// Blocking modal for required updates.
/// Cannot be dismissed - user must restart to continue.
/// </summary>
public class UpdateRequiredModal : MonoBehaviour {
[Header("Panel Elements")]
public GameObject panel;
public GameObject modalBlocker;
[Header("Content")]
public TextMeshProUGUI titleText;
public TextMeshProUGUI versionText;
public TextMeshProUGUI messageText;
[Header("Buttons")]
public Button restartButton;
void Awake() {
if (panel != null) panel.SetActive(false);
if (modalBlocker != null) modalBlocker.SetActive(false);
if (restartButton != null) restartButton.onClick.AddListener(OnRestartClicked);
}
/// <summary>
/// Show the required update modal.
/// This modal cannot be dismissed by the user.
/// </summary>
public void Show(string version, string message) {
if (titleText != null) titleText.text = "Update Required";
if (versionText != null) versionText.text = $"Version {version}";
if (messageText != null) messageText.text = message;
if (panel != null) panel.SetActive(true);
if (modalBlocker != null) modalBlocker.SetActive(true);
Debug.Log($"[UpdateRequiredModal] Showing required update: {version}");
}
private void OnRestartClicked() {
Debug.Log("[UpdateRequiredModal] Restart button clicked");
UpdateNotificationManager.Instance?.RestartNow();
}
// Note: No Hide() method intentionally - this modal cannot be dismissed
}
}