feat(gameplay): 更新单位属性和材质配置

- 调整多个单位的基础生命值和攻击力数值平衡
- 修改CoreBattleGround材质的纹理缩放比例
- 更新地砖材质颜色为白色
- 优化纹理资源设置,调整最大纹理尺寸并添加PlayableAds平台支持
- 修复弓箭手技能目标查找逻辑,改用统一的敌人检测方法
- 为水晶矿添加中立阵营设置和光球视觉效果同步
- 调整预制体中的全局缩放参数
- 为狼人和石巨人添加NavMeshAgent组件支持
- 优化水晶矿血条显示位置计算方式
```
This commit is contained in:
huadexuan committed 2026-09-20 21:52:31 +08:00
1 parent 0e7de5f885
commit 53d95802e5
448 files changed
+133333 -472

No files matched your search

@@ -0,0 +1,511 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Native
{
[InitializeOnLoad]
internal static class NativeDllLoader
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
// ---- Platform P/Invoke ----------------------------------------- //
#if UNITY_EDITOR_WIN
private const uint GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT = 0x00000002;
[DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr LoadLibraryW(string lpLibFileName);
[DllImport("kernel32", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool FreeLibrary(IntPtr hModule);
[DllImport("kernel32", SetLastError = true)]
private static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);
[DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetModuleHandleExW(uint dwFlags, string lpModuleName, out IntPtr phModule);
private static string Win32Error() =>
new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()).Message;
private static IntPtr PlatformLoad(string path)
{
IntPtr h = LoadLibraryW(path);
if (h == IntPtr.Zero)
CodelyLogger.LogWarning($"NativeDllLoader: LoadLibraryW failed: {Win32Error()}");
return h;
}
private static void PlatformFree(IntPtr handle) { if (handle != IntPtr.Zero) FreeLibrary(handle); }
private static IntPtr PlatformGetProc(IntPtr handle, string symbol)
{
IntPtr ptr = GetProcAddress(handle, symbol);
if (ptr == IntPtr.Zero)
CodelyLogger.LogWarning($"NativeDllLoader: GetProcAddress('{symbol}') failed: {Win32Error()}");
return ptr;
}
// Path-based probe: returns true iff the module at `path` is still loaded with the
// given `expected` handle. UNCHANGED_REFCOUNT avoids bumping the refcount, so this
// is purely a query — never call FreeLibrary on the result.
private static bool PlatformIsHandleStillLoaded(string path, IntPtr expected)
{
if (expected == IntPtr.Zero) return false;
return GetModuleHandleExW(
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
path,
out IntPtr current)
&& current == expected;
}
#elif UNITY_EDITOR_OSX
private const int RTLD_NOW = 0x2;
private const int RTLD_NOLOAD = 0x10; // dyld value (differs from glibc)
[DllImport("libdl")]
private static extern IntPtr dlopen(string filename, int flags);
[DllImport("libdl")]
private static extern int dlclose(IntPtr handle);
[DllImport("libdl")]
private static extern IntPtr dlsym(IntPtr handle, string symbol);
[DllImport("libdl")]
private static extern IntPtr dlerror();
private static string DlError() { IntPtr p = dlerror(); return p != IntPtr.Zero ? Marshal.PtrToStringAnsi(p) : "unknown error"; }
private static IntPtr PlatformLoad(string path)
{
dlerror();
IntPtr h = dlopen(path, RTLD_NOW);
if (h == IntPtr.Zero)
CodelyLogger.LogWarning($"NativeDllLoader: dlopen failed: {DlError()}");
return h;
}
private static void PlatformFree(IntPtr handle) { if (handle != IntPtr.Zero) dlclose(handle); }
private static IntPtr PlatformGetProc(IntPtr handle, string symbol)
{
dlerror();
IntPtr ptr = dlsym(handle, symbol);
if (ptr == IntPtr.Zero)
CodelyLogger.LogWarning($"NativeDllLoader: dlsym('{symbol}') failed: {DlError()}");
return ptr;
}
// Path-based probe via dlopen(RTLD_NOLOAD): returns the handle iff the image is
// already mapped at `path`, NULL otherwise. NOLOAD still bumps the refcount on
// success, so we balance with dlclose immediately. Never call PlatformFree on
// anything other than handles we explicitly loaded.
private static bool PlatformIsHandleStillLoaded(string path, IntPtr expected)
{
if (expected == IntPtr.Zero) return false;
dlerror();
IntPtr probe = dlopen(path, RTLD_NOW | RTLD_NOLOAD);
if (probe == IntPtr.Zero) return false;
bool matches = probe == expected;
dlclose(probe);
return matches;
}
#else // UNITY_EDITOR_LINUX
private const int RTLD_NOW = 0x2;
private const int RTLD_NOLOAD = 0x4; // glibc value (differs from dyld)
[DllImport("libdl.so.2")]
private static extern IntPtr dlopen(string filename, int flags);
[DllImport("libdl.so.2")]
private static extern int dlclose(IntPtr handle);
[DllImport("libdl.so.2")]
private static extern IntPtr dlsym(IntPtr handle, string symbol);
[DllImport("libdl.so.2")]
private static extern IntPtr dlerror();
private static string DlError() { IntPtr p = dlerror(); return p != IntPtr.Zero ? Marshal.PtrToStringAnsi(p) : "unknown error"; }
private static IntPtr PlatformLoad(string path)
{
dlerror();
IntPtr h = dlopen(path, RTLD_NOW);
if (h == IntPtr.Zero)
CodelyLogger.LogWarning($"NativeDllLoader: dlopen failed: {DlError()}");
return h;
}
private static void PlatformFree(IntPtr handle) { if (handle != IntPtr.Zero) dlclose(handle); }
private static IntPtr PlatformGetProc(IntPtr handle, string symbol)
{
dlerror();
IntPtr ptr = dlsym(handle, symbol);
if (ptr == IntPtr.Zero)
CodelyLogger.LogWarning($"NativeDllLoader: dlsym('{symbol}') failed: {DlError()}");
return ptr;
}
// Path-based probe via dlopen(RTLD_NOLOAD): returns the handle iff the image is
// already mapped at `path`, NULL otherwise. NOLOAD still bumps the refcount on
// success, so we balance with dlclose immediately. Never call PlatformFree on
// anything other than handles we explicitly loaded.
private static bool PlatformIsHandleStillLoaded(string path, IntPtr expected)
{
if (expected == IntPtr.Zero) return false;
dlerror();
IntPtr probe = dlopen(path, RTLD_NOW | RTLD_NOLOAD);
if (probe == IntPtr.Zero) return false;
bool matches = probe == expected;
dlclose(probe);
return matches;
}
#endif
// ---- Event handlers ---------------------------------------------- //
static NativeDllLoader()
{
#if UNITY_2020_1_OR_NEWER
UnityEditor.PackageManager.Events.registeringPackages -= OnPackagesRegistering;
UnityEditor.PackageManager.Events.registeringPackages += OnPackagesRegistering;
#endif
EditorApplication.quitting -= OnEditorQuitting;
EditorApplication.quitting += OnEditorQuitting;
AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload;
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
}
private static void OnEditorQuitting() => Unload("unity_quit");
private static void OnBeforeAssemblyReload()
{
UnregisterLogCallback();
}
#if UNITY_2020_1_OR_NEWER
private static void OnPackagesRegistering(UnityEditor.PackageManager.PackageRegistrationEventArgs args)
{
// changedFrom / changedTo are paired. Restart only when this
// package's native plugin bytes actually change. Removal is
// args.removed only.
UnityEditor.PackageManager.PackageInfo current;
try { current = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(NativeDllLoader).Assembly); }
catch { return; }
string ourName = current?.name;
if (string.IsNullOrEmpty(ourName)) return;
int changeCount = Math.Min(args.changedFrom.Count, args.changedTo.Count);
for (int i = 0; i < changeCount; i++)
{
var from = args.changedFrom[i];
var to = args.changedTo[i];
if (from.name != ourName || to.name != ourName) continue;
if (BridgeUpdateRestartPrompt.IsNativeDllContentChanged(
PluginPathUnder(from.resolvedPath),
PluginPathUnder(to.resolvedPath)))
{
BridgeUpdateRestartPrompt.MarkUpdated();
Unload("package_updating");
}
return;
}
bool added = false;
foreach (var package in args.added)
{
if (package.name != ourName) continue;
added = true;
break;
}
if (added) return;
foreach (var package in args.removed)
{
if (package.name != ourName) continue;
Unload("package_removed");
return;
}
}
#endif
// ---- State ----------------------------------------------------- //
private const string HandleKey = "NativeDllLoader._handle";
private static IntPtr _handle = IntPtr.Zero;
internal static bool IsLoaded => _handle != IntPtr.Zero;
/// <summary>
/// Fired just before the native library is unloaded.
/// Subscribers (e.g. UnityTcpBridge) should call their own Stop() here
/// so higher-level teardown happens before the handle is freed.
/// The argument is why the unload is happening (e.g. "unity_quit",
/// "package_updating"), destined for the heartbeat file's "reason"
/// field; null means no specific reason (generic "stopped").
/// </summary>
internal static event Action<string> OnUnload;
// ---- Log callback ---------------------------------------------- //
// Native setter — resolved from "Log_SetCallback" and held here so callers can reference NativeDllLoader.SetLogCallback directly.
internal delegate void SetLogCallbackDelegate(IntPtr callback);
internal static SetLogCallbackDelegate SetLogCallback;
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void NativeLogCallbackDelegate(int level, [MarshalAs(UnmanagedType.LPStr)] string message);
// Kept as a field to prevent GC collection while native holds the pointer.
private static NativeLogCallbackDelegate _logCallback;
private static void RegisterLogCallback()
{
if (!IsLoaded) return;
try
{
_logCallback = (level, message) =>
{
switch (level)
{
case 0: CodelyLogger.Log($"[NativeTcpBridge] {message}"); break;
case 1: CodelyLogger.LogWarning($"[NativeTcpBridge] {message}"); break;
default: CodelyLogger.LogError($"[NativeTcpBridge] {message}"); break;
}
};
IntPtr ptr = Marshal.GetFunctionPointerForDelegate(_logCallback);
SetLogCallback?.Invoke(ptr);
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"NativeDllLoader: RegisterLogCallback failed: {ex.Message}");
}
}
private static void UnregisterLogCallback()
{
if (!IsLoaded) return;
try { SetLogCallback?.Invoke(IntPtr.Zero); } catch { }
_logCallback = null;
}
// ---- Public API ------------------------------------------------ //
internal static bool Load()
{
if (_handle != IntPtr.Zero) return true;
string dllPath = FindDllPath();
if (string.IsNullOrEmpty(dllPath)) return false;
// On domain reload the DLL stays loaded in the process. Recover the handle
// from SessionState so we can re-resolve delegates without reloading the binary.
// Validate via a path-based probe first — a package update or other unload may
// have invalidated the handle, and calling FreeLibrary / dlclose on a freed
// handle is undefined behavior.
string stored = SessionState.GetString(HandleKey, "0");
if (long.TryParse(stored, out long handleValue) && handleValue != 0)
{
var storedHandle = new IntPtr(handleValue);
if (PlatformIsHandleStillLoaded(dllPath, storedHandle))
{
_handle = storedHandle;
if (ResolveAll())
{
CodelyLogger.Log("NativeDllLoader: resolved all symbols with stored handle");
RegisterLogCallback();
BridgeUpdateRestartPrompt.RememberNativeDllFromFile(dllPath);
return true;
}
// Handle is valid but symbols are missing (rare ABI mismatch). The handle
// really refers to our module, so freeing it here is safe.
PlatformFree(_handle);
_handle = IntPtr.Zero;
}
else
{
CodelyLogger.Log("NativeDllLoader: stored native handle is no longer loaded; reloading from disk");
}
SessionState.EraseString(HandleKey);
}
bool isLoaded = LoadFrom(dllPath);
if(isLoaded) {
RegisterLogCallback();
BridgeUpdateRestartPrompt.RememberNativeDllFromFile(dllPath);
return true;
}
return false;
}
internal static bool Reload()
{
Unload();
return Load();
}
internal static void Unload(string reason = null)
{
if (_handle == IntPtr.Zero) return;
OnUnload?.Invoke(reason);
UnregisterLogCallback();
PlatformFree(_handle);
_handle = IntPtr.Zero;
NativeUnityTcpBridgeAPI.NTB_GetAbiVersion = null;
NativeUnityTcpBridgeAPI.NTB_StartBridge = null;
NativeUnityTcpBridgeAPI.NTB_Stop = null;
NativeUnityTcpBridgeAPI.NTB_StopWithReason = null;
NativeUnityTcpBridgeAPI.NTB_IsRunning = null;
NativeUnityTcpBridgeAPI.NTB_GetBoundPort = null;
NativeUnityTcpBridgeAPI.NTB_GetConnectionCount = null;
NativeUnityTcpBridgeAPI.NTB_TryDequeueCommand = null;
NativeUnityTcpBridgeAPI.NTB_TryBeginCommand = null;
NativeUnityTcpBridgeAPI.NTB_EnqueueResponse = null;
NativeUnityTcpBridgeAPI.NTB_GetClientsJson = null;
NativeUnityTcpBridgeAPI.NTB_PauseManagedCommandProcessing = null;
NativeUnityTcpBridgeAPI.NTB_ResumeManagedCommandProcessing = null;
NativeUnityTcpBridgeAPI.NTB_NotifyAll = null;
NativeUnityTcpBridgeAPI.NTB_SetStreamPort = null;
SetLogCallback = null;
SessionState.EraseString(HandleKey);
}
// ---- Private helpers ------------------------------------------- //
private static bool LoadFrom(string dllPath)
{
_handle = PlatformLoad(dllPath);
if (_handle == IntPtr.Zero)
{
CodelyLogger.LogWarning($"NativeDllLoader: failed to load '{dllPath}'.");
return false;
}
if (!ResolveAll())
{
PlatformFree(_handle);
_handle = IntPtr.Zero;
return false;
}
SessionState.SetString(HandleKey, _handle.ToInt64().ToString());
CodelyLogger.Log("NativeDllLoader: loaded DLL and resolved all symbols");
return true;
}
// The ABI handshake must come first: it is the only symbol whose
// signature is guaranteed to match across versions, so it has to be
// checked before any other symbol is *called*. (Resolving the rest is
// harmless on its own — GetDelegateForFunctionPointer does not call
// into native — but a mismatched call would read arguments that were
// never passed.) Failing here is the designed outcome on a version
// skew, not an error path worth recovering from.
private static bool ResolveAll()
{
if (!CheckAbiVersion()) return false;
return
Resolve("NTB_StartBridge", ref NativeUnityTcpBridgeAPI.NTB_StartBridge) &&
Resolve("NTB_StopWithReason", ref NativeUnityTcpBridgeAPI.NTB_StopWithReason) &&
Resolve("NTB_Stop", ref NativeUnityTcpBridgeAPI.NTB_Stop) &&
Resolve("NTB_IsRunning", ref NativeUnityTcpBridgeAPI.NTB_IsRunning) &&
Resolve("NTB_GetBoundPort", ref NativeUnityTcpBridgeAPI.NTB_GetBoundPort) &&
Resolve("NTB_GetConnectionCount", ref NativeUnityTcpBridgeAPI.NTB_GetConnectionCount) &&
Resolve("NTB_TryDequeueCommand", ref NativeUnityTcpBridgeAPI.NTB_TryDequeueCommand) &&
Resolve("NTB_TryBeginCommand", ref NativeUnityTcpBridgeAPI.NTB_TryBeginCommand) &&
Resolve("NTB_EnqueueResponse", ref NativeUnityTcpBridgeAPI.NTB_EnqueueResponse) &&
Resolve("NTB_GetClientsJson", ref NativeUnityTcpBridgeAPI.NTB_GetClientsJson) &&
Resolve("NTB_PauseManagedCommandProcessing", ref NativeUnityTcpBridgeAPI.NTB_PauseManagedCommandProcessing) &&
Resolve("NTB_ResumeManagedCommandProcessing", ref NativeUnityTcpBridgeAPI.NTB_ResumeManagedCommandProcessing) &&
Resolve("NTB_NotifyAll", ref NativeUnityTcpBridgeAPI.NTB_NotifyAll) &&
Resolve("NTB_SetStreamPort", ref NativeUnityTcpBridgeAPI.NTB_SetStreamPort) &&
Resolve("Log_SetCallback", ref SetLogCallback);
}
private static bool CheckAbiVersion()
{
if (!Resolve("NTB_GetAbiVersion", ref NativeUnityTcpBridgeAPI.NTB_GetAbiVersion))
{
CodelyLogger.LogWarning(
"NativeDllLoader: native plugin predates ABI versioning (no NTB_GetAbiVersion); refusing to bind.");
return false;
}
int nativeAbi;
try
{
nativeAbi = NativeUnityTcpBridgeAPI.NTB_GetAbiVersion();
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"NativeDllLoader: ABI version probe failed: {ex.Message}");
return false;
}
if (nativeAbi != NativeUnityTcpBridgeAPI.ExpectedAbiVersion)
{
CodelyLogger.LogWarning(
$"NativeDllLoader: native ABI {nativeAbi} does not match the expected {NativeUnityTcpBridgeAPI.ExpectedAbiVersion}; " +
"refusing to bind. If this appears during startup right after a package update, it is expected and harmless: " +
"Unity is running a previously compiled assembly against the updated plugin, and the bridge will start normally " +
"once script compilation finishes and the domain reloads.");
return false;
}
return true;
}
private static bool Resolve<T>(string symbol, ref T del) where T : Delegate
{
IntPtr ptr = PlatformGetProc(_handle, symbol);
if (ptr == IntPtr.Zero) return false;
del = Marshal.GetDelegateForFunctionPointer<T>(ptr);
return true;
}
private static string PluginPathUnder(string packageRoot)
{
if (string.IsNullOrEmpty(packageRoot)) return null;
#if UNITY_EDITOR_WIN
string relative = Path.Combine("Plugins", "Win", "NativeTcpBridge.dll");
#elif UNITY_EDITOR_OSX
string relative = Path.Combine("Plugins", "macOS", "libNativeTcpBridge.dylib");
#else
string relative = Path.Combine("Plugins", "Linux", "x86_64", "libNativeTcpBridge.so");
#endif
return Path.GetFullPath(Path.Combine(packageRoot, relative));
}
private static string FindDllPath()
{
try
{
var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(NativeDllLoader).Assembly);
if (packageInfo == null)
{
CodelyLogger.LogWarning("NativeDllLoader: could not locate package via PackageInfo.");
return null;
}
string fullPath = PluginPathUnder(packageInfo.resolvedPath);
if (!string.IsNullOrEmpty(fullPath) && File.Exists(fullPath)) return fullPath;
CodelyLogger.LogWarning($"NativeDllLoader: DLL not found at '{fullPath}'.");
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"NativeDllLoader: path lookup failed. {ex.Message}");
}
return null;
}
#else
// ---- Unsupported platform stubs -------------------------------- //
internal static bool IsLoaded => false;
internal static bool Load() => false;
internal static bool Reload() => false;
internal static void Unload(string reason = null) { }
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d4dbfef085167ee4a847caa783ef5e58
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,81 @@
using System;
namespace UnityTcp.Editor.Native
{
// P/Invoke surface for the unified NativeTcpBridge (NTB_*) C ABI.
// One TCP listener serves both inbound commands and outbound notifications.
internal static class NativeUnityTcpBridgeAPI
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
// Must equal NTB_ABI_VERSION in NativeTcpBridge.h. NativeDllLoader
// reads the native value before calling anything else and refuses to
// bind on a mismatch — see the ABI stability note in that header.
//
// The mismatch is not hypothetical bookkeeping: Unity 2019 runs the
// previously compiled assembly once against a freshly updated native
// plugin before it recompiles, so this exact check is what stands
// between a version skew and a call with the wrong argument count.
internal const int ExpectedAbiVersion = 2;
// FROZEN — never change this signature; it is the handshake that lets
// every other signature here change safely. See NativeTcpBridge.h.
internal delegate int GetAbiVersionDelegate();
// heartbeatFilePathUtf8Z/projectPathUtf8Z are UTF-8, null-terminated
// byte arrays (see NativeUnityTcpBridgeHost.Utf8Z) — not the raw .NET
// string marshaler, to match this file's existing byte[]-buffer
// convention. Idempotent: if the native singleton is already running
// (e.g. reattaching after a domain reload), returns the existing
// bound port without re-binding and without touching the heartbeat
// writer, whose lifetime matches the TCP listener's exactly.
//
// Named "StartBridge", not "Start": the native entry point was renamed
// when it gained the heartbeat parameters, so that assemblies compiled
// against the old 2-arg NTB_Start fail to resolve it and shut down
// cleanly instead of calling it with the wrong argument count. See the
// ABI stability note in NativeTcpBridge.h before renaming or changing
// the signature of anything here.
internal delegate int StartBridgeDelegate(int requestedPort, int maxFrameBytes, byte[] heartbeatFilePathUtf8Z, byte[] projectPathUtf8Z, int heartbeatIntervalMs, int streamPort);
internal delegate void StopDelegate();
// Added after ABI v1 shipped, deliberately without a version bump:
// additive entry points can't cause the argument-count hazard the
// guard exists for (a stale assembly never resolves a name it wasn't
// compiled with). Resolved as optional — see ResolveAll — so callers
// must null-check and fall back to NTB_Stop, though with managed and
// native always shipping together it is only ever null on a dev-time
// skew. reasonUtf8Z is UTF-8, null-terminated (see
// NativeUnityTcpBridgeHost.Utf8Z); it lands in the heartbeat file's
// "reason" field while stopped.
internal delegate void StopWithReasonDelegate(byte[] reasonUtf8Z);
internal delegate int IsRunningDelegate();
internal delegate int GetBoundPortDelegate();
internal delegate int GetConnectionCountDelegate();
internal delegate int TryDequeueCommandDelegate(out ulong outRequestId, byte[] buffer, int bufferCapacity, out int outPayloadBytes);
internal delegate int TryBeginCommandDelegate(ulong requestId);
internal delegate int EnqueueResponseDelegate(ulong requestId, byte[] payload, int payloadBytes);
internal delegate int GetClientsJsonDelegate(byte[] buffer, int bufferCapacity, out int outBytes);
internal delegate void PauseManagedCommandProcessingDelegate();
internal delegate void ResumeManagedCommandProcessingDelegate();
internal delegate int NotifyAllDelegate(byte[] payload, int payloadBytes);
// The only piece of native heartbeat state meant to change after
// NTB_StartBridge — safe to call at any time.
internal delegate void SetStreamPortDelegate(int streamPort);
internal static GetAbiVersionDelegate NTB_GetAbiVersion;
internal static StartBridgeDelegate NTB_StartBridge;
internal static StopDelegate NTB_Stop;
internal static StopWithReasonDelegate NTB_StopWithReason;
internal static IsRunningDelegate NTB_IsRunning;
internal static GetBoundPortDelegate NTB_GetBoundPort;
internal static GetConnectionCountDelegate NTB_GetConnectionCount;
internal static TryDequeueCommandDelegate NTB_TryDequeueCommand;
internal static TryBeginCommandDelegate NTB_TryBeginCommand;
internal static EnqueueResponseDelegate NTB_EnqueueResponse;
internal static GetClientsJsonDelegate NTB_GetClientsJson;
internal static PauseManagedCommandProcessingDelegate NTB_PauseManagedCommandProcessing;
internal static ResumeManagedCommandProcessingDelegate NTB_ResumeManagedCommandProcessing;
internal static NotifyAllDelegate NTB_NotifyAll;
internal static SetStreamPortDelegate NTB_SetStreamPort;
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c267a73ff2c54b93b0242fa0c945f713
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,392 @@
using System;
using System.Collections.Generic;
using System.Text;
using Codely.Newtonsoft.Json;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Native
{
// Managed wrapper around the unified NativeTcpBridge (NTB_*) C ABI.
// One TCP listener serves both inbound commands and outbound notifications;
// clients that advertise CLIENT_VERSION>=2 are eligible for notifications.
internal static class NativeUnityTcpBridgeHost
{
private const int InboundBufferSize = 8 * 1024 * 1024;
private const int ClientSnapshotBufferSize = 512 * 1024;
private static readonly byte[] InboundBuffer = new byte[InboundBufferSize];
private static readonly byte[] ClientSnapshotBuffer = new byte[ClientSnapshotBufferSize];
private sealed class ClientSnapshot
{
public List<ClientSnapshotEntry> clients { get; set; }
}
private sealed class ClientSnapshotEntry
{
public ulong id { get; set; }
public string endpoint { get; set; }
public string platform { get; set; }
public int version { get; set; }
}
// ---- Start / Stop ---------------------------------------------- //
// heartbeatFilePath/projectPath configure the handshake file written
// on a fresh bind (see NTB_StartBridge's doc comment). These only
// take effect on a true fresh start, not a reattach.
// heartbeatIntervalMs is accepted for ABI compatibility and ignored.
public static bool StartOrAttach(
int requestedPort, int maxFrameBytes,
string heartbeatFilePath, string projectPath, int heartbeatIntervalMs, int streamPort,
out int boundPort)
{
boundPort = 0;
if (!NativeDllLoader.IsLoaded && !NativeDllLoader.Load())
{
CodelyLogger.LogError("NativeTcpBridge: failed to load native library. Ensure the plugin is installed correctly.");
return false;
}
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
// After a domain reload the managed side restarts but the native server
// remains bound to its existing port. Probe first so we attach to the
// existing listener instead of relying solely on NTB_StartBridge's idempotency.
if (IsRunning())
{
int existingPort = GetBoundPort();
if (existingPort > 0)
{
boundPort = existingPort;
return true;
}
}
int startResult = NativeUnityTcpBridgeAPI.NTB_StartBridge(
requestedPort, maxFrameBytes,
Utf8Z(heartbeatFilePath), Utf8Z(projectPath), heartbeatIntervalMs, streamPort);
if (startResult <= 0) return false;
boundPort = startResult;
return true;
#else
return false;
#endif
}
catch (Exception ex)
{
CodelyLogger.LogError($"NativeTcpBridge start failed: {ex.Message}");
return false;
}
}
// reason, when given, ends up in the heartbeat file's "reason" field
// for as long as the bridge stays stopped (e.g. "manually_stopped",
// "unity_quit", "package_updating"), so clients can tell a deliberate
// shutdown from an unexpected one. Null keeps the generic "stopped".
public static void StopServer(string reason = null)
{
if (!NativeDllLoader.IsLoaded) return;
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
// NTB_StopWithReason is optional (older DLLs predate it and
// ship the same ABI version) — fall back to plain NTB_Stop.
if (!string.IsNullOrEmpty(reason) && NativeUnityTcpBridgeAPI.NTB_StopWithReason != null)
NativeUnityTcpBridgeAPI.NTB_StopWithReason(Utf8Z(reason));
else
NativeUnityTcpBridgeAPI.NTB_Stop();
#endif
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"NativeTcpBridge stop failed: {ex.Message}");
}
}
// ---- Query methods --------------------------------------------- //
public static bool IsRunning()
{
if (!NativeDllLoader.IsLoaded)
{
return false;
}
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return NativeUnityTcpBridgeAPI.NTB_IsRunning() == 1;
#else
return false;
#endif
}
catch { return false; }
}
public static int GetBoundPort()
{
if (!NativeDllLoader.IsLoaded)
{
CodelyLogger.LogError("NativeTcpBridge is not started. Call Start Bridge first.");
return 0;
}
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return NativeUnityTcpBridgeAPI.NTB_GetBoundPort?.Invoke() ?? 0;
#else
return 0;
#endif
}
catch { return 0; }
}
public static int GetConnectionCount()
{
if (!NativeDllLoader.IsLoaded)
{
CodelyLogger.LogError("NativeTcpBridge is not started. Call Start Bridge first.");
return 0;
}
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return Math.Max(0, NativeUnityTcpBridgeAPI.NTB_GetConnectionCount());
#else
return 0;
#endif
}
catch { return 0; }
}
// ---- Command queue --------------------------------------------- //
public static bool TryDequeueCommand(out ulong requestId, out string commandText)
{
requestId = 0;
commandText = null;
if (!NativeDllLoader.IsLoaded)
{
CodelyLogger.LogError("NativeTcpBridge is not started. Call Start Bridge first.");
return false;
}
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
if (NativeUnityTcpBridgeAPI.NTB_TryDequeueCommand(
out requestId,
InboundBuffer,
InboundBuffer.Length,
out int payloadBytes) != 1)
{
return false;
}
if (payloadBytes <= 0 || payloadBytes > InboundBuffer.Length) return false;
commandText = Encoding.UTF8.GetString(InboundBuffer, 0, payloadBytes);
return !string.IsNullOrEmpty(commandText);
#else
return false;
#endif
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"NativeTcpBridge dequeue failed: {ex.Message}");
return false;
}
}
public static bool TryBeginCommand(ulong requestId)
{
if (!NativeDllLoader.IsLoaded || requestId == 0) return false;
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return NativeUnityTcpBridgeAPI.NTB_TryBeginCommand(requestId) == 1;
#else
return false;
#endif
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"NativeTcpBridge begin command failed: {ex.Message}");
return false;
}
}
public static bool EnqueueResponse(ulong requestId, string responseJson)
{
if (!NativeDllLoader.IsLoaded)
{
CodelyLogger.LogError("NativeTcpBridge is not started. Call Start Bridge first.");
return false;
}
if (requestId == 0 || string.IsNullOrEmpty(responseJson)) return false;
try
{
byte[] payload = Encoding.UTF8.GetBytes(responseJson);
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return NativeUnityTcpBridgeAPI.NTB_EnqueueResponse(requestId, payload, payload.Length) == 1;
#else
return false;
#endif
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"NativeTcpBridge enqueue response failed: {ex.Message}");
return false;
}
}
// ---- Managed command lifecycle -------------------------------- //
public static void PauseManagedCommandProcessing()
{
if (!NativeDllLoader.IsLoaded)
{
CodelyLogger.LogError("NativeTcpBridge is not started. Call Start Bridge first.");
return;
}
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
NativeUnityTcpBridgeAPI.NTB_PauseManagedCommandProcessing?.Invoke();
#endif
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"NativeTcpBridge PauseManagedCommandProcessing failed: {ex.Message}");
}
}
public static void ResumeManagedCommandProcessing()
{
if (!NativeDllLoader.IsLoaded)
{
CodelyLogger.LogError("NativeTcpBridge is not started. Call Start Bridge first.");
return;
}
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
NativeUnityTcpBridgeAPI.NTB_ResumeManagedCommandProcessing?.Invoke();
#endif
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"NativeTcpBridge ResumeManagedCommandProcessing failed: {ex.Message}");
}
}
// ---- Notify ---------------------------------------------------- //
public static bool NotifyAll(string payloadJson)
{
if (!NativeDllLoader.IsLoaded)
{
CodelyLogger.LogError("NativeTcpBridge is not started.");
return false;
}
if (string.IsNullOrEmpty(payloadJson)) return false;
try
{
byte[] bytes = Encoding.UTF8.GetBytes(payloadJson);
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return NativeUnityTcpBridgeAPI.NTB_NotifyAll(bytes, bytes.Length) == 1;
#else
return false;
#endif
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"NativeTcpBridge NotifyAll failed: {ex.Message}");
return false;
}
}
// ---- Connected clients ----------------------------------------- //
public static Dictionary<string, string> GetConnectedClients()
{
var result = new Dictionary<string, string>();
if (!NativeDllLoader.IsLoaded)
{
CodelyLogger.LogError("NativeTcpBridge is not started. Call Start Bridge first.");
return result;
}
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
if (NativeUnityTcpBridgeAPI.NTB_GetClientsJson(
ClientSnapshotBuffer,
ClientSnapshotBuffer.Length,
out int bytes) != 1)
{
return result;
}
if (bytes <= 0 || bytes > ClientSnapshotBuffer.Length) return result;
string json = Encoding.UTF8.GetString(ClientSnapshotBuffer, 0, bytes);
var snapshot = JsonConvert.DeserializeObject<ClientSnapshot>(json);
if (snapshot?.clients == null) return result;
foreach (var client in snapshot.clients)
{
if (string.IsNullOrEmpty(client?.endpoint)) continue;
result[client.endpoint] = string.IsNullOrEmpty(client.platform) ? "unknown" : client.platform;
}
#endif
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"NativeTcpBridge get clients failed: {ex.Message}");
}
return result;
}
// ---- Heartbeat --------------------------------------------------- //
// Forwards the NWB (WebRTC/stream) signaling port to native. Kept
// for ABI compatibility; the handshake file no longer surfaces
// stream_port.
public static void SetStreamPort(int streamPort)
{
if (!NativeDllLoader.IsLoaded) return;
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
NativeUnityTcpBridgeAPI.NTB_SetStreamPort?.Invoke(streamPort);
#endif
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"NativeTcpBridge SetStreamPort failed: {ex.Message}");
}
}
// UTF-8, null-terminated — matches the byte[] convention this P/Invoke
// surface uses elsewhere, rather than relying on string marshaling
// attributes that vary across Mono/IL2CPP versions.
private static byte[] Utf8Z(string s) => Encoding.UTF8.GetBytes((s ?? string.Empty) + "\0");
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5d7d1713daea4bae8ac66a966cea6298
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,283 @@
using System;
using System.Runtime.InteropServices;
namespace UnityTcp.Editor.Native
{
/// <summary>
/// Low-level P/Invoke declarations for the NativeWindowBridge streaming plugin.
/// Mirrors NativeWindowBridge.h C ABI exactly.
/// </summary>
internal static class NativeWindowBridgeAPI
{
// Keep the delegate type available cross-platform so managed references compile.
// On unsupported platforms, NWB_SetLogCallback is a no-op and never reaches native code.
internal delegate void NWB_LogCallbackDelegate(int level, string message);
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
private const string DllName = "NativeWindowBridge";
[DllImport(DllName, EntryPoint = "NWB_StartServer")]
private static extern int NWB_StartServer_Native(int httpPort);
[DllImport(DllName, EntryPoint = "NWB_StopServer")]
private static extern void NWB_StopServer_Native();
[DllImport(DllName, EntryPoint = "NWB_IsRunning")]
private static extern int NWB_IsRunning_Native();
[DllImport(DllName, EntryPoint = "NWB_GetBoundPort")]
private static extern int NWB_GetBoundPort_Native();
[DllImport(DllName, EntryPoint = "NWB_UpdateWindowList", CharSet = CharSet.Ansi)]
private static extern int NWB_UpdateWindowList_Native(string windowsJson);
[DllImport(DllName, EntryPoint = "NWB_SetUnityPid")]
private static extern void NWB_SetUnityPid_Native(int pid);
[DllImport(DllName, EntryPoint = "NWB_GetWindowListJson")]
private static extern int NWB_GetWindowListJson_Native(byte[] buffer, int bufferCapacity, out int outBytes);
[DllImport(DllName, EntryPoint = "NWB_GetStreamStatusJson")]
private static extern int NWB_GetStreamStatusJson_Native(byte[] buffer, int bufferCapacity, out int outBytes);
// Offscreen rendering mode: C# pushes frames and polls input events.
[DllImport(DllName, EntryPoint = "NWB_StartOffscreenCapture")]
private static extern int NWB_StartOffscreenCapture_Native(int fps, int width, int height);
/// <summary>Stops offscreen/OS capture on the native side (resets m_OffscreenMode).</summary>
[DllImport(DllName, EntryPoint = "NWB_StopCapture")]
private static extern void NWB_StopCapture_Native();
[DllImport(DllName, EntryPoint = "NWB_PushFrame")]
private static extern int NWB_PushFrame_Native(IntPtr bgraData, int width, int height, int stride);
[DllImport(DllName, EntryPoint = "NWB_GetPendingInput")]
private static extern int NWB_GetPendingInput_Native(byte[] buffer, int bufferSize);
[DllImport(DllName, EntryPoint = "NWB_GetPendingOffscreenRequest")]
private static extern int NWB_GetPendingOffscreenRequest_Native(
byte[] windowTypeBuf, int bufSize,
out int outFps, out int outWidth, out int outHeight, out float outDpr);
[DllImport(DllName, EntryPoint = "NWB_GetPendingCompositeRequest")]
private static extern int NWB_GetPendingCompositeRequest_Native(
byte[] jsonBuf, int bufSize,
out int outFps, out int outWidth, out int outHeight, out float outDpr);
[DllImport(DllName, EntryPoint = "NWB_IsOffscreenActive")]
private static extern int NWB_IsOffscreenActive_Native();
// Check for pending resize request from frontend (via /stream/resize).
// Returns 1 if pending (and clears it), 0 otherwise.
[DllImport(DllName, EntryPoint = "NWB_GetPendingResize")]
private static extern int NWB_GetPendingResize_Native(out int outWidth, out int outHeight, out float outDpr);
// Query the active offscreen capture window type (survives domain reload).
// Returns bytes written (excluding null), or 0 if no active offscreen.
[DllImport(DllName, EntryPoint = "NWB_GetActiveOffscreenWindowType")]
private static extern int NWB_GetActiveOffscreenWindowType_Native(byte[] buffer, int bufferSize);
// Send a UTF-8 JSON message to the browser via the WebRTC DataChannel.
[DllImport(DllName, EntryPoint = "NWB_SendDataChannelMessage")]
private static extern int NWB_SendDataChannelMessage_Native(byte[] jsonUtf8, int length);
// Seconds since the last browser heartbeat arrived via DataChannel.
// Tracked in C++ (WebRTC thread), accurate even when C# main thread
// is blocked. Returns -1 if no heartbeat has been received yet.
[DllImport(DllName, EntryPoint = "NWB_GetSecondsSinceLastHeartbeat")]
private static extern float NWB_GetSecondsSinceLastHeartbeat_Native();
[DllImport(DllName, EntryPoint = "NWB_SetLogCallback")]
private static extern void NWB_SetLogCallback_Native(NWB_LogCallbackDelegate callback);
#endif
// Public managed surface. On unsupported platforms this is intentionally
// implemented as no-op/default so shared code compiles and does not call native.
internal static int NWB_StartServer(int httpPort)
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_StartServer_Native(httpPort);
#else
return 0;
#endif
}
internal static void NWB_StopServer()
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
NWB_StopServer_Native();
#endif
}
internal static int NWB_IsRunning()
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_IsRunning_Native();
#else
return 0;
#endif
}
internal static int NWB_GetBoundPort()
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_GetBoundPort_Native();
#else
return 0;
#endif
}
internal static int NWB_UpdateWindowList(string windowsJson)
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_UpdateWindowList_Native(windowsJson);
#else
return 0;
#endif
}
internal static void NWB_SetUnityPid(int pid)
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
NWB_SetUnityPid_Native(pid);
#endif
}
internal static int NWB_GetWindowListJson(byte[] buffer, int bufferCapacity, out int outBytes)
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_GetWindowListJson_Native(buffer, bufferCapacity, out outBytes);
#else
outBytes = 0;
return 0;
#endif
}
internal static int NWB_GetStreamStatusJson(byte[] buffer, int bufferCapacity, out int outBytes)
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_GetStreamStatusJson_Native(buffer, bufferCapacity, out outBytes);
#else
outBytes = 0;
return 0;
#endif
}
internal static int NWB_StartOffscreenCapture(int fps, int width, int height)
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_StartOffscreenCapture_Native(fps, width, height);
#else
return 0;
#endif
}
internal static void NWB_StopCapture()
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
NWB_StopCapture_Native();
#endif
}
internal static int NWB_PushFrame(IntPtr bgraData, int width, int height, int stride)
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_PushFrame_Native(bgraData, width, height, stride);
#else
return 0;
#endif
}
internal static int NWB_GetPendingInput(byte[] buffer, int bufferSize)
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_GetPendingInput_Native(buffer, bufferSize);
#else
return 0;
#endif
}
internal static int NWB_GetPendingOffscreenRequest(
byte[] windowTypeBuf, int bufSize, out int outFps, out int outWidth, out int outHeight, out float outDpr)
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_GetPendingOffscreenRequest_Native(windowTypeBuf, bufSize, out outFps, out outWidth, out outHeight, out outDpr);
#else
outFps = 0;
outWidth = 0;
outHeight = 0;
outDpr = 0f;
return 0;
#endif
}
internal static int NWB_GetPendingCompositeRequest(
byte[] jsonBuf, int bufSize, out int outFps, out int outWidth, out int outHeight, out float outDpr)
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_GetPendingCompositeRequest_Native(jsonBuf, bufSize, out outFps, out outWidth, out outHeight, out outDpr);
#else
outFps = 0;
outWidth = 0;
outHeight = 0;
outDpr = 0f;
return 0;
#endif
}
internal static int NWB_IsOffscreenActive()
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_IsOffscreenActive_Native();
#else
return 0;
#endif
}
internal static int NWB_GetPendingResize(out int outWidth, out int outHeight, out float outDpr)
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_GetPendingResize_Native(out outWidth, out outHeight, out outDpr);
#else
outWidth = 0;
outHeight = 0;
outDpr = 0f;
return 0;
#endif
}
internal static int NWB_GetActiveOffscreenWindowType(byte[] buffer, int bufferSize)
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_GetActiveOffscreenWindowType_Native(buffer, bufferSize);
#else
return 0;
#endif
}
internal static int NWB_SendDataChannelMessage(byte[] jsonUtf8, int length)
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_SendDataChannelMessage_Native(jsonUtf8, length);
#else
return 0;
#endif
}
internal static float NWB_GetSecondsSinceLastHeartbeat()
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NWB_GetSecondsSinceLastHeartbeat_Native();
#else
return -1f;
#endif
}
internal static void NWB_SetLogCallback(NWB_LogCallbackDelegate callback)
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
NWB_SetLogCallback_Native(callback);
#endif
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cfd60c322495e4341b9d561a3d2f4ddd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,389 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Native
{
internal static partial class NativeWindowBridgeHost
{
private static GameObject[] s_CachedAddComponentTargets;
private static string s_AddComponentSearchFilter = "";
private static float s_AddComponentPopupWidth;
private static float s_AddComponentPopupHeight;
private static bool s_AddComponentReflectionDone;
private static Type s_AddComponentDataSourceType;
private static Type s_ComponentDropdownItemType;
private static Type s_AdvancedDropdownDataSourceType;
private static Type s_AdvancedDropdownItemType;
private static FieldInfo s_AddComponentWindowStateField;
private static PropertyInfo s_AdvancedDropdownMainTreeProp;
private static PropertyInfo s_AdvancedDropdownSearchTreeProp;
private static PropertyInfo s_AdvancedDropdownItemChildrenProp;
private static PropertyInfo s_AdvancedDropdownItemDisplayNameProp;
private static PropertyInfo s_AdvancedDropdownItemHasChildrenProp;
private static PropertyInfo s_ComponentDropdownMenuPathProp;
private static MethodInfo s_AdvancedDropdownReloadDataMethod;
private static MethodInfo s_AdvancedDropdownRebuildSearchMethod;
private static MethodInfo s_ExecuteMenuItemOnGameObjectsMethod;
private static void EnsureAddComponentReflection()
{
if (s_AddComponentReflectionDone) return;
s_AddComponentReflectionDone = true;
try
{
var editorAsm = typeof(EditorWindow).Assembly;
s_AddComponentDataSourceType = editorAsm.GetType("UnityEditor.AddComponent.AddComponentDataSource");
s_ComponentDropdownItemType = editorAsm.GetType("UnityEditor.AddComponent.ComponentDropdownItem");
s_AdvancedDropdownDataSourceType = editorAsm.GetType("UnityEditor.IMGUI.Controls.AdvancedDropdownDataSource");
s_AdvancedDropdownItemType = editorAsm.GetType("UnityEditor.IMGUI.Controls.AdvancedDropdownItem");
Type addComponentWindowType = editorAsm.GetType("UnityEditor.AddComponent.AddComponentWindow");
if (addComponentWindowType != null)
{
s_AddComponentWindowStateField = addComponentWindowType.GetField(
"s_State",
BindingFlags.Static | BindingFlags.NonPublic);
}
if (s_AdvancedDropdownDataSourceType != null)
{
s_AdvancedDropdownMainTreeProp = s_AdvancedDropdownDataSourceType.GetProperty(
"mainTree",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
s_AdvancedDropdownSearchTreeProp = s_AdvancedDropdownDataSourceType.GetProperty(
"searchTree",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
s_AdvancedDropdownReloadDataMethod = s_AdvancedDropdownDataSourceType.GetMethod(
"ReloadData",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
s_AdvancedDropdownRebuildSearchMethod = s_AdvancedDropdownDataSourceType.GetMethod(
"RebuildSearch",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
}
if (s_AdvancedDropdownItemType != null)
{
s_AdvancedDropdownItemChildrenProp = s_AdvancedDropdownItemType.GetProperty(
"children",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
s_AdvancedDropdownItemHasChildrenProp = s_AdvancedDropdownItemType.GetProperty(
"hasChildren",
BindingFlags.Instance | BindingFlags.NonPublic);
for (Type t = s_AdvancedDropdownItemType; t != null && s_AdvancedDropdownItemDisplayNameProp == null; t = t.BaseType)
{
s_AdvancedDropdownItemDisplayNameProp = t.GetProperty(
"displayName",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
}
}
if (s_ComponentDropdownItemType != null)
{
s_ComponentDropdownMenuPathProp = s_ComponentDropdownItemType.GetProperty(
"menuPath",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
}
s_ExecuteMenuItemOnGameObjectsMethod = typeof(EditorApplication).GetMethod(
"ExecuteMenuItemOnGameObjects",
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public,
null,
new[] { typeof(string), typeof(GameObject[]) },
null);
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-AddComponent] Reflection init error: {ex.Message}");
}
}
private static bool TryExtractAddComponentPopup(
EditorWindow hostedWindow, float relX, float relY, float popW, float popH)
{
EnsureAddComponentReflection();
if (hostedWindow == null) return false;
try
{
FieldInfo targetsField = hostedWindow.GetType().GetField(
"m_GameObjects",
BindingFlags.Instance | BindingFlags.NonPublic);
s_CachedAddComponentTargets = targetsField?.GetValue(hostedWindow) as GameObject[];
if (s_CachedAddComponentTargets == null || s_CachedAddComponentTargets.Length == 0)
{
LogVerbose("[NWB-AddComponent] No target GameObjects on AddComponentWindow");
return false;
}
PropertyInfo searchProp = hostedWindow.GetType().GetProperty(
"searchString",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (searchProp != null && string.IsNullOrEmpty(s_AddComponentSearchFilter))
{
s_AddComponentSearchFilter = searchProp.GetValue(hostedWindow) as string ?? "";
}
s_AddComponentPopupWidth = popW;
s_AddComponentPopupHeight = popH;
s_LastFrontendPopupX = relX;
s_LastFrontendPopupY = relY;
return TrySendAddComponentPopup(relX, relY, popW, popH);
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-AddComponent] Extract error: {ex.Message}\n{ex.StackTrace}");
return false;
}
}
private static bool TrySendAddComponentPopup(float relX, float relY, float popW, float popH)
{
EnsureAddComponentReflection();
if (s_CachedAddComponentTargets == null || s_CachedAddComponentTargets.Length == 0)
return false;
if (s_AddComponentDataSourceType == null ||
s_AddComponentWindowStateField == null ||
s_AdvancedDropdownMainTreeProp == null ||
s_AdvancedDropdownReloadDataMethod == null)
{
CodelyLogger.LogWarning("[NWB-AddComponent] Required reflection types not found");
return false;
}
try
{
object state = s_AddComponentWindowStateField.GetValue(null);
if (state == null)
state = Activator.CreateInstance(s_AddComponentWindowStateField.FieldType);
object dataSource = Activator.CreateInstance(
s_AddComponentDataSourceType,
state,
s_CachedAddComponentTargets);
s_AdvancedDropdownReloadDataMethod.Invoke(dataSource, null);
object mainTree = s_AdvancedDropdownMainTreeProp.GetValue(dataSource);
object treeToRender = mainTree;
if (!string.IsNullOrEmpty(s_AddComponentSearchFilter) &&
s_AdvancedDropdownRebuildSearchMethod != null &&
mainTree != null)
{
s_AdvancedDropdownRebuildSearchMethod.Invoke(
dataSource,
new object[] { s_AddComponentSearchFilter, mainTree });
object searchTree = s_AdvancedDropdownSearchTreeProp?.GetValue(dataSource);
if (searchTree != null)
treeToRender = searchTree;
}
if (treeToRender == null)
{
LogVerbose("[NWB-AddComponent] Component tree is null");
return false;
}
var pathList = new List<string>();
int itemIndex = 0;
int validCount = 0;
s_FrontendPopupIdCounter++;
s_CurrentFrontendPopupId = "popup_" + s_FrontendPopupIdCounter;
s_PopupSourceType = "add_component";
var sb = new StringBuilder(65536);
sb.Append("{\"type\":\"popup_show\",\"id\":\"");
sb.Append(s_CurrentFrontendPopupId);
sb.Append("\",\"sourceType\":\"add_component\",\"x\":");
sb.Append(Mathf.RoundToInt(relX));
sb.Append(",\"y\":");
sb.Append(Mathf.RoundToInt(relY));
sb.Append(",\"width\":");
sb.Append(Mathf.RoundToInt(Mathf.Max(popW, 200f)));
sb.Append(",\"height\":");
sb.Append(Mathf.RoundToInt(Mathf.Max(popH, 280f)));
sb.Append(",\"searchText\":\"");
sb.Append(EscapeJsonString(s_AddComponentSearchFilter ?? ""));
sb.Append("\",\"items\":[");
bool needComma = false;
AppendAddComponentDropdownItems(
sb,
GetAdvancedDropdownChildren(treeToRender),
ref itemIndex,
ref validCount,
ref needComma,
pathList);
sb.Append("]}");
if (validCount <= 0)
{
LogVerbose("[NWB-AddComponent] No selectable component items extracted");
return false;
}
s_CachedPopupItemPaths = pathList.ToArray();
string payload = sb.ToString();
int payloadBytes = Encoding.UTF8.GetByteCount(payload);
bool sent;
if (payloadBytes > kMaxDataChannelPayloadBytes)
{
sent = SendPopupPayloadChunked(s_CurrentFrontendPopupId, payload, payloadBytes);
if (sent)
LogVerbose($"[NWB-AddComponent] Sent chunked popup: bytes={payloadBytes} items={validCount}");
}
else
{
sent = SendDataChannelMessage(payload);
}
s_FrontendPopupSent = sent;
if (sent)
{
CancelNativeDelayedPopup();
LogVerbose($"[NWB-AddComponent] Sent popup: id={s_CurrentFrontendPopupId} items={validCount} search='{s_AddComponentSearchFilter}'");
}
else
{
CodelyLogger.LogWarning($"[NWB-AddComponent] Failed to send popup: id={s_CurrentFrontendPopupId}");
}
return sent;
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-AddComponent] Send error: {ex.Message}\n{ex.StackTrace}");
return false;
}
}
private static IEnumerable GetAdvancedDropdownChildren(object item)
{
if (item == null || s_AdvancedDropdownItemChildrenProp == null)
return new object[0];
return s_AdvancedDropdownItemChildrenProp.GetValue(item) as IEnumerable ?? new object[0];
}
private static void AppendAddComponentDropdownItems(
StringBuilder sb,
IEnumerable items,
ref int itemIndex,
ref int validCount,
ref bool needComma,
List<string> pathList)
{
if (items == null) return;
foreach (object item in items)
{
if (item == null) continue;
if (item.GetType().Name == "SeparatorDropdownItem")
continue;
string label = GetAdvancedDropdownDisplayName(item);
if (string.IsNullOrEmpty(label))
continue;
bool hasChildren = s_AdvancedDropdownItemHasChildrenProp != null &&
(bool)s_AdvancedDropdownItemHasChildrenProp.GetValue(item);
if (hasChildren)
{
if (needComma) sb.Append(',');
needComma = true;
sb.Append("{\"label\":\"");
sb.Append(EscapeJsonString(label));
sb.Append("\",\"enabled\":true,\"checked\":false,\"separator\":false,\"submenu\":true,\"children\":[");
bool childComma = false;
int childValid = 0;
AppendAddComponentDropdownItems(
sb,
GetAdvancedDropdownChildren(item),
ref itemIndex,
ref childValid,
ref childComma,
pathList);
sb.Append("]}");
if (childValid > 0)
validCount++;
continue;
}
if (s_ComponentDropdownItemType == null ||
!s_ComponentDropdownItemType.IsInstanceOfType(item) ||
s_ComponentDropdownMenuPathProp == null)
{
continue;
}
string menuPath = s_ComponentDropdownMenuPathProp.GetValue(item) as string;
if (string.IsNullOrEmpty(menuPath))
continue;
if (needComma) sb.Append(',');
needComma = true;
sb.Append("{\"index\":");
sb.Append(itemIndex);
sb.Append(",\"path\":\"");
sb.Append(EscapeJsonString(menuPath));
sb.Append("\",\"label\":\"");
sb.Append(EscapeJsonString(label));
sb.Append("\",\"enabled\":true,\"checked\":false,\"separator\":false}");
pathList.Add(menuPath);
itemIndex++;
validCount++;
}
}
private static string GetAdvancedDropdownDisplayName(object item)
{
if (item == null || s_AdvancedDropdownItemDisplayNameProp == null)
return "";
return s_AdvancedDropdownItemDisplayNameProp.GetValue(item) as string ?? "";
}
private static bool TryHandleAddComponentPopupSelect(string path)
{
if (string.IsNullOrEmpty(path) || s_CachedAddComponentTargets == null)
return false;
EnsureAddComponentReflection();
if (s_ExecuteMenuItemOnGameObjectsMethod == null)
{
CodelyLogger.LogWarning("[NWB-AddComponent] ExecuteMenuItemOnGameObjects not found");
return false;
}
try
{
bool ok = (bool)s_ExecuteMenuItemOnGameObjectsMethod.Invoke(
null,
new object[] { path, s_CachedAddComponentTargets });
LogVerbose($"[NWB-AddComponent] ExecuteMenuItemOnGameObjects path='{path}' ok={ok}");
return ok;
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-AddComponent] Execute error: {ex.Message}");
return false;
}
}
private static void ClearAddComponentPopupState()
{
s_CachedAddComponentTargets = null;
s_AddComponentSearchFilter = "";
s_AddComponentPopupWidth = 0f;
s_AddComponentPopupHeight = 0f;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8c743eceaf57cd840917d1baa74322d3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,659 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Native
{
internal static partial class NativeWindowBridgeHost
{
private enum AdvancedDropdownHandlerKind
{
StatelessIndex,
Multiselect,
MaterialShader,
}
private static object s_CachedAdvancedDropdownDataSource;
private static AdvancedDropdownHandlerKind s_AdvancedDropdownHandlerKind;
private static MaterialEditor s_CachedAdvancedDropdownMaterialEditor;
private static string s_AdvancedDropdownSearchFilter = "";
private static float s_AdvancedDropdownPopupWidth;
private static float s_AdvancedDropdownPopupHeight;
private static bool s_AdvancedDropdownWindowReflectionDone;
private static PropertyInfo s_AdvancedDropdownWindowDataSourceProp;
private static PropertyInfo s_AdvancedDropdownWindowSearchProp;
private static PropertyInfo s_AdvancedDropdownItemElementIndexProp;
private static PropertyInfo s_AdvancedDropdownItemIdProp;
private static PropertyInfo s_AdvancedDropdownDataSourceSelectedIdsProp;
private static PropertyInfo s_ShaderDropdownItemFullNameProp;
private static FieldInfo s_MaterialEditorsField;
private static FieldInfo s_MaterialEditorShaderField;
private static FieldInfo s_ShaderDropdownCurrentShaderField;
private static MethodInfo s_MaterialEditorOnSelectedShaderPopupMethod;
private static MethodInfo s_MultiselectUpdateSelectedIdMethod;
private static Type s_StatelessAdvancedDropdownType;
private static FieldInfo s_StatelessAdvancedDropdownResultField;
private static FieldInfo s_StatelessAdvancedDropdownWindowClosedField;
private static FieldInfo s_StatelessAdvancedDropdownShouldReturnValueField;
private static FieldInfo s_StatelessAdvancedDropdownParentWindowField;
private static void EnsureAdvancedDropdownWindowReflection()
{
if (s_AdvancedDropdownWindowReflectionDone) return;
s_AdvancedDropdownWindowReflectionDone = true;
try
{
EnsureAddComponentReflection();
var editorAsm = typeof(EditorWindow).Assembly;
Type advancedDropdownWindowType = editorAsm.GetType(
"UnityEditor.IMGUI.Controls.AdvancedDropdownWindow");
if (advancedDropdownWindowType != null)
{
s_AdvancedDropdownWindowDataSourceProp = advancedDropdownWindowType.GetProperty(
"dataSource",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
s_AdvancedDropdownWindowSearchProp = advancedDropdownWindowType.GetProperty(
"searchString",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
}
if (s_AdvancedDropdownItemType != null)
{
s_AdvancedDropdownItemElementIndexProp = s_AdvancedDropdownItemType.GetProperty(
"elementIndex",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
s_AdvancedDropdownItemIdProp = s_AdvancedDropdownItemType.GetProperty(
"id",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
}
if (s_AdvancedDropdownDataSourceType != null)
{
s_AdvancedDropdownDataSourceSelectedIdsProp = s_AdvancedDropdownDataSourceType.GetProperty(
"selectedIDs",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
}
s_StatelessAdvancedDropdownType = editorAsm.GetType("UnityEditor.StatelessAdvancedDropdown");
if (s_StatelessAdvancedDropdownType != null)
{
const BindingFlags staticFlags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public;
s_StatelessAdvancedDropdownResultField = s_StatelessAdvancedDropdownType.GetField("m_Result", staticFlags);
s_StatelessAdvancedDropdownWindowClosedField = s_StatelessAdvancedDropdownType.GetField("m_WindowClosed", staticFlags);
s_StatelessAdvancedDropdownShouldReturnValueField = s_StatelessAdvancedDropdownType.GetField("m_ShouldReturnValue", staticFlags);
s_StatelessAdvancedDropdownParentWindowField = s_StatelessAdvancedDropdownType.GetField("s_ParentWindow", staticFlags);
}
Type multiselectDataSourceType = editorAsm.GetType(
"UnityEditor.IMGUI.Controls.MultiselectDataSource");
if (multiselectDataSourceType != null)
{
s_MultiselectUpdateSelectedIdMethod = multiselectDataSourceType.GetMethod(
"UpdateSelectedId",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
}
foreach (Type nested in typeof(MaterialEditor).GetNestedTypes(
BindingFlags.NonPublic | BindingFlags.Public))
{
if (nested.Name == "ShaderDropdownItem")
{
s_ShaderDropdownItemFullNameProp = nested.GetProperty(
"fullName",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
break;
}
}
foreach (Type nested in typeof(MaterialEditor).GetNestedTypes(
BindingFlags.NonPublic | BindingFlags.Public))
{
if (nested.Name != "ShaderSelectionDropdown") continue;
foreach (Type inner in nested.GetNestedTypes(
BindingFlags.NonPublic | BindingFlags.Public))
{
if (inner.Name == "ShaderDropdownDataSource")
{
s_ShaderDropdownCurrentShaderField = inner.GetField(
"m_CurrentShader",
BindingFlags.Instance | BindingFlags.NonPublic);
break;
}
}
break;
}
s_MaterialEditorsField = typeof(MaterialEditor).GetField(
"s_MaterialEditors",
BindingFlags.Static | BindingFlags.NonPublic);
s_MaterialEditorShaderField = typeof(MaterialEditor).GetField(
"m_Shader",
BindingFlags.Instance | BindingFlags.NonPublic);
s_MaterialEditorOnSelectedShaderPopupMethod = typeof(MaterialEditor).GetMethod(
"OnSelectedShaderPopup",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-AdvancedDropdown] Reflection init error: {ex.Message}");
}
}
private static bool TryExtractAdvancedDropdownPopup(
EditorWindow hostedWindow, float relX, float relY, float popW, float popH)
{
EnsureAdvancedDropdownWindowReflection();
if (hostedWindow == null || s_AdvancedDropdownWindowDataSourceProp == null)
return false;
try
{
object dataSource = s_AdvancedDropdownWindowDataSourceProp.GetValue(hostedWindow);
if (dataSource == null)
return false;
string dataSourceName = dataSource.GetType().Name;
AdvancedDropdownHandlerKind kind;
switch (dataSourceName)
{
case "ShaderDropdownDataSource":
kind = AdvancedDropdownHandlerKind.MaterialShader;
s_CachedAdvancedDropdownMaterialEditor = FindMaterialEditorForShaderDropdown(dataSource);
if (s_CachedAdvancedDropdownMaterialEditor == null)
{
LogVerbose("[NWB-AdvancedDropdown] No MaterialEditor for shader dropdown");
return false;
}
break;
case "SimpleDataSource":
case "MultiLevelDataSource":
kind = AdvancedDropdownHandlerKind.StatelessIndex;
s_CachedAdvancedDropdownMaterialEditor = null;
break;
case "MultiselectDataSource":
kind = AdvancedDropdownHandlerKind.Multiselect;
s_CachedAdvancedDropdownMaterialEditor = null;
break;
default:
LogVerbose($"[NWB-AdvancedDropdown] Unsupported dataSource: {dataSourceName}");
return false;
}
s_CachedAdvancedDropdownDataSource = dataSource;
s_AdvancedDropdownHandlerKind = kind;
if (string.IsNullOrEmpty(s_AdvancedDropdownSearchFilter) &&
s_AdvancedDropdownWindowSearchProp != null)
{
s_AdvancedDropdownSearchFilter =
s_AdvancedDropdownWindowSearchProp.GetValue(hostedWindow) as string ?? "";
}
s_AdvancedDropdownPopupWidth = popW;
s_AdvancedDropdownPopupHeight = popH;
s_LastFrontendPopupX = relX;
s_LastFrontendPopupY = relY;
LogVerbose($"[NWB-AdvancedDropdown] Extracting {dataSourceName} ({kind})");
return TrySendAdvancedDropdownPopup(relX, relY, popW, popH);
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-AdvancedDropdown] Extract error: {ex.Message}\n{ex.StackTrace}");
return false;
}
}
private static MaterialEditor FindMaterialEditorForShaderDropdown(object dataSource)
{
if (s_MaterialEditorsField == null)
return null;
Shader currentShader = null;
if (s_ShaderDropdownCurrentShaderField != null)
currentShader = s_ShaderDropdownCurrentShaderField.GetValue(dataSource) as Shader;
var editors = s_MaterialEditorsField.GetValue(null) as IList;
if (editors == null || editors.Count == 0)
return null;
MaterialEditor inspectorMatch = null;
MaterialEditor shaderMatch = null;
foreach (object editorObj in editors)
{
var materialEditor = editorObj as MaterialEditor;
if (materialEditor == null)
continue;
if (s_OffscreenTarget != null &&
IsMaterialEditorOnTargetInspector(materialEditor))
{
inspectorMatch = materialEditor;
}
if (s_MaterialEditorShaderField != null && currentShader != null)
{
var editorShader = s_MaterialEditorShaderField.GetValue(materialEditor) as Shader;
if (editorShader == currentShader)
shaderMatch = materialEditor;
}
}
return inspectorMatch ?? shaderMatch ?? (editors[0] as MaterialEditor);
}
private static bool IsMaterialEditorOnTargetInspector(MaterialEditor materialEditor)
{
if (s_OffscreenTarget == null)
return false;
try
{
var trackerField = s_OffscreenTarget.GetType().GetField(
"m_Tracker",
BindingFlags.Instance | BindingFlags.NonPublic);
object tracker = trackerField?.GetValue(s_OffscreenTarget);
if (tracker == null)
return false;
PropertyInfo activeEditorsProp = tracker.GetType().GetProperty(
"activeEditors",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
var activeEditors = activeEditorsProp?.GetValue(tracker) as UnityEditor.Editor[];
if (activeEditors == null)
return false;
foreach (UnityEditor.Editor editor in activeEditors)
{
if (editor == materialEditor)
return true;
}
}
catch
{
// Best-effort inspector association.
}
return false;
}
private static bool TrySendAdvancedDropdownPopup(float relX, float relY, float popW, float popH)
{
EnsureAdvancedDropdownWindowReflection();
if (s_CachedAdvancedDropdownDataSource == null || s_AdvancedDropdownMainTreeProp == null)
{
CodelyLogger.LogWarning("[NWB-AdvancedDropdown] Missing cached dropdown state");
return false;
}
try
{
object mainTree = s_AdvancedDropdownMainTreeProp.GetValue(s_CachedAdvancedDropdownDataSource);
object treeToRender = mainTree;
if (!string.IsNullOrEmpty(s_AdvancedDropdownSearchFilter) &&
s_AdvancedDropdownRebuildSearchMethod != null &&
mainTree != null)
{
s_AdvancedDropdownRebuildSearchMethod.Invoke(
s_CachedAdvancedDropdownDataSource,
new object[] { s_AdvancedDropdownSearchFilter, mainTree });
object searchTree = s_AdvancedDropdownSearchTreeProp?.GetValue(s_CachedAdvancedDropdownDataSource);
if (searchTree != null)
treeToRender = searchTree;
}
if (treeToRender == null)
{
LogVerbose("[NWB-AdvancedDropdown] Dropdown tree is null");
return false;
}
var pathList = new List<string>();
int itemIndex = 0;
int validCount = 0;
var selectedIds = GetAdvancedDropdownSelectedIds(s_CachedAdvancedDropdownDataSource);
s_FrontendPopupIdCounter++;
s_CurrentFrontendPopupId = "popup_" + s_FrontendPopupIdCounter;
s_PopupSourceType = "advanced_dropdown";
var sb = new StringBuilder(65536);
sb.Append("{\"type\":\"popup_show\",\"id\":\"");
sb.Append(s_CurrentFrontendPopupId);
sb.Append("\",\"sourceType\":\"advanced_dropdown\",\"x\":");
sb.Append(Mathf.RoundToInt(relX));
sb.Append(",\"y\":");
sb.Append(Mathf.RoundToInt(relY));
sb.Append(",\"width\":");
sb.Append(Mathf.RoundToInt(Mathf.Max(popW, 200f)));
sb.Append(",\"height\":");
sb.Append(Mathf.RoundToInt(Mathf.Max(popH, 280f)));
sb.Append(",\"searchText\":\"");
sb.Append(EscapeJsonString(s_AdvancedDropdownSearchFilter ?? ""));
sb.Append("\",\"items\":[");
bool needComma = false;
AppendAdvancedDropdownItems(
sb,
GetAdvancedDropdownChildren(treeToRender),
ref itemIndex,
ref validCount,
ref needComma,
pathList,
selectedIds);
sb.Append("]}");
if (validCount <= 0)
{
LogVerbose("[NWB-AdvancedDropdown] No selectable dropdown items extracted");
return false;
}
s_CachedPopupItemPaths = pathList.ToArray();
string payload = sb.ToString();
int payloadBytes = Encoding.UTF8.GetByteCount(payload);
bool sent;
if (payloadBytes > kMaxDataChannelPayloadBytes)
{
sent = SendPopupPayloadChunked(s_CurrentFrontendPopupId, payload, payloadBytes);
if (sent)
LogVerbose($"[NWB-AdvancedDropdown] Sent chunked popup: bytes={payloadBytes} items={validCount}");
}
else
{
sent = SendDataChannelMessage(payload);
}
s_FrontendPopupSent = sent;
if (sent)
{
CancelNativeDelayedPopup();
LogVerbose($"[NWB-AdvancedDropdown] Sent popup: id={s_CurrentFrontendPopupId} kind={s_AdvancedDropdownHandlerKind} items={validCount}");
}
else
{
CodelyLogger.LogWarning($"[NWB-AdvancedDropdown] Failed to send popup: id={s_CurrentFrontendPopupId}");
}
return sent;
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-AdvancedDropdown] Send error: {ex.Message}\n{ex.StackTrace}");
return false;
}
}
private static HashSet<int> GetAdvancedDropdownSelectedIds(object dataSource)
{
var selected = new HashSet<int>();
if (dataSource == null || s_AdvancedDropdownDataSourceSelectedIdsProp == null)
return selected;
var selectedIds = s_AdvancedDropdownDataSourceSelectedIdsProp.GetValue(dataSource) as IList;
if (selectedIds == null)
return selected;
foreach (object idObj in selectedIds)
{
if (idObj is int id)
selected.Add(id);
}
return selected;
}
private static int GetAdvancedDropdownItemId(object item)
{
if (item == null || s_AdvancedDropdownItemIdProp == null)
return 0;
return (int)s_AdvancedDropdownItemIdProp.GetValue(item);
}
private static int GetAdvancedDropdownElementIndex(object item)
{
if (item == null || s_AdvancedDropdownItemElementIndexProp == null)
return -1;
return (int)s_AdvancedDropdownItemElementIndexProp.GetValue(item);
}
private static void AppendAdvancedDropdownItems(
StringBuilder sb,
IEnumerable items,
ref int itemIndex,
ref int validCount,
ref bool needComma,
List<string> pathList,
HashSet<int> selectedIds)
{
if (items == null) return;
foreach (object item in items)
{
if (item == null) continue;
if (item.GetType().Name == "SeparatorDropdownItem")
continue;
string label = GetAdvancedDropdownDisplayName(item);
if (string.IsNullOrEmpty(label))
continue;
bool hasChildren = s_AdvancedDropdownItemHasChildrenProp != null &&
(bool)s_AdvancedDropdownItemHasChildrenProp.GetValue(item);
if (hasChildren)
{
if (needComma) sb.Append(',');
needComma = true;
sb.Append("{\"label\":\"");
sb.Append(EscapeJsonString(label));
sb.Append("\",\"enabled\":true,\"checked\":false,\"separator\":false,\"submenu\":true,\"children\":[");
bool childComma = false;
int childValid = 0;
AppendAdvancedDropdownItems(
sb,
GetAdvancedDropdownChildren(item),
ref itemIndex,
ref childValid,
ref childComma,
pathList,
selectedIds);
sb.Append("]}");
if (childValid > 0)
validCount++;
continue;
}
string path = null;
if (item.GetType().Name == "ShaderDropdownItem" &&
s_ShaderDropdownItemFullNameProp != null)
{
path = s_ShaderDropdownItemFullNameProp.GetValue(item) as string;
}
else
{
int elementIndex = GetAdvancedDropdownElementIndex(item);
if (elementIndex >= 0)
path = elementIndex.ToString();
}
if (string.IsNullOrEmpty(path))
continue;
bool isChecked = selectedIds != null && selectedIds.Contains(GetAdvancedDropdownItemId(item));
if (needComma) sb.Append(',');
needComma = true;
sb.Append("{\"index\":");
sb.Append(itemIndex);
sb.Append(",\"path\":\"");
sb.Append(EscapeJsonString(path));
sb.Append("\",\"label\":\"");
sb.Append(EscapeJsonString(label));
sb.Append("\",\"enabled\":true,\"checked\":");
sb.Append(isChecked ? "true" : "false");
sb.Append(",\"separator\":false}");
pathList.Add(path);
itemIndex++;
validCount++;
}
}
/// <summary>
/// Returns true when the frontend popup should stay open (multiselect).
/// </summary>
private static bool TryHandleAdvancedDropdownPopupSelect(string path)
{
if (string.IsNullOrEmpty(path) || s_CachedAdvancedDropdownDataSource == null)
return false;
EnsureAdvancedDropdownWindowReflection();
try
{
switch (s_AdvancedDropdownHandlerKind)
{
case AdvancedDropdownHandlerKind.MaterialShader:
TryHandleMaterialShaderDropdownSelect(path);
return false;
case AdvancedDropdownHandlerKind.Multiselect:
TryHandleMultiselectDropdownSelect(path);
return true;
default:
TryHandleStatelessIndexDropdownSelect(path);
return false;
}
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-AdvancedDropdown] Select error: {ex.Message}");
return false;
}
}
private static bool TryHandleMaterialShaderDropdownSelect(string path)
{
if (s_CachedAdvancedDropdownMaterialEditor == null ||
s_MaterialEditorOnSelectedShaderPopupMethod == null)
{
CodelyLogger.LogWarning("[NWB-AdvancedDropdown] Material shader select handler unavailable");
return false;
}
s_MaterialEditorOnSelectedShaderPopupMethod.Invoke(
s_CachedAdvancedDropdownMaterialEditor,
new object[] { path });
LogVerbose($"[NWB-AdvancedDropdown] Applied shader: '{path}'");
return true;
}
private static bool TryHandleStatelessIndexDropdownSelect(string path)
{
if (!int.TryParse(path, out int selectedIndex))
return false;
if (s_StatelessAdvancedDropdownResultField == null ||
s_StatelessAdvancedDropdownWindowClosedField == null)
{
CodelyLogger.LogWarning("[NWB-AdvancedDropdown] StatelessAdvancedDropdown fields not found");
return false;
}
s_StatelessAdvancedDropdownResultField.SetValue(null, selectedIndex);
s_StatelessAdvancedDropdownWindowClosedField.SetValue(null, true);
RepaintStatelessAdvancedDropdownParent();
LogVerbose($"[NWB-AdvancedDropdown] Applied index selection: {selectedIndex}");
return true;
}
private static bool TryHandleMultiselectDropdownSelect(string path)
{
if (!int.TryParse(path, out int selectedIndex) ||
s_MultiselectUpdateSelectedIdMethod == null)
{
return false;
}
object item = FindAdvancedDropdownLeafByElementIndex(
s_AdvancedDropdownMainTreeProp.GetValue(s_CachedAdvancedDropdownDataSource),
selectedIndex);
if (item == null)
{
CodelyLogger.LogWarning($"[NWB-AdvancedDropdown] Multiselect item not found: index={selectedIndex}");
return true;
}
s_MultiselectUpdateSelectedIdMethod.Invoke(s_CachedAdvancedDropdownDataSource, new[] { item });
if (s_StatelessAdvancedDropdownShouldReturnValueField != null)
s_StatelessAdvancedDropdownShouldReturnValueField.SetValue(null, true);
RepaintStatelessAdvancedDropdownParent();
s_FrontendPopupSent = false;
TrySendAdvancedDropdownPopup(
s_LastFrontendPopupX,
s_LastFrontendPopupY,
s_AdvancedDropdownPopupWidth,
s_AdvancedDropdownPopupHeight);
LogVerbose($"[NWB-AdvancedDropdown] Applied multiselect index: {selectedIndex}");
return true;
}
private static object FindAdvancedDropdownLeafByElementIndex(object node, int elementIndex)
{
if (node == null)
return null;
foreach (object child in GetAdvancedDropdownChildren(node))
{
if (child == null || child.GetType().Name == "SeparatorDropdownItem")
continue;
bool hasChildren = s_AdvancedDropdownItemHasChildrenProp != null &&
(bool)s_AdvancedDropdownItemHasChildrenProp.GetValue(child);
if (hasChildren)
{
object found = FindAdvancedDropdownLeafByElementIndex(child, elementIndex);
if (found != null)
return found;
continue;
}
if (GetAdvancedDropdownElementIndex(child) == elementIndex)
return child;
}
return null;
}
private static void RepaintStatelessAdvancedDropdownParent()
{
EditorWindow parent = null;
if (s_StatelessAdvancedDropdownParentWindowField != null)
parent = s_StatelessAdvancedDropdownParentWindowField.GetValue(null) as EditorWindow;
parent?.Repaint();
s_OffscreenTarget?.Repaint();
}
private static void ClearAdvancedDropdownPopupState()
{
s_CachedAdvancedDropdownDataSource = null;
s_CachedAdvancedDropdownMaterialEditor = null;
s_AdvancedDropdownSearchFilter = "";
s_AdvancedDropdownPopupWidth = 0f;
s_AdvancedDropdownPopupHeight = 0f;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7ccca71b3087a944087f66ed1d09e5a1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cc8e2741237d471ca15a88be94f12527
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,380 @@
using System;
using System.Reflection;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Native
{
internal static partial class NativeWindowBridgeHost
{
private static FieldInfo s_SceneViewLastCursorField;
private static string s_LastSentEditorCursorStyle;
private static string s_ActiveDragCursorStyle;
private static Vector2 s_LastCursorProbePos;
private static bool s_HasLastCursorProbePos;
private const float kSplitGrabDist = 5f;
private static void EnsureEditorCursorReflection()
{
if (s_SceneViewLastCursorField != null)
return;
s_SceneViewLastCursorField = typeof(SceneView).GetField(
"s_LastCursor",
BindingFlags.Static | BindingFlags.NonPublic);
}
private static void RememberCursorProbePosition(Vector2 mousePos)
{
if (mousePos.sqrMagnitude > 0.01f)
s_LastCursorProbePos = mousePos;
else if (s_HasLastMousePos)
s_LastCursorProbePos = s_LastMousePos;
else if (s_OffscreenTarget != null)
{
Rect pos = s_OffscreenTarget.position;
s_LastCursorProbePos = new Vector2(pos.width * 0.5f, pos.height * 0.5f);
}
s_HasLastCursorProbePos = true;
}
private static Vector2 GetCursorProbePosition()
{
return s_HasLastCursorProbePos ? s_LastCursorProbePos : Vector2.zero;
}
private static void SendGuiEvent(object view, Event evt)
{
if (view == null || evt == null)
return;
MethodInfo sendEvent = view.GetType().GetMethod(
"SendEvent",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
null,
new[] { typeof(Event) },
null);
sendEvent?.Invoke(view, new object[] { evt });
}
private static void RunCursorGuiCycle(Vector2 mousePos)
{
if (s_OffscreenTarget == null)
return;
object hostView = GetParentView(s_OffscreenTarget);
if (hostView != null)
{
SendGuiEvent(hostView, new Event { type = EventType.Layout, mousePosition = mousePos });
SendGuiEvent(hostView, new Event { type = EventType.Repaint, mousePosition = mousePos });
SendGuiEvent(hostView, new Event { type = EventType.MouseMove, mousePosition = mousePos, delta = Vector2.zero });
}
if (s_OffscreenTarget is SceneView)
{
s_OffscreenTarget.SendEvent(new Event { type = EventType.Layout, mousePosition = mousePos });
s_OffscreenTarget.SendEvent(new Event { type = EventType.Repaint, mousePosition = mousePos });
s_OffscreenTarget.SendEvent(new Event { type = EventType.MouseMove, mousePosition = mousePos, delta = Vector2.zero });
}
}
private static MouseCursor FindSplitCursorForHotControl(object view, int hotControl)
{
if (view == null || hotControl == 0)
return MouseCursor.Arrow;
Type viewType = view.GetType();
if (viewType.Name == "SplitView")
{
FieldInfo controlIdField = viewType.GetField("controlID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
FieldInfo verticalField = viewType.GetField("vertical", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
PropertyInfo childrenProp = viewType.GetProperty("children", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (controlIdField != null && verticalField != null &&
(int)controlIdField.GetValue(view) == hotControl)
{
return (bool)verticalField.GetValue(view)
? MouseCursor.SplitResizeUpDown
: MouseCursor.SplitResizeLeftRight;
}
if (childrenProp?.GetValue(view) is Array children)
{
for (int i = 0; i < children.Length; i++)
{
MouseCursor nested = FindSplitCursorForHotControl(children.GetValue(i), hotControl);
if (nested != MouseCursor.Arrow)
return nested;
}
}
return MouseCursor.Arrow;
}
PropertyInfo childProp = viewType.GetProperty("children", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (childProp?.GetValue(view) is Array childViews)
{
for (int i = 0; i < childViews.Length; i++)
{
MouseCursor nested = FindSplitCursorForHotControl(childViews.GetValue(i), hotControl);
if (nested != MouseCursor.Arrow)
return nested;
}
}
return MouseCursor.Arrow;
}
/// <summary>
/// Split resize cursors live on DockArea/SplitView, not SceneView.s_LastCursor.
/// </summary>
private static MouseCursor TryResolveSplitCursor(object hostView, Vector2 mousePosInView)
{
if (hostView == null)
return MouseCursor.Arrow;
int hotControl = GUIUtility.hotControl;
if (hotControl != 0)
{
object hostWindow = s_CompositeActive
? GetCompositeHostWindow()
: GetViewWindow(hostView);
MouseCursor dragCursor = FindSplitCursorForHotControl(GetRootSplitView(hostWindow), hotControl);
if (dragCursor == MouseCursor.Arrow)
dragCursor = FindSplitCursorForHotControl(hostView, hotControl);
if (dragCursor != MouseCursor.Arrow)
return dragCursor;
}
PropertyInfo parentProp = hostView.GetType().GetProperty("parent", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
PropertyInfo positionProp = hostView.GetType().GetProperty("position", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
object view = hostView;
for (int guard = 0; guard < 8 && view != null; guard++)
{
object splitParent = parentProp?.GetValue(view);
if (splitParent == null || splitParent.GetType().Name != "SplitView")
break;
Type splitType = splitParent.GetType();
MethodInfo indexOfChild = splitType.GetMethod("IndexOfChild", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
FieldInfo verticalField = splitType.GetField("vertical", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
FieldInfo controlIdField = splitType.GetField("controlID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
PropertyInfo childrenProp = splitType.GetProperty("children", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (indexOfChild == null || verticalField == null || childrenProp == null)
break;
int splitControlId = controlIdField != null ? (int)controlIdField.GetValue(splitParent) : 0;
if (hotControl != 0 && hotControl != splitControlId)
{
view = splitParent;
continue;
}
int idx = (int)indexOfChild.Invoke(splitParent, new[] { view });
bool vertical = (bool)verticalField.GetValue(splitParent);
int childCount = (childrenProp.GetValue(splitParent) as Array)?.Length ?? 0;
Rect position = positionProp != null ? (Rect)positionProp.GetValue(view) : Rect.zero;
if (vertical)
{
if (idx != 0 && mousePosInView.y <= kSplitGrabDist)
return MouseCursor.SplitResizeUpDown;
if (idx != childCount - 1 &&
mousePosInView.y >= position.height - kSplitGrabDist + 1f &&
mousePosInView.y <= position.height)
return MouseCursor.SplitResizeUpDown;
}
else
{
if (idx != 0 && mousePosInView.x <= kSplitGrabDist)
return MouseCursor.SplitResizeLeftRight;
if (idx != childCount - 1 &&
mousePosInView.x >= position.width - kSplitGrabDist + 1f &&
mousePosInView.x <= position.width)
return MouseCursor.SplitResizeLeftRight;
}
view = splitParent;
}
return MouseCursor.Arrow;
}
private static MouseCursor ViewToolToMouseCursor(ViewTool tool)
{
switch (tool)
{
case ViewTool.Pan: return MouseCursor.Pan;
case ViewTool.Orbit: return MouseCursor.Orbit;
case ViewTool.FPS: return MouseCursor.FPS;
case ViewTool.Zoom: return MouseCursor.Zoom;
default: return MouseCursor.Arrow;
}
}
private static bool IsEditorViewToolCursorContext()
{
if (s_OffscreenScenePanLocked)
return true;
#if UNITY_2020_1_OR_NEWER
return global::UnityEditor.Tools.viewToolActive;
#else
return global::UnityEditor.Tools.current == Tool.View;
#endif
}
private static MouseCursor TryResolveSceneViewCursor()
{
// During drag, SceneView clutch tools (right=FPS, alt+left=Orbit, etc.)
// update Tools.viewTool but s_LastCursor is stale because we skip the
// Layout/Repaint probe cycle to preserve hotControl.
if (s_RemoteMouseButtonDown && IsEditorViewToolCursorContext())
return ViewToolToMouseCursor(global::UnityEditor.Tools.viewTool);
EnsureEditorCursorReflection();
if (s_SceneViewLastCursorField == null)
return MouseCursor.Arrow;
try
{
return (MouseCursor)s_SceneViewLastCursorField.GetValue(null);
}
catch (Exception ex)
{
LogVerbose($"[NWB-Cursor] Read SceneView.s_LastCursor failed: {ex.Message}");
return MouseCursor.Arrow;
}
}
private static string MouseCursorToStyleId(MouseCursor cursor)
{
switch (cursor)
{
case MouseCursor.Text: return "text";
case MouseCursor.ResizeVertical:
case MouseCursor.SplitResizeUpDown: return "ns-resize";
case MouseCursor.ResizeHorizontal:
case MouseCursor.SplitResizeLeftRight: return "ew-resize";
case MouseCursor.Link: return "pointer";
case MouseCursor.SlideArrow: return "col-resize";
case MouseCursor.ResizeUpRight: return "nesw-resize";
case MouseCursor.ResizeUpLeft: return "nwse-resize";
case MouseCursor.MoveArrow: return "move";
case MouseCursor.RotateArrow: return "rotate";
case MouseCursor.ScaleArrow: return "scale";
case MouseCursor.ArrowPlus: return "copy";
case MouseCursor.ArrowMinus: return "not-allowed";
case MouseCursor.Pan: return "pan";
case MouseCursor.Orbit: return "orbit";
case MouseCursor.Zoom: return "zoom";
case MouseCursor.FPS: return "fps";
case MouseCursor.CustomCursor: return "custom";
default: return "default";
}
}
private static MouseCursor TryResolveEditorMouseCursor()
{
if (s_OffscreenTarget != null)
{
object hostView = GetParentView(s_OffscreenTarget);
if (hostView != null)
{
MouseCursor splitCursor = TryResolveSplitCursor(hostView, GetCursorProbePosition());
if (splitCursor != MouseCursor.Arrow)
return splitCursor;
}
}
if (s_OffscreenTarget is SceneView)
{
MouseCursor sceneCursor = TryResolveSceneViewCursor();
if (sceneCursor != MouseCursor.Arrow)
return sceneCursor;
}
return MouseCursor.Arrow;
}
private static void TrySendDefaultEditorCursor()
{
if (!s_OffscreenActive)
return;
string styleId = "default";
if (styleId == "default" && s_RemoteMouseButtonDown && !string.IsNullOrEmpty(s_ActiveDragCursorStyle))
styleId = s_ActiveDragCursorStyle;
if (styleId == s_LastSentEditorCursorStyle)
return;
s_LastSentEditorCursorStyle = styleId;
SendDataChannelMessage("{\"type\":\"cursor_style\",\"cursor\":\"" + styleId + "\"}");
LogVerbose($"[NWB-Cursor] -> {styleId} (outside hit)");
}
private static void TrySendEditorCursorStyle()
{
if (!s_OffscreenActive || s_OffscreenTarget == null)
return;
if (s_OffscreenTargetType != null &&
s_OffscreenTargetType.Name == "GameView" &&
global::UnityEngine.Cursor.lockState == CursorLockMode.Locked)
{
return;
}
string styleId = MouseCursorToStyleId(TryResolveEditorMouseCursor());
if (styleId == "default" && s_RemoteMouseButtonDown && !string.IsNullOrEmpty(s_ActiveDragCursorStyle))
styleId = s_ActiveDragCursorStyle;
else if (styleId != "default")
s_ActiveDragCursorStyle = styleId;
if (styleId == s_LastSentEditorCursorStyle)
return;
s_LastSentEditorCursorStyle = styleId;
SendDataChannelMessage("{\"type\":\"cursor_style\",\"cursor\":\"" + styleId + "\"}");
LogVerbose($"[NWB-Cursor] -> {styleId}");
}
private static void LockEditorDragCursorFromHover()
{
if (!string.IsNullOrEmpty(s_LastSentEditorCursorStyle) &&
s_LastSentEditorCursorStyle != "default")
{
s_ActiveDragCursorStyle = s_LastSentEditorCursorStyle;
}
}
private static void UpdateEditorCursorFromInput(string type, Vector2 mousePos)
{
if (s_OffscreenTarget == null || s_OffscreenTargetType == null ||
s_OffscreenTargetType.Name == "GameView")
{
return;
}
if (type == "mouseup")
s_ActiveDragCursorStyle = null;
RememberCursorProbePosition(mousePos);
mousePos = GetCursorProbePosition();
if (!s_RemoteMouseButtonDown)
RunCursorGuiCycle(mousePos);
TrySendEditorCursorStyle();
}
private static void ClearEditorCursorState(bool notifyFrontend = true)
{
s_LastSentEditorCursorStyle = null;
s_ActiveDragCursorStyle = null;
s_HasLastCursorProbePos = false;
if (!notifyFrontend || !s_OffscreenActive)
return;
SendDataChannelMessage("{\"type\":\"cursor_style\",\"cursor\":\"default\"}");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0962d207323241b68d2a3b53baab997f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,724 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Native
{
internal static partial class NativeWindowBridgeHost
{
private static int s_DragAndDropTestingLogCount;
private static int s_ProjectSelectionSnapshotLogCount;
private static int s_BlockedNativeProjectDragLogCount;
private static int s_NativeDragCancelWatchdogVersion;
private static int s_SyntheticDragThresholdLogCount;
private static bool s_RemoteNativeDragAndDropActive;
private static EditorWindow s_RemoteNativeDragSourceWindow;
private static UnityEngine.Object[] s_ProjectAssetDragObjects;
private static string[] s_ProjectAssetDragPaths;
private static bool s_SyntheticDragGestureTracking;
private static Vector2 s_SyntheticDragStartPosition;
private static string s_SyntheticDragWindowTypeName;
// Ignore tiny pointer jitter so click interactions (e.g. hierarchy foldout)
// are not misclassified as drag gestures.
private const float kSyntheticDragStartThreshold = 3f;
private const float kSyntheticDragStartThresholdSqr =
kSyntheticDragStartThreshold * kSyntheticDragStartThreshold;
private static bool ShouldStartSyntheticProjectAssetDragForInput(string inputType, string windowTypeName)
{
return inputType == "mousedrag" &&
(windowTypeName == "ProjectBrowser" || windowTypeName == "UnityEditor.ProjectBrowser");
}
private static bool ShouldStartSyntheticHierarchyGameObjectDragForInput(string inputType, string windowTypeName)
{
return inputType == "mousedrag" &&
(windowTypeName == "SceneHierarchyWindow" || windowTypeName == "UnityEditor.SceneHierarchyWindow");
}
private static bool ShouldCaptureProjectAssetSelectionForInput(string inputType, string windowTypeName)
{
return inputType == "mousedown" &&
(windowTypeName == "ProjectBrowser" || windowTypeName == "UnityEditor.ProjectBrowser");
}
private static bool ShouldBlockNativeEditorDragStart(string inputType, string windowTypeName)
{
return ShouldStartSyntheticProjectAssetDragForInput(inputType, windowTypeName) ||
ShouldStartSyntheticHierarchyGameObjectDragForInput(inputType, windowTypeName);
}
private static bool ShouldForwardNativeDragAndDropEvent(string inputType)
{
return inputType == "mousedrag" || inputType == "mouseup";
}
private static bool ShouldUseCompositeMouseCaptureForInput(string inputType)
{
return !s_RemoteNativeDragAndDropActive || !ShouldForwardNativeDragAndDropEvent(inputType);
}
private static bool ShouldTrackSyntheticDragWindowType(string windowTypeName)
{
return windowTypeName == "ProjectBrowser" ||
windowTypeName == "UnityEditor.ProjectBrowser" ||
windowTypeName == "SceneHierarchyWindow" ||
windowTypeName == "UnityEditor.SceneHierarchyWindow";
}
private static void UpdateSyntheticDragGestureState(
EditorWindow window,
string inputType,
Vector2 mousePosition)
{
string windowTypeName = window?.GetType().FullName ?? window?.GetType().Name;
bool trackableWindow = !string.IsNullOrEmpty(windowTypeName) &&
ShouldTrackSyntheticDragWindowType(windowTypeName);
if (inputType == "mousedown")
{
if (trackableWindow)
{
s_SyntheticDragGestureTracking = true;
s_SyntheticDragStartPosition = mousePosition;
s_SyntheticDragWindowTypeName = windowTypeName;
}
else
{
ClearSyntheticDragGestureState();
}
return;
}
if (inputType == "mouseup")
{
ClearSyntheticDragGestureState();
}
}
private static bool HasSyntheticDragThresholdMet(
EditorWindow window,
string inputType,
Vector2 mousePosition)
{
if (window == null || inputType != "mousedrag")
return false;
string windowTypeName = window.GetType().FullName ?? window.GetType().Name;
if (!s_SyntheticDragGestureTracking ||
string.IsNullOrEmpty(s_SyntheticDragWindowTypeName) ||
!string.Equals(s_SyntheticDragWindowTypeName, windowTypeName, StringComparison.Ordinal))
{
return false;
}
float sqrDistance = (mousePosition - s_SyntheticDragStartPosition).sqrMagnitude;
bool reachedThreshold = sqrDistance >= kSyntheticDragStartThresholdSqr;
if (!reachedThreshold)
{
s_SyntheticDragThresholdLogCount++;
if (s_SyntheticDragThresholdLogCount <= 10 || s_SyntheticDragThresholdLogCount % 100 == 0)
{
LogVerbose(
$"[NWB-DnD] Ignore tiny drag before threshold: " +
$"win={windowTypeName} dist={Mathf.Sqrt(sqrDistance):F2}px " +
$"threshold={kSyntheticDragStartThreshold:F1}px");
}
}
return reachedThreshold;
}
private static void ClearSyntheticDragGestureState()
{
s_SyntheticDragGestureTracking = false;
s_SyntheticDragStartPosition = Vector2.zero;
s_SyntheticDragWindowTypeName = null;
}
private static bool TryStartSyntheticProjectAssetDrag(
EditorWindow window,
string inputType,
Vector2 mousePosition)
{
if (window == null) return false;
if (s_RemoteNativeDragAndDropActive && !HasActiveUnityDragAndDropData())
FinishRemoteNativeDragAndDrop();
if (s_RemoteNativeDragAndDropActive) return false;
if (!ShouldStartSyntheticProjectAssetDragForInput(inputType, window.GetType().FullName ?? window.GetType().Name))
return false;
if (!HasSyntheticDragThresholdMet(window, inputType, mousePosition))
return false;
try
{
var refs = new System.Collections.Generic.List<UnityEngine.Object>();
var paths = new System.Collections.Generic.List<string>();
AddCachedProjectAssetDragObjects(refs, paths);
if (refs.Count == 0)
{
foreach (UnityEngine.Object obj in Selection.objects)
{
if (obj == null) continue;
string path = AssetDatabase.GetAssetPath(obj);
if (string.IsNullOrEmpty(path)) continue;
refs.Add(obj);
paths.Add(path);
}
}
if (refs.Count == 0)
return false;
BeginRemoteNativeDragAndDrop(
window,
refs.ToArray(),
paths.ToArray(),
DragAndDropVisualMode.Generic,
$"Started synthetic Project asset drag: count={refs.Count}");
return true;
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-DnD] Failed to start synthetic Project asset drag: {ex.Message}");
return false;
}
}
private static bool TryStartSyntheticHierarchyGameObjectDrag(
EditorWindow window,
string inputType,
Vector2 mousePosition)
{
if (window == null) return false;
if (s_RemoteNativeDragAndDropActive && !HasActiveUnityDragAndDropData())
FinishRemoteNativeDragAndDrop();
if (s_RemoteNativeDragAndDropActive) return false;
if (!ShouldStartSyntheticHierarchyGameObjectDragForInput(inputType, window.GetType().FullName ?? window.GetType().Name))
return false;
if (!HasSyntheticDragThresholdMet(window, inputType, mousePosition))
return false;
try
{
var refs = new System.Collections.Generic.List<UnityEngine.Object>();
AddHierarchyDragGameObjects(window, refs);
if (refs.Count == 0)
{
foreach (UnityEngine.Object obj in Selection.objects)
{
if (obj is GameObject)
refs.Add(obj);
}
}
if (refs.Count == 0)
return false;
BeginRemoteNativeDragAndDrop(
window,
refs.ToArray(),
new string[0],
DragAndDropVisualMode.Move,
$"Started synthetic Hierarchy GameObject drag: count={refs.Count}");
return true;
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-DnD] Failed to start synthetic Hierarchy GameObject drag: {ex.Message}");
return false;
}
}
private static void BeginRemoteNativeDragAndDrop(
EditorWindow sourceWindow,
UnityEngine.Object[] objectReferences,
string[] paths,
DragAndDropVisualMode visualMode,
string logMessage)
{
DragAndDrop.PrepareStartDrag();
DragAndDrop.objectReferences = objectReferences;
DragAndDrop.paths = paths;
DragAndDrop.visualMode = visualMode;
ClearRemoteDragControls();
s_CompositeMouseCaptureView = null;
s_CompositeMouseCaptureSlot = null;
s_RemoteNativeDragAndDropActive = true;
s_RemoteNativeDragSourceWindow = sourceWindow;
ClearSyntheticDragGestureState();
s_DragAndDropTestingLogCount++;
if (s_DragAndDropTestingLogCount <= 10 || s_DragAndDropTestingLogCount % 100 == 0)
LogVerbose($"[NWB-DnD] {logMessage}");
}
private static bool TryBlockNativeEditorDragStart(
EditorWindow window,
string inputType,
Vector2 mousePosition)
{
if (window == null) return false;
if (!ShouldBlockNativeEditorDragStart(inputType, window.GetType().FullName ?? window.GetType().Name))
return false;
if (!HasSyntheticDragThresholdMet(window, inputType, mousePosition))
return false;
ClearRemoteDragControls();
s_CompositeMouseCaptureView = null;
s_CompositeMouseCaptureSlot = null;
ClearSourceDragSelection(window);
ScheduleNativeDragDropCancelWatchdog();
s_BlockedNativeProjectDragLogCount++;
if (s_BlockedNativeProjectDragLogCount <= 10 || s_BlockedNativeProjectDragLogCount % 100 == 0)
LogVerbose("[NWB-DnD] Blocked native editor DragAndDrop.StartDrag fallback");
return true;
}
private static bool TryForwardRemoteNativeDragAndDropEvent(
EditorWindow target,
string inputType,
Vector2 mousePosition,
EventModifiers modifiers)
{
if (!s_RemoteNativeDragAndDropActive) return false;
if (target == null || !ShouldForwardNativeDragAndDropEvent(inputType)) return false;
if (!HasActiveUnityDragAndDropData())
{
FinishRemoteNativeDragAndDrop();
return false;
}
Event dragUpdated = new Event
{
type = EventType.DragUpdated,
mousePosition = mousePosition,
modifiers = modifiers
};
target.SendEvent(dragUpdated);
if (inputType == "mouseup")
{
try
{
ScheduleRemoteDragModalCancelWatchdog();
Event dragPerform = new Event
{
type = EventType.DragPerform,
mousePosition = mousePosition,
modifiers = modifiers
};
target.SendEvent(dragPerform);
}
finally
{
FinishRemoteNativeDragAndDrop();
}
}
return true;
}
private static void FinishRemoteNativeDragAndDrop()
{
s_RemoteNativeDragAndDropActive = false;
s_NativeDragCancelWatchdogVersion++;
ClearSyntheticDragGestureState();
ClearSourceDragSelection(s_RemoteNativeDragSourceWindow);
s_RemoteNativeDragSourceWindow = null;
ClearProjectAssetDragSnapshot();
DragAndDrop.PrepareStartDrag();
}
private static void ClearRemoteDragControls()
{
GUIUtility.hotControl = 0;
DragAndDrop.activeControlID = 0;
}
private static void ClearSourceDragSelection(EditorWindow window)
{
ClearProjectBrowserListAreaDragSelection(window);
ClearSceneHierarchyDragSelection(window);
}
private static bool HasActiveUnityDragAndDropData()
{
bool hasObjects = DragAndDrop.objectReferences != null &&
DragAndDrop.objectReferences.Length > 0;
bool hasPaths = DragAndDrop.paths != null &&
DragAndDrop.paths.Length > 0;
return hasObjects || hasPaths;
}
private static void CaptureProjectAssetSelectionAfterMouseDown(EditorWindow window, string inputType)
{
if (window == null) return;
string windowTypeName = window.GetType().FullName ?? window.GetType().Name;
if (!ShouldCaptureProjectAssetSelectionForInput(inputType, windowTypeName))
{
if (inputType == "mousedown")
ClearProjectAssetDragSnapshot();
return;
}
try
{
var ids = new System.Collections.Generic.List<int>();
AddProjectBrowserListAreaDragSelection(window, ids);
AddProjectBrowserListAreaSelection(window, ids);
AddProjectBrowserTreeStateSelection(window, "m_AssetTreeState", ids);
AddProjectBrowserTreeStateSelection(window, "m_FolderTreeState", ids);
SetProjectAssetDragSnapshot(ids);
}
catch (Exception ex)
{
ClearProjectAssetDragSnapshot();
if (s_ProjectSelectionSnapshotLogCount <= 5)
CodelyLogger.LogWarning($"[NWB-DnD] Failed to capture Project selection snapshot: {ex.Message}");
}
}
private static void AddProjectBrowserListAreaSelection(
EditorWindow window,
System.Collections.Generic.List<int> ids)
{
object listArea = GetMemberValue(window, "ListArea") ?? GetMemberValue(window, "m_ListArea");
if (listArea == null) return;
var getSelection = listArea.GetType().GetMethod(
"GetSelection",
System.Reflection.BindingFlags.Instance |
System.Reflection.BindingFlags.Public |
System.Reflection.BindingFlags.NonPublic);
if (getSelection?.Invoke(listArea, null) is int[] selection)
AddUniqueIds(ids, selection);
}
private static void AddProjectBrowserListAreaDragSelection(
EditorWindow window,
System.Collections.Generic.List<int> ids)
{
object listArea = GetMemberValue(window, "ListArea") ?? GetMemberValue(window, "m_ListArea");
object localAssets = GetMemberValue(listArea, "m_LocalAssets");
object dragSelection = GetMemberValue(localAssets, "m_DragSelection");
if (dragSelection is IEnumerable enumerable)
AddUniqueIds(ids, enumerable);
}
private static void ClearProjectBrowserListAreaDragSelection(EditorWindow window)
{
if (window == null) return;
object listArea = GetMemberValue(window, "ListArea") ?? GetMemberValue(window, "m_ListArea");
object localAssets = GetMemberValue(listArea, "m_LocalAssets");
object dragSelection = GetMemberValue(localAssets, "m_DragSelection");
ClearListLikeObject(dragSelection);
}
private static void AddHierarchyDragGameObjects(
EditorWindow window,
System.Collections.Generic.List<UnityEngine.Object> refs)
{
object treeView = GetSceneHierarchyTreeView(window);
object dragSelection = GetMemberValue(treeView, "m_DragSelection");
object selected = InvokeMember(dragSelection, "Get");
if (selected is IEnumerable enumerable)
{
foreach (object value in enumerable)
{
if (!(value is int id)) continue;
UnityEngine.Object obj = InstanceIdExtensions.InstanceIdToObject(id);
if (obj is GameObject && !refs.Contains(obj))
refs.Add(obj);
}
}
}
private static void ClearSceneHierarchyDragSelection(EditorWindow window)
{
object treeView = GetSceneHierarchyTreeView(window);
object dragSelection = GetMemberValue(treeView, "m_DragSelection");
ClearListLikeObject(dragSelection);
}
private static object GetSceneHierarchyTreeView(EditorWindow window)
{
object sceneHierarchy = GetMemberValue(window, "sceneHierarchy") ?? GetMemberValue(window, "m_SceneHierarchy");
return GetMemberValue(sceneHierarchy, "treeView") ?? GetMemberValue(sceneHierarchy, "m_TreeView");
}
private static void AddProjectBrowserTreeStateSelection(
EditorWindow window,
string fieldName,
System.Collections.Generic.List<int> ids)
{
object state = GetMemberValue(window, fieldName);
object selected = GetMemberValue(state, "selectedIDs") ?? GetMemberValue(state, "m_SelectedIDs");
if (selected is IEnumerable enumerable)
{
foreach (object value in enumerable)
{
if (value is int id && !ids.Contains(id))
ids.Add(id);
}
}
}
private static object GetMemberValue(object target, string name)
{
if (target == null) return null;
var type = target.GetType();
var flags = System.Reflection.BindingFlags.Instance |
System.Reflection.BindingFlags.Public |
System.Reflection.BindingFlags.NonPublic;
var property = type.GetProperty(name, flags);
if (property != null)
return property.GetValue(target);
var field = type.GetField(name, flags);
return field?.GetValue(target);
}
private static object InvokeMember(object target, string name)
{
if (target == null) return null;
var method = target.GetType().GetMethod(
name,
System.Reflection.BindingFlags.Instance |
System.Reflection.BindingFlags.Public |
System.Reflection.BindingFlags.NonPublic,
null,
Type.EmptyTypes,
null);
return method?.Invoke(target, null);
}
private static void AddUniqueIds(System.Collections.Generic.List<int> ids, int[] values)
{
if (values == null) return;
foreach (int id in values)
{
if (id != 0 && !ids.Contains(id))
ids.Add(id);
}
}
private static void AddUniqueIds(System.Collections.Generic.List<int> ids, IEnumerable values)
{
if (values == null) return;
foreach (object value in values)
{
if (value is int id && id != 0 && !ids.Contains(id))
ids.Add(id);
}
}
private static bool ClearListLikeObject(object value)
{
if (value == null) return false;
var clear = value.GetType().GetMethod(
"Clear",
System.Reflection.BindingFlags.Instance |
System.Reflection.BindingFlags.Public |
System.Reflection.BindingFlags.NonPublic,
null,
Type.EmptyTypes,
null);
if (clear == null) return false;
clear.Invoke(value, null);
return true;
}
private static void SetProjectAssetDragSnapshot(System.Collections.Generic.List<int> ids)
{
var refs = new System.Collections.Generic.List<UnityEngine.Object>();
var paths = new System.Collections.Generic.List<string>();
foreach (int id in ids)
{
string path = InstanceIdExtensions.AssetPathFromInstanceId(id);
if (string.IsNullOrEmpty(path)) continue;
UnityEngine.Object obj = InstanceIdExtensions.InstanceIdToObject(id);
if (obj == null)
obj = AssetDatabase.LoadMainAssetAtPath(path);
if (obj == null) continue;
refs.Add(obj);
paths.Add(path);
}
s_ProjectAssetDragObjects = refs.Count > 0 ? refs.ToArray() : null;
s_ProjectAssetDragPaths = paths.Count > 0 ? paths.ToArray() : null;
s_ProjectSelectionSnapshotLogCount++;
if (s_ProjectSelectionSnapshotLogCount <= 10 || s_ProjectSelectionSnapshotLogCount % 100 == 0)
LogVerbose($"[NWB-DnD] Project selection snapshot: ids={ids.Count} assets={refs.Count}");
}
private static void AddCachedProjectAssetDragObjects(
System.Collections.Generic.List<UnityEngine.Object> refs,
System.Collections.Generic.List<string> paths)
{
if (s_ProjectAssetDragObjects == null || s_ProjectAssetDragPaths == null)
return;
int count = Math.Min(s_ProjectAssetDragObjects.Length, s_ProjectAssetDragPaths.Length);
for (int i = 0; i < count; i++)
{
UnityEngine.Object obj = s_ProjectAssetDragObjects[i];
string path = s_ProjectAssetDragPaths[i];
if (obj == null || string.IsNullOrEmpty(path)) continue;
refs.Add(obj);
paths.Add(path);
}
}
private static void ClearProjectAssetDragSnapshot()
{
s_ProjectAssetDragObjects = null;
s_ProjectAssetDragPaths = null;
}
private static void ScheduleNativeDragDropCancelWatchdog()
{
#if UNITY_EDITOR_WIN
var hwnds = GetAllUnityHwnds();
if (hwnds == null || hwnds.Count == 0)
return;
int version = ++s_NativeDragCancelWatchdogVersion;
ThreadPool.QueueUserWorkItem(_ =>
{
const int vkEscape = 0x1B;
for (int i = 0; i < 20; i++)
{
if (version != s_NativeDragCancelWatchdogVersion)
return;
foreach (IntPtr hwnd in hwnds)
{
if (hwnd == IntPtr.Zero) continue;
PostMessageW(hwnd, WM_CANCELMODE, IntPtr.Zero, IntPtr.Zero);
PostMessageW(hwnd, WM_LBUTTONUP, IntPtr.Zero, IntPtr.Zero);
PostMessageW(hwnd, WM_KEYDOWN, (IntPtr)vkEscape, IntPtr.Zero);
PostMessageW(hwnd, WM_KEYUP, (IntPtr)vkEscape, IntPtr.Zero);
}
Thread.Sleep(50);
}
});
#endif
}
private static void ScheduleRemoteDragModalCancelWatchdog()
{
#if UNITY_EDITOR_WIN
var baseline = new HashSet<IntPtr>(GetAllUnityHwnds());
foreach (var hwnd in s_TransparentHwnds)
baseline.Add(hwnd);
if (s_MaskWindowHwnd != IntPtr.Zero)
baseline.Add(s_MaskWindowHwnd);
if (s_OffscreenTargetContainerHwnd != IntPtr.Zero)
baseline.Add(s_OffscreenTargetContainerHwnd);
int version = ++s_NativeDragCancelWatchdogVersion;
ThreadPool.QueueUserWorkItem(_ =>
{
const int maxAttempts = 160; // ~4s at 25ms; enough for modal creation during DragPerform.
const int sleepMs = 25;
const uint vkEscape = 0x1B;
const uint wmClose = 0x0010;
for (int i = 0; i < maxAttempts; i++)
{
if (version != s_NativeDragCancelWatchdogVersion)
return;
IntPtr modalHwnd = FindNewRemoteDragBlockingModal(baseline);
if (modalHwnd != IntPtr.Zero)
{
string className = GetHwndClassName(modalHwnd);
string title = GetHwndTitle(modalHwnd);
PostMessageW(modalHwnd, WM_CANCELMODE, IntPtr.Zero, IntPtr.Zero);
PostMessageW(modalHwnd, WM_KEYDOWN, (IntPtr)vkEscape, IntPtr.Zero);
PostMessageW(modalHwnd, WM_KEYUP, (IntPtr)vkEscape, IntPtr.Zero);
PostMessageW(modalHwnd, wmClose, IntPtr.Zero, IntPtr.Zero);
CodelyLogger.Log(
$"[NWB-DnD] Auto-canceled blocking modal after remote drag: " +
$"hwnd=0x{modalHwnd.ToInt64():X} class={className} title=\"{title}\"");
return;
}
Thread.Sleep(sleepMs);
}
});
#endif
}
#if UNITY_EDITOR_WIN
private static IntPtr FindNewRemoteDragBlockingModal(HashSet<IntPtr> baseline)
{
IntPtr result = IntPtr.Zero;
uint currentPid = (uint)System.Diagnostics.Process.GetCurrentProcess().Id;
IntPtr hwnd = GetWindow(GetDesktopWindow(), GW_CHILD);
int safety = 0;
while (hwnd != IntPtr.Zero && safety < 4096)
{
if (!baseline.Contains(hwnd) && hwnd != s_MaskWindowHwnd)
{
GetWindowThreadProcessId(hwnd, out uint pid);
if (pid == currentPid && IsWindowVisible(hwnd))
{
string className = GetHwndClassName(hwnd);
if (IsRemoteDragBlockingModalClass(hwnd, className))
{
result = hwnd;
break;
}
}
}
hwnd = GetWindow(hwnd, GW_HWNDNEXT);
safety++;
}
return result;
}
private static bool IsRemoteDragBlockingModalClass(IntPtr hwnd, string className)
{
if (string.IsNullOrEmpty(className) || className == "#32768")
return false;
if (className == "#32770")
return true;
string title = GetHwndTitle(hwnd);
if (string.IsNullOrEmpty(title) || title.EndsWith(" - Streaming", StringComparison.Ordinal))
return false;
IntPtr owner = GetWindow(hwnd, GW_OWNER);
bool ownedUnityWindow = owner != IntPtr.Zero &&
className.IndexOf("Unity", StringComparison.OrdinalIgnoreCase) >= 0;
bool containerLikeDialog = className.IndexOf("Container", StringComparison.OrdinalIgnoreCase) >= 0 &&
className.IndexOf("Unity", StringComparison.OrdinalIgnoreCase) >= 0;
return ownedUnityWindow || containerLikeDialog;
}
private static string GetHwndClassName(IntPtr hwnd)
{
var sb = new System.Text.StringBuilder(256);
return GetClassNameW(hwnd, sb, sb.Capacity) > 0 ? sb.ToString() : "";
}
private static string GetHwndTitle(IntPtr hwnd)
{
var sb = new System.Text.StringBuilder(256);
return GetWindowTextW(hwnd, sb, sb.Capacity) > 0 ? sb.ToString() : "";
}
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetWindowTextW(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount);
private const uint GW_OWNER = 4;
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3b4c0cb574e24535b75009a3555ad4d0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,453 @@
using System;
using System.Reflection;
using System.Text;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Native
{
internal static partial class NativeWindowBridgeHost
{
private const float kIconSelectorPopupWidth = 140f;
private const float kIconSelectorPopupHeightWithLabels = 126f;
private const float kIconSelectorPopupHeightNoLabels = 86f;
private const int kIconSelectorLabelCount = 8;
private const int kIconSelectorDotCount = 16;
private const string kIconSelectorPathNone = "__none__";
private static bool s_IconSelectorReflectionDone;
private static Type s_IconSelectorType;
private static FieldInfo s_IconSelectorTargetObjectField;
private static FieldInfo s_IconSelectorTargetObjectListField;
private static FieldInfo s_IconSelectorShowLabelIconsField;
private static FieldInfo s_IconSelectorMultipleSelectedIconsField;
private static MethodInfo s_IconSelectorCopyIconToImporterMethod;
private static EditorWindow s_CachedIconSelectorWindow;
private static UnityEngine.Object[] s_IconSelectorTargetObjects;
private static bool s_IconSelectorSessionActive;
private static bool s_IconSelectorShowLabelIcons;
private static string s_IconSelectorSelectedPath = "";
private static void EnsureIconSelectorReflection()
{
if (s_IconSelectorReflectionDone) return;
s_IconSelectorReflectionDone = true;
try
{
var editorAsm = typeof(EditorWindow).Assembly;
s_IconSelectorType = editorAsm.GetType("UnityEditor.IconSelector");
if (s_IconSelectorType == null)
{
CodelyLogger.LogWarning("[NWB-IconSelector] IconSelector type not found");
return;
}
const BindingFlags inst = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
const BindingFlags stat = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public;
s_IconSelectorTargetObjectField = s_IconSelectorType.GetField("m_TargetObject", inst);
s_IconSelectorTargetObjectListField = s_IconSelectorType.GetField("m_TargetObjectList", inst);
s_IconSelectorShowLabelIconsField = s_IconSelectorType.GetField("m_ShowLabelIcons", inst);
s_IconSelectorMultipleSelectedIconsField = s_IconSelectorType.GetField("m_MultipleSelectedIcons", inst);
s_IconSelectorCopyIconToImporterMethod = s_IconSelectorType.GetMethod(
"CopyIconToImporter", stat, null, new[] { typeof(MonoScript) }, null);
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-IconSelector] Reflection init failed: {ex.Message}");
}
}
private static bool TryExtractIconSelectorPopup(
EditorWindow hostedWindow, float relX, float relY, float popW, float popH)
{
EnsureIconSelectorReflection();
if (hostedWindow == null || s_IconSelectorType == null)
return false;
try
{
s_CachedIconSelectorWindow = hostedWindow;
s_IconSelectorTargetObjects = s_IconSelectorTargetObjectListField?.GetValue(hostedWindow) as UnityEngine.Object[];
if (s_IconSelectorTargetObjects == null || s_IconSelectorTargetObjects.Length == 0)
{
UnityEngine.Object single =
s_IconSelectorTargetObjectField?.GetValue(hostedWindow) as UnityEngine.Object;
if (single != null)
s_IconSelectorTargetObjects = new[] { single };
}
if (s_IconSelectorTargetObjects == null || s_IconSelectorTargetObjects.Length == 0)
{
LogVerbose("[NWB-IconSelector] No target objects on IconSelector");
return false;
}
s_IconSelectorShowLabelIcons = s_IconSelectorShowLabelIconsField != null &&
(bool)s_IconSelectorShowLabelIconsField.GetValue(hostedWindow);
bool multiple = s_IconSelectorMultipleSelectedIconsField != null &&
(bool)s_IconSelectorMultipleSelectedIconsField.GetValue(hostedWindow);
s_IconSelectorSelectedPath = multiple ? "" : ResolveIconSelectorSelectedPath(s_IconSelectorTargetObjects[0]);
TryHideIconSelectorNativeWindow(hostedWindow);
s_LastFrontendPopupX = relX;
s_LastFrontendPopupY = relY;
float height = s_IconSelectorShowLabelIcons
? kIconSelectorPopupHeightWithLabels
: kIconSelectorPopupHeightNoLabels;
float width = Mathf.Max(popW, kIconSelectorPopupWidth);
height = Mathf.Max(popH, height);
return TrySendIconSelectorPopup(relX, relY, width, height);
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-IconSelector] Extract error: {ex.Message}");
return false;
}
}
private static void TryHideIconSelectorNativeWindow(EditorWindow window)
{
if (window == null) return;
try
{
Rect pos = window.position;
pos.x = -20000f;
pos.y = -20000f;
window.position = pos;
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-IconSelector] Hide native window failed: {ex.Message}");
}
}
private static bool TrySendIconSelectorPopup(float relX, float relY, float popW, float popH)
{
try
{
s_FrontendPopupIdCounter++;
s_CurrentFrontendPopupId = "popup_" + s_FrontendPopupIdCounter;
s_PopupSourceType = "icon_selector";
s_IconSelectorSessionActive = true;
var sb = new StringBuilder(8192);
sb.Append("{\"type\":\"popup_show\",\"id\":\"");
sb.Append(s_CurrentFrontendPopupId);
sb.Append("\",\"sourceType\":\"icon_selector\",\"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(",\"title\":\"Select Icon\",\"showLabelIcons\":");
sb.Append(s_IconSelectorShowLabelIcons ? "true" : "false");
sb.Append(",\"selectedPath\":\"");
sb.Append(EscapeJsonString(s_IconSelectorSelectedPath ?? ""));
sb.Append("\"");
if (s_IconSelectorShowLabelIcons)
{
sb.Append(",\"labelIcons\":[");
AppendIconSelectorIconArray(sb, "label", kIconSelectorLabelCount, true);
sb.Append(']');
}
sb.Append(",\"dotIcons\":[");
AppendIconSelectorIconArray(sb, "dot", kIconSelectorDotCount, false);
sb.Append("]}");
bool sent = SendDataChannelMessage(sb.ToString());
if (sent)
{
s_FrontendPopupSent = true;
LogVerbose("[NWB-IconSelector] Sent icon selector to frontend");
}
else
{
ClearIconSelectorPopupState();
}
return sent;
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-IconSelector] Send popup failed: {ex.Message}");
ClearIconSelectorPopupState();
return false;
}
}
private static void AppendIconSelectorIconArray(StringBuilder sb, string kind, int count, bool labelIcon)
{
for (int i = 0; i < count; i++)
{
if (i > 0) sb.Append(',');
string iconName = labelIcon
? "sv_icon_name" + i
: "sv_icon_dot" + i + "_sml";
string image = EncodeEditorIconDataUri(iconName);
sb.Append("{\"path\":\"");
sb.Append(kind);
sb.Append(':');
sb.Append(i);
sb.Append("\",\"image\":\"");
sb.Append(EscapeJsonString(image));
sb.Append("\"}");
}
}
private static string EncodeEditorIconDataUri(string iconName)
{
if (string.IsNullOrEmpty(iconName))
return "";
try
{
GUIContent content = EditorGUIUtility.IconContent(iconName);
return TryEncodeAnnotationIconPngDataUri(content?.image as Texture2D);
}
catch
{
return "";
}
}
private static MethodInfo s_GetIconForObjectMethod;
private static MethodInfo s_SetIconForObjectMethod;
private static Texture2D GetIconForObjectReflect(UnityEngine.Object obj)
{
if (obj == null) return null;
try
{
if (s_GetIconForObjectMethod == null)
{
s_GetIconForObjectMethod = typeof(EditorGUIUtility).GetMethod(
"GetIconForObject",
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public,
null,
new[] { typeof(UnityEngine.Object) },
null);
}
return s_GetIconForObjectMethod?.Invoke(null, new object[] { obj }) as Texture2D;
}
catch
{
return null;
}
}
private static void SetIconForObjectReflect(UnityEngine.Object obj, Texture2D icon)
{
if (obj == null) return;
try
{
if (s_SetIconForObjectMethod == null)
{
s_SetIconForObjectMethod = typeof(EditorGUIUtility).GetMethod(
"SetIconForObject",
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public,
null,
new[] { typeof(UnityEngine.Object), typeof(Texture2D) },
null);
}
s_SetIconForObjectMethod?.Invoke(null, new object[] { obj, icon });
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-IconSelector] SetIconForObject failed: {ex.Message}");
}
}
private static string ResolveIconSelectorSelectedPath(UnityEngine.Object target)
{
Texture2D icon = GetIconForObjectReflect(target);
if (icon == null)
return kIconSelectorPathNone;
if (s_IconSelectorShowLabelIcons)
{
for (int i = 0; i < kIconSelectorLabelCount; i++)
{
if (IconMatchesName(icon, "sv_icon_name" + i) || IconMatchesName(icon, "sv_label_" + i))
return "label:" + i;
}
}
for (int i = 0; i < kIconSelectorDotCount; i++)
{
if (IconMatchesName(icon, "sv_icon_dot" + i + "_sml") ||
IconMatchesName(icon, "sv_icon_dot" + i + "_pix16_gizmo"))
{
return "dot:" + i;
}
}
return "";
}
private static bool IconMatchesName(Texture2D icon, string iconName)
{
if (icon == null || string.IsNullOrEmpty(iconName))
return false;
GUIContent content = EditorGUIUtility.IconContent(iconName);
return content?.image != null && ReferenceEquals(content.image, icon);
}
private static Texture2D ResolveLargeIconTexture(string path)
{
if (string.IsNullOrEmpty(path))
return null;
if (path == kIconSelectorPathNone)
return null;
if (path.StartsWith("label:", StringComparison.Ordinal))
{
if (int.TryParse(path.Substring("label:".Length), out int index))
{
GUIContent content = EditorGUIUtility.IconContent("sv_label_" + index);
return content?.image as Texture2D;
}
}
if (path.StartsWith("dot:", StringComparison.Ordinal))
{
if (int.TryParse(path.Substring("dot:".Length), out int index))
{
GUIContent content = EditorGUIUtility.IconContent("sv_icon_dot" + index + "_pix16_gizmo");
return content?.image as Texture2D;
}
}
return null;
}
private static void ApplyIconToIconSelectorTargets(Texture2D icon)
{
UnityEngine.Object[] targets = s_IconSelectorTargetObjects;
if (targets == null || targets.Length == 0)
return;
Undo.RecordObjects(targets, "Set Icon On GameObject");
foreach (UnityEngine.Object target in targets)
{
if (target == null) continue;
SetIconForObjectReflect(target, icon);
if (target is MonoScript monoScript && icon != null)
s_IconSelectorCopyIconToImporterMethod?.Invoke(null, new object[] { monoScript });
}
TryForceReloadInspectors();
TryInvokeAnnotationWindowIconChanged();
}
private static void TryForceReloadInspectors()
{
try
{
MethodInfo method = typeof(EditorUtility).GetMethod(
"ForceReloadInspectors",
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
method?.Invoke(null, null);
}
catch
{
// internal API; fall back to repaint-only below
}
InternalEditorUtility.RepaintAllViews();
}
private static void TryInvokeAnnotationWindowIconChanged()
{
try
{
Type annotationWindowType = typeof(EditorWindow).Assembly.GetType("UnityEditor.AnnotationWindow");
MethodInfo iconChanged = annotationWindowType?.GetMethod(
"IconChanged",
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
iconChanged?.Invoke(null, null);
}
catch
{
// optional
}
}
/// <summary>
/// Returns true when the icon selector popup should stay open.
/// </summary>
private static bool TryHandleIconSelectorPopupSelect(string path)
{
if (!s_IconSelectorSessionActive)
return false;
if (path == kIconSelectorPathNone)
{
ApplyIconToIconSelectorTargets(null);
s_IconSelectorSelectedPath = kIconSelectorPathNone;
return true;
}
Texture2D icon = ResolveLargeIconTexture(path);
if (icon == null && path != kIconSelectorPathNone)
{
CodelyLogger.LogWarning($"[NWB-IconSelector] Unknown icon path: {path}");
return true;
}
ApplyIconToIconSelectorTargets(icon);
s_IconSelectorSelectedPath = path;
return true;
}
private static void CloseNativeIconSelectorWindow()
{
if (s_CachedIconSelectorWindow == null)
return;
try
{
s_CachedIconSelectorWindow.Close();
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-IconSelector] Close failed: {ex.Message}");
}
}
private static void CancelIconSelectorSession()
{
CloseNativeIconSelectorWindow();
ClearIconSelectorPopupState();
if (s_OffscreenTarget != null)
{
s_OffscreenTarget.Repaint();
InternalEditorUtility.RepaintAllViews();
}
}
private static void ClearIconSelectorPopupState()
{
s_CachedIconSelectorWindow = null;
s_IconSelectorTargetObjects = null;
s_IconSelectorSessionActive = false;
s_IconSelectorShowLabelIcons = false;
s_IconSelectorSelectedPath = "";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ce4f20ec6ac047238d7a3c651f3ab061
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,104 @@
using System;
namespace UnityTcp.Editor.Native
{
// Minimal JSON parsing helpers shared across the NativeWindowBridgeHost
// partial class files. These avoid pulling in a full JSON library for
// the small, well-known message structures used by the bridge protocol.
internal static partial class NativeWindowBridgeHost
{
private static string ExtractJsonString(string json, string key)
{
string search = "\"" + key + "\":\"";
int pos = json.IndexOf(search, StringComparison.Ordinal);
if (pos < 0) return "";
pos += search.Length;
int end = json.IndexOf('"', pos);
return end < 0 ? "" : json.Substring(pos, end - pos);
}
private static float ExtractJsonFloat(string json, string key)
{
string search = "\"" + key + "\":";
int pos = json.IndexOf(search, StringComparison.Ordinal);
if (pos < 0) return 0f;
pos += search.Length;
int end = pos;
while (end < json.Length && (char.IsDigit(json[end]) || json[end] == '.' || json[end] == '-' || json[end] == 'e' || json[end] == 'E' || json[end] == '+'))
end++;
if (float.TryParse(json.Substring(pos, end - pos), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out float val))
return val;
return 0f;
}
private static int ExtractJsonInt(string json, string key)
{
return (int)ExtractJsonFloat(json, key);
}
private static bool ExtractJsonBool(string json, string key)
{
string search = "\"" + key + "\":";
int pos = json.IndexOf(search, StringComparison.Ordinal);
if (pos < 0) return false;
pos += search.Length;
while (pos < json.Length && json[pos] == ' ') pos++;
return pos < json.Length && json[pos] == 't';
}
private static float ExtractJsonArrayFloat(string json, string key, int index)
{
string search = "\"" + key + "\":[";
int pos = json.IndexOf(search, StringComparison.Ordinal);
if (pos < 0) return 0f;
pos += search.Length;
for (int i = 0; i < index; i++)
{
int comma = json.IndexOf(',', pos);
if (comma < 0) return 0f;
pos = comma + 1;
}
int end = pos;
while (end < json.Length && json[end] != ',' && json[end] != ']')
end++;
if (float.TryParse(json.Substring(pos, end - pos).Trim(),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out float val))
return val;
return 0f;
}
// Used only by SaveCompositeSlotMapping / PrePopulateCompositeSlots
// for the internal {s, i, t} JSON format. Kept separate from the
// protocol helpers above because the key format differs slightly
// (returns null instead of "" on miss).
private static string ExtractJsonStringValue(string json, string key)
{
string pattern = "\"" + key + "\":\"";
int start = json.IndexOf(pattern);
if (start < 0) return null;
start += pattern.Length;
int end = json.IndexOf('"', start);
if (end < 0) return null;
return json.Substring(start, end - start);
}
private static int ExtractJsonIntValue(string json, string key)
{
string pattern = "\"" + key + "\":";
int start = json.IndexOf(pattern);
if (start < 0) return 0;
start += pattern.Length;
int end = start;
while (end < json.Length && (char.IsDigit(json[end]) || json[end] == '-'))
end++;
if (end == start) return 0;
int val;
return int.TryParse(json.Substring(start, end - start), out val) ? val : 0;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 598f8ac157764224683170b05221725c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,539 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
namespace UnityTcp.Editor.Native
{
internal static partial class NativeWindowBridgeHost
{
#if UNITY_EDITOR_WIN
// ── New Input System (com.unity.inputsystem) input injection ──
//
// The new Input System (activeInputHandler=2) processes keyboard through
// WM_INPUT (Raw Input), NOT WM_KEYDOWN. The WM_INPUT path is guarded by
// `isFocused = GetApplication().IsFocused()` which returns false when the
// editor is offscreen. So neither WM_KEYDOWN nor WM_INPUT reaches the new
// Input System in streaming/offscreen mode.
//
// Solution: use InputSystem.QueueStateEvent to directly inject keyboard
// state into the new Input System's managed event queue, bypassing the
// native Win32 message pipeline and the isFocused check entirely.
//
// QueueStateEvent replaces the ENTIRE keyboard state, so we must track
// all held keys and send the complete state on each keydown/keyup.
// s_WinHeldKeys already tracks held keys as browser key strings.
private static Type s_InputSystemType;
private static Type s_KeyboardType;
private static Type s_KeyboardStateType;
private static Type s_KeyType;
private static System.Reflection.MethodInfo s_QueueStateEventMethod;
private static System.Reflection.MethodInfo s_GetDeviceMethod;
private static System.Reflection.MethodInfo s_KeySetMethod;
private static bool s_InputSystemRefResolved;
// Mouse-specific reflection cache (resolved alongside keyboard refs).
private static Type s_MouseType;
private static Type s_MouseStateType;
private static System.Reflection.MethodInfo s_MouseGetDeviceMethod;
private static System.Reflection.MethodInfo s_MouseQueueStateEventMethod;
// Keep the latest known screen position for cleanup paths.
private static Vector2 s_InputSystemMouseScreenPos = Vector2.zero;
private static bool s_InputSystemMouseScreenPosValid;
// Browser button values currently held (for building MouseState.buttons bitmask).
private static readonly HashSet<int> s_InputSystemHeldMouseButtons = new HashSet<int>();
private static System.Reflection.FieldInfo s_InputManagerInstanceField;
private static System.Reflection.FieldInfo s_InputManagerHasFocusField;
private static int s_InputSystemDiagCount = 0;
// Consume one diagnostic log budget slot for noisy reflection/input probes.
private static bool TryConsumeInputSystemDiagBudget(int limit = 3)
{
if (s_InputSystemDiagCount >= limit) return false;
s_InputSystemDiagCount++;
return true;
}
// Emit InputSystem diagnostics with a shared rate limit.
private static void LogInputSystemDiagLimited(string message, int limit = 3)
{
if (TryConsumeInputSystemDiagBudget(limit))
LogVerbose(message);
}
/// <summary>
/// Lazily resolve Input System types via reflection. The package may not
/// be installed in all projects, so all access is reflection-based.
/// </summary>
private static bool TryResolveInputSystemRefs()
{
if (s_InputSystemRefResolved) return true;
try
{
// Find the InputSystem assembly by name first (more reliable than GetType)
const string asmName = "Unity.InputSystem";
System.Reflection.Assembly inputAsm = null;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
if (asm.GetName().Name == asmName)
{
inputAsm = asm;
break;
}
}
// Fallback: search by type name
if (inputAsm == null)
{
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
var type = asm.GetType("UnityEngine.InputSystem.InputSystem");
if (type != null)
{
inputAsm = asm;
break;
}
}
}
if (inputAsm == null)
{
if (TryConsumeInputSystemDiagBudget())
{
var asmNames = new List<string>();
foreach (var a in AppDomain.CurrentDomain.GetAssemblies())
{
string n = a.GetName().Name;
if (n.Contains("Input") || n.Contains("input"))
asmNames.Add(n);
}
LogVerbose($"[NWB-NewInputSystem] Could not find InputSystem assembly. Input-related assemblies: [{string.Join(", ", asmNames)}]");
}
return false;
}
s_InputSystemType = inputAsm.GetType("UnityEngine.InputSystem.InputSystem");
if (s_InputSystemType == null)
{
LogInputSystemDiagLimited("[NWB-NewInputSystem] Found assembly but InputSystem type is null");
return false;
}
s_KeyboardType = s_InputSystemType.Assembly.GetType("UnityEngine.InputSystem.Keyboard");
s_KeyboardStateType = s_InputSystemType.Assembly.GetType("UnityEngine.InputSystem.LowLevel.KeyboardState");
s_KeyType = s_InputSystemType.Assembly.GetType("UnityEngine.InputSystem.Key");
if (s_KeyboardType == null || s_KeyboardStateType == null || s_KeyType == null)
{
LogInputSystemDiagLimited($"[NWB-NewInputSystem] Type resolution failed: Keyboard={s_KeyboardType != null} KeyboardState={s_KeyboardStateType != null} Key={s_KeyType != null}");
return false;
}
// InputSystem.GetDevice<Keyboard>() -> Keyboard.current
// Use GetMethods + filter to avoid AmbiguousMatchException from multiple GetDevice overloads.
foreach (var m in s_InputSystemType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
{
if (m.Name == "GetDevice" && m.IsGenericMethod && m.GetParameters().Length == 0)
{
s_GetDeviceMethod = m.MakeGenericMethod(s_KeyboardType);
break;
}
}
if (s_GetDeviceMethod == null)
{
LogInputSystemDiagLimited("[NWB-NewInputSystem] GetDevice method not found");
return false;
}
// InputSystem.QueueStateEvent<TState>(InputDevice device, TState state, double time = -1)
foreach (var m in s_InputSystemType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
{
if (m.Name == "QueueStateEvent" && m.IsGenericMethod)
{
var parms = m.GetParameters();
if (parms.Length == 3 && parms[2].ParameterType == typeof(double))
{
s_QueueStateEventMethod = m.MakeGenericMethod(s_KeyboardStateType);
break;
}
}
}
if (s_QueueStateEventMethod == null)
{
LogInputSystemDiagLimited("[NWB-NewInputSystem] QueueStateEvent method not found");
return false;
}
// KeyboardState.Set(Key key, bool state) - used to set individual key bits.
foreach (var m in s_KeyboardStateType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance))
{
if (m.Name == "Set")
{
var parms = m.GetParameters();
if (parms.Length == 2 && parms[0].ParameterType == s_KeyType && parms[1].ParameterType == typeof(bool))
{
s_KeySetMethod = m;
break;
}
}
}
// InputManager.s_Manager (singleton) and m_HasFocus field.
var inputManagerType = s_InputSystemType.Assembly.GetType("UnityEngine.InputSystem.InputManager");
if (inputManagerType != null)
{
s_InputManagerInstanceField = s_InputSystemType.GetField("s_Manager",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
if (s_InputManagerInstanceField != null)
{
s_InputManagerHasFocusField = inputManagerType.GetField("m_HasFocus",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
}
}
// ── Mouse type resolution ──
// Same pattern as Keyboard: resolve Mouse device type, MouseState struct,
// GetDevice<Mouse>() and QueueStateEvent<MouseState>() via reflection.
s_MouseType = s_InputSystemType.Assembly.GetType("UnityEngine.InputSystem.Mouse");
s_MouseStateType = s_InputSystemType.Assembly.GetType("UnityEngine.InputSystem.LowLevel.MouseState");
if (s_MouseType != null && s_MouseStateType != null)
{
// GetDevice<Mouse>()
foreach (var m in s_InputSystemType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
{
if (m.Name == "GetDevice" && m.IsGenericMethod && m.GetParameters().Length == 0)
{
s_MouseGetDeviceMethod = m.MakeGenericMethod(s_MouseType);
break;
}
}
// QueueStateEvent<MouseState>(InputDevice, MouseState, double)
foreach (var m in s_InputSystemType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
{
if (m.Name == "QueueStateEvent" && m.IsGenericMethod)
{
var parms = m.GetParameters();
if (parms.Length == 3 && parms[2].ParameterType == typeof(double))
{
s_MouseQueueStateEventMethod = m.MakeGenericMethod(s_MouseStateType);
break;
}
}
}
}
// Mark as resolved only after everything above succeeds.
s_InputSystemRefResolved = true;
return true;
}
catch (Exception ex)
{
LogInputSystemDiagLimited($"[NWB-NewInputSystem] TryResolveInputSystemRefs exception: type={ex.GetType().Name} msg={ex.Message}");
return false;
}
}
/// <summary>
/// Parse an InputSystem.Key enum name using Enum.IsDefined/Enum.Parse,
/// returning null on failure instead of throwing ArgumentException.
/// </summary>
private static object TryParseKey(string keyName)
{
if (Enum.IsDefined(s_KeyType, keyName))
return Enum.Parse(s_KeyType, keyName);
return null;
}
/// <summary>
/// Map a browser key string to an InputSystem.Key enum value.
/// Uses TryParse to avoid throwing on cross-version enum name changes.
/// </summary>
private static object BrowserKeyToInputSystemKey(string key)
{
if (string.IsNullOrEmpty(key) || s_KeyType == null) return null;
// Single-character keys: a-z, 0-9, symbols
if (key.Length == 1)
{
char c = key[0];
if (c >= 'a' && c <= 'z') return TryParseKey(c.ToString().ToUpperInvariant());
if (c >= 'A' && c <= 'Z') return TryParseKey(c.ToString());
if (c >= '0' && c <= '9') return TryParseKey("Digit" + c);
switch (c)
{
case ' ': return TryParseKey("Space");
case '-': return TryParseKey("Minus");
case '=': return TryParseKey("Equals");
case '[': return TryParseKey("LeftBracket");
case ']': return TryParseKey("RightBracket");
case ';': return TryParseKey("Semicolon");
case ',': return TryParseKey("Comma");
case '.': return TryParseKey("Period");
case '/': return TryParseKey("Slash");
case '`': return TryParseKey("Backquote");
case '\\': return TryParseKey("Backslash");
case '\'': return TryParseKey("Quote");
}
return null;
}
switch (key)
{
case "Enter":
case "Return": return TryParseKey("Enter");
case "Tab": return TryParseKey("Tab");
case "Escape": return TryParseKey("Escape");
case "Backspace": return TryParseKey("Backspace");
case "Delete": return TryParseKey("Delete");
case "Insert": return TryParseKey("Insert");
case "Home": return TryParseKey("Home");
case "End": return TryParseKey("End");
case "PageUp": return TryParseKey("PageUp");
case "PageDown": return TryParseKey("PageDown");
case "ArrowUp": return TryParseKey("UpArrow");
case "ArrowDown": return TryParseKey("DownArrow");
case "ArrowLeft": return TryParseKey("LeftArrow");
case "ArrowRight": return TryParseKey("RightArrow");
case "Shift": return TryParseKey("LeftShift");
case "Control": return TryParseKey("LeftCtrl");
case "Alt": return TryParseKey("LeftAlt");
case "Meta": return TryParseKey("LeftMeta");
case "CapsLock": return TryParseKey("CapsLock");
case "F1": return TryParseKey("F1");
case "F2": return TryParseKey("F2");
case "F3": return TryParseKey("F3");
case "F4": return TryParseKey("F4");
case "F5": return TryParseKey("F5");
case "F6": return TryParseKey("F6");
case "F7": return TryParseKey("F7");
case "F8": return TryParseKey("F8");
case "F9": return TryParseKey("F9");
case "F10": return TryParseKey("F10");
case "F11": return TryParseKey("F11");
case "F12": return TryParseKey("F12");
// Numpad keys
case "Numpad0": return TryParseKey("Numpad0");
case "Numpad1": return TryParseKey("Numpad1");
case "Numpad2": return TryParseKey("Numpad2");
case "Numpad3": return TryParseKey("Numpad3");
case "Numpad4": return TryParseKey("Numpad4");
case "Numpad5": return TryParseKey("Numpad5");
case "Numpad6": return TryParseKey("Numpad6");
case "Numpad7": return TryParseKey("Numpad7");
case "Numpad8": return TryParseKey("Numpad8");
case "Numpad9": return TryParseKey("Numpad9");
case "NumpadEnter": return TryParseKey("NumpadEnter");
case "NumpadMultiply": return TryParseKey("NumpadMultiply");
case "NumpadAdd": return TryParseKey("NumpadAdd");
case "NumpadSubtract": return TryParseKey("NumpadSubtract");
case "NumpadDecimal": return TryParseKey("NumpadDecimal");
case "NumpadDivide": return TryParseKey("NumpadDivide");
// Extended keys
case "ContextMenu": return TryParseKey("ContextMenu");
case "PrintScreen": return TryParseKey("PrintScreen");
case "ScrollLock": return TryParseKey("ScrollLock");
case "Pause": return TryParseKey("Pause");
case "NumLock": return TryParseKey("NumLock");
default: return null;
}
}
/// <summary>
/// Inject the current set of held keys into the new Input System via QueueStateEvent.
/// This bypasses the native Win32 message pipeline and the isFocused check that blocks
/// WM_INPUT in offscreen mode. Call after updating s_WinHeldKeys.
/// </summary>
private static void QueueInputSystemKeyboardState()
{
if (!TryResolveInputSystemRefs())
{
LogInputSystemDiagLimited("[NWB-NewInputSystem] TryResolveInputSystemRefs returned false - InputSystem package not found");
return;
}
try
{
// Get Keyboard.current
object keyboard = s_GetDeviceMethod.Invoke(null, null);
if (keyboard == null) return;
if (s_InputManagerInstanceField != null && s_InputManagerHasFocusField != null)
{
object manager = s_InputManagerInstanceField.GetValue(null);
if (manager != null)
{
bool currentFocus = (bool)s_InputManagerHasFocusField.GetValue(manager);
if (!currentFocus)
s_InputManagerHasFocusField.SetValue(manager, true);
}
}
object state = Activator.CreateInstance(s_KeyboardStateType);
foreach (string heldKey in s_WinHeldKeys)
{
object inputKey = BrowserKeyToInputSystemKey(heldKey);
if (inputKey != null && s_KeySetMethod != null)
s_KeySetMethod.Invoke(state, new object[] { inputKey, true });
}
double eventTime = EditorApplication.timeSinceStartup;
s_QueueStateEventMethod.Invoke(null, new object[] { keyboard, state, eventTime });
}
catch (Exception ex)
{
LogVerbose($"[NWB-NewInputSystem] QueueInputSystemKeyboardState error: type={ex.GetType().Name} msg={ex.Message}");
}
}
/// <summary>
/// Inject mouse position and button state into the new Input System via QueueStateEvent.
/// Used during streaming (browser mouse events) and by the LegacyMouseBridge (after streaming).
/// </summary>
private static void QueueInputSystemMouseState(Vector2 screenPos, int browserButton, bool isPressed)
{
if (!s_InputSystemRefResolved)
{
if (!TryResolveInputSystemRefs()) return;
}
if (s_MouseGetDeviceMethod == null || s_MouseQueueStateEventMethod == null) return;
try
{
object mouse = s_MouseGetDeviceMethod.Invoke(null, null);
if (mouse == null) return;
// Update held button set.
if (browserButton >= 0)
{
if (isPressed)
s_InputSystemHeldMouseButtons.Add(browserButton);
else
s_InputSystemHeldMouseButtons.Remove(browserButton);
}
s_InputSystemMouseScreenPos = screenPos;
s_InputSystemMouseScreenPosValid = true;
if (s_InputManagerInstanceField != null && s_InputManagerHasFocusField != null)
{
object manager = s_InputManagerInstanceField.GetValue(null);
if (manager != null)
{
bool currentFocus = (bool)s_InputManagerHasFocusField.GetValue(manager);
if (!currentFocus)
s_InputManagerHasFocusField.SetValue(manager, true);
}
}
// Build MouseState with position and button bitmask.
// buttons bitmask: bit0=left, bit1=right, bit2=middle
// Browser button mapping: 0=left→bit0, 1=middle→bit2, 2=right→bit1
ushort buttons = 0;
foreach (int btn in s_InputSystemHeldMouseButtons)
{
switch (btn)
{
case 0: buttons |= 0x0001; break; // left - > bit 0
case 1: buttons |= 0x0004; break; // middle - > bit 2
case 2: buttons |= 0x0002; break; // right - > bit 1
}
}
object state = Activator.CreateInstance(s_MouseStateType);
var posField = s_MouseStateType.GetField("position");
posField?.SetValue(state, screenPos);
var buttonsField = s_MouseStateType.GetField("buttons");
buttonsField?.SetValue(state, buttons);
// Set clickCount=1 on mousedown events to prevent FastMouse.MergeForward
// from merging the mousedown (position A) with subsequent mousedrag
// events (position B). Without this, the merged event keeps only the
// mousedrag's position, so the game's InputReader sets
// startingTouchPosition = mousedrag position instead of mousedown position,
// making dragLen = 0 and preventing drag detection.
// MergeForward skips merging when clickCount differs between events.
if (browserButton >= 0 && isPressed)
{
var clickCountField = s_MouseStateType.GetField("clickCount");
clickCountField?.SetValue(state, (ushort)1);
}
double eventTime = EditorApplication.timeSinceStartup;
s_MouseQueueStateEventMethod.Invoke(null, new object[] { mouse, state, eventTime });
}
catch (Exception ex)
{
LogInputSystemDiagLimited($"[NWB-NewInputSystem] QueueInputSystemMouseState error: type={ex.GetType().Name} msg={ex.Message}");
}
}
/// <summary>
/// Clear all held mouse buttons and queue a neutral MouseState.
/// Called from cleanup paths (play mode transition, stop offscreen, etc.)
/// </summary>
private static void ClearInputSystemMouseState()
{
if (s_InputSystemHeldMouseButtons.Count == 0) return;
s_InputSystemHeldMouseButtons.Clear();
Vector2 releasePos = s_InputSystemMouseScreenPosValid ? s_InputSystemMouseScreenPos : Vector2.zero;
QueueInputSystemMouseState(releasePos, -1, false);
}
// ── Post-streaming InputSystem recovery via domain reload ──
//
// Root cause: After streaming stops, PollAbsoluteMousePosition() stops queuing
// events into the native event buffer. The native NewInput state (event buffer,
// window handle in ScreenCoordinatesToPlayerDisplayCoordinates) is corrupted.
//
// A domain reload fixes this because:
// 1. Native side: RuntimeStatic<InputSystemState> is destroyed and recreated
// → InputEventState::Reset() clears buffers
// 2. NewInput::Open() is called again with the correct GameView window handle
// → RegisterRawInput() re-registers with fresh state
// 3. InputSystem package's InputManager re-initializes
// → onUpdate, onShouldRunUpdate callbacks re-registered
//
// Domain reload can only be triggered in Edit mode (not during Play).
// If the user stops streaming while in Play mode, we fall back to the
// LegacyMouseBridge which polls Input.mousePosition and injects via
// QueueStateEvent until the native pipeline recovers.
// SessionState key to request a domain reload after exiting Play mode.
private const string s_PendingReloadAfterPlayKey = "Codely_NWB_PendingReloadAfterPlay";
/// <summary>
/// Called from StopOffscreenCapture to recover the native InputSystem pipeline.
/// Triggers a domain reload if in Edit mode, or schedules one for after Play
/// mode exits. Falls back to LegacyMouseBridge if domain reload is unavailable.
/// </summary>
internal static void RecoverInputSystemAfterStreaming()
{
if (!EditorApplication.isPlaying)
{
// Edit mode: domain reload is safe and clean.
// It fully resets native InputSystem state (event buffers, window handles,
// raw input registration) and re-initializes the InputSystem package.
LogVerbose("[NWB-NewInputSystem] RecoverInputSystemAfterStreaming: requesting domain reload (Edit mode)");
EditorApplication.delayCall += () =>
{
EditorUtility.RequestScriptReload();
};
}
else
{
// Play mode: can't domain reload.
LogVerbose("[NWB-NewInputSystem] RecoverInputSystemAfterStreaming: in Play mode, using LegacyMouseBridge fallback");
// Schedule a domain reload for when Play mode exits.
SessionState.SetBool(s_PendingReloadAfterPlayKey, true);
}
}
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5a37b20dc39f4ab6afbfd7ae2fbc9bc5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 545f82bbdd67e0a40884db8207f8837e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,541 @@
using System;
using System.Collections;
using System.Reflection;
using System.Text;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Native
{
internal static partial class NativeWindowBridgeHost
{
// DockArea.kTabHeight — duplicated here to avoid coupling to Unity internals.
private const float kDockTabStripHeight = 19f;
// HostView/DockArea Styles.genericMenuTopOffset default (--window-generic-menu-top-offset).
private const float kGenericMenuTopOffset = 20f;
private const float kPaneOptionsButtonHeight = 18f;
private const float kPaneOptionsHitWidth = 52f;
private static bool s_PaneOptionsMouseDownPending;
private static bool s_PaneOptionsReflectionDone;
private static Type s_HostViewType;
private static MethodInfo s_HostViewAddDefaultItemsToMenu;
private static MethodInfo s_HostViewAddWindowActionMenu;
private static FieldInfo s_GenericMenuItemsField;
private static Type s_GenericMenuItemType;
private static FieldInfo s_MenuItemContentField;
private static FieldInfo s_MenuItemSeparatorField;
private static FieldInfo s_MenuItemOnField;
private static FieldInfo s_MenuItemFuncField;
private static FieldInfo s_MenuItemFunc2Field;
private static FieldInfo s_MenuItemUserDataField;
private struct CachedPaneOptionsEntry
{
public Delegate callback;
public object userData;
public bool enabled;
}
private static CachedPaneOptionsEntry[] s_CachedPaneOptionsEntries;
private static void EnsurePaneOptionsReflection()
{
if (s_PaneOptionsReflectionDone) return;
s_PaneOptionsReflectionDone = true;
try
{
var editorAsm = typeof(EditorWindow).Assembly;
s_HostViewType = editorAsm.GetType("UnityEditor.HostView");
if (s_HostViewType != null)
{
s_HostViewAddDefaultItemsToMenu = s_HostViewType.GetMethod(
"AddDefaultItemsToMenu",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
s_HostViewAddWindowActionMenu = s_HostViewType.GetMethod(
"AddWindowActionMenu",
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
}
s_GenericMenuItemsField = typeof(GenericMenu).GetField(
"m_MenuItems", BindingFlags.Instance | BindingFlags.NonPublic);
if (s_GenericMenuItemsField != null)
{
var sample = new GenericMenu();
IList list = s_GenericMenuItemsField.GetValue(sample) as IList;
if (list != null && list.Count == 0)
{
sample.AddItem(new GUIContent("x"), false, () => { });
list = s_GenericMenuItemsField.GetValue(sample) as IList;
}
if (list != null && list.Count > 0)
s_GenericMenuItemType = list[0].GetType();
}
if (s_GenericMenuItemType != null)
{
const BindingFlags f = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
s_MenuItemContentField = s_GenericMenuItemType.GetField("content", f);
s_MenuItemSeparatorField = s_GenericMenuItemType.GetField("separator", f);
s_MenuItemOnField = s_GenericMenuItemType.GetField("on", f);
s_MenuItemFuncField = s_GenericMenuItemType.GetField("func", f);
s_MenuItemFunc2Field = s_GenericMenuItemType.GetField("func2", f);
s_MenuItemUserDataField = s_GenericMenuItemType.GetField("userData", f);
}
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-PaneOptions] Reflection init failed: {ex.Message}");
}
}
/// <summary>
/// Strict DockArea tab-strip hit-test (tab header only).
/// Keep this narrow so toolbar controls (e.g. GameView Gizmos) are not
/// misclassified as tab-header clicks.
/// </summary>
private static bool IsInDockTitleBarRegion(float mouseY)
{
float tabStripH = Mathf.Max(s_TabBarOffsetY, kDockTabStripHeight);
return mouseY >= -2f && mouseY <= tabStripH + 2f;
}
/// <summary>
/// PaneOptions (⋮) button vertical band in DockArea header.
/// Unity draws it around tabStrip + genericMenuTopOffset.
/// </summary>
private static bool IsInPaneOptionsVerticalBand(float mouseY)
{
float tabStripH = Mathf.Max(s_TabBarOffsetY, kDockTabStripHeight);
float top = tabStripH + kGenericMenuTopOffset - 2f;
float bottom = top + kPaneOptionsButtonHeight + 4f;
return mouseY >= top && mouseY <= bottom;
}
private static bool IsPaneOptionsClick(float mouseX, float mouseY, float viewWidth)
{
// In Play mode, the game area starts below the toolbar (tab strip + toolbar).
// The PaneOptions vertical band extends into this region, so game UI buttons
// at the top-right (e.g. "Menu") can be mistaken for the ⋮ pane-options button
// when the GameView is narrow (composite mode). Skip PaneOptions interception
// for clicks that fall in the game play area.
bool isGameViewPlayMode = s_OffscreenTargetType != null
&& s_OffscreenTargetType.Name == "GameView"
&& EditorApplication.isPlaying;
if (isGameViewPlayMode && mouseY >= s_TabBarOffsetY + kGameViewToolbarHeight)
return false;
if (!IsInPaneOptionsVerticalBand(mouseY)) return false;
if (viewWidth <= kPaneOptionsHitWidth + 4f) return false;
return mouseX >= viewWidth - kPaneOptionsHitWidth;
}
/// <summary>
/// Unity menu shortcuts appear as either "Label _%s" (GenericMenu / GUIContent)
/// or "Label %F5" (ShortcutManager SequenceToMenuString, space-separated).
/// </summary>
private static void SplitUnityMenuText(string text, out string label, out string shortcut)
{
label = text ?? "";
shortcut = "";
if (string.IsNullOrEmpty(text)) return;
int sep = text.LastIndexOf(" _", StringComparison.Ordinal);
if (sep >= 0)
{
label = text.Substring(0, sep);
shortcut = FormatUnityShortcutDisplay(text.Substring(sep + 2));
return;
}
int space = text.LastIndexOf(' ');
if (space < 0) return;
string tail = text.Substring(space + 1);
if (!LooksLikeUnityMenuShortcutTail(tail)) return;
label = text.Substring(0, space);
shortcut = FormatUnityShortcutDisplay(tail);
}
private static bool LooksLikeUnityMenuShortcutTail(string tail)
{
if (string.IsNullOrEmpty(tail) || tail.Length < 2) return false;
int i = 0;
while (i < tail.Length)
{
char c = tail[i];
if (c == '%' || c == '#' || c == '&' || c == '^' || c == '_')
{
i++;
continue;
}
break;
}
if (i >= tail.Length || i == 0) return false;
for (int j = i; j < tail.Length; j++)
{
if (!char.IsLetterOrDigit(tail[j])) return false;
}
return true;
}
private static string FormatUnityShortcutDisplay(string raw)
{
if (string.IsNullOrEmpty(raw)) return "";
if (raw.Length > 1 && raw[0] == '_')
raw = raw.Substring(1);
var sb = new StringBuilder(raw.Length + 8);
for (int i = 0; i < raw.Length; i++)
{
switch (raw[i])
{
case '%': sb.Append("Ctrl+"); break;
case '#': sb.Append("Shift+"); break;
case '&': sb.Append("Alt+"); break;
case '^': sb.Append("Ctrl+"); break;
default: sb.Append(raw[i]); break;
}
}
string result = sb.ToString();
if (result.EndsWith("+", StringComparison.Ordinal))
result = result.Substring(0, result.Length - 1);
if (result.Length >= 2 && result[0] == 'f' && char.IsDigit(result[1]))
result = result.ToUpperInvariant();
return result;
}
private static void AppendPaneOptionsMenuItemJson(
StringBuilder sb, int index, string text, string tooltip,
bool enabled, bool isOn, bool isSep, ref int validCount)
{
SplitUnityMenuText(text, out string displayLabel, out string shortcut);
if (validCount > 0) sb.Append(',');
sb.Append("{\"index\":");
sb.Append(index);
sb.Append(",\"path\":\"");
sb.Append(EscapeJsonString(text));
sb.Append("\",\"label\":\"");
sb.Append(EscapeJsonString(displayLabel));
sb.Append("\",\"tooltip\":\"");
sb.Append(EscapeJsonString(tooltip));
sb.Append("\",\"enabled\":");
sb.Append(enabled ? "true" : "false");
sb.Append(",\"checked\":");
sb.Append(isOn ? "true" : "false");
sb.Append(",\"separator\":");
sb.Append(isSep ? "true" : "false");
if (!string.IsNullOrEmpty(shortcut))
{
sb.Append(",\"shortcut\":\"");
sb.Append(EscapeJsonString(shortcut));
sb.Append('"');
}
sb.Append('}');
validCount++;
}
/// <summary>
/// mousedown on ⋮: skip SendEvent so SceneView picking / DropdownButton
/// hotControl are not activated before mouseup builds the frontend menu.
/// </summary>
private static bool TryBeginPaneOptionsClick(float mouseX, float mouseY, float viewWidth)
{
if (s_FrontendPopupSent || s_OffscreenTarget == null) return false;
if (!IsPaneOptionsClick(mouseX, mouseY, viewWidth)) return false;
s_PaneOptionsMouseDownPending = true;
if (GUIUtility.hotControl != 0)
GUIUtility.hotControl = 0;
LogVerbose($"[NWB-PaneOptions] mousedown captured at ({mouseX:F0},{mouseY:F0})");
return true;
}
/// <summary>
/// Build the DockArea PaneOptions menu without GenericMenu.DropDown,
/// send popup_show to the browser, cache callbacks for popup_select.
/// </summary>
private static bool TryInterceptPaneOptionsClick(float mouseX, float mouseY, float viewWidth, float ppp)
{
if (s_FrontendPopupSent || s_OffscreenTarget == null) return false;
bool pendingUp = s_PaneOptionsMouseDownPending;
if (!pendingUp && !IsPaneOptionsClick(mouseX, mouseY, viewWidth)) return false;
EnsurePaneOptionsReflection();
if (s_GenericMenuItemsField == null || s_GenericMenuItemType == null)
{
CodelyLogger.LogWarning("[NWB-PaneOptions] Reflection not ready, cannot intercept");
return false;
}
try
{
object dockArea = GetParentView(s_OffscreenTarget);
if (dockArea == null) return false;
GenericMenu menu = BuildPaneOptionsGenericMenu(dockArea, s_OffscreenTarget);
if (menu == null) return false;
IList rawItems = s_GenericMenuItemsField.GetValue(menu) as IList;
if (rawItems == null || rawItems.Count == 0) return false;
float tabStripH = Mathf.Max(s_TabBarOffsetY, kDockTabStripHeight);
ComputePopupPositionFromLocalClick(
mouseX,
mouseY,
tabStripH + kGenericMenuTopOffset + 2f,
2f,
out float popX,
out float popY,
explicitSourceView: dockArea,
explicitPpp: ppp);
s_FrontendPopupIdCounter++;
s_CurrentFrontendPopupId = "popup_" + s_FrontendPopupIdCounter;
s_PopupSourceType = "pane_options";
s_CachedPaneOptionsEntries = new CachedPaneOptionsEntry[rawItems.Count];
var sb = new StringBuilder(2048);
sb.Append("{\"type\":\"popup_show\",\"id\":\"");
sb.Append(s_CurrentFrontendPopupId);
sb.Append("\",\"sourceType\":\"pane_options\",\"x\":");
sb.Append(Mathf.RoundToInt(popX));
sb.Append(",\"y\":");
sb.Append(Mathf.RoundToInt(popY));
sb.Append(",\"items\":[");
int validCount = 0;
for (int i = 0; i < rawItems.Count; i++)
{
object mi = rawItems[i];
if (mi == null) continue;
bool isSep = s_MenuItemSeparatorField != null && (bool)s_MenuItemSeparatorField.GetValue(mi);
bool isOn = s_MenuItemOnField != null && (bool)s_MenuItemOnField.GetValue(mi);
object content = s_MenuItemContentField?.GetValue(mi);
string text = "";
string tooltip = "";
if (content is GUIContent gc)
{
text = gc.text ?? "";
tooltip = gc.tooltip ?? "";
}
Delegate func = s_MenuItemFuncField?.GetValue(mi) as Delegate;
Delegate func2 = s_MenuItemFunc2Field?.GetValue(mi) as Delegate;
object userData = s_MenuItemUserDataField?.GetValue(mi);
bool enabled = func != null || func2 != null;
s_CachedPaneOptionsEntries[i] = new CachedPaneOptionsEntry
{
callback = func2 ?? func,
userData = userData,
enabled = enabled
};
AppendPaneOptionsMenuItemJson(sb, i, text, tooltip, enabled, isOn, isSep, ref validCount);
}
sb.Append("]}");
if (validCount == 0) return false;
if (GUIUtility.hotControl != 0)
GUIUtility.hotControl = 0;
CancelNativeDelayedPopup(forceRemove: true);
TryDismissWin32TrackPopupMenu();
s_PendingNativePopupCloseFrames = Math.Max(s_PendingNativePopupCloseFrames, 60);
s_SuppressTargetRepaintWhilePopupOpen = true;
bool sent = SendDataChannelMessage(sb.ToString());
s_FrontendPopupSent = sent;
if (sent)
{
s_PaneOptionsMouseDownPending = false;
CodelyLogger.Log($"[NWB-PaneOptions] Sent frontend menu: id={s_CurrentFrontendPopupId} items={validCount} target={s_OffscreenTargetType?.Name}");
}
else
{
CodelyLogger.LogWarning("[NWB-PaneOptions] Failed to send popup_show via DataChannel");
}
return sent;
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-PaneOptions] Intercept failed: {ex.Message}");
return false;
}
}
/// <summary>
/// Right-click on tab header: build and send the same DockArea menu
/// (Close Tab, Lock, Maximize, etc.) used by the ⋮ pane-options button.
/// Called from TrySynthesizeAndForwardContextMenuPopup when the click
/// falls in the tab strip region (y &lt;= s_TabBarOffsetY).
/// </summary>
private static bool TrySendTabHeaderContextMenu(float clickX, float clickY)
{
EnsurePaneOptionsReflection();
if (s_OffscreenTarget == null || s_GenericMenuItemsField == null) return false;
try
{
object dockArea = GetParentView(s_OffscreenTarget);
if (dockArea == null) return false;
GenericMenu menu = BuildPaneOptionsGenericMenu(dockArea, s_OffscreenTarget);
if (menu == null) return false;
IList rawItems = s_GenericMenuItemsField.GetValue(menu) as IList;
if (rawItems == null || rawItems.Count == 0) return false;
s_FrontendPopupIdCounter++;
s_CurrentFrontendPopupId = "popup_" + s_FrontendPopupIdCounter;
s_PopupSourceType = "pane_options";
s_CachedPaneOptionsEntries = new CachedPaneOptionsEntry[rawItems.Count];
var sb = new StringBuilder(2048);
sb.Append("{\"type\":\"popup_show\",\"id\":\"");
sb.Append(s_CurrentFrontendPopupId);
sb.Append("\",\"sourceType\":\"pane_options\",\"x\":");
float ppp = EditorGUIUtility.pixelsPerPoint;
ComputePopupPositionFromLocalClick(
clickX,
clickY,
clickY + 2f,
2f,
out float popX,
out float popY,
explicitSourceView: dockArea,
explicitPpp: ppp);
sb.Append(Mathf.RoundToInt(popX));
sb.Append(",\"y\":");
sb.Append(Mathf.RoundToInt(popY));
sb.Append(",\"items\":[");
int validCount = 0;
for (int i = 0; i < rawItems.Count; i++)
{
object mi = rawItems[i];
if (mi == null) continue;
bool isSep = s_MenuItemSeparatorField != null && (bool)s_MenuItemSeparatorField.GetValue(mi);
bool isOn = s_MenuItemOnField != null && (bool)s_MenuItemOnField.GetValue(mi);
object content = s_MenuItemContentField?.GetValue(mi);
string text = "";
string tooltip = "";
if (content is GUIContent gc)
{
text = gc.text ?? "";
tooltip = gc.tooltip ?? "";
}
Delegate func = s_MenuItemFuncField?.GetValue(mi) as Delegate;
Delegate func2 = s_MenuItemFunc2Field?.GetValue(mi) as Delegate;
object userData = s_MenuItemUserDataField?.GetValue(mi);
bool enabled = func != null || func2 != null;
s_CachedPaneOptionsEntries[i] = new CachedPaneOptionsEntry
{
callback = func2 ?? func,
userData = userData,
enabled = enabled
};
AppendPaneOptionsMenuItemJson(sb, i, text, tooltip, enabled, isOn, isSep, ref validCount);
}
sb.Append("]}");
if (validCount == 0) return false;
if (GUIUtility.hotControl != 0)
GUIUtility.hotControl = 0;
CancelNativeDelayedPopup(forceRemove: true);
TryDismissWin32TrackPopupMenu();
s_PendingNativePopupCloseFrames = Math.Max(s_PendingNativePopupCloseFrames, 60);
s_SuppressTargetRepaintWhilePopupOpen = true;
bool sent = SendDataChannelMessage(sb.ToString());
s_FrontendPopupSent = sent;
if (sent)
CodelyLogger.Log($"[NWB-PaneOptions] Sent tab-header context menu: id={s_CurrentFrontendPopupId} items={validCount} target={s_OffscreenTargetType?.Name}");
else
CodelyLogger.LogWarning("[NWB-PaneOptions] Failed to send tab-header context menu");
return sent;
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-PaneOptions] Tab header context menu failed: {ex.Message}");
return false;
}
}
private static GenericMenu BuildPaneOptionsGenericMenu(object dockArea, EditorWindow view)
{
var menu = new GenericMenu();
if (view is IHasCustomMenu customMenu)
customMenu.AddItemsToMenu(menu);
if (s_HostViewAddDefaultItemsToMenu != null && dockArea != null)
s_HostViewAddDefaultItemsToMenu.Invoke(dockArea, new object[] { menu, view });
if (s_HostViewAddWindowActionMenu != null)
s_HostViewAddWindowActionMenu.Invoke(null, new object[] { menu, view });
return menu;
}
private static void HandlePaneOptionsPopupSelect(int index)
{
if (s_CachedPaneOptionsEntries == null ||
index < 0 || index >= s_CachedPaneOptionsEntries.Length)
{
CodelyLogger.LogWarning($"[NWB-PaneOptions] Invalid select index={index}");
return;
}
CachedPaneOptionsEntry entry = s_CachedPaneOptionsEntries[index];
if (!entry.enabled || entry.callback == null)
{
LogVerbose($"[NWB-PaneOptions] Select ignored: index={index} enabled={entry.enabled}");
return;
}
try
{
if (entry.callback is GenericMenu.MenuFunction2 mf2)
mf2(entry.userData);
else if (entry.callback is GenericMenu.MenuFunction mf)
mf();
LogVerbose($"[NWB-PaneOptions] Invoked callback for index={index}");
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-PaneOptions] Callback error index={index}: {ex.Message}");
}
s_CachedPaneOptionsEntries = null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d8e7d5db7d64f5bab21ee3b8cde1f2d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d85f27e739a9aae48970ceff2bf63921
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,314 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Native
{
// Popup window interception — auto-dock to composite DockArea.
// Detects new EditorWindows created after composite streaming starts
// (e.g. via menu items or context-menu "Add Tab") and automatically
// docks them as tabs in the existing composite DockArea.
internal static partial class NativeWindowBridgeHost
{
// Countdown frames for scanning untracked panes after native "Add Tab".
// Set to >0 when a trigger is detected; decremented each frame until 0.
// Used by composite pane tracking loops on supported streaming platforms.
#pragma warning disable CS0414
private static int s_CompositeTrackPanesCountdown;
#pragma warning restore CS0414
// Baseline set of EditorWindow InstanceIDs captured when composite
// streaming starts. Windows not in this set are considered "new".
private static HashSet<long> s_CompositeKnownWindowIds = new HashSet<long>();
/// <summary>
/// Detect EditorWindows in the composite ContainerWindow that are NOT
/// tracked in s_CompositeSlots (e.g. tabs added via native "Add Tab"
/// context menu) and create slots for them so the capture loop can
/// capture their content. Called each frame from CaptureAndPushCompositeFrame.
/// </summary>
private static void TrackUntrackedCompositePanes()
{
if (!s_CompositeActive || s_CompositeSlots.Count == 0) return;
// Collect tracked InstanceIDs
var trackedIds = new HashSet<long>();
foreach (var slot in s_CompositeSlots.Values)
if (slot?.Window != null)
trackedIds.Add(slot.Window.GetStableInstanceId());
object compositeHostWindow = GetCompositeHostWindow();
if (compositeHostWindow == null) return;
// Get NormalizedRect from first slot (new tab shares the same rect)
Rect defaultRect = new Rect(0, 0, 1, 1);
foreach (var slot in s_CompositeSlots.Values)
{
if (slot != null) { defaultRect = slot.NormalizedRect; break; }
}
bool anyAdded = false;
foreach (var w in Resources.FindObjectsOfTypeAll<EditorWindow>())
{
if (w == null || trackedIds.Contains(w.GetStableInstanceId())) continue;
object parentView = GetParentView(w);
if (parentView == null) continue;
object windowHost = GetViewWindow(parentView);
if (windowHost == null || !ReferenceEquals(windowHost, compositeHostWindow))
continue;
string typeName = w.GetType().FullName ?? w.GetType().Name;
if (typeName == "UnityTcp.Editor.Native.StreamingMaskWindow") continue;
string slotId = "native-" + typeName.Replace(".", "-").ToLowerInvariant()
+ "-" + w.GetStableInstanceId();
var newSlot = new CompositeCaptureSlot
{
SlotId = slotId,
WindowTypeName = typeName,
WindowType = w.GetType(),
Window = w,
OwnsWindow = false,
CloseWindowOnRelease = true,
NormalizedRect = defaultRect
};
#if UNITY_EDITOR_WIN
newSlot.ContainerHwnd = Cn.Tuanjie.Codely.Editor.NativeWindowHelper.GetHWND(w);
IntPtr reflectedGUIView = Cn.Tuanjie.Codely.Editor.EditorWindowNativeHandleHelper
.GetGUIViewHandle(w);
if (reflectedGUIView != IntPtr.Zero)
newSlot.GUIViewHwnd = reflectedGUIView;
// Remove WS_EX_LAYERED to prevent DoPaint transparent mode
if (newSlot.ContainerHwnd != IntPtr.Zero)
{
int style = GetWindowLongW(newSlot.ContainerHwnd, GWL_EXSTYLE);
if ((style & WS_EX_LAYERED) != 0)
SetWindowLongW(newSlot.ContainerHwnd, GWL_EXSTYLE, style & ~WS_EX_LAYERED);
}
#endif
#if UNITY_EDITOR_OSX
foreach (var existSlot in s_CompositeSlots.Values)
{
if (existSlot?.Window != null && existSlot.NSWindow != IntPtr.Zero)
{ newSlot.NSWindow = existSlot.NSWindow; break; }
}
#endif
s_CompositeSlots[slotId] = newSlot;
w.autoRepaintOnSceneChange = true;
ForceDoubleLayoutRepaint(w);
w.Repaint();
anyAdded = true;
CodelyLogger.Log($"[NWB-Composite] Auto-tracked native tab: {typeName} (id={w.GetStableInstanceId()})");
}
if (anyAdded)
EditorApplication.QueuePlayerLoopUpdate();
}
/// <summary>
/// Snapshot all current EditorWindow InstanceIDs so new windows created
/// AFTER this call can be detected by InterceptAndDockNewPopupWindows.
/// Called once when composite streaming first starts.
/// </summary>
private static void InitCompositeWindowTracking()
{
s_CompositeKnownWindowIds.Clear();
foreach (var w in Resources.FindObjectsOfTypeAll<EditorWindow>())
{
if (w != null)
s_CompositeKnownWindowIds.Add(w.GetStableInstanceId());
}
CodelyLogger.Log($"[NWB-Composite] InitCompositeWindowTracking: baseline={s_CompositeKnownWindowIds.Count} windows");
}
/// <summary>
/// Detect EditorWindows that appeared AFTER composite streaming started
/// and automatically dock them as tabs in the composite DockArea.
/// Called from ManageWindowBridge when the window list changes.
/// Returns true if any windows were docked (caller should re-push the
/// window list so the /windows endpoint reflects the new docked state).
/// The frontend learns about the new tab via editor_windows_changed →
/// refreshDynamicWindows, so no separate DataChannel notification is needed.
/// </summary>
internal static bool InterceptAndDockNewPopupWindows()
{
if (!s_CompositeActive || s_CompositeSlots.Count == 0)
return false;
var allWindows = Resources.FindObjectsOfTypeAll<EditorWindow>();
var currentIds = new HashSet<long>();
var newWindows = new System.Collections.Generic.List<EditorWindow>();
foreach (var w in allWindows)
{
if (w == null) continue;
long id = w.GetStableInstanceId();
currentIds.Add(id);
if (!s_CompositeKnownWindowIds.Contains(id))
newWindows.Add(w);
}
// Update known set so closed-then-reopened windows are still tracked
s_CompositeKnownWindowIds = currentIds;
if (newWindows.Count == 0) return false;
// Collect composite slot InstanceIDs for fast lookup
var compositeInstanceIds = new HashSet<long>();
foreach (var slot in s_CompositeSlots.Values)
{
if (slot?.Window != null)
compositeInstanceIds.Add(slot.Window.GetStableInstanceId());
}
object compositeDockArea = FindExistingCompositeDockArea();
if (compositeDockArea == null)
{
CodelyLogger.Log("[NWB-PopupDock] InterceptPopup: no composite DockArea found, skipping");
return false;
}
// Get the composite's ContainerWindow for comparison
object compositeHostWindow = GetCompositeHostWindow();
bool anyDocked = false;
foreach (var w in newWindows)
{
string typeName = w.GetType().FullName ?? w.GetType().Name;
string title = w.titleContent?.text ?? "";
if (compositeInstanceIds.Contains(w.GetStableInstanceId()))
{
LogVerbose($"[NWB-PopupDock] Skip already-tracked: {typeName} \"{title}\"");
continue;
}
// Skip streaming infrastructure windows
if (typeName == "UnityTcp.Editor.Native.StreamingMaskWindow")
continue;
if (title.EndsWith(" - Streaming", StringComparison.Ordinal))
continue;
// Skip windows that have no parent (not yet fully initialized)
object parentView = GetParentView(w);
if (parentView == null)
{
LogVerbose($"[NWB-PopupDock] Skip no-parent: {typeName} \"{title}\"");
continue;
}
// Skip windows already in the composite ContainerWindow
if (compositeHostWindow != null)
{
object windowHost = GetViewWindow(parentView);
if (windowHost != null && ReferenceEquals(windowHost, compositeHostWindow))
{
LogVerbose($"[NWB-PopupDock] Skip already-in-composite: {typeName} \"{title}\"");
continue;
}
}
// Dock this popup into the composite DockArea
if (TryDockWindowToCompositeDockArea(w, compositeDockArea, typeName, title))
{
anyDocked = true;
CodelyLogger.Log($"[NWB-PopupDock] Auto-docked popup as tab: {typeName} \"{title}\"");
}
else
{
CodelyLogger.LogWarning($"[NWB-PopupDock] Failed to dock: {typeName} \"{title}\"");
}
}
// Schedule pane tracking so newly-docked windows get a proper
// CompositeCaptureSlot (required for capture after drag-out).
if (anyDocked)
s_CompositeTrackPanesCountdown = 3;
return anyDocked;
}
/// <summary>
/// Move an EditorWindow from its current DockArea into the composite
/// DockArea using RemoveTab + AddTab (same proven pattern as TabDrag).
/// The old DockArea / ContainerWindow is cleaned up automatically
/// when killIfEmpty=true.
/// </summary>
private static bool TryDockWindowToCompositeDockArea(
EditorWindow window, object targetDockArea, string typeName, string title)
{
if (window == null || targetDockArea == null) return false;
try
{
EnsureTabDragReflection();
object sourceDockArea = GetParentView(window);
if (sourceDockArea == null || sourceDockArea.GetType().Name != "DockArea")
{
LogVerbose($"[NWB-Composite] InterceptPopup: {typeName} has no source DockArea, skipping");
return false;
}
// Remove from old DockArea (killIfEmpty=true closes empty container)
if (s_DockRemoveTabMethod != null)
{
bool killIfEmpty = !ReferenceEquals(sourceDockArea, targetDockArea);
s_DockRemoveTabMethod.Invoke(sourceDockArea, new object[] { window, killIfEmpty, false });
}
else
{
LogVerbose("[NWB-Composite] InterceptPopup: RemoveTab method unavailable, trying direct AddTab");
}
// Add as tab to composite DockArea
if (s_DockAddTabIndexedMethod != null)
{
int insertIndex = GetDockPaneCount(targetDockArea);
s_DockAddTabIndexedMethod.Invoke(targetDockArea, new object[] { insertIndex, window, false });
}
else
{
// Fallback: use the simpler AddTab(EditorWindow, bool) overload
System.Type dockAreaType = targetDockArea.GetType();
MethodInfo addTab = dockAreaType.GetMethod("AddTab",
BindingFlags.Instance | BindingFlags.Public,
null,
new System.Type[] { typeof(EditorWindow), typeof(bool) },
null);
if (addTab == null)
{
CodelyLogger.LogWarning("[NWB-Composite] InterceptPopup: no AddTab method found");
return false;
}
addTab.Invoke(targetDockArea, new object[] { window, false });
}
// Enable autoRepaintOnSceneChange for proper capture rendering
window.autoRepaintOnSceneChange = true;
// Initialize GPU buffers for capture
ForceDoubleLayoutRepaint(window);
window.Repaint();
EditorApplication.QueuePlayerLoopUpdate();
// Repaint composite windows to reflect new tab bar
foreach (CompositeCaptureSlot slot in s_CompositeSlots.Values)
slot?.Window?.Repaint();
return true;
}
catch (Exception ex)
{
CodelyLogger.LogWarning(
$"[NWB-Composite] InterceptPopup: failed to dock {typeName}: " +
(ex.InnerException?.Message ?? ex.Message));
return false;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f4dae5430152464ea32a160ba046a027
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f8ca3f52a6bb41eda025a75d988e23ab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,253 @@
using System;
using System.Collections;
using System.Reflection;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Native
{
internal static partial class NativeWindowBridgeHost
{
private static Vector2 s_LastInputScreenPoint;
private static bool s_TabDragReflectionDone;
private static Type s_DockAreaType;
private static FieldInfo s_DockIsDraggingField;
private static FieldInfo s_DockDragPaneField;
private static FieldInfo s_DockDropInfoField;
private static FieldInfo s_DockOriginalDragSourceField;
private static FieldInfo s_DockPanesField;
private static MethodInfo s_DockResetDragVarsMethod;
private static MethodInfo s_DockRemoveTabMethod;
private static MethodInfo s_DockAddTabIndexedMethod;
private static PropertyInfo s_DockSelectedProperty;
private static Type s_DropInfoType;
private static FieldInfo s_DropInfoDropAreaField;
private static Type s_PaneDragTabType;
private static PropertyInfo s_PaneDragTabGetProperty;
private static MethodInfo s_PaneDragTabCloseMethod;
private static void EnsureTabDragReflection()
{
if (s_TabDragReflectionDone) return;
s_TabDragReflectionDone = true;
try
{
var editorAsm = typeof(EditorWindow).Assembly;
s_DockAreaType = editorAsm.GetType("UnityEditor.DockArea");
s_DropInfoType = editorAsm.GetType("UnityEditor.DropInfo");
s_PaneDragTabType = editorAsm.GetType("UnityEditor.PaneDragTab");
if (s_DockAreaType != null)
{
const BindingFlags f = BindingFlags.Static | BindingFlags.NonPublic;
s_DockIsDraggingField = s_DockAreaType.GetField("s_IsDragging", f);
s_DockDragPaneField = s_DockAreaType.GetField("s_DragPane", f);
s_DockDropInfoField = s_DockAreaType.GetField("s_DropInfo", f);
s_DockOriginalDragSourceField = s_DockAreaType.GetField("s_OriginalDragSource", f);
s_DockPanesField = s_DockAreaType.GetField("m_Panes", BindingFlags.Instance | BindingFlags.NonPublic);
s_DockResetDragVarsMethod = s_DockAreaType.GetMethod("ResetDragVars", f);
s_DockRemoveTabMethod = s_DockAreaType.GetMethod(
"RemoveTab",
BindingFlags.Instance | BindingFlags.Public,
null,
new[] { typeof(EditorWindow), typeof(bool), typeof(bool) },
null);
s_DockAddTabIndexedMethod = s_DockAreaType.GetMethod(
"AddTab",
BindingFlags.Instance | BindingFlags.Public,
null,
new[] { typeof(int), typeof(EditorWindow), typeof(bool) },
null);
s_DockSelectedProperty = s_DockAreaType.GetProperty(
"selected", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
}
if (s_DropInfoType != null)
s_DropInfoDropAreaField = s_DropInfoType.GetField("dropArea", BindingFlags.Instance | BindingFlags.Public);
if (s_PaneDragTabType != null)
{
s_PaneDragTabGetProperty = s_PaneDragTabType.GetProperty(
"get", BindingFlags.Static | BindingFlags.Public);
s_PaneDragTabCloseMethod = s_PaneDragTabType.GetMethod(
"Close", BindingFlags.Instance | BindingFlags.Public);
}
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-TabDrag] Reflection init failed: {ex.Message}");
}
}
private static void UpdateLastInputScreenPoint(float rawX, float rawY, float ppp, Vector2 localMousePos)
{
if (s_CompositeActive)
{
s_LastInputScreenPoint = new Vector2(
s_CompositeFrameBoundsLogical.xMin +
(rawX - s_CompositeFrameOffsetPixels.x) /
Mathf.Max(s_CompositeFrameScalePixels.x * ppp, 0.001f),
s_CompositeFrameBoundsLogical.yMin +
(rawY - s_CompositeFrameOffsetPixels.y) /
Mathf.Max(s_CompositeFrameScalePixels.y * ppp, 0.001f));
return;
}
object parentView = s_OffscreenTarget != null ? GetParentView(s_OffscreenTarget) : null;
if (parentView != null)
{
Rect viewRect = GetViewScreenPosition(parentView);
s_LastInputScreenPoint = new Vector2(viewRect.xMin + localMousePos.x, viewRect.yMin + localMousePos.y);
}
else
{
s_LastInputScreenPoint = localMousePos;
}
}
private static bool IsStreamingTabDragPendingFloatingWindow()
{
if (!s_CompositeActive && !s_OffscreenActive) return false;
EnsureTabDragReflection();
if (s_DockIsDraggingField == null || !(bool)s_DockIsDraggingField.GetValue(null))
return false;
object dropInfo = s_DockDropInfoField?.GetValue(null);
if (dropInfo != null && s_DropInfoDropAreaField != null)
{
object dropArea = s_DropInfoDropAreaField.GetValue(dropInfo);
if (dropArea != null)
return false;
}
return s_DockDragPaneField?.GetValue(null) is EditorWindow;
}
private static object FindDockAreaAtScreenPoint(Vector2 screenPoint)
{
object bestDock = null;
float bestArea = float.MaxValue;
if (s_CompositeActive)
{
foreach (CompositeCaptureSlot slot in s_CompositeSlots.Values)
{
if (slot?.Window == null) continue;
object dock = GetParentView(slot.Window);
if (dock == null || dock.GetType().Name != "DockArea") continue;
Rect rect = GetViewScreenPosition(dock);
if (!rect.Contains(screenPoint)) continue;
float area = rect.width * rect.height;
if (area < bestArea)
{
bestArea = area;
bestDock = dock;
}
}
if (bestDock != null) return bestDock;
return FindExistingCompositeDockArea();
}
if (s_OffscreenTarget != null)
{
object dock = GetParentView(s_OffscreenTarget);
if (dock != null && dock.GetType().Name == "DockArea")
return dock;
}
return null;
}
private static int GetDockPaneCount(object dockArea)
{
if (dockArea == null || s_DockPanesField == null) return 0;
IList panes = s_DockPanesField.GetValue(dockArea) as IList;
return panes?.Count ?? 0;
}
/// <summary>
/// When a tab is released over the streaming canvas center, Unity would
/// create a new floating ContainerWindow (DropInfo.dropArea == null).
/// Re-route that drop as AddTab on the DockArea under the cursor instead.
/// Edge/pane drops (dropArea set) still go through SendEvent normally.
/// </summary>
private static bool TryCompleteStreamingTabDragAsAddTab(Vector2 screenMousePos)
{
if (!IsStreamingTabDragPendingFloatingWindow()) return false;
EnsureTabDragReflection();
if (s_DockRemoveTabMethod == null || s_DockAddTabIndexedMethod == null)
return false;
var dragPane = s_DockDragPaneField?.GetValue(null) as EditorWindow;
var originalSource = s_DockOriginalDragSourceField?.GetValue(null);
if (dragPane == null || originalSource == null) return false;
object targetDock = FindDockAreaAtScreenPoint(screenMousePos);
if (targetDock == null)
{
LogVerbose("[NWB-TabDrag] No target DockArea for AddTab fallback");
return false;
}
try
{
bool killIfEmpty = !ReferenceEquals(originalSource, targetDock);
s_DockRemoveTabMethod.Invoke(originalSource, new object[] { dragPane, killIfEmpty, false });
int insertIndex = GetDockPaneCount(targetDock);
s_DockAddTabIndexedMethod.Invoke(targetDock, new object[] { insertIndex, dragPane, false });
if (s_DockSelectedProperty != null && s_DockSelectedProperty.CanWrite)
s_DockSelectedProperty.SetValue(targetDock, insertIndex);
CloseActivePaneDragTab();
s_DockResetDragVarsMethod?.Invoke(null, null);
if (GUIUtility.hotControl != 0)
GUIUtility.hotControl = 0;
dragPane.Repaint();
if (targetDock is ScriptableObject dockSo)
{
MethodInfo repaint = dockSo.GetType().GetMethod(
"Repaint", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
null, Type.EmptyTypes, null);
repaint?.Invoke(dockSo, null);
}
if (s_CompositeActive)
{
foreach (CompositeCaptureSlot slot in s_CompositeSlots.Values)
slot?.Window?.Repaint();
}
else
{
s_OffscreenTarget?.Repaint();
}
CodelyLogger.Log(
$"[NWB-TabDrag] AddTab fallback: {dragPane.GetType().Name} -> DockArea at ({screenMousePos.x:F0},{screenMousePos.y:F0})");
return true;
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-TabDrag] AddTab fallback failed: {ex.Message}");
return false;
}
}
private static void CloseActivePaneDragTab()
{
if (s_PaneDragTabGetProperty == null || s_PaneDragTabCloseMethod == null) return;
object paneDrag = s_PaneDragTabGetProperty.GetValue(null, null);
if (paneDrag != null)
s_PaneDragTabCloseMethod.Invoke(paneDrag, null);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 671d1e5f6ca4427ca5de143bfb9a7e2c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 37098c3bc4e96b248976396cda60381b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,274 @@
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Native
{
/// <summary>
/// A utility EditorWindow shown during offscreen streaming.
/// Displays a message that the editor is hidden
/// and provides a button to disconnect and restore the editor.
///
/// The native window handle is excluded from hide operations in
/// NativeWindowBridgeHost, so it remains visible while other Unity
/// windows are hidden.
///
/// Focus() calls triggered by popup creation target specific Unity
/// ContainerWindows (not all windows), so this mask window is NOT
/// brought to front by those calls.
/// </summary>
internal class StreamingMaskWindow : EditorWindow
{
private static StreamingMaskWindow s_Instance;
private static bool s_ClosingFromCode;
private GUIStyle _titleStyle;
private GUIStyle _messageStyle;
private GUIStyle _buttonStyle;
private bool _stylesInitialized;
private const float kWindowWidth = 380f;
private const float kWindowHeight = 170f;
#if TUANJIE_1_OR_NEWER || TUANJIE_1
private const string kEditorName = "Tuanjie Editor";
#else
private const string kEditorName = "Unity Editor";
#endif
internal static string WindowTitle => kEditorName + " - Streaming";
internal static StreamingMaskWindow TryGetInstance() => s_Instance;
internal static StreamingMaskWindow ShowMask()
{
if (s_Instance != null)
{
ApplyBottomRightPosition(s_Instance);
s_Instance.ShowUtility();
CodelyLogger.Log("[StreamingMask] Mask window re-shown");
return s_Instance;
}
// After domain reload, s_Instance is null but a serialized
// EditorWindow instance may still exist. Reuse it — Close()+create
// races OnDestroy (Unity Close is often deferred) and the dying
// window would clear the new s_Instance after a few reconnects.
var existing = Resources.FindObjectsOfTypeAll<StreamingMaskWindow>();
StreamingMaskWindow first = null;
foreach (var w in existing)
{
if (w == null) continue;
if (first == null)
{
first = w;
continue;
}
// Extra domain-reload leftovers. Close is deferred, but
// OnDestroy only clears s_Instance when s_Instance == this.
try { w.Close(); }
catch (System.Exception) { }
}
if (first != null)
{
s_Instance = first;
first.titleContent = new GUIContent(WindowTitle);
first.minSize = new Vector2(kWindowWidth, kWindowHeight);
first.maxSize = new Vector2(kWindowWidth, kWindowHeight);
ApplyBottomRightPosition(first);
first.ShowUtility();
CodelyLogger.Log("[StreamingMask] Mask window reused after domain reload");
return first;
}
var win = CreateInstance<StreamingMaskWindow>();
win.titleContent = new GUIContent(WindowTitle);
win.minSize = new Vector2(kWindowWidth, kWindowHeight);
win.maxSize = new Vector2(kWindowWidth, kWindowHeight);
win.ShowUtility();
ApplyBottomRightPosition(win);
s_Instance = win;
CodelyLogger.Log("[StreamingMask] Mask window shown");
return win;
}
internal static void ApplyBottomRightPosition(EditorWindow win)
{
if (win == null) return;
// Bottom-right of the primary screen, just above the taskbar.
// Screen.currentResolution is physical pixels; EditorWindow.position is logical.
const float kTaskbarHeight = 48f;
const float kBottomMargin = 8f;
float dpiScale = EditorGUIUtility.pixelsPerPoint;
float logicalScreenW = Screen.currentResolution.width / dpiScale;
float logicalScreenH = Screen.currentResolution.height / dpiScale;
win.position = new Rect(
logicalScreenW - kWindowWidth,
logicalScreenH - kTaskbarHeight - kWindowHeight - kBottomMargin,
kWindowWidth,
kWindowHeight);
}
/// <summary>
/// Programmatically close the mask window (called from StopOffscreenCapture).
/// Sets a guard flag to prevent OnDestroy from triggering a recursive stop.
/// </summary>
internal static void HideMask()
{
s_ClosingFromCode = true;
try
{
if (s_Instance != null)
{
s_Instance.Close();
}
// Domain reload can clear s_Instance while the serialized
// EditorWindow object still exists. Close any such zombies.
foreach (var w in Resources.FindObjectsOfTypeAll<StreamingMaskWindow>())
{
if (w != null && w != s_Instance)
{
w.Close();
}
}
}
catch (System.Exception ex)
{
CodelyLogger.LogWarning($"[StreamingMask] HideMask close failed: {ex.Message}");
}
finally
{
s_ClosingFromCode = false;
s_Instance = null;
}
CodelyLogger.Log("[StreamingMask] Mask window hidden");
}
internal static bool IsActive => s_Instance != null;
private void OnGUI()
{
InitStyles();
GUILayout.FlexibleSpace();
EditorGUILayout.LabelField(kEditorName, _titleStyle);
EditorGUILayout.Space(8);
EditorGUILayout.LabelField(
"Editor is hidden while streaming via Codely Cowork.",
_messageStyle);
EditorGUILayout.Space(16);
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button(
"Disconnect & Restore",
_buttonStyle,
GUILayout.Width(200),
GUILayout.Height(32)))
{
EditorApplication.delayCall += () =>
{
NativeWindowBridgeHost.StopOffscreenCapture();
};
}
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
GUILayout.FlexibleSpace();
}
private void OnDestroy()
{
// Close() is often deferred. A dying leftover instance must not
// clear a newer s_Instance or stop an active stream.
if (s_Instance != this)
return;
s_Instance = null;
if (s_ClosingFromCode)
return;
// User closed the mask window manually (e.g. clicked X).
// Treat this as a disconnect request.
if (NativeWindowBridgeHost.IsOffscreenActive)
{
CodelyLogger.Log("[StreamingMask] User closed mask window, stopping offscreen capture");
EditorApplication.delayCall += () =>
{
NativeWindowBridgeHost.StopOffscreenCapture();
};
}
}
private void InitStyles()
{
if (_stylesInitialized) return;
_stylesInitialized = true;
_titleStyle = new GUIStyle(EditorStyles.boldLabel)
{
fontSize = 18,
alignment = TextAnchor.MiddleCenter
};
_messageStyle = new GUIStyle(EditorStyles.label)
{
fontSize = 12,
alignment = TextAnchor.MiddleCenter,
wordWrap = true,
normal = { textColor = new Color(0.7f, 0.7f, 0.7f) }
};
_buttonStyle = new GUIStyle(GUI.skin.button)
{
fontSize = 13,
fontStyle = FontStyle.Bold,
padding = new RectOffset(16, 16, 6, 6)
};
}
}
}
#else
using UnityEditor;
namespace UnityTcp.Editor.Native
{
/// <summary>
/// No-op placeholder for non-Windows/macOS editor platforms.
/// This keeps cross-platform compilation valid when shared code
/// references StreamingMaskWindow in common paths.
/// </summary>
internal class StreamingMaskWindow : EditorWindow
{
internal static string WindowTitle => "Editor - Streaming";
internal static StreamingMaskWindow TryGetInstance() => null;
internal static StreamingMaskWindow ShowMask()
{
return null;
}
internal static void ApplyBottomRightPosition(EditorWindow win)
{
}
internal static void HideMask()
{
// Intentionally empty on unsupported platforms.
}
internal static bool IsActive
{
get { return false; }
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d48a4e6a1b3c7d5e9f0a2b4c6d8e0f12
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: