chore(bridge): 删除桥接包的提交记录和Git属性配置 删除了 .codely-bridge-commit 文件中存储的提交哈希值 移除了 .gitattributes 文件中的行结束符配置规则 ```
2357 lines
109 KiB
C#
2357 lines
109 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
using UnityEngine.UIElements;
|
|
using UnityTcp.Editor.Helpers;
|
|
|
|
namespace UnityTcp.Editor.Native
|
|
{
|
|
// Popup menu interception — build, send, and execute popup menus on the
|
|
// frontend HTML overlay instead of native Win32/macOS popup windows.
|
|
// Handles GenericMenu (MenuController), DisplayPopupMenu (main menu),
|
|
// FlexibleMenu, and other popup types. Extracts menu items via
|
|
// reflection, serialises them to JSON, and sends through DataChannel.
|
|
internal static partial class NativeWindowBridgeHost
|
|
{
|
|
// ----------------------------------------------------------------
|
|
// Popup-specific fields & constants
|
|
// ----------------------------------------------------------------
|
|
|
|
// Last SceneView UITK toolbar trigger rect (in Unity pixel coordinates).
|
|
// Used to provide pixel-accurate popup/panel anchor from Unity side.
|
|
private static bool s_HasToolbarAnchorRectPx;
|
|
private static Rect s_LastToolbarAnchorRectPx;
|
|
// Keep popup payload under typical remote SCTP/DataChannel max message size.
|
|
private const int kMaxDataChannelPayloadBytes = 63 * 1024;
|
|
// Keep chunk body small enough after JSON escaping overhead.
|
|
private const int kPopupChunkDataMaxChars = 12 * 1024;
|
|
|
|
// Path used by C++ MenuController for temporary popup menus.
|
|
private const string kPopupMenuPath = "CONTEXT/TEMPORARY-OBJECT-DISPLAY";
|
|
|
|
// Reflection cache for Menu.RemoveMenuItem — used to cancel native delayed popups.
|
|
private static MethodInfo s_MenuRemoveMenuItemMethod;
|
|
private static MethodInfo s_MenuGetMenuItemsMethod;
|
|
private static bool s_MenuRemoveMenuItemReflectionDone;
|
|
|
|
// Reflection cache for Unsupported.GetSubmenus/GetSubmenusCommands fallback
|
|
// when Menu.GetMenuItems is not available (e.g. Unity 2019).
|
|
private static MethodInfo s_UnsupportedGetSubmenusMethod;
|
|
private static MethodInfo s_UnsupportedGetSubmenusCommandsMethod;
|
|
private static MethodInfo s_MenuGetCheckedMethod;
|
|
private static MethodInfo s_MenuGetEnabledMethod;
|
|
private static MethodInfo s_MenuMenuItemExistsMethod;
|
|
private static bool s_UnsupportedMenuReflectionDone;
|
|
|
|
// Cached original popup indices from Unsupported.GetSubmenusCommands,
|
|
// used to map frontend selection index back to the original popup index.
|
|
private static int[] s_CachedPopupOriginalIndices;
|
|
|
|
// Cached popup item full paths for ExecuteMenuItem callback.
|
|
private static string[] s_CachedPopupItemPaths;
|
|
// Cached FlexibleMenu callback and PopupWindow reference for selection.
|
|
private static object s_CachedFlexibleMenu;
|
|
private static object s_CachedFlexibleMenuItemProvider;
|
|
private static System.Delegate s_CachedFlexibleMenuCallback;
|
|
// Cached PopupList (ProjectBrowser search filter) selection callback/items.
|
|
private static System.Delegate s_CachedPopupListSelectDelegate;
|
|
private static object[] s_CachedPopupListItems;
|
|
// Cached ConnectionTreeViewWindow items and their onSelected callbacks.
|
|
private static System.Action[] s_CachedConnectionCallbacks;
|
|
// Cached IGameViewSizeMenuUser for header toggle actions.
|
|
private static object s_CachedGameViewSizeMenuUser;
|
|
// Cached PopupCallbackInfo for delayed popup replay.
|
|
// We intentionally clear PopupCallbackInfo.instance to suppress native popup UI,
|
|
// so we keep this object and restore it when applying popup_select.
|
|
private static object s_CachedDelayedPopupCallbackInfo;
|
|
// Popup source type: "generic" (MenuController) or "flexible" (FlexibleMenu)
|
|
private static string s_PopupSourceType;
|
|
// Suppress target Repaint while a frontend popup overlay is active.
|
|
private static bool s_SuppressTargetRepaintWhilePopupOpen;
|
|
// Set on remote right-mousedown for Project/Hierarchy and cleared once
|
|
// the menu is forwarded or cancelled. Prevents composite per-slot
|
|
// Repaint from firing ShowDelayedContextMenu before mouseup/ContextClick.
|
|
private static bool s_ExpectContextMenuForward;
|
|
|
|
// Signature of the last MenuController items we sent to the frontend.
|
|
// Format: "count|firstPath". Used to detect stale gDelayedContextMenu
|
|
// data left by a failed ShowDelayedContextMenu call.
|
|
// A new GenericMenu popup will have a different signature, allowing it through.
|
|
private static string s_LastMenuControllerSignature;
|
|
|
|
// When set, StripPopupMenuPathPrefix also strips this prefix from item paths.
|
|
// Used for DisplayPopupMenu forwarding (e.g., "Assets", "GameObject").
|
|
private static string s_DisplayPopupMenuStripPrefix;
|
|
|
|
// Background auto-dismiss flag for TrackPopupMenuEx modal loops.
|
|
// Set to 1 while SendEvent calls may trigger a native popup; a
|
|
// ThreadPool worker posts WM_CANCELMODE to break the block.
|
|
private static volatile int s_PopupAutoDismissActive;
|
|
|
|
// Set to 1 by the auto-dismiss worker when it finds and dismisses
|
|
// a native #32768 popup window. Used by the main thread to confirm
|
|
// that TrackPopupMenuEx actually fired (timing is unreliable).
|
|
private static volatile int s_AutoDismissFoundNativePopup;
|
|
|
|
// ----------------------------------------------------------------
|
|
// Popup payload chunking
|
|
// ----------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Send oversized popup payload via chunked DataChannel messages.
|
|
/// The browser reassembles payload and then handles it as normal popup_show JSON.
|
|
/// </summary>
|
|
private static bool SendPopupPayloadChunked(string popupId, string payloadJson, int payloadBytes)
|
|
{
|
|
if (string.IsNullOrEmpty(popupId) || string.IsNullOrEmpty(payloadJson)) return false;
|
|
int totalChunks = (payloadJson.Length + kPopupChunkDataMaxChars - 1) / kPopupChunkDataMaxChars;
|
|
if (totalChunks <= 0) totalChunks = 1;
|
|
|
|
string begin = "{\"type\":\"popup_payload_begin\",\"id\":\"" +
|
|
EscapeJsonString(popupId) +
|
|
"\",\"totalChunks\":" + totalChunks +
|
|
",\"totalBytes\":" + payloadBytes + "}";
|
|
if (!SendDataChannelMessage(begin)) return false;
|
|
|
|
for (int i = 0; i < totalChunks; i++)
|
|
{
|
|
int start = i * kPopupChunkDataMaxChars;
|
|
int len = Math.Min(kPopupChunkDataMaxChars, payloadJson.Length - start);
|
|
string chunk = payloadJson.Substring(start, len);
|
|
|
|
var sb = new StringBuilder(len + 128);
|
|
sb.Append("{\"type\":\"popup_payload_chunk\",\"id\":\"");
|
|
sb.Append(EscapeJsonString(popupId));
|
|
sb.Append("\",\"seq\":");
|
|
sb.Append(i);
|
|
sb.Append(",\"data\":\"");
|
|
sb.Append(EscapeJsonString(chunk));
|
|
sb.Append("\"}");
|
|
if (!SendDataChannelMessage(sb.ToString())) return false;
|
|
}
|
|
|
|
string end = "{\"type\":\"popup_payload_end\",\"id\":\"" + EscapeJsonString(popupId) + "\"}";
|
|
return SendDataChannelMessage(end);
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Toolbar anchor rect (for pixel-accurate popup positioning)
|
|
// ----------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Try to capture the clicked UITK toolbar element rect in SceneView.
|
|
/// The rect is stored in Unity pixel coordinates for frontend anchoring.
|
|
/// </summary>
|
|
private static void TryCaptureToolbarAnchorRect(float x, float y, float ppp)
|
|
{
|
|
s_HasToolbarAnchorRectPx = false;
|
|
if (s_OffscreenTarget == null || ppp <= 0f) return;
|
|
if (!(s_OffscreenTarget is SceneView)) return;
|
|
|
|
try
|
|
{
|
|
var root = s_OffscreenTarget.rootVisualElement;
|
|
if (root == null || root.panel == null) return;
|
|
|
|
var mousePt = new Vector2(x, y);
|
|
var picked = root.panel.Pick(mousePt);
|
|
if (picked == null) return;
|
|
|
|
VisualElement anchor = null;
|
|
for (var el = picked; el != null; el = el.parent)
|
|
{
|
|
Rect wb = el.worldBound;
|
|
if (!wb.Contains(mousePt)) continue;
|
|
if (wb.width < 14f || wb.width > 180f) continue;
|
|
if (wb.height < 12f || wb.height > 40f) continue;
|
|
anchor = el;
|
|
}
|
|
if (anchor == null) return;
|
|
|
|
Rect bound = anchor.worldBound;
|
|
s_LastToolbarAnchorRectPx = new Rect(
|
|
bound.x * ppp,
|
|
bound.y * ppp,
|
|
bound.width * ppp,
|
|
bound.height * ppp);
|
|
s_HasToolbarAnchorRectPx = s_LastToolbarAnchorRectPx.width > 0.1f &&
|
|
s_LastToolbarAnchorRectPx.height > 0.1f;
|
|
|
|
if (s_HasToolbarAnchorRectPx)
|
|
{
|
|
LogVerbose($"[NWB-AnchorRect] Captured anchor rect px=({s_LastToolbarAnchorRectPx.x:F0},{s_LastToolbarAnchorRectPx.y:F0},{s_LastToolbarAnchorRectPx.width:F0},{s_LastToolbarAnchorRectPx.height:F0}) type={anchor.GetType().Name}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-AnchorRect] Capture failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Append optional Unity-side anchor rect for pixel-accurate alignment.
|
|
/// </summary>
|
|
private static void AppendToolbarAnchorRectJson(StringBuilder sb)
|
|
{
|
|
if (sb == null || !s_HasToolbarAnchorRectPx) return;
|
|
if (s_LastToolbarAnchorRectPx.width <= 0f || s_LastToolbarAnchorRectPx.height <= 0f) return;
|
|
|
|
sb.Append(",\"anchorRect\":{\"x\":");
|
|
sb.Append(Mathf.RoundToInt(s_LastToolbarAnchorRectPx.x));
|
|
sb.Append(",\"y\":");
|
|
sb.Append(Mathf.RoundToInt(s_LastToolbarAnchorRectPx.y));
|
|
sb.Append(",\"w\":");
|
|
sb.Append(Mathf.RoundToInt(s_LastToolbarAnchorRectPx.width));
|
|
sb.Append(",\"h\":");
|
|
sb.Append(Mathf.RoundToInt(s_LastToolbarAnchorRectPx.height));
|
|
sb.Append("}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert a logical screen-space point to frontend frame pixels.
|
|
/// In composite mode this applies frame bounds/scale/offset mapping.
|
|
/// </summary>
|
|
private static Vector2 ConvertLogicalScreenPointToFramePixels(Vector2 logicalScreenPt, float ppp)
|
|
{
|
|
if (s_CompositeActive)
|
|
{
|
|
float scaleX = Mathf.Max(s_CompositeFrameScalePixels.x * ppp, 0.001f);
|
|
float scaleY = Mathf.Max(s_CompositeFrameScalePixels.y * ppp, 0.001f);
|
|
return new Vector2(
|
|
s_CompositeFrameOffsetPixels.x + (logicalScreenPt.x - s_CompositeFrameBoundsLogical.xMin) * scaleX,
|
|
s_CompositeFrameOffsetPixels.y + (logicalScreenPt.y - s_CompositeFrameBoundsLogical.yMin) * scaleY);
|
|
}
|
|
|
|
// Single-view mode expects local pixels (point * ppp).
|
|
return logicalScreenPt * ppp;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert a view-local point to frontend frame pixels.
|
|
/// </summary>
|
|
private static bool TryConvertViewLocalPointToFramePixels(object sourceView, Vector2 localPt, float ppp, out Vector2 framePx)
|
|
{
|
|
framePx = Vector2.zero;
|
|
if (ppp <= 0f) return false;
|
|
|
|
if (sourceView == null)
|
|
{
|
|
framePx = localPt * ppp;
|
|
return true;
|
|
}
|
|
|
|
if (!s_CompositeActive)
|
|
{
|
|
framePx = localPt * ppp;
|
|
return true;
|
|
}
|
|
|
|
Rect viewRect = GetViewScreenPosition(sourceView);
|
|
Vector2 logicalScreen = new Vector2(viewRect.xMin + localPt.x, viewRect.yMin + localPt.y);
|
|
framePx = ConvertLogicalScreenPointToFramePixels(logicalScreen, ppp);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve popup source view. Uses explicit view when provided,
|
|
/// otherwise falls back to current offscreen target's parent view.
|
|
/// </summary>
|
|
private static object ResolvePopupSourceView(object explicitSourceView = null)
|
|
{
|
|
if (explicitSourceView != null) return explicitSourceView;
|
|
return s_OffscreenTarget != null ? GetParentView(s_OffscreenTarget) : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert local click coordinates to popup position in frame pixels.
|
|
/// popY = max(minTopLocalY, clickY + clickYOffsetLocal) after conversion.
|
|
/// </summary>
|
|
private static void ComputePopupPositionFromLocalClick(
|
|
float clickX,
|
|
float clickY,
|
|
float minTopLocalY,
|
|
float clickYOffsetLocal,
|
|
out float popX,
|
|
out float popY,
|
|
object explicitSourceView = null,
|
|
float? explicitPpp = null)
|
|
{
|
|
float ppp = explicitPpp ?? EditorGUIUtility.pixelsPerPoint;
|
|
object sourceView = ResolvePopupSourceView(explicitSourceView);
|
|
|
|
TryConvertViewLocalPointToFramePixels(
|
|
sourceView, new Vector2(clickX, clickY), ppp, out Vector2 clickPxFrame);
|
|
TryConvertViewLocalPointToFramePixels(
|
|
sourceView, new Vector2(clickX, minTopLocalY), ppp, out Vector2 minTopPxFrame);
|
|
TryConvertViewLocalPointToFramePixels(
|
|
sourceView, new Vector2(clickX, clickY + clickYOffsetLocal), ppp, out Vector2 clickOffsetPxFrame);
|
|
|
|
popX = clickPxFrame.x;
|
|
popY = Mathf.Max(minTopPxFrame.y, clickOffsetPxFrame.y);
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// MenuController helpers
|
|
// ----------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Check whether MenuController currently has queued items under
|
|
/// kPopupMenuPath. RemoveMenuItem logs an internal Unity error when
|
|
/// the menu does not exist, so we must guard before removing.
|
|
/// </summary>
|
|
private static bool HasPendingPopupMenuItems()
|
|
{
|
|
try
|
|
{
|
|
if (s_MenuGetMenuItemsMethod != null)
|
|
{
|
|
var items = s_MenuGetMenuItemsMethod.Invoke(null,
|
|
new object[] { kPopupMenuPath, true, false }) as System.Array;
|
|
return items != null && items.Length > 0;
|
|
}
|
|
// Fallback: Unsupported.GetSubmenus (public API, available in Unity 2019)
|
|
EnsureUnsupportedMenuReflection();
|
|
if (s_UnsupportedGetSubmenusMethod != null)
|
|
{
|
|
var subs = s_UnsupportedGetSubmenusMethod.Invoke(null,
|
|
new object[] { kPopupMenuPath }) as string[];
|
|
return subs != null && subs.Length > 0;
|
|
}
|
|
// Last resort: Menu.MenuItemExists
|
|
if (s_MenuMenuItemExistsMethod != null)
|
|
return (bool)s_MenuMenuItemExistsMethod.Invoke(null, new object[] { kPopupMenuPath });
|
|
return false;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initialize reflection cache for Unsupported.GetSubmenus/GetSubmenusCommands
|
|
/// and Menu.GetChecked/GetEnabled — used as fallback when Menu.GetMenuItems
|
|
/// does not exist (e.g. Unity 2019).
|
|
/// </summary>
|
|
private static void EnsureUnsupportedMenuReflection()
|
|
{
|
|
if (s_UnsupportedMenuReflectionDone) return;
|
|
s_UnsupportedMenuReflectionDone = true;
|
|
try
|
|
{
|
|
var asm = typeof(EditorWindow).Assembly;
|
|
var unsupportedType = asm.GetType("UnityEditor.Unsupported");
|
|
if (unsupportedType != null)
|
|
{
|
|
s_UnsupportedGetSubmenusMethod = unsupportedType.GetMethod("GetSubmenus",
|
|
BindingFlags.Static | BindingFlags.Public,
|
|
null, new System.Type[] { typeof(string) }, null);
|
|
s_UnsupportedGetSubmenusCommandsMethod = unsupportedType.GetMethod("GetSubmenusCommands",
|
|
BindingFlags.Static | BindingFlags.Public,
|
|
null, new System.Type[] { typeof(string) }, null);
|
|
}
|
|
var menuType = asm.GetType("UnityEditor.Menu");
|
|
if (menuType != null)
|
|
{
|
|
s_MenuGetCheckedMethod = menuType.GetMethod("GetChecked",
|
|
BindingFlags.Static | BindingFlags.Public,
|
|
null, new System.Type[] { typeof(string) }, null);
|
|
s_MenuGetEnabledMethod = menuType.GetMethod("GetEnabled",
|
|
BindingFlags.Static | BindingFlags.Public,
|
|
null, new System.Type[] { typeof(string) }, null);
|
|
s_MenuMenuItemExistsMethod = menuType.GetMethod("MenuItemExists",
|
|
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public,
|
|
null, new System.Type[] { typeof(string) }, null);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-MenuFallback] Reflection init error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build popup JSON from Unsupported.GetSubmenus + GetSubmenusCommands.
|
|
/// Generic fallback for Unity 2019 where Menu.GetMenuItems is unavailable.
|
|
/// Works for ANY DoPopup-based menu, not just specific EditorWindow types.
|
|
/// </summary>
|
|
private static bool SendDelayedPopupViaSubmenus(
|
|
float relX, float relY, float ppp)
|
|
{
|
|
EnsureUnsupportedMenuReflection();
|
|
if (s_UnsupportedGetSubmenusMethod == null)
|
|
{
|
|
LogVerbose("[NWB-MenuFallback] Unsupported.GetSubmenus not available");
|
|
return false;
|
|
}
|
|
|
|
string[] submenus = s_UnsupportedGetSubmenusMethod.Invoke(null,
|
|
new object[] { kPopupMenuPath }) as string[];
|
|
if (submenus == null || submenus.Length == 0)
|
|
{
|
|
LogVerbose("[NWB-MenuFallback] No items under " + kPopupMenuPath);
|
|
return false;
|
|
}
|
|
|
|
// Read original popup indices (command = IntToString(originalIndex))
|
|
string[] commands = null;
|
|
if (s_UnsupportedGetSubmenusCommandsMethod != null)
|
|
{
|
|
commands = s_UnsupportedGetSubmenusCommandsMethod.Invoke(null,
|
|
new object[] { kPopupMenuPath }) as string[];
|
|
}
|
|
|
|
string prefix = kPopupMenuPath + "/";
|
|
|
|
s_FrontendPopupIdCounter++;
|
|
s_CurrentFrontendPopupId = "popup_" + s_FrontendPopupIdCounter;
|
|
|
|
var sb = new StringBuilder(2048);
|
|
sb.Append("{\"type\":\"popup_show\",\"id\":\"");
|
|
sb.Append(s_CurrentFrontendPopupId);
|
|
sb.Append("\",\"x\":");
|
|
sb.Append(Mathf.RoundToInt(relX));
|
|
sb.Append(",\"y\":");
|
|
sb.Append(Mathf.RoundToInt(relY));
|
|
sb.Append(",\"width\":0,\"height\":0,\"items\":[");
|
|
|
|
s_CachedPopupItemPaths = new string[submenus.Length];
|
|
s_CachedPopupOriginalIndices = new int[submenus.Length];
|
|
|
|
for (int i = 0; i < submenus.Length; i++)
|
|
{
|
|
if (i > 0) sb.Append(',');
|
|
string fullPath = submenus[i];
|
|
s_CachedPopupItemPaths[i] = fullPath;
|
|
|
|
// Parse original index from command string
|
|
int origIdx = i;
|
|
if (commands != null && i < commands.Length)
|
|
{
|
|
int.TryParse(commands[i], out origIdx);
|
|
}
|
|
s_CachedPopupOriginalIndices[i] = origIdx;
|
|
|
|
// Strip prefix for display label
|
|
string label = fullPath.StartsWith(prefix, StringComparison.Ordinal)
|
|
? fullPath.Substring(prefix.Length)
|
|
: fullPath;
|
|
|
|
bool isChecked = false;
|
|
bool isEnabled = true;
|
|
try
|
|
{
|
|
if (s_MenuGetCheckedMethod != null)
|
|
isChecked = (bool)s_MenuGetCheckedMethod.Invoke(null, new object[] { fullPath });
|
|
if (s_MenuGetEnabledMethod != null)
|
|
isEnabled = (bool)s_MenuGetEnabledMethod.Invoke(null, new object[] { fullPath });
|
|
}
|
|
catch { }
|
|
|
|
sb.Append("{\"index\":");
|
|
sb.Append(i);
|
|
sb.Append(",\"path\":\"");
|
|
sb.Append(EscapeJsonString(fullPath));
|
|
sb.Append("\",\"label\":\"");
|
|
sb.Append(EscapeJsonString(label));
|
|
sb.Append("\",\"tooltip\":\"\",\"enabled\":");
|
|
sb.Append(isEnabled ? "true" : "false");
|
|
sb.Append(",\"checked\":");
|
|
sb.Append(isChecked ? "true" : "false");
|
|
sb.Append(",\"separator\":false}");
|
|
}
|
|
sb.Append("]}");
|
|
|
|
string json = sb.ToString();
|
|
bool sent = SendDataChannelMessage(json);
|
|
s_FrontendPopupSent = sent;
|
|
if (sent)
|
|
{
|
|
// Suppress target repaints while popup is open. On Windows,
|
|
// ShowDelayedContextMenu runs at the START of the next
|
|
// OnInputEvent. If we don't suppress repaints, the offscreen
|
|
// capture loop sends Layout/Repaint events that trigger the
|
|
// native TrackPopupMenuEx popup alongside our frontend popup.
|
|
s_SuppressTargetRepaintWhilePopupOpen = true;
|
|
TryDismissWin32TrackPopupMenu();
|
|
s_PendingNativePopupCloseFrames = Math.Max(
|
|
s_PendingNativePopupCloseFrames, 60);
|
|
}
|
|
LogVerbose($"[NWB-MenuFallback] Sent popup via Unsupported.GetSubmenus: id={s_CurrentFrontendPopupId} items={submenus.Length} sent={sent}");
|
|
return sent;
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Native popup dismissal
|
|
// ----------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Enumerate ALL windows (visible AND hidden) belonging to the current
|
|
/// Unity process. Unlike GetAllUnityHwnds() which filters by
|
|
/// IsWindowVisible, this includes hidden windows — essential during
|
|
/// streaming when all editor windows are hidden via HWND_BOTTOM.
|
|
/// </summary>
|
|
private static List<IntPtr> GetAllProcessHwnds()
|
|
{
|
|
var result = new List<IntPtr>();
|
|
#if UNITY_EDITOR_WIN
|
|
uint currentPid = (uint)System.Diagnostics.Process.GetCurrentProcess().Id;
|
|
IntPtr hWnd = GetWindow(GetDesktopWindow(), GW_CHILD);
|
|
int safety = 0;
|
|
while (hWnd != IntPtr.Zero && safety < 4096)
|
|
{
|
|
GetWindowThreadProcessId(hWnd, out uint pid);
|
|
if (pid == currentPid)
|
|
result.Add(hWnd);
|
|
hWnd = GetWindow(hWnd, GW_HWNDNEXT);
|
|
safety++;
|
|
}
|
|
#endif
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dismiss any leftover native Win32 popup menu by targeting ONLY the
|
|
/// popup menu window (#32768). This is called AFTER we have already
|
|
/// read and forwarded the menu items, so there is no TrackPopupMenuEx
|
|
/// modal loop to break. We only need to close a visible popup.
|
|
///
|
|
/// IMPORTANT: Do NOT send WM_KEYDOWN+VK_ESCAPE to ordinary editor
|
|
/// windows — the ESC key events accumulate in the message queue and
|
|
/// can close the StreamingMaskWindow or disrupt other windows.
|
|
/// </summary>
|
|
private static void TryDismissWin32TrackPopupMenu()
|
|
{
|
|
#if UNITY_EDITOR_WIN
|
|
try
|
|
{
|
|
uint myPid = (uint)System.Diagnostics.Process.GetCurrentProcess().Id;
|
|
|
|
// Only target the popup menu window (#32768) — never blast
|
|
// WM_CANCELMODE or VK_ESCAPE to all process windows.
|
|
IntPtr popupHwnd = FindOwnPopupMenuWindow(myPid);
|
|
if (popupHwnd != IntPtr.Zero)
|
|
{
|
|
const uint WM_CLOSE = 0x0010;
|
|
PostMessageW(popupHwnd, WM_CANCELMODE, IntPtr.Zero, IntPtr.Zero);
|
|
PostMessageW(popupHwnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
|
|
LogVerbose($"[NWB-ContextMenu] Posted WM_CLOSE to popup #32768 hwnd=0x{popupHwnd.ToInt64():X}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-ContextMenu] dismiss failed: {ex.Message}");
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// <summary>
|
|
/// Start a background worker that aggressively dismisses any
|
|
/// TrackPopupMenuEx modal loop that appears during our SendEvent calls.
|
|
///
|
|
/// TrackPopupMenuEx runs a modal message loop (xxxMNLoop) that blocks
|
|
/// the calling thread. During streaming the editor windows are hidden,
|
|
/// so the popup appears on the desktop and the user cannot interact with
|
|
/// the streamed view.
|
|
///
|
|
/// Dismiss strategies:
|
|
/// 1. WM_CANCELMODE to all pre-captured process windows (harmless to
|
|
/// normal windows, triggers xxxEndMenu in the menu modal loop)
|
|
/// 2. FindOwnPopupMenuWindow() to locate the popup menu window that is
|
|
/// created AFTER we start, then send WM_CANCELMODE + VK_ESCAPE
|
|
/// specifically to it (the menu loop processes keyboard input)
|
|
/// 3. WM_CANCELMODE to the foreground window
|
|
///
|
|
/// IMPORTANT: VK_ESCAPE is ONLY sent to #32768 popup menu windows.
|
|
/// Sending VK_ESCAPE to ordinary editor windows causes ESC key events
|
|
/// to accumulate in the message queue, which can close the
|
|
/// StreamingMaskWindow and terminate the streaming session.
|
|
/// </summary>
|
|
/// <param name="detectOnly">When true, the worker only detects and
|
|
/// dismisses the #32768 popup directly (no broadcast WM_CANCELMODE
|
|
/// to editor windows). Uses SpinWait for fast polling so the popup
|
|
/// is caught within ~10μs of creation. Use for DisplayPopupMenu
|
|
/// targets where broadcast WM_CANCELMODE would kill the popup
|
|
/// via the TrackPopupMenuEx modal loop before
|
|
/// FindOwnPopupMenuWindow can detect it (race condition).</param>
|
|
private static void ScheduleNativePopupAutoDismiss(bool detectOnly = false)
|
|
{
|
|
#if UNITY_EDITOR_WIN
|
|
System.Threading.Interlocked.Exchange(ref s_PopupAutoDismissActive, 1);
|
|
System.Threading.Interlocked.Exchange(ref s_AutoDismissFoundNativePopup, 0);
|
|
var preHwnds = GetAllProcessHwnds();
|
|
long startTick = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
double tickFreq = System.Diagnostics.Stopwatch.Frequency;
|
|
System.Threading.ThreadPool.QueueUserWorkItem(_ =>
|
|
{
|
|
uint myPid = (uint)System.Diagnostics.Process.GetCurrentProcess().Id;
|
|
const uint VK_ESCAPE = 0x1B;
|
|
int iteration = 0;
|
|
bool loggedStart = false;
|
|
|
|
while (System.Threading.Interlocked.CompareExchange(
|
|
ref s_PopupAutoDismissActive, 0, 0) == 1)
|
|
{
|
|
try
|
|
{
|
|
iteration++;
|
|
double elapsedMs = (System.Diagnostics.Stopwatch.GetTimestamp() - startTick)
|
|
/ tickFreq * 1000.0;
|
|
|
|
if (!loggedStart)
|
|
{
|
|
loggedStart = true;
|
|
LogVerbose($"[NWB-AutoDismiss] Worker started: {preHwnds.Count} " +
|
|
$"pre-captured HWNDs, iteration={iteration}, elapsed={elapsedMs:F1}ms" +
|
|
$" detectOnly={detectOnly}");
|
|
}
|
|
|
|
// Strategy 1: Detect #32768 popup owned by our
|
|
// process. Uses FindWindowExW iteration instead of
|
|
// FindWindowW, because FindWindowW returns the first
|
|
// #32768 window (which may belong to another process
|
|
// like Explorer or the taskbar).
|
|
IntPtr popupHwnd = FindOwnPopupMenuWindow(myPid);
|
|
if (popupHwnd != IntPtr.Zero)
|
|
{
|
|
System.Threading.Interlocked.Exchange(
|
|
ref s_AutoDismissFoundNativePopup, 1);
|
|
// TrackPopupMenuEx's modal loop only responds to
|
|
// WM_CANCELMODE sent to the OWNER window, not
|
|
// to the popup window itself. Broadcast to all
|
|
// pre-captured HWNDs (which include the owner).
|
|
foreach (IntPtr hwnd in preHwnds)
|
|
PostMessageW(hwnd, WM_CANCELMODE, IntPtr.Zero, IntPtr.Zero);
|
|
PostMessageW(popupHwnd, WM_CANCELMODE, IntPtr.Zero, IntPtr.Zero);
|
|
PostMessageW(popupHwnd, WM_KEYDOWN, (IntPtr)VK_ESCAPE, IntPtr.Zero);
|
|
if (iteration <= 3 || iteration % 10 == 0)
|
|
{
|
|
LogVerbose($"[NWB-AutoDismiss] Found popup #32768 hwnd=0x{popupHwnd.ToInt64():X}, " +
|
|
$"iter={iteration}, elapsed={elapsedMs:F1}ms detectOnly={detectOnly}");
|
|
}
|
|
}
|
|
|
|
if (!detectOnly)
|
|
{
|
|
// Strategy 2: Pre-emptive broadcast WM_CANCELMODE
|
|
// to all process windows BEFORE popup is found.
|
|
// Skipped in detectOnly mode to avoid stale
|
|
// WM_CANCELMODE dismissing popup before detection.
|
|
// (When popup IS found, broadcast happens above.)
|
|
foreach (IntPtr hwnd in preHwnds)
|
|
{
|
|
PostMessageW(hwnd, WM_CANCELMODE, IntPtr.Zero, IntPtr.Zero);
|
|
}
|
|
|
|
// Strategy 3: Also target the foreground window with
|
|
// WM_CANCELMODE only (no VK_ESCAPE).
|
|
IntPtr fg = GetForegroundWindow();
|
|
if (fg != IntPtr.Zero)
|
|
{
|
|
GetWindowThreadProcessId(fg, out uint fgPid);
|
|
if (fgPid == myPid)
|
|
PostMessageW(fg, WM_CANCELMODE, IntPtr.Zero, IntPtr.Zero);
|
|
}
|
|
}
|
|
|
|
if (iteration == 50)
|
|
{
|
|
LogVerbose($"[NWB-AutoDismiss] Still running after 50 iterations, " +
|
|
$"elapsed={elapsedMs:F1}ms, popup=#32768 found={popupHwnd != IntPtr.Zero}");
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
// In detectOnly mode, use SpinWait for fast polling (~10μs
|
|
// per iteration) so the popup is caught within microseconds
|
|
// of creation. Switch to Sleep(1) after 20ms to limit CPU
|
|
// in edge cases (e.g., popup never created).
|
|
if (detectOnly)
|
|
{
|
|
double ms = (System.Diagnostics.Stopwatch.GetTimestamp() - startTick)
|
|
/ tickFreq * 1000.0;
|
|
if (ms < 20.0)
|
|
System.Threading.Thread.SpinWait(100);
|
|
else
|
|
System.Threading.Thread.Sleep(1);
|
|
}
|
|
else
|
|
{
|
|
System.Threading.Thread.Sleep(1);
|
|
}
|
|
}
|
|
|
|
double totalMs = (System.Diagnostics.Stopwatch.GetTimestamp() - startTick)
|
|
/ tickFreq * 1000.0;
|
|
LogVerbose($"[NWB-AutoDismiss] Worker stopped after {iteration} iterations, " +
|
|
$"total={totalMs:F1}ms detectOnly={detectOnly}");
|
|
});
|
|
#endif
|
|
}
|
|
|
|
private static void CancelNativePopupAutoDismiss()
|
|
{
|
|
System.Threading.Interlocked.Exchange(ref s_PopupAutoDismissActive, 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Combined cleanup: close Unity ContainerWindow popups (sm=2)
|
|
/// and dismiss Win32 #32768 TrackPopupMenuEx popups. These two
|
|
/// operations are almost always needed together after intercepting
|
|
/// a native popup.
|
|
/// </summary>
|
|
private static void DismissAllNativePopups()
|
|
{
|
|
ScanAndCloseNativePopups();
|
|
TryDismissWin32TrackPopupMenu();
|
|
}
|
|
|
|
#if UNITY_EDITOR_WIN
|
|
/// <summary>
|
|
/// Find a popup menu window (#32768 class) owned by our process.
|
|
/// Unlike FindWindowW which only returns the FIRST #32768 window
|
|
/// (which may belong to another process like the taskbar or
|
|
/// Explorer), this iterates through ALL #32768 windows until
|
|
/// one matching our PID is found.
|
|
/// </summary>
|
|
private static IntPtr FindOwnPopupMenuWindow(uint myPid)
|
|
{
|
|
IntPtr hwnd = IntPtr.Zero;
|
|
while (true)
|
|
{
|
|
hwnd = FindWindowExW(IntPtr.Zero, hwnd, "#32768", null);
|
|
if (hwnd == IntPtr.Zero) break;
|
|
GetWindowThreadProcessId(hwnd, out uint pid);
|
|
if (pid == myPid) return hwnd;
|
|
}
|
|
return IntPtr.Zero;
|
|
}
|
|
#endif
|
|
|
|
/// <summary>
|
|
/// Probe whether DisplayPopupMenu queued a gDelayedContextMenu.
|
|
/// Sends a Layout event wrapped with auto-dismiss protection:
|
|
/// GUIView::OnInputEvent checks HasDelayedContextMenu() at the top
|
|
/// of EVERY event — if true, ShowDelayedContextMenu() fires
|
|
/// TrackPopupMenuEx. The auto-dismiss worker detects the #32768
|
|
/// popup and dismisses it immediately. Returns true if a popup
|
|
/// was found and dismissed (meaning DisplayPopupMenu was called
|
|
/// during the preceding mousedown).
|
|
/// </summary>
|
|
private static bool ProbeAndDismissDelayedContextMenu()
|
|
{
|
|
#if UNITY_EDITOR_WIN
|
|
if (s_OffscreenTarget == null) return false;
|
|
|
|
System.Threading.Interlocked.Exchange(ref s_AutoDismissFoundNativePopup, 0);
|
|
ScheduleNativePopupAutoDismiss(detectOnly: true);
|
|
long probeStart = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
try
|
|
{
|
|
s_OffscreenTarget.SendEvent(new Event { type = EventType.Layout });
|
|
}
|
|
catch (Exception) { }
|
|
finally
|
|
{
|
|
CancelNativePopupAutoDismiss();
|
|
}
|
|
TryDismissWin32TrackPopupMenu();
|
|
|
|
double probeMs = (System.Diagnostics.Stopwatch.GetTimestamp() - probeStart)
|
|
/ (double)System.Diagnostics.Stopwatch.Frequency * 1000.0;
|
|
bool found = System.Threading.Interlocked
|
|
.CompareExchange(ref s_AutoDismissFoundNativePopup, 0, 0) == 1;
|
|
LogVerbose($"[NWB-DisplayPopup] ProbeDelayed: found={found} elapsed={probeMs:F1}ms");
|
|
return found;
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clear gDelayedContextMenu by removing menu items first (so
|
|
/// ShowDelayedContextMenu finds nothing and skips TrackPopupMenuEx),
|
|
/// then sending a Layout event through GUIView.OnInputEvent which
|
|
/// checks HasDelayedContextMenu() and clears the global.
|
|
///
|
|
/// IMPORTANT: This method removes items from MenuController via
|
|
/// CancelNativeDelayedPopup, so it must NOT be called while the
|
|
/// frontend popup is active and waiting for popup_select — calling
|
|
/// it prematurely would cause ExecuteMenuItem to fail.
|
|
/// Safe call sites:
|
|
/// 1. ClearFrontendPopupState (AFTER ExecuteMenuItem)
|
|
/// 2. FAILED path (DC send failed, no frontend popup)
|
|
/// 3. Exception handler cleanup
|
|
/// </summary>
|
|
private static void ConsumeDelayedContextMenu()
|
|
{
|
|
#if UNITY_EDITOR_WIN
|
|
if (s_OffscreenTarget == null) return;
|
|
|
|
long consumeStart = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
double freq = System.Diagnostics.Stopwatch.Frequency;
|
|
|
|
// Step 1: Remove menu items BEFORE triggering the consume event.
|
|
bool hadItems = HasPendingPopupMenuItems();
|
|
CancelNativeDelayedPopup();
|
|
bool hasItemsAfter = HasPendingPopupMenuItems();
|
|
LogVerbose($"[NWB-ConsumeCtx] Step1: hadItems={hadItems} hasItemsAfter={hasItemsAfter}");
|
|
|
|
// Suppress C++ ErrorString("Menu %s couldn't be found")
|
|
var prevFilter = Debug.unityLogger.filterLogType;
|
|
Debug.unityLogger.filterLogType = LogType.Exception;
|
|
|
|
// Step 2: Send Layout event to trigger HasDelayedContextMenu check.
|
|
ScheduleNativePopupAutoDismiss();
|
|
try
|
|
{
|
|
long preSend = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
s_OffscreenTarget.SendEvent(new Event { type = EventType.Layout });
|
|
double sendMs = (System.Diagnostics.Stopwatch.GetTimestamp() - preSend) / freq * 1000.0;
|
|
bool stillHasItems = HasPendingPopupMenuItems();
|
|
double totalMs = (System.Diagnostics.Stopwatch.GetTimestamp() - consumeStart) / freq * 1000.0;
|
|
LogVerbose($"[NWB-ConsumeCtx] Step2: Layout sent in {sendMs:F1}ms, itemsAfterLayout={stillHasItems}, total={totalMs:F1}ms");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CodelyLogger.Log($"[NWB-ConsumeCtx] Step2 FAILED: {ex.Message}");
|
|
}
|
|
finally
|
|
{
|
|
CancelNativePopupAutoDismiss();
|
|
Debug.unityLogger.filterLogType = prevFilter;
|
|
}
|
|
|
|
TryDismissWin32TrackPopupMenu();
|
|
#endif
|
|
}
|
|
|
|
/// <summary>
|
|
/// Start a background thread that monitors for #32768 popup windows
|
|
/// and dismisses them for a fixed duration. Self-terminates after
|
|
/// maxDurationMs without requiring explicit cancellation.
|
|
/// Used during StopOffscreenCapture to catch ShowDelayedContextMenu
|
|
/// that fires on the next C++ DoRepaint after windows are restored.
|
|
/// </summary>
|
|
private static void ScheduleTimeLimitedPopupDismiss(int maxDurationMs)
|
|
{
|
|
#if UNITY_EDITOR_WIN
|
|
var preHwnds = GetAllProcessHwnds();
|
|
System.Threading.ThreadPool.QueueUserWorkItem(_ =>
|
|
{
|
|
uint myPid = (uint)System.Diagnostics.Process.GetCurrentProcess().Id;
|
|
const uint VK_ESCAPE = 0x1B;
|
|
long startTick = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
double tickFreq = System.Diagnostics.Stopwatch.Frequency;
|
|
int dismissCount = 0;
|
|
|
|
while (true)
|
|
{
|
|
double elapsed = (System.Diagnostics.Stopwatch.GetTimestamp() - startTick)
|
|
/ tickFreq * 1000.0;
|
|
if (elapsed > maxDurationMs) break;
|
|
|
|
try
|
|
{
|
|
IntPtr popupHwnd = FindOwnPopupMenuWindow(myPid);
|
|
if (popupHwnd != IntPtr.Zero)
|
|
{
|
|
foreach (IntPtr hwnd in preHwnds)
|
|
PostMessageW(hwnd, WM_CANCELMODE, IntPtr.Zero, IntPtr.Zero);
|
|
PostMessageW(popupHwnd, WM_CANCELMODE, IntPtr.Zero, IntPtr.Zero);
|
|
PostMessageW(popupHwnd, WM_KEYDOWN, (IntPtr)VK_ESCAPE, IntPtr.Zero);
|
|
dismissCount++;
|
|
if (dismissCount <= 3)
|
|
LogVerbose($"[NWB-StopGuard] Dismissed #32768 hwnd=0x{popupHwnd.ToInt64():X} at {elapsed:F0}ms");
|
|
}
|
|
}
|
|
catch { }
|
|
System.Threading.Thread.Sleep(5);
|
|
}
|
|
|
|
if (dismissCount > 0)
|
|
LogVerbose($"[NWB-StopGuard] Finished: dismissed={dismissCount}");
|
|
});
|
|
#endif
|
|
}
|
|
|
|
private static void CancelNativeDelayedPopup(bool forceRemove = false)
|
|
{
|
|
#if UNITY_EDITOR_WIN
|
|
try
|
|
{
|
|
|
|
if (!s_MenuRemoveMenuItemReflectionDone)
|
|
{
|
|
s_MenuRemoveMenuItemReflectionDone = true;
|
|
var menuType = typeof(EditorWindow).Assembly.GetType("UnityEditor.Menu");
|
|
if (menuType != null)
|
|
{
|
|
s_MenuGetMenuItemsMethod = menuType.GetMethod("GetMenuItems",
|
|
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public,
|
|
null, new[] { typeof(string), typeof(bool), typeof(bool) }, null);
|
|
s_MenuRemoveMenuItemMethod = menuType.GetMethod("RemoveMenuItem",
|
|
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public,
|
|
null, new[] { typeof(string) }, null);
|
|
}
|
|
if (s_MenuRemoveMenuItemMethod == null)
|
|
CodelyLogger.LogWarning("[NWB-PopupCancel] Menu.RemoveMenuItem not found");
|
|
}
|
|
if (s_MenuRemoveMenuItemMethod != null)
|
|
{
|
|
// Guard: avoid Unity internal error "Menu ... couldn't be found"
|
|
// when this path is called for non-MenuController popups.
|
|
if (!HasPendingPopupMenuItems())
|
|
{
|
|
LogVerbose("[NWB-PopupCancel] Skip remove: no pending MenuController popup items");
|
|
return;
|
|
}
|
|
// Temporarily suppress Unity internal "Menu ... couldn't be found"
|
|
// error that occurs due to TOCTOU race: HasPendingPopupMenuItems()
|
|
// returns true, but menu items are consumed by native code
|
|
// (e.g. during domain reload) before RemoveMenuItem executes.
|
|
var prevFilter = Debug.unityLogger.filterLogType;
|
|
Debug.unityLogger.filterLogType = LogType.Exception;
|
|
try
|
|
{
|
|
s_MenuRemoveMenuItemMethod.Invoke(null, new object[] { kPopupMenuPath });
|
|
}
|
|
finally
|
|
{
|
|
Debug.unityLogger.filterLogType = prevFilter;
|
|
}
|
|
LogVerbose("[NWB-PopupCancel] Removed delayed popup menu to prevent native TrackPopupMenuEx");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-PopupCancel] Error: {ex.Message}");
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Popup extraction & dispatch
|
|
// ----------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Try to extract popup menu data from MenuController and send it to
|
|
/// the browser frontend as a JSON message. MenuController stores the
|
|
/// GenericMenu items under "CONTEXT/TEMPORARY-OBJECT-DISPLAY/" when
|
|
/// DisplayCustomContextPopupMenu is called.
|
|
/// Returns true if popup data was successfully extracted and sent.
|
|
/// </summary>
|
|
private static bool TryExtractAndSendPopupToFrontend(object containerWindow, int showMode,
|
|
Rect cwPos, Rect targetScreenPos, float ppp)
|
|
{
|
|
if (s_FrontendPopupSent) return true;
|
|
|
|
try
|
|
{
|
|
if (showMode != 1) return false;
|
|
|
|
float relX = (cwPos.x - targetScreenPos.x) * ppp;
|
|
float relY = (cwPos.y - targetScreenPos.y) * ppp;
|
|
float popW = cwPos.width * ppp;
|
|
float popH = cwPos.height * ppp;
|
|
s_LastFrontendPopupX = relX;
|
|
s_LastFrontendPopupY = relY;
|
|
|
|
// Strategy 1: PopupWindowContent-based popups.
|
|
// If the ContainerWindow hosts popup content, check for known
|
|
// types first (SceneRenderModeWindow, etc.), then fall back to
|
|
// FlexibleMenu extraction. Do NOT fall through to Strategy 2.
|
|
if (ContainerWindowHasPopupContent(containerWindow))
|
|
{
|
|
// Check for SceneRenderModeWindow (draw mode popup) before FlexibleMenu.
|
|
string contentTypeName = GetPopupWindowContentTypeName(containerWindow);
|
|
if (contentTypeName == "SceneRenderModeWindow")
|
|
{
|
|
LogVerbose("[NWB-PopupIntercept] Using DrawMode extraction path");
|
|
return TryExtractDrawModePopup(relX, relY);
|
|
}
|
|
if (contentTypeName == "SceneFXWindow")
|
|
{
|
|
LogVerbose("[NWB-PopupIntercept] Using SceneFX extraction path");
|
|
return TryExtractSceneFXPopup(relX, relY);
|
|
}
|
|
if (contentTypeName == "SceneViewCameraWindow")
|
|
{
|
|
LogVerbose("[NWB-PopupIntercept] Using SceneCamera panel extraction path");
|
|
return TryExtractSceneViewCameraPanel(relX, relY);
|
|
}
|
|
|
|
if (contentTypeName == "ConnectionTreeViewWindow")
|
|
{
|
|
LogVerbose("[NWB-PopupIntercept] Using ConnectionTreeView extraction path");
|
|
return TryExtractConnectionTreeViewPopup(containerWindow, relX, relY, popW, popH);
|
|
}
|
|
|
|
LogVerbose("[NWB-PopupIntercept] Detected PopupWindowContent, using FlexibleMenu path");
|
|
return TryExtractFlexibleMenuPopup(containerWindow, relX, relY, popW, popH);
|
|
}
|
|
|
|
// Strategy 1.5: Detect hosted EditorWindow type and dispatch
|
|
// to specialized extractors. AnnotationWindow (Gizmos) does
|
|
// not use MenuController; its data comes from
|
|
// AnnotationUtility.GetAnnotations(). Other hosted
|
|
// EditorWindows fall through to Strategy 2 where the
|
|
// signature comparison filters stale MenuController data.
|
|
{
|
|
object rootView = null;
|
|
var rvProp = containerWindow.GetType().GetProperty("rootView",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
rootView = rvProp?.GetValue(containerWindow);
|
|
if (rootView != null)
|
|
{
|
|
object hostedWindow = null;
|
|
var wcField2 = rootView.GetType().GetField("m_WindowContent",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
hostedWindow = wcField2?.GetValue(rootView);
|
|
if (hostedWindow == null)
|
|
{
|
|
var avProp = rootView.GetType().GetProperty("actualView",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
hostedWindow = avProp?.GetValue(rootView);
|
|
}
|
|
if (hostedWindow is EditorWindow)
|
|
{
|
|
System.Type hostedType = hostedWindow.GetType();
|
|
string hostedName = hostedType.Name;
|
|
LogVerbose($"[NWB-PopupIntercept] Hosted EditorWindow: {hostedName}");
|
|
|
|
if (hostedName == "AnnotationWindow")
|
|
{
|
|
LogVerbose("[NWB-PopupIntercept] Using AnnotationUtility extraction path");
|
|
return TryExtractAnnotationWindowPopupFull(relX, relY);
|
|
}
|
|
|
|
if (hostedName == "IconSelector")
|
|
{
|
|
LogVerbose("[NWB-PopupIntercept] Using IconSelector extraction path");
|
|
return TryExtractIconSelectorPopup(
|
|
(EditorWindow)hostedWindow, relX, relY, popW, popH);
|
|
}
|
|
|
|
if (hostedName == "AddComponentWindow")
|
|
{
|
|
LogVerbose("[NWB-PopupIntercept] Using AddComponent extraction path");
|
|
return TryExtractAddComponentPopup(
|
|
(EditorWindow)hostedWindow, relX, relY, popW, popH);
|
|
}
|
|
|
|
if (hostedName == "AdvancedDropdownWindow")
|
|
{
|
|
if (TryExtractAdvancedDropdownPopup(
|
|
(EditorWindow)hostedWindow, relX, relY, popW, popH))
|
|
{
|
|
LogVerbose("[NWB-PopupIntercept] Using AdvancedDropdown extraction path");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// GridSettingsWindow: extract as a panel with interactive controls.
|
|
if (hostedName == "GridSettingsWindow")
|
|
{
|
|
LogVerbose("[NWB-PopupIntercept] Using Grid panel extraction path");
|
|
return TryExtractGridSettingsPanel(relX, relY);
|
|
}
|
|
|
|
// SnapIncrementSettingsWindow: extract as a panel with interactive controls.
|
|
if (hostedName == "SnapIncrementSettingsWindow")
|
|
{
|
|
LogVerbose("[NWB-PopupIntercept] Using SnapIncrement panel extraction path");
|
|
return TryExtractSnapIncrementPanel(relX, relY);
|
|
}
|
|
|
|
// SnapSettingsWindow (Grid Snapping): extract as panel.
|
|
if (hostedName == "SnapSettingsWindow")
|
|
{
|
|
LogVerbose("[NWB-PopupIntercept] Using SnapSettings (Grid Snapping) panel extraction path");
|
|
return TryExtractSnapSettingsPanel(relX, relY);
|
|
}
|
|
|
|
// Other SettingsWindows are not yet supported.
|
|
if (hostedName.EndsWith("SettingsWindow"))
|
|
{
|
|
LogVerbose($"[NWB-PopupIntercept] {hostedName} is a complex UITK window, skipping menu extraction");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strategy 2: GenericMenu popups (e.g. right-click context menus, Gizmos).
|
|
// Read item data from MenuController and compare its signature against
|
|
// the last sent set to avoid re-sending stale gDelayedContextMenu data.
|
|
System.Type menuType = typeof(EditorWindow).Assembly.GetType("UnityEditor.Menu");
|
|
if (menuType == null)
|
|
{
|
|
CodelyLogger.LogWarning("[NWB-FrontendPopup] Menu type not found");
|
|
return false;
|
|
}
|
|
|
|
MethodInfo getMenuItemsMethod = menuType.GetMethod("GetMenuItems",
|
|
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public,
|
|
null,
|
|
new System.Type[] { typeof(string), typeof(bool), typeof(bool) },
|
|
null);
|
|
|
|
string sig = GetMenuControllerSignature();
|
|
|
|
if (getMenuItemsMethod != null)
|
|
{
|
|
System.Array menuItems = getMenuItemsMethod.Invoke(null,
|
|
new object[] { kPopupMenuPath, true, false }) as System.Array;
|
|
|
|
if (menuItems == null || menuItems.Length == 0)
|
|
{
|
|
LogVerbose("[NWB-FrontendPopup] No MenuController items found");
|
|
return false;
|
|
}
|
|
|
|
if (sig == s_LastMenuControllerSignature)
|
|
{
|
|
LogVerbose($"[NWB-FrontendPopup] MenuController data unchanged (sig={sig}, last={s_LastMenuControllerSignature}) — stale, skipping. This popup will be handled by TryInterceptDelayedPopup instead.");
|
|
return false;
|
|
}
|
|
LogVerbose($"[NWB-FrontendPopup] MenuController signature changed: last={s_LastMenuControllerSignature} new={sig}, sending via Strategy 2");
|
|
|
|
return SendGenericMenuPopup(menuItems, menuType, relX, relY, popW, popH);
|
|
}
|
|
|
|
// Fallback: Unsupported.GetSubmenus (Unity 2019)
|
|
if (sig == "0|" || sig == s_LastMenuControllerSignature)
|
|
{
|
|
LogVerbose($"[NWB-FrontendPopup] No items or unchanged (sig={sig}) — skipping fallback");
|
|
return false;
|
|
}
|
|
LogVerbose($"[NWB-FrontendPopup] Using Unsupported.GetSubmenus fallback, sig={sig}");
|
|
return SendDelayedPopupViaSubmenus(relX, relY, 1f);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-FrontendPopup] Error extracting popup data: {ex.Message}\n{ex.StackTrace}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// ContainerWindow popup content detection
|
|
// ----------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Return true if the ContainerWindow contains a PopupWindowContent
|
|
/// (e.g. FlexibleMenu, AnnotationWindow, GameViewSizeMenu).
|
|
/// Such windows are created via PopupWindow.Show(), NOT via
|
|
/// GenericMenu/DisplayCustomMenu, so their data must be read from
|
|
/// the window content, NOT from MenuController.
|
|
/// </summary>
|
|
private static bool ContainerWindowHasPopupContent(object containerWindow)
|
|
{
|
|
try
|
|
{
|
|
// Get rootView — try property first, then field (Unity version safety)
|
|
object rootView = null;
|
|
PropertyInfo rootViewProp = containerWindow.GetType().GetProperty("rootView",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
rootView = rootViewProp?.GetValue(containerWindow);
|
|
|
|
if (rootView == null)
|
|
{
|
|
// Some Unity versions expose it as a field
|
|
FieldInfo rootViewField = containerWindow.GetType().GetField("m_RootView",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
rootView = rootViewField?.GetValue(containerWindow);
|
|
}
|
|
|
|
if (rootView == null)
|
|
{
|
|
CodelyLogger.LogWarning("[NWB-PopupContentCheck] rootView is null on ContainerWindow");
|
|
return false;
|
|
}
|
|
|
|
LogVerbose($"[NWB-PopupContentCheck] rootView type={rootView.GetType().Name}");
|
|
return ViewHierarchyHasPopupWindowContent(rootView);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-PopupContentCheck] Exception: {ex.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check whether the given type name (or any of its base types) is "PopupWindowContent".
|
|
/// Uses name comparison to avoid cross-assembly IsAssignableFrom failures.
|
|
/// </summary>
|
|
private static bool IsPopupWindowContentType(System.Type type)
|
|
{
|
|
while (type != null && type != typeof(object))
|
|
{
|
|
if (type.Name == "PopupWindowContent") return true;
|
|
type = type.BaseType;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static bool ViewHierarchyHasPopupWindowContent(object view)
|
|
{
|
|
if (view == null) return false;
|
|
System.Type viewType = view.GetType();
|
|
|
|
// Check 1: PopupView type name (Unity internal class for PopupWindow.Show()).
|
|
if (viewType.Name == "PopupView")
|
|
{
|
|
LogVerbose("[NWB-PopupContentCheck] Found PopupView in view hierarchy");
|
|
return true;
|
|
}
|
|
|
|
// Check 2: HostView → hosted EditorWindow → m_Content (PopupWindowContent).
|
|
// Unity's HostView exposes the hosted window via several possible members.
|
|
// Try m_WindowContent first, then actualView property (used in some versions).
|
|
object hostedWindow = null;
|
|
FieldInfo wcField = viewType.GetField("m_WindowContent",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
if (wcField != null)
|
|
hostedWindow = wcField.GetValue(view);
|
|
|
|
// Some Unity versions use "actualView" property on HostView
|
|
if (hostedWindow == null)
|
|
{
|
|
PropertyInfo avProp = viewType.GetProperty("actualView",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
if (avProp != null)
|
|
hostedWindow = avProp.GetValue(view);
|
|
}
|
|
|
|
if (hostedWindow != null)
|
|
{
|
|
System.Type hwType = hostedWindow.GetType();
|
|
LogVerbose($"[NWB-PopupContentCheck] hostedWindow type={hwType.FullName}");
|
|
|
|
// 2a: hostedWindow itself is PopupWindowContent
|
|
if (IsPopupWindowContentType(hwType))
|
|
{
|
|
LogVerbose($"[NWB-PopupContentCheck] Found PopupWindowContent: {hwType.Name}");
|
|
return true;
|
|
}
|
|
|
|
// 2b: hostedWindow type name contains "Popup" (e.g. PopupWindow, PopupWindowWithOverlay)
|
|
// These EditorWindow subclasses host PopupWindowContent in m_Content.
|
|
if (hwType.Name.Contains("Popup"))
|
|
{
|
|
LogVerbose($"[NWB-PopupContentCheck] hostedWindow is Popup-type: {hwType.Name}");
|
|
return true;
|
|
}
|
|
|
|
// 2c: Explicit m_Content field check for any EditorWindow
|
|
FieldInfo contentField = hwType.GetField("m_Content",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
if (contentField != null)
|
|
{
|
|
object content = contentField.GetValue(hostedWindow);
|
|
if (content != null)
|
|
{
|
|
LogVerbose($"[NWB-PopupContentCheck] m_Content type={content.GetType().FullName}");
|
|
if (IsPopupWindowContentType(content.GetType()))
|
|
{
|
|
LogVerbose($"[NWB-PopupContentCheck] Found PopupWindowContent via m_Content: {content.GetType().Name}");
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
LogVerbose("[NWB-PopupContentCheck] No hostedWindow found on HostView");
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Menu tree building & JSON serialization
|
|
// ----------------------------------------------------------------
|
|
|
|
private sealed class PopupMenuTreeNode
|
|
{
|
|
public string Label = "";
|
|
public string Path = "";
|
|
public int Index = -1;
|
|
public bool Separator;
|
|
public bool Enabled = true;
|
|
public bool Checked;
|
|
public string Tooltip = "";
|
|
public readonly List<PopupMenuTreeNode> Children = new List<PopupMenuTreeNode>();
|
|
}
|
|
|
|
private static string StripPopupMenuPathPrefix(string itemPath)
|
|
{
|
|
if (string.IsNullOrEmpty(itemPath))
|
|
return "";
|
|
|
|
string path = itemPath;
|
|
|
|
string popupPrefix = kPopupMenuPath + "/";
|
|
if (path.StartsWith(popupPrefix, StringComparison.Ordinal))
|
|
path = path.Substring(popupPrefix.Length);
|
|
|
|
// Strip DisplayPopupMenu prefix (e.g., "Assets/", "GameObject/") when
|
|
// forwarding main-menu-based context menus to the frontend overlay.
|
|
if (!string.IsNullOrEmpty(s_DisplayPopupMenuStripPrefix))
|
|
{
|
|
string dpPrefix = s_DisplayPopupMenuStripPrefix + "/";
|
|
if (path.StartsWith(dpPrefix, StringComparison.Ordinal))
|
|
path = path.Substring(dpPrefix.Length);
|
|
}
|
|
|
|
// Merged component context menus (CONTEXT/GameObject, CONTEXT/Transform, …)
|
|
// can retain parent chains that omit TEMPORARY-OBJECT-DISPLAY from the
|
|
// extracted path. Strip the internal CONTEXT/<type>/ namespace so we do
|
|
// not render a spurious root "CONTEXT" submenu in the HTML overlay.
|
|
if (path.StartsWith("CONTEXT/", StringComparison.Ordinal))
|
|
{
|
|
string rest = path.Substring("CONTEXT/".Length);
|
|
int slash = rest.IndexOf('/');
|
|
path = slash >= 0 ? rest.Substring(slash + 1) : "";
|
|
}
|
|
|
|
if (path == "CONTEXT")
|
|
return "";
|
|
|
|
return path;
|
|
}
|
|
|
|
private static string[] NormalizePopupMenuSegments(string relativePath)
|
|
{
|
|
if (string.IsNullOrEmpty(relativePath))
|
|
return Array.Empty<string>();
|
|
|
|
string[] segments = relativePath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
|
|
if (segments.Length > 0 &&
|
|
string.Equals(segments[0], "CONTEXT", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (segments.Length == 1)
|
|
return Array.Empty<string>();
|
|
|
|
var trimmed = new string[segments.Length - 1];
|
|
Array.Copy(segments, 1, trimmed, 0, trimmed.Length);
|
|
return trimmed;
|
|
}
|
|
|
|
return segments;
|
|
}
|
|
|
|
private static PopupMenuTreeNode FindOrCreateChild(
|
|
List<PopupMenuTreeNode> siblings, string segment)
|
|
{
|
|
foreach (PopupMenuTreeNode existing in siblings)
|
|
{
|
|
if (!existing.Separator && existing.Label == segment)
|
|
return existing;
|
|
}
|
|
|
|
var created = new PopupMenuTreeNode { Label = segment };
|
|
siblings.Add(created);
|
|
return created;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unity GetMenuItems(includeSeparators:true) emits submenu parent entries
|
|
/// (e.g. "Prefab") before/after "Prefab/..." children. If a parent was first
|
|
/// registered as a leaf, clear its leaf metadata once it becomes a branch.
|
|
/// </summary>
|
|
private static void PromoteMenuTreeNodeToBranch(PopupMenuTreeNode node)
|
|
{
|
|
if (node == null || node.Children.Count == 0)
|
|
return;
|
|
|
|
node.Path = "";
|
|
node.Index = -1;
|
|
node.Separator = false;
|
|
}
|
|
|
|
private static void MergeDuplicateMenuTreeNodes(List<PopupMenuTreeNode> nodes)
|
|
{
|
|
for (int i = nodes.Count - 1; i >= 0; i--)
|
|
{
|
|
PopupMenuTreeNode node = nodes[i];
|
|
MergeDuplicateMenuTreeNodes(node.Children);
|
|
|
|
if (node.Separator || string.IsNullOrEmpty(node.Label))
|
|
continue;
|
|
|
|
for (int j = 0; j < i; j++)
|
|
{
|
|
PopupMenuTreeNode other = nodes[j];
|
|
if (other.Separator || other.Label != node.Label)
|
|
continue;
|
|
|
|
other.Children.AddRange(node.Children);
|
|
PromoteMenuTreeNodeToBranch(other);
|
|
if (string.IsNullOrEmpty(other.Path) && !string.IsNullOrEmpty(node.Path))
|
|
{
|
|
other.Path = node.Path;
|
|
other.Index = node.Index;
|
|
}
|
|
nodes.RemoveAt(i);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static List<PopupMenuTreeNode> BuildHierarchicalPopupMenuTree(
|
|
System.Array menuItems, System.Type menuType,
|
|
PropertyInfo pathProp, PropertyInfo isSepProp,
|
|
PropertyInfo tooltipProp, PropertyInfo contentProp,
|
|
MethodInfo getCheckedMethod, MethodInfo getEnabledMethod,
|
|
bool forceAllEnabled = false)
|
|
{
|
|
var roots = new List<PopupMenuTreeNode>();
|
|
s_CachedPopupItemPaths = new string[menuItems.Length];
|
|
|
|
for (int i = 0; i < menuItems.Length; i++)
|
|
{
|
|
object mi = menuItems.GetValue(i);
|
|
string itemPath = pathProp?.GetValue(mi) as string ?? "";
|
|
bool isSep = isSepProp != null && (bool)isSepProp.GetValue(mi);
|
|
string itemTooltip = "";
|
|
try
|
|
{
|
|
if (tooltipProp != null)
|
|
itemTooltip = tooltipProp.GetValue(mi) as string ?? "";
|
|
if (string.IsNullOrEmpty(itemTooltip) && contentProp != null)
|
|
{
|
|
object content = contentProp.GetValue(mi);
|
|
if (content != null)
|
|
{
|
|
PropertyInfo gcTooltip = content.GetType().GetProperty("tooltip",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
itemTooltip = gcTooltip?.GetValue(content) as string ?? "";
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
s_CachedPopupItemPaths[i] = itemPath;
|
|
string relativePath = StripPopupMenuPathPrefix(itemPath).TrimEnd('/');
|
|
if (string.IsNullOrEmpty(relativePath))
|
|
{
|
|
if (isSep)
|
|
{
|
|
roots.Add(new PopupMenuTreeNode { Separator = true });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
bool isChecked = false;
|
|
bool isEnabled = true;
|
|
if (!isSep && !string.IsNullOrEmpty(itemPath))
|
|
{
|
|
try { isChecked = (bool)getCheckedMethod?.Invoke(null, new object[] { itemPath }); } catch { }
|
|
// When forceAllEnabled is set, items were cached before
|
|
// CancelNativeDelayedPopup removed them from MenuController.
|
|
// Menu.GetEnabled returns false for removed items, causing
|
|
// grayed-out text in the frontend popup.
|
|
if (!forceAllEnabled)
|
|
{
|
|
try { isEnabled = (bool)getEnabledMethod?.Invoke(null, new object[] { itemPath }); } catch { }
|
|
}
|
|
}
|
|
|
|
string[] segments = NormalizePopupMenuSegments(relativePath);
|
|
if (segments.Length == 0) continue;
|
|
|
|
List<PopupMenuTreeNode> level = roots;
|
|
for (int s = 0; s < segments.Length; s++)
|
|
{
|
|
bool isLeaf = s == segments.Length - 1;
|
|
PopupMenuTreeNode node = FindOrCreateChild(level, segments[s]);
|
|
if (isLeaf)
|
|
{
|
|
// Submenu parent rows (e.g. "Prefab") are emitted alongside
|
|
// "Prefab/..." children. Keep them as branches, not leaves.
|
|
if (node.Children.Count > 0)
|
|
{
|
|
node.Enabled = isEnabled;
|
|
node.Checked = isChecked;
|
|
if (!string.IsNullOrEmpty(itemTooltip))
|
|
node.Tooltip = itemTooltip;
|
|
}
|
|
else
|
|
{
|
|
node.Path = itemPath;
|
|
node.Index = i;
|
|
node.Separator = isSep;
|
|
node.Enabled = isEnabled;
|
|
node.Checked = isChecked;
|
|
node.Tooltip = itemTooltip;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// A submenu parent row may have been registered as a leaf
|
|
// before its "Parent/Child" entries arrive — reopen as branch.
|
|
if (!string.IsNullOrEmpty(node.Path))
|
|
{
|
|
node.Path = "";
|
|
node.Index = -1;
|
|
node.Separator = false;
|
|
}
|
|
level = node.Children;
|
|
}
|
|
}
|
|
}
|
|
|
|
MergeDuplicateMenuTreeNodes(roots);
|
|
return roots;
|
|
}
|
|
|
|
private static void AppendPopupMenuTreeJson(StringBuilder sb, List<PopupMenuTreeNode> nodes, ref int validCount)
|
|
{
|
|
for (int n = 0; n < nodes.Count; n++)
|
|
{
|
|
PopupMenuTreeNode node = nodes[n];
|
|
if (node.Separator && node.Children.Count == 0 && string.IsNullOrEmpty(node.Path))
|
|
{
|
|
if (validCount > 0) sb.Append(',');
|
|
sb.Append("{\"separator\":true}");
|
|
validCount++;
|
|
continue;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(node.Label) && node.Children.Count == 0)
|
|
continue;
|
|
|
|
if (string.Equals(node.Label, "CONTEXT", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (node.Children.Count > 0)
|
|
AppendPopupMenuTreeJson(sb, node.Children, ref validCount);
|
|
continue;
|
|
}
|
|
|
|
if (node.Children.Count == 0 && string.IsNullOrEmpty(node.Path) && node.Index < 0)
|
|
continue;
|
|
|
|
if (validCount > 0) sb.Append(',');
|
|
sb.Append("{\"index\":");
|
|
sb.Append(node.Index);
|
|
sb.Append(",\"path\":\"");
|
|
sb.Append(EscapeJsonString(node.Path));
|
|
sb.Append("\",\"label\":\"");
|
|
sb.Append(EscapeJsonString(node.Label));
|
|
sb.Append("\",\"tooltip\":\"");
|
|
sb.Append(EscapeJsonString(node.Tooltip));
|
|
sb.Append("\",\"enabled\":");
|
|
sb.Append(node.Enabled ? "true" : "false");
|
|
sb.Append(",\"checked\":");
|
|
sb.Append(node.Checked ? "true" : "false");
|
|
sb.Append(",\"separator\":");
|
|
sb.Append(node.Separator ? "true" : "false");
|
|
|
|
if (node.Children.Count > 0)
|
|
{
|
|
sb.Append(",\"submenu\":true,\"children\":[");
|
|
int childCount = 0;
|
|
AppendPopupMenuTreeJson(sb, node.Children, ref childCount);
|
|
sb.Append(']');
|
|
}
|
|
|
|
sb.Append('}');
|
|
validCount++;
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// GenericMenu popup sending
|
|
// ----------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Build and send popup_show JSON for GenericMenu items from MenuController.
|
|
/// </summary>
|
|
private static bool SendGenericMenuPopup(System.Array menuItems, System.Type menuType,
|
|
float relX, float relY, float popW, float popH,
|
|
bool hierarchicalContextMenu = false,
|
|
bool forceAllEnabled = false)
|
|
{
|
|
System.Type smiType = menuItems.GetValue(0).GetType();
|
|
PropertyInfo pathProp = smiType.GetProperty("path",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
PropertyInfo isSepProp = smiType.GetProperty("isSeparator",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
PropertyInfo tooltipProp = smiType.GetProperty("tooltip",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
PropertyInfo contentProp = smiType.GetProperty("content",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
|
|
MethodInfo getCheckedMethod = menuType.GetMethod("GetChecked",
|
|
BindingFlags.Static | BindingFlags.Public,
|
|
null, new System.Type[] { typeof(string) }, null);
|
|
MethodInfo getEnabledMethod = menuType.GetMethod("GetEnabled",
|
|
BindingFlags.Static | BindingFlags.Public,
|
|
null, new System.Type[] { typeof(string) }, null);
|
|
|
|
s_FrontendPopupIdCounter++;
|
|
s_CurrentFrontendPopupId = "popup_" + s_FrontendPopupIdCounter;
|
|
s_PopupSourceType = hierarchicalContextMenu ? "generic_context" : "generic";
|
|
|
|
var sb = new StringBuilder(4096);
|
|
sb.Append("{\"type\":\"popup_show\",\"id\":\"");
|
|
sb.Append(s_CurrentFrontendPopupId);
|
|
sb.Append("\",\"sourceType\":\"");
|
|
sb.Append(s_PopupSourceType);
|
|
sb.Append("\",\"x\":");
|
|
sb.Append(Mathf.RoundToInt(relX));
|
|
sb.Append(",\"y\":");
|
|
sb.Append(Mathf.RoundToInt(relY));
|
|
sb.Append(",\"width\":");
|
|
sb.Append(Mathf.RoundToInt(popW));
|
|
sb.Append(",\"height\":");
|
|
sb.Append(Mathf.RoundToInt(popH));
|
|
sb.Append(",\"items\":[");
|
|
|
|
int validCount = 0;
|
|
if (hierarchicalContextMenu)
|
|
{
|
|
List<PopupMenuTreeNode> roots = BuildHierarchicalPopupMenuTree(
|
|
menuItems, menuType, pathProp, isSepProp, tooltipProp, contentProp,
|
|
getCheckedMethod, getEnabledMethod, forceAllEnabled);
|
|
AppendPopupMenuTreeJson(sb, roots, ref validCount);
|
|
}
|
|
else
|
|
{
|
|
s_CachedPopupItemPaths = new string[menuItems.Length];
|
|
for (int i = 0; i < menuItems.Length; i++)
|
|
{
|
|
object mi = menuItems.GetValue(i);
|
|
string itemPath = pathProp?.GetValue(mi) as string ?? "";
|
|
bool isSep = isSepProp != null && (bool)isSepProp.GetValue(mi);
|
|
string itemTooltip = "";
|
|
try
|
|
{
|
|
if (tooltipProp != null)
|
|
itemTooltip = tooltipProp.GetValue(mi) as string ?? "";
|
|
if (string.IsNullOrEmpty(itemTooltip) && contentProp != null)
|
|
{
|
|
object content = contentProp.GetValue(mi);
|
|
if (content != null)
|
|
{
|
|
PropertyInfo gcTooltip = content.GetType().GetProperty("tooltip",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
itemTooltip = gcTooltip?.GetValue(content) as string ?? "";
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
s_CachedPopupItemPaths[i] = itemPath;
|
|
|
|
string label = StripPopupMenuPathPrefix(itemPath);
|
|
|
|
bool isChecked = false;
|
|
bool isEnabled = true;
|
|
if (!isSep && !string.IsNullOrEmpty(itemPath))
|
|
{
|
|
try { isChecked = (bool)getCheckedMethod?.Invoke(null, new object[] { itemPath }); } catch { }
|
|
// When forceAllEnabled is set, items were cached before
|
|
// CancelNativeDelayedPopup removed them from MenuController.
|
|
// Menu.GetEnabled would return false for removed items, causing
|
|
// grayed-out text in the frontend popup.
|
|
if (!forceAllEnabled)
|
|
{
|
|
try { isEnabled = (bool)getEnabledMethod?.Invoke(null, new object[] { itemPath }); } catch { }
|
|
}
|
|
}
|
|
|
|
if (validCount > 0) sb.Append(',');
|
|
sb.Append("{\"index\":");
|
|
sb.Append(i);
|
|
sb.Append(",\"path\":\"");
|
|
sb.Append(EscapeJsonString(itemPath));
|
|
sb.Append("\",\"label\":\"");
|
|
sb.Append(EscapeJsonString(label));
|
|
sb.Append("\",\"tooltip\":\"");
|
|
sb.Append(EscapeJsonString(itemTooltip));
|
|
sb.Append("\",\"enabled\":");
|
|
sb.Append(isEnabled ? "true" : "false");
|
|
sb.Append(",\"checked\":");
|
|
sb.Append(isChecked ? "true" : "false");
|
|
sb.Append(",\"separator\":");
|
|
sb.Append(isSep ? "true" : "false");
|
|
sb.Append('}');
|
|
validCount++;
|
|
}
|
|
}
|
|
|
|
sb.Append("]}");
|
|
|
|
string json = sb.ToString();
|
|
long dcSendTick = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
bool sent = SendDataChannelMessage(json);
|
|
double dcSendMs = (System.Diagnostics.Stopwatch.GetTimestamp() - dcSendTick)
|
|
/ (double)System.Diagnostics.Stopwatch.Frequency * 1000.0;
|
|
LogVerbose($"[NWB-CTX-DIAG] popup_show DC send: sent={sent} payload={json.Length}B took={dcSendMs:F1}ms");
|
|
if (!sent)
|
|
{
|
|
float hbSecs = NativeWindowBridgeAPI.NWB_GetSecondsSinceLastHeartbeat();
|
|
CodelyLogger.LogWarning($"[NWB-FrontendPopup] DataChannel send failed " +
|
|
$"(payload={json.Length}B, heartbeatAge={hbSecs:F2}s, " +
|
|
$"offscreenActive={NativeWindowBridgeAPI.NWB_IsOffscreenActive()})");
|
|
// DataChannel may be recovering after TrackPopupMenuEx modal
|
|
// loop was broken by WM_CANCELMODE. Use progressive backoff
|
|
// with multiple retries to ride out the transient stall.
|
|
int[] delaysMs = { 10, 20, 40, 80, 150 };
|
|
for (int r = 0; r < delaysMs.Length && !sent; r++)
|
|
{
|
|
System.Threading.Thread.Sleep(delaysMs[r]);
|
|
sent = SendDataChannelMessage(json);
|
|
}
|
|
if (sent)
|
|
LogVerbose("[NWB-FrontendPopup] DataChannel send succeeded after retry");
|
|
}
|
|
s_FrontendPopupSent = sent;
|
|
if (sent)
|
|
{
|
|
s_SuppressTargetRepaintWhilePopupOpen = true;
|
|
|
|
if (hierarchicalContextMenu)
|
|
{
|
|
// Close any lingering #32768 or ContainerWindow popups.
|
|
DismissAllNativePopups();
|
|
s_PendingNativePopupCloseFrames = Math.Max(s_PendingNativePopupCloseFrames, 60);
|
|
}
|
|
|
|
string firstCachedPath = s_CachedPopupItemPaths != null && s_CachedPopupItemPaths.Length > 0
|
|
? s_CachedPopupItemPaths[0] : "";
|
|
s_LastMenuControllerSignature = $"{menuItems.Length}|{firstCachedPath}";
|
|
LogVerbose($"[NWB-FrontendPopup] Sent GenericMenu popup: id={s_CurrentFrontendPopupId} items={validCount} hierarchical={hierarchicalContextMenu} pos=({relX:F0},{relY:F0}) sig={s_LastMenuControllerSignature}");
|
|
}
|
|
else
|
|
CodelyLogger.LogWarning("[NWB-FrontendPopup] Failed to send GenericMenu popup via DataChannel");
|
|
|
|
return sent;
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Repaint suppression & context-menu state
|
|
// ----------------------------------------------------------------
|
|
|
|
private static bool ShouldSuppressTargetRepaint()
|
|
{
|
|
return s_SuppressTargetRepaintWhilePopupOpen;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Block Repaint passes that would call ShowDelayedContextMenu while a
|
|
/// remote context-menu gesture is in flight or a frontend overlay is open.
|
|
/// </summary>
|
|
private static bool ShouldBlockNativeContextMenuRepaint()
|
|
{
|
|
if (ShouldSuppressTargetRepaint()) return true;
|
|
if (s_FrontendPopupSent) return true;
|
|
if (s_PaneOptionsMouseDownPending) return true;
|
|
if (s_ExpectContextMenuForward) return true;
|
|
if (s_PendingNativePopupCloseFrames > 0) return true;
|
|
|
|
double elapsed = EditorApplication.timeSinceStartup - s_LastRemoteMousedownTime;
|
|
if (elapsed <= kRemotePopupGracePeriod && HasPendingPopupMenuItems())
|
|
return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
private static void ClearContextMenuForwardState()
|
|
{
|
|
s_ExpectContextMenuForward = false;
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// DisplayPopupMenu forwarding (ProjectBrowser, SceneHierarchyWindow)
|
|
// ----------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Map the current offscreen target window type to the main menu prefix
|
|
/// used by EditorUtility.DisplayPopupMenu for its context menu.
|
|
/// Returns null for window types that use GenericMenu.DropDown instead.
|
|
/// </summary>
|
|
private static string GetDisplayPopupMenuPrefix()
|
|
{
|
|
if (s_OffscreenTargetType == null) return null;
|
|
string name = s_OffscreenTargetType.Name;
|
|
switch (name)
|
|
{
|
|
case "ProjectBrowser": return "Assets";
|
|
case "SceneHierarchyWindow": return "GameObject";
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read main menu items under the given prefix and forward them as a
|
|
/// hierarchical context menu to the frontend overlay. Used for windows
|
|
/// that call EditorUtility.DisplayPopupMenu (e.g., ProjectBrowser →
|
|
/// "Assets/", SceneHierarchyWindow → "GameObject/") which stores items
|
|
/// in the main menu bar rather than MenuController's temporary path.
|
|
/// </summary>
|
|
private static bool TryForwardDisplayPopupMenu(float clickX, float clickY, string menuPrefix)
|
|
{
|
|
if (s_FrontendPopupSent) return false;
|
|
if (string.IsNullOrEmpty(menuPrefix)) return false;
|
|
|
|
try
|
|
{
|
|
System.Type menuType = typeof(EditorWindow).Assembly.GetType("UnityEditor.Menu");
|
|
if (menuType == null)
|
|
{
|
|
LogVerbose("[NWB-DisplayPopup] Menu type not found");
|
|
return false;
|
|
}
|
|
|
|
MethodInfo getMenuItemsMethod = menuType.GetMethod("GetMenuItems",
|
|
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public,
|
|
null,
|
|
new System.Type[] { typeof(string), typeof(bool), typeof(bool) },
|
|
null);
|
|
if (getMenuItemsMethod == null)
|
|
{
|
|
LogVerbose("[NWB-DisplayPopup] GetMenuItems method not found");
|
|
return false;
|
|
}
|
|
|
|
System.Array menuItems = getMenuItemsMethod.Invoke(null,
|
|
new object[] { menuPrefix, true, false }) as System.Array;
|
|
if (menuItems == null || menuItems.Length == 0)
|
|
{
|
|
LogVerbose($"[NWB-DisplayPopup] No items under '{menuPrefix}'");
|
|
return false;
|
|
}
|
|
|
|
ComputePopupPositionFromLocalClick(
|
|
clickX, clickY, s_TabBarOffsetY + 4f, 4f,
|
|
out float popX, out float popY);
|
|
|
|
// Set the strip prefix so BuildHierarchicalPopupMenuTree renders
|
|
// relative labels (e.g., "Create" instead of "Assets/Create").
|
|
s_DisplayPopupMenuStripPrefix = menuPrefix;
|
|
try
|
|
{
|
|
bool sent = SendGenericMenuPopup(
|
|
menuItems, menuType, popX, popY, 0, 0,
|
|
hierarchicalContextMenu: true);
|
|
if (sent)
|
|
{
|
|
s_PopupSourceType = "display_popup_menu";
|
|
s_PendingNativePopupCloseFrames = Math.Max(
|
|
s_PendingNativePopupCloseFrames, 60);
|
|
// gDelayedContextMenu was already consumed by
|
|
// ProbeAndDismissDelayedContextMenu before this
|
|
// method is called. Clean up any residual popups.
|
|
DismissAllNativePopups();
|
|
LogVerbose($"[NWB-DisplayPopup] Forwarded '{menuPrefix}' menu: " +
|
|
$"id={s_CurrentFrontendPopupId} items={menuItems.Length}");
|
|
}
|
|
else
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-DisplayPopup] Failed to send '{menuPrefix}' popup_show");
|
|
}
|
|
return sent;
|
|
}
|
|
finally
|
|
{
|
|
s_DisplayPopupMenuStripPrefix = null;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-DisplayPopup] Error forwarding '{menuPrefix}': {ex.Message}");
|
|
s_DisplayPopupMenuStripPrefix = null;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Context-click synthesis & forwarding
|
|
// ----------------------------------------------------------------
|
|
|
|
private static bool TrySynthesizeAndForwardContextMenuPopup(
|
|
float clickX, float clickY, EventModifiers mods, Vector2 mousePos, Vector2 mouseDelta)
|
|
{
|
|
// If the popup was already forwarded during mousedown (when
|
|
// GenericMenu.ShowAsContext fires inside SendEvent(MouseDown)),
|
|
// skip ContextClick synthesis entirely.
|
|
if (s_FrontendPopupSent) return true;
|
|
|
|
// For GameView/SceneView, only synthesize ContextClick when the click
|
|
// is in the DockArea tab header region. The tab header context menu
|
|
// (Close Tab, Maximize, etc.) requires ContextClick to appear, but
|
|
// we must not interfere with game input or scene navigation below it.
|
|
if (!ShouldSynthesizeContextClickForTarget())
|
|
{
|
|
bool isInTabHeader = s_TabBarOffsetY > 0 && mousePos.y <= s_TabBarOffsetY;
|
|
if (!isInTabHeader)
|
|
return false;
|
|
}
|
|
|
|
// Tab-header right-click: show DockArea tab menu (Close Tab, Lock,
|
|
// Maximize, etc.) instead of the content context menu. Must be
|
|
// checked BEFORE GetDisplayPopupMenuPrefix to avoid the Assets/
|
|
// menu stealing the click in the tab strip region.
|
|
if (s_TabBarOffsetY > 0 && mousePos.y <= s_TabBarOffsetY)
|
|
{
|
|
bool tabMenuSent = TrySendTabHeaderContextMenu(clickX, clickY);
|
|
if (tabMenuSent)
|
|
{
|
|
ClearContextMenuForwardState();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Windows that use EditorUtility.DisplayPopupMenu (ProjectBrowser,
|
|
// SceneHierarchyWindow) queue a C++ gDelayedContextMenu with a main
|
|
// menu prefix (e.g., "Assets/"). Sending ContextClick would trigger
|
|
// DisplayPopupMenu → ShowDelayedContextMenu → TrackPopupMenuEx on the
|
|
// next SendEvent, blocking the main thread and showing a native popup
|
|
// on the hidden editor window. Instead, read items directly from the
|
|
// main menu bar and forward them without ever queuing the delayed popup.
|
|
string displayPrefix = GetDisplayPopupMenuPrefix();
|
|
if (!string.IsNullOrEmpty(displayPrefix))
|
|
{
|
|
s_SuppressTargetRepaintWhilePopupOpen = true;
|
|
bool forwarded = TryForwardDisplayPopupMenu(clickX, clickY, displayPrefix);
|
|
if (forwarded)
|
|
{
|
|
ClearContextMenuForwardState();
|
|
return true;
|
|
}
|
|
s_SuppressTargetRepaintWhilePopupOpen = false;
|
|
LogVerbose($"[NWB-DisplayPopup] Direct forwarding failed for prefix='{displayPrefix}', falling through to ContextClick synthesis");
|
|
}
|
|
|
|
s_SuppressTargetRepaintWhilePopupOpen = true;
|
|
long synthStart = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
double synthFreq = System.Diagnostics.Stopwatch.Frequency;
|
|
ScheduleNativePopupAutoDismiss();
|
|
try
|
|
{
|
|
Event warmMove = new Event
|
|
{
|
|
type = EventType.MouseMove,
|
|
mousePosition = mousePos,
|
|
delta = mouseDelta,
|
|
modifiers = mods
|
|
};
|
|
s_OffscreenTarget.SendEvent(warmMove);
|
|
|
|
long preLayoutTick = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
s_OffscreenTarget.SendEvent(new Event { type = EventType.Layout });
|
|
double layoutMs = (System.Diagnostics.Stopwatch.GetTimestamp() - preLayoutTick) / synthFreq * 1000.0;
|
|
LogVerbose($"[NWB-CTX-DIAG] Layout sent in {layoutMs:F1}ms, s_FrontendPopupSent={s_FrontendPopupSent}");
|
|
|
|
DismissAllNativePopups();
|
|
|
|
long preCtxTick = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
Event contextEvt = new Event
|
|
{
|
|
type = EventType.ContextClick,
|
|
mousePosition = mousePos,
|
|
button = 1,
|
|
modifiers = mods
|
|
};
|
|
s_OffscreenTarget.SendEvent(contextEvt);
|
|
double ctxMs = (System.Diagnostics.Stopwatch.GetTimestamp() - preCtxTick) / synthFreq * 1000.0;
|
|
string postCtxSig = GetMenuControllerSignature();
|
|
LogVerbose($"[NWB-CTX-DIAG] ContextClick at ({mousePos.x:F0},{mousePos.y:F0}) for {s_OffscreenTargetType?.Name}, took {ctxMs:F1}ms, postMCSig={postCtxSig}");
|
|
if (ctxMs > 200)
|
|
CodelyLogger.LogWarning($"[NWB-ContextClick] SendEvent(ContextClick) BLOCKED for {ctxMs:F0}ms");
|
|
}
|
|
catch (Exception ctxEx)
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-ContextClick] Failed: {ctxEx.Message}");
|
|
s_SuppressTargetRepaintWhilePopupOpen = false;
|
|
ConsumeDelayedContextMenu();
|
|
CancelNativeDelayedPopup();
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
CancelNativePopupAutoDismiss();
|
|
// Transition to a self-terminating guard that keeps
|
|
// dismissing #32768 popups during Phase2/Phase3 (DC send).
|
|
// The aggressive ScheduleNativePopupAutoDismiss (which
|
|
// targets ALL HWNDs) is no longer needed, but a targeted
|
|
// #32768-only guard must stay active to cover any late
|
|
// TrackPopupMenuEx triggered by implicit events.
|
|
ScheduleTimeLimitedPopupDismiss(10000);
|
|
}
|
|
TryDismissWin32TrackPopupMenu();
|
|
|
|
double postTryMs = (System.Diagnostics.Stopwatch.GetTimestamp() - synthStart) / synthFreq * 1000.0;
|
|
LogVerbose($"[NWB-CTX-DIAG] PostTry: elapsed={postTryMs:F1}ms, s_FrontendPopupSent={s_FrontendPopupSent}");
|
|
|
|
// ── Phase 2: cache items from MenuController ──
|
|
// gDelayedContextMenu is already CLEARED by ContextClick processing:
|
|
// OnInputEvent(ContextClick) → HasDelayedContextMenu() TRUE
|
|
// → ShowDelayedContextMenu() → TrackPopupMenuEx (dismissed by AutoDismiss)
|
|
// → gDelayedContextMenu = NULL.
|
|
// Items MUST remain in MenuController so HandleFrontendPopupSelect
|
|
// can execute them via EditorApplication.ExecuteMenuItem later.
|
|
System.Array cachedMenuItems = null;
|
|
System.Type cachedMenuType = null;
|
|
try
|
|
{
|
|
cachedMenuType = typeof(EditorWindow).Assembly.GetType("UnityEditor.Menu");
|
|
if (cachedMenuType != null)
|
|
{
|
|
MethodInfo getItemsMethod = cachedMenuType.GetMethod("GetMenuItems",
|
|
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public,
|
|
null,
|
|
new System.Type[] { typeof(string), typeof(bool), typeof(bool) },
|
|
null);
|
|
if (getItemsMethod != null)
|
|
{
|
|
cachedMenuItems = getItemsMethod.Invoke(null,
|
|
new object[] { kPopupMenuPath, true, false }) as System.Array;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception cacheEx)
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-CTX-DIAG] Phase2 cache FAILED: {cacheEx.Message}");
|
|
}
|
|
|
|
int cachedCount = cachedMenuItems?.Length ?? 0;
|
|
bool itemsPresent = HasPendingPopupMenuItems();
|
|
LogVerbose($"[NWB-CTX-DIAG] Phase2: cached={cachedCount} items, itemsPresent={itemsPresent}, s_FrontendPopupSent={s_FrontendPopupSent}");
|
|
|
|
// Close any ContainerWindow (showMode=2) created by the implicit
|
|
// SendLayoutEvent inside OnInputEvent which triggers
|
|
// GenericMenu.ShowAsContext via m_RightClickedDelayFrame.
|
|
DismissAllNativePopups();
|
|
|
|
// ── Phase 3: send cached items to frontend via DataChannel ──
|
|
float prePhase3HbAge = NativeWindowBridgeAPI.NWB_GetSecondsSinceLastHeartbeat();
|
|
int prePhase3OffActive = NativeWindowBridgeAPI.NWB_IsOffscreenActive();
|
|
LogVerbose($"[NWB-CTX-DIAG] Phase3: cachedCount={cachedCount}, " +
|
|
$"s_FrontendPopupSent={s_FrontendPopupSent}, " +
|
|
$"heartbeatAge={prePhase3HbAge:F2}s, offActive={prePhase3OffActive}");
|
|
if (cachedCount > 0 && !s_FrontendPopupSent)
|
|
{
|
|
ComputePopupPositionFromLocalClick(
|
|
clickX, clickY, s_TabBarOffsetY + 4f, 4f,
|
|
out float popX, out float popY);
|
|
|
|
for (int attempt = 0; attempt < 3 && !s_FrontendPopupSent; attempt++)
|
|
{
|
|
if (attempt > 0)
|
|
{
|
|
System.Threading.Thread.Sleep(30 + attempt * 30);
|
|
LogVerbose($"[NWB-ContextClick] DataChannel retry #{attempt + 1}");
|
|
}
|
|
|
|
bool sent = SendGenericMenuPopup(
|
|
cachedMenuItems, cachedMenuType, popX, popY, 0, 0,
|
|
hierarchicalContextMenu: true,
|
|
forceAllEnabled: true);
|
|
if (sent)
|
|
{
|
|
s_PendingNativePopupCloseFrames = Math.Max(s_PendingNativePopupCloseFrames, 60);
|
|
DismissAllNativePopups();
|
|
ClearContextMenuForwardState();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
double totalSynthMs = (System.Diagnostics.Stopwatch.GetTimestamp() - synthStart) / synthFreq * 1000.0;
|
|
if (s_FrontendPopupSent)
|
|
{
|
|
// Drain gDelayedContextMenu: ConsoleWindow's
|
|
// m_RightClickedDelayFrame may still be >0. Each Layout
|
|
// decrements it; when it reaches 0 GenericMenu.ShowAsContext
|
|
// fires and sets gDelayedContextMenu. Without draining,
|
|
// the next capture-loop Repaint would trigger
|
|
// ShowDelayedContextMenu → TrackPopupMenuEx → visible
|
|
// native popup flash. Send a few protected Layout events
|
|
// to exhaust the countdown and break any resulting
|
|
// TrackPopupMenuEx immediately. Menu items are NOT removed
|
|
// — they remain for ExecuteMenuItem on popup_select.
|
|
for (int drain = 0; drain < 4; drain++)
|
|
{
|
|
ScheduleNativePopupAutoDismiss();
|
|
try { s_OffscreenTarget.SendEvent(new Event { type = EventType.Layout }); }
|
|
catch (Exception) { }
|
|
finally { CancelNativePopupAutoDismiss(); }
|
|
DismissAllNativePopups();
|
|
}
|
|
bool finalItems = HasPendingPopupMenuItems();
|
|
CodelyLogger.Log($"[NWB-CTX-DIAG] SUCCESS in {totalSynthMs:F1}ms, itemsKept={finalItems}");
|
|
ScheduleTimeLimitedPopupDismiss(3000);
|
|
return true;
|
|
}
|
|
|
|
// Failed path: always clean up gDelayedContextMenu to prevent
|
|
// native popup appearing even when our DataChannel send failed.
|
|
LogVerbose("[NWB-CTX-DIAG] FAILED path: cleanup gDelayedContextMenu");
|
|
ConsumeDelayedContextMenu();
|
|
s_SuppressTargetRepaintWhilePopupOpen = false;
|
|
|
|
// Force-close ContainerWindows: the normal grace period
|
|
// (kRemotePopupGracePeriod=0.5s) has expired during the DC
|
|
// retry loop (~1.2s). Without forceClose, ScanAndCloseNativePopups
|
|
// won't close showMode==2 containers created by GenericMenu.
|
|
s_PendingNativePopupCloseFrames = Math.Max(s_PendingNativePopupCloseFrames, 120);
|
|
DismissAllNativePopups();
|
|
|
|
// Start a long-running background guard thread that keeps
|
|
// dismissing #32768 popups independent of the main thread.
|
|
// This covers any TrackPopupMenuEx that fires during the
|
|
// subsequent mouseup event processing or later Layout/Repaint
|
|
// cycles after this function returns.
|
|
ScheduleTimeLimitedPopupDismiss(15000);
|
|
|
|
float failHbAge = NativeWindowBridgeAPI.NWB_GetSecondsSinceLastHeartbeat();
|
|
CodelyLogger.LogWarning($"[NWB-ContextClick] FAILED — DataChannel send failed after {totalSynthMs:F1}ms " +
|
|
$"(cachedItems={cachedCount}, heartbeatAge={failHbAge:F2}s, " +
|
|
$"offscreenActive={NativeWindowBridgeAPI.NWB_IsOffscreenActive()})");
|
|
return false;
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Menu item execution
|
|
// ----------------------------------------------------------------
|
|
|
|
private static bool TryExecuteForwardedMenuItem(string menuPath)
|
|
{
|
|
if (string.IsNullOrEmpty(menuPath)) return false;
|
|
|
|
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
|
|
if (executed)
|
|
{
|
|
LogVerbose($"[NWB-FrontendPopup] ExecuteMenuItem succeeded: {menuPath}");
|
|
return true;
|
|
}
|
|
|
|
UnityEngine.Object[] ctx = Selection.objects;
|
|
if (ctx != null && ctx.Length > 0)
|
|
{
|
|
MethodInfo withCtx = typeof(EditorApplication).GetMethod(
|
|
"ExecuteMenuItemWithTemporaryContext",
|
|
BindingFlags.Static | BindingFlags.NonPublic);
|
|
if (withCtx != null)
|
|
{
|
|
try
|
|
{
|
|
executed = (bool)withCtx.Invoke(null, new object[] { menuPath, ctx });
|
|
if (executed)
|
|
{
|
|
LogVerbose($"[NWB-FrontendPopup] ExecuteMenuItemWithTemporaryContext succeeded: {menuPath}");
|
|
return true;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-FrontendPopup] ExecuteMenuItemWithTemporaryContext error: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
CodelyLogger.LogWarning($"[NWB-FrontendPopup] ExecuteMenuItem returned false for: {menuPath}");
|
|
return false;
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Extract popup items from UnityEditor.PopupList and send as popup_show.
|
|
/// PopupList is used by ProjectBrowser search filter dropdowns.
|
|
/// </summary>
|
|
private static bool TryExtractPopupListFromWindowContent(object windowContent, float relX, float relY, float popW, float popH)
|
|
{
|
|
try
|
|
{
|
|
if (windowContent == null) return false;
|
|
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
|
|
|
FieldInfo dataField = windowContent.GetType().GetField("m_Data", flags);
|
|
object data = dataField?.GetValue(windowContent);
|
|
if (data == null)
|
|
{
|
|
LogVerbose("[NWB-PopupList] m_Data is null; cannot extract");
|
|
return false;
|
|
}
|
|
|
|
FieldInfo listField = data.GetType().GetField("m_ListElements", flags);
|
|
System.Collections.IList elements = listField?.GetValue(data) as System.Collections.IList;
|
|
if (elements == null || elements.Count == 0)
|
|
{
|
|
LogVerbose("[NWB-PopupList] m_ListElements is empty");
|
|
return false;
|
|
}
|
|
|
|
FieldInfo cbField = data.GetType().GetField("m_OnSelectCallback", flags);
|
|
s_CachedPopupListSelectDelegate = cbField?.GetValue(data) as System.Delegate;
|
|
|
|
s_FrontendPopupIdCounter++;
|
|
s_CurrentFrontendPopupId = "popup_" + s_FrontendPopupIdCounter;
|
|
s_PopupSourceType = "popup_list";
|
|
s_CachedPopupItemPaths = new string[elements.Count];
|
|
s_CachedPopupListItems = new object[elements.Count];
|
|
|
|
var sb = new StringBuilder(2048);
|
|
sb.Append("{\"type\":\"popup_show\",\"id\":\"");
|
|
sb.Append(s_CurrentFrontendPopupId);
|
|
sb.Append("\",\"x\":");
|
|
sb.Append(Mathf.RoundToInt(relX));
|
|
sb.Append(",\"y\":");
|
|
sb.Append(Mathf.RoundToInt(relY));
|
|
sb.Append(",\"width\":");
|
|
sb.Append(Mathf.RoundToInt(popW));
|
|
sb.Append(",\"height\":");
|
|
sb.Append(Mathf.RoundToInt(popH));
|
|
sb.Append(",\"items\":[");
|
|
|
|
int validCount = 0;
|
|
for (int i = 0; i < elements.Count; i++)
|
|
{
|
|
object element = elements[i];
|
|
if (element == null) continue;
|
|
string label = GetPopupListElementText(element);
|
|
if (string.IsNullOrEmpty(label)) continue;
|
|
|
|
bool selected = GetPopupListElementBool(element, "selected", "m_Selected");
|
|
bool enabled = !HasPopupListElementBool(element, "enabled", "m_Enabled") ||
|
|
GetPopupListElementBool(element, "enabled", "m_Enabled");
|
|
|
|
s_CachedPopupListItems[i] = element;
|
|
s_CachedPopupItemPaths[i] = label;
|
|
|
|
if (validCount > 0) sb.Append(',');
|
|
sb.Append("{\"index\":");
|
|
sb.Append(i);
|
|
sb.Append(",\"path\":\"");
|
|
sb.Append(EscapeJsonString(label));
|
|
sb.Append("\",\"label\":\"");
|
|
sb.Append(EscapeJsonString(label));
|
|
sb.Append("\",\"tooltip\":\"\",\"enabled\":");
|
|
sb.Append(enabled ? "true" : "false");
|
|
sb.Append(",\"checked\":");
|
|
sb.Append(selected ? "true" : "false");
|
|
sb.Append(",\"separator\":false}");
|
|
validCount++;
|
|
}
|
|
sb.Append("]}");
|
|
|
|
if (validCount == 0)
|
|
{
|
|
LogVerbose("[NWB-PopupList] No valid entries after extraction");
|
|
return false;
|
|
}
|
|
|
|
bool sent = SendDataChannelMessage(sb.ToString());
|
|
s_FrontendPopupSent = sent;
|
|
if (sent)
|
|
{
|
|
s_SuppressTargetRepaintWhilePopupOpen = true;
|
|
CancelNativeDelayedPopup();
|
|
LogVerbose($"[NWB-PopupList] Sent popup: id={s_CurrentFrontendPopupId} items={validCount}");
|
|
}
|
|
else
|
|
{
|
|
CodelyLogger.LogWarning("[NWB-PopupList] Failed to send popup_show");
|
|
}
|
|
return sent;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-PopupList] Extraction error: {ex.Message}\n{ex.StackTrace}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string GetPopupListElementText(object element)
|
|
{
|
|
if (element == null) return "";
|
|
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
|
|
|
PropertyInfo textProp = element.GetType().GetProperty("text", flags);
|
|
if (textProp != null && textProp.PropertyType == typeof(string))
|
|
{
|
|
try { return textProp.GetValue(element, null) as string ?? ""; } catch { }
|
|
}
|
|
|
|
FieldInfo contentField = element.GetType().GetField("m_Content", flags);
|
|
GUIContent content = contentField?.GetValue(element) as GUIContent;
|
|
return content?.text ?? "";
|
|
}
|
|
|
|
private static bool HasPopupListElementBool(object element, string propertyName, string fieldName)
|
|
{
|
|
if (element == null) return false;
|
|
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
|
return element.GetType().GetProperty(propertyName, flags) != null ||
|
|
element.GetType().GetField(fieldName, flags) != null;
|
|
}
|
|
|
|
private static bool GetPopupListElementBool(object element, string propertyName, string fieldName)
|
|
{
|
|
if (element == null) return false;
|
|
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
|
PropertyInfo prop = element.GetType().GetProperty(propertyName, flags);
|
|
if (prop != null && prop.PropertyType == typeof(bool))
|
|
{
|
|
try { return (bool)prop.GetValue(element, null); } catch { }
|
|
}
|
|
FieldInfo field = element.GetType().GetField(fieldName, flags);
|
|
if (field != null && field.FieldType == typeof(bool))
|
|
{
|
|
try { return (bool)field.GetValue(element); } catch { }
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static void HandlePopupListSelect(int index, string path)
|
|
{
|
|
if (s_CachedPopupListSelectDelegate == null)
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-PopupList] Missing select callback for index={index}");
|
|
return;
|
|
}
|
|
|
|
object item = (s_CachedPopupListItems != null && index >= 0 && index < s_CachedPopupListItems.Length)
|
|
? s_CachedPopupListItems[index]
|
|
: null;
|
|
|
|
if (item == null && !string.IsNullOrEmpty(path) && s_CachedPopupItemPaths != null && s_CachedPopupListItems != null)
|
|
{
|
|
for (int i = 0; i < s_CachedPopupItemPaths.Length && i < s_CachedPopupListItems.Length; i++)
|
|
{
|
|
if (string.Equals(s_CachedPopupItemPaths[i], path, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
item = s_CachedPopupListItems[i];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (item == null)
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-PopupList] Missing list element for index={index}, path={path}");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
s_CachedPopupListSelectDelegate.DynamicInvoke(item);
|
|
LogVerbose($"[NWB-PopupList] Invoked m_OnSelectCallback for index={index}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CodelyLogger.LogWarning($"[NWB-PopupList] Select callback invoke failed: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
}
|