using System;
using System.Collections.Generic;
using System.Globalization;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.IO;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.Rendering;
using UnityEngine.UIElements;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Native
{
///
/// High-level managed host for NativeWindowBridge.
/// Handles availability probing, domain reload safety (start-or-attach),
/// and UTF-8 buffer management for native JSON exchange.
/// Follows the same pattern as NativeTcpBridgeHost.
///
internal static partial class NativeWindowBridgeHost
{
// Verbose logging toggle for streaming-related diagnostics.
// When true, all [NWB-*] logs are printed. When false, only
// errors and critical state changes are printed.
// Toggle at runtime via menu: Codely > Streaming > Toggle Verbose Log
internal static bool s_OffscreenVerboseLog = true;
///
/// Log only when verbose mode is enabled. Use for high-frequency
/// or detailed diagnostic messages. Errors and critical state
/// changes should use CodelyLogger.Log/LogWarning/LogError directly.
///
private static void LogVerbose(string msg)
{
if (s_OffscreenVerboseLog)
CodelyLogger.Log(msg);
}
private const int JsonBufferSize = 512 * 1024;
private static readonly byte[] JsonBuffer = new byte[JsonBufferSize];
private static bool availabilityChecked;
private static bool available;
private static string availabilityReason = "Native availability not checked yet.";
#if UNITY_EDITOR_WIN
private static bool windowsDllSearchPathConfigured;
private static bool windowsNativeDllLoaded;
#endif
#if UNITY_EDITOR_OSX
private static bool macNativeDylibLoadAttempted;
private static bool macNativeDylibLoaded;
private static string macNativeDylibLoadDetail = "Not attempted.";
#endif
// pollingRegistered is non-serialized; resets to false on domain reload.
// This is intentional: EditorApplication.update subscriptions are also
// cleared on reload, so we must re-register.
private static bool pollingRegistered;
// Called after domain reload to re-register polling if server is still running.
[UnityEditor.InitializeOnLoadMethod]
private static void OnDomainReload()
{
// Register cleanup handler for the NEXT domain reload so we can null the
// native log callback before C# delegates are garbage-collected. Without
// this the old DLL instance's background threads would call a dangling
// function pointer and crash.
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
// Listen for play mode changes to force-repaint GameView immediately
// after exiting play mode, preventing a multi-second frame stall.
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
// Subscribe to Unity Progress API so we can forward compilation
// status descriptions to the cowork frontend via DataChannel.
RegisterCompilationProgressTracker();
// Delay check to avoid calling into native too early.
EditorApplication.delayCall += () =>
{
if (IsAvailable() && IsRunning())
{
// Re-register log callback (nulled in OnBeforeAssemblyReload
// to prevent dangling C# delegate during reload).
RegisterNativeLogCallback();
RegisterOffscreenPolling();
TryRestoreOffscreenAfterReload();
}
// Notify browser that domain reload is complete so it can
// exit the domain_reloading overlay immediately.
SendDataChannelMessage("{\"type\":\"domain_reload_complete\"}");
CodelyLogger.Log("[NWB-DC] Sent: {\"type\":\"domain_reload_complete\"}");
// Defensive self-heal: if Unity was left in accessory policy
// after an abnormal stream shutdown/reload, restore normal app visibility.
EnsureMacEditorVisibilitySelfHeal();
};
}
///
/// During play mode transitions Unity may internally re-layout, re-show, or
/// re-activate windows, breaking the offscreen hiding state. Re-apply
/// transparency and deactivation on EVERY state change (including
/// ExitingEditMode and ExitingPlayMode) to prevent the editor from
/// flashing visible or staying visible after Stop.
///
// Remaining extra repaints after play-mode transition. Decremented each
// editor update frame so repaints spread across distinct frames instead of
// batching in a single tick (EditorApplication.delayCall can coalesce).
private static int s_PendingPlayModeRepaints;
private static void OnPlayModeStateChanged(PlayModeStateChange state)
{
#if UNITY_EDITOR_WIN
// Even when offscreen is not active, we may need to restore InputSystem
// device state after a prior streaming session disabled devices via
// OnFocusChanged(false). When the user enters Play mode without streaming,
// Mouse.current may still have DisabledWhileInBackground flag set.
if (!s_OffscreenActive && state == PlayModeStateChange.EnteredPlayMode)
{
EditorApplication.delayCall += () =>
{
if (!EditorApplication.isPlaying) return;
};
}
#endif
if (!s_OffscreenActive)
{
#if UNITY_EDITOR_WIN
// If a domain reload was requested while in Play mode (via
// RecoverInputSystemAfterStreaming), execute it now that we've
// exited Play mode.
if (state == PlayModeStateChange.EnteredEditMode &&
SessionState.GetBool(s_PendingReloadAfterPlayKey, false))
{
SessionState.SetBool(s_PendingReloadAfterPlayKey, false);
CodelyLogger.Log("[NWB-NewInputSystem] Executing pending domain reload after Play mode exit");
EditorApplication.delayCall += () => EditorUtility.RequestScriptReload();
}
#endif
return;
}
#if UNITY_EDITOR_WIN
s_WinPlayKeyLastFocusTime = 0;
s_WinHeldKeys.Clear();
ClearInputSystemMouseState();
#endif
#if UNITY_EDITOR_OSX
s_MacPlayKeyboardActive = false;
s_MacPlayKeyLastTime = 0;
s_MacHeldKeys.Clear();
s_MacHeldMouseButtons.Clear();
s_MacKeyReinjectionFrames = 0;
#endif
// Immediately re-hide on all transitions to prevent flash.
ReapplyWindowTransparency();
DeactivateUnityKeepWindows();
// Composite mode: mirror Tuanjie's native behavior of auto-
// activating GameView on Play and restoring previous tab on Stop.
// Tuanjie does this via EditorApplicationLayout.InitPlaymodeLayout
// → WindowLayout.ShowAppropriateViewOnEnterExitPlaymode → Focus().
// Use SessionState because domain reload happens between
// ExitingEditMode and EnteredPlayMode, wiping static fields.
if (s_CompositeActive && s_CompositeSlots != null && s_CompositeSlots.Count > 1)
{
if (state == PlayModeStateChange.ExitingEditMode)
{
string activeType = GetCompositeActiveWindowTypeName();
if (!string.IsNullOrEmpty(activeType))
{
SessionState.SetString(kCompositePrePlayTabKey, activeType);
LogVerbose($"[NWB-Composite] Saved pre-play active tab: {activeType}");
}
ActivateCompositeGameViewTab();
}
else if (state == PlayModeStateChange.EnteredPlayMode)
{
ActivateCompositeGameViewTab();
}
else if (state == PlayModeStateChange.EnteredEditMode)
{
string savedType = SessionState.GetString(kCompositePrePlayTabKey, "");
SessionState.EraseString(kCompositePrePlayTabKey);
if (!string.IsNullOrEmpty(savedType))
RestoreCompositeTabByTypeName(savedType);
}
}
if (state == PlayModeStateChange.EnteredEditMode ||
state == PlayModeStateChange.EnteredPlayMode)
{
if (s_OffscreenTarget != null)
{
s_OffscreenTarget.Repaint();
s_PendingPlayModeRepaints = 3;
EditorApplication.update += PlayModeRepaintTick;
}
}
}
private static void PlayModeRepaintTick()
{
if (s_PendingPlayModeRepaints <= 0 || !s_OffscreenActive || s_OffscreenTarget == null)
{
s_PendingPlayModeRepaints = 0;
EditorApplication.update -= PlayModeRepaintTick;
return;
}
s_PendingPlayModeRepaints--;
s_OffscreenTarget.Repaint();
}
///
/// Called BEFORE assembly reload. The native C++ singleton (HTTP server,
/// WebRTC session, encoder) survives domain reload because it lives in
/// the unmanaged DLL address space. All HTTP handler callbacks are pure
/// C++ lambdas capturing the native singleton — no C# delegates involved.
/// So we keep the server running and only clean up C# side state.
/// The only C# callback is the log callback, which must be nulled to
/// prevent the native thread from calling a dangling managed delegate.
///
private const string kOrphanedFloatingWindowKey = "NWB_OrphanedFloatingWindowId";
private const string kWindowRectsSaveKey = "NWB_SavedWindowRects";
private const string kClientDprKey = "NWB_ClientDPR";
private static void OnBeforeAssemblyReload()
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
// Notify browser that domain reload is imminent
SendDataChannelMessage("{\"type\":\"domain_reload\"}");
CodelyLogger.Log("[NWB-DC] Sent: {\"type\":\"domain_reload\"}");
// Unsubscribe from Progress events — compilation is done,
// domain reload is starting. The domain_reload message
// above already notifies the frontend to show the reload overlay.
UnregisterCompilationProgressTracker();
// Close floating capture window (single-view mode only)
if (s_OffscreenAutoFloatingWindow)
{
if (s_OffscreenFloatingWindowInstanceId != 0)
SessionState.SetString(kOrphanedFloatingWindowKey, s_OffscreenFloatingWindowInstanceId.ToString());
if (s_OffscreenTarget != null)
{
try { s_OffscreenTarget.Close(); }
catch (Exception) { }
}
}
// Composite mode: save slot mapping, request JSON, and release RTs,
// but keep EditorWindows alive. DockArea serializes m_Panes/m_Selected,
// so the active tab survives domain reload automatically.
if (s_CompositeActive)
{
SaveCompositeSlotMapping();
ReleaseCompositeRenderTargets();
// Save composite request so TryRestoreOffscreenAfterReload can
// auto-resume without waiting for the browser to re-send layout.
if (!string.IsNullOrEmpty(s_LastCompositeRequestJson))
{
SessionState.SetString(kCompositeRestoreJsonKey, s_LastCompositeRequestJson);
SessionState.SetInt(kCompositeRestoreFpsKey, s_OffscreenFps);
}
CodelyLogger.Log("[NWB] Released composite RTs before assembly reload (windows preserved)");
}
else if (s_CompositeRT != null || s_CompositePaneDragRT != null || s_CompositeReadbackTex != null)
{
ReleaseCompositeRenderTargets();
}
// Persist client DPR so capture scaling survives domain reload.
SessionState.SetFloat(kClientDprKey, s_ClientDPR);
// Persist closed-docked-GameView flag so restoration survives domain reload.
SessionState.SetBool(kClosedDockedGameViewKey, s_ClosedDockedGameViewForStreaming);
// Sibling type name is already saved in CloseExistingGameViewsForStreaming.
// Drop popup/context-menu state without notifying the browser — the
// data channel is about to be torn down and stale MenuController refs
// must not survive into the next domain.
ClearFrontendPopupState(notifyFrontend: false);
// Reset C# tracking flags; will be restored after reload via
// TryRestoreOffscreenAfterReload / PollOffscreenRequests.
s_OffscreenActive = false;
s_OffscreenLoopRegistered = false;
// Null the log callback — this is the ONLY C# delegate the native
// side holds. NWB_SetLogCallback is an atomic store, never blocks.
try
{
NativeWindowBridgeAPI.NWB_SetLogCallback(null);
s_NativeLogCallbackRegistered = false;
s_NativeLogCallback = null;
}
catch (Exception) { }
// NOTE: Do NOT call NWB_StopServer or NWB_StopCapture here.
// The native singleton keeps the HTTP server, WebRTC session,
// and offscreen mode alive across domain reload. Benefits:
// - Port stays the same (no re-bind)
// - WebRTC connection persists (browser doesn't disconnect)
// - No deadlock risk (Stop() is never called on main thread)
// - TryRestoreOffscreenAfterReload detects active offscreen
// and auto-resumes frame pushing after reload completes
#endif
}
// Remaining re-hide passes after domain reload restore. Decremented each
// editor update frame so passes spread across distinct frames.
private static int s_PendingReloadRehidePasses;
private static void ReloadRehideTick()
{
if (s_PendingReloadRehidePasses <= 0 || !s_OffscreenActive)
{
s_PendingReloadRehidePasses = 0;
EditorApplication.update -= ReloadRehideTick;
return;
}
s_PendingReloadRehidePasses--;
#if UNITY_EDITOR_WIN
ReapplyWindowTransparency();
DeactivateUnityKeepWindows();
#elif UNITY_EDITOR_OSX
ReapplyWindowTransparency();
DeactivateUnityKeepWindows();
#endif
}
// =====================================================================
// Compilation progress tracking: subscribe to UnityEditor.Progress
// events and forward compilation status to the cowork frontend via
// DataChannel so the browser overlay can display live build info.
// UnityEditor.Progress API is only available in Unity 2020+.
// =====================================================================
private static bool s_CompilationProgressRegistered;
#if UNITY_2020_2_OR_NEWER
private static void RegisterCompilationProgressTracker()
{
if (s_CompilationProgressRegistered) return;
s_CompilationProgressRegistered = true;
Progress.added += OnCompilationProgressAdded;
Progress.updated += OnCompilationProgressUpdated;
Progress.removed += OnCompilationProgressRemoved;
// Scan existing items in case compilation already started
foreach (var item in Progress.EnumerateItems())
{
if (item.name == "Compiling Scripts" && item.running)
{
CodelyLogger.Log($"[NWB-DC] Progress found existing: name=\"{item.name}\" description=\"{item.description}\"");
SendCompilationProgressMessage(item.description);
}
}
}
private static void UnregisterCompilationProgressTracker()
{
if (!s_CompilationProgressRegistered) return;
s_CompilationProgressRegistered = false;
Progress.added -= OnCompilationProgressAdded;
Progress.updated -= OnCompilationProgressUpdated;
Progress.removed -= OnCompilationProgressRemoved;
}
private static void OnCompilationProgressAdded(Progress.Item[] items)
{
foreach (var item in items)
{
if (item.name == "Compiling Scripts" && item.running)
{
CodelyLogger.Log($"[NWB-DC] Progress.added: name=\"{item.name}\" description=\"{item.description}\"");
SendCompilationProgressMessage(item.description);
return;
}
}
}
private static void OnCompilationProgressUpdated(Progress.Item[] items)
{
foreach (var item in items)
{
if (item.name == "Compiling Scripts" && item.running)
{
CodelyLogger.Log($"[NWB-DC] Progress.updated: name=\"{item.name}\" description=\"{item.description}\" progress={item.progress}");
SendCompilationProgressMessage(item.description);
return;
}
}
}
private static void OnCompilationProgressRemoved(Progress.Item[] items)
{
foreach (var item in items)
{
if (item.name == "Compiling Scripts")
{
CodelyLogger.Log($"[NWB-DC] Progress.removed: name=\"{item.name}\" status={item.status}");
// Check for actual compilation errors via console log entries.
// Progress API always reports Succeeded even on compile failure.
int? errors = CompilationHelper.GetCompilationErrors();
bool hasErrors = errors.HasValue && errors.Value > 0;
string phase = hasErrors ? "failed" : "compile_done";
string json = "{\"type\":\"compilation_progress\",\"phase\":\"" + phase + "\"";
if (hasErrors)
json += ",\"errors\":" + errors.Value;
json += "}";
try { SendDataChannelMessage(json); } catch (Exception) { }
CodelyLogger.Log($"[NWB-DC] Sent: {json}");
return;
}
}
}
private static void SendCompilationProgressMessage(string description)
{
if (string.IsNullOrEmpty(description)) description = "";
string json = "{\"type\":\"compilation_progress\",\"phase\":\"compiling\",\"description\":\""
+ EscapeJsonString(description) + "\"}";
try { SendDataChannelMessage(json); } catch (Exception) { }
CodelyLogger.Log($"[NWB-DC] Sent: {json}");
}
#else // UNITY_2020_2_OR_NEWER
// Unity 2019 fallback: no Progress API, use CompilationPipeline events
private static void RegisterCompilationProgressTracker()
{
if (s_CompilationProgressRegistered) return;
s_CompilationProgressRegistered = true;
UnityEditor.Compilation.CompilationPipeline.compilationStarted += OnCompilationStarted_2019;
UnityEditor.Compilation.CompilationPipeline.compilationFinished += OnCompilationFinished_2019;
}
private static void UnregisterCompilationProgressTracker()
{
if (!s_CompilationProgressRegistered) return;
s_CompilationProgressRegistered = false;
UnityEditor.Compilation.CompilationPipeline.compilationStarted -= OnCompilationStarted_2019;
UnityEditor.Compilation.CompilationPipeline.compilationFinished -= OnCompilationFinished_2019;
}
private static void OnCompilationStarted_2019(object obj)
{
CodelyLogger.Log("[NWB-DC] CompilationPipeline.compilationStarted");
string json = "{\"type\":\"compilation_progress\",\"phase\":\"compiling\",\"description\":\"\"}";
try { SendDataChannelMessage(json); } catch (Exception) { }
CodelyLogger.Log($"[NWB-DC] Sent: {json}");
}
private static void OnCompilationFinished_2019(object obj)
{
CodelyLogger.Log("[NWB-DC] CompilationPipeline.compilationFinished");
int? errors = CompilationHelper.GetCompilationErrors();
bool hasErrors = errors.HasValue && errors.Value > 0;
string phase = hasErrors ? "failed" : "compile_done";
string json = "{\"type\":\"compilation_progress\",\"phase\":\"" + phase + "\"";
if (hasErrors)
json += ",\"errors\":" + errors.Value;
json += "}";
try { SendDataChannelMessage(json); } catch (Exception) { }
CodelyLogger.Log($"[NWB-DC] Sent: {json}");
}
private static void SendCompilationProgressMessage(string description)
{
// No-op on Unity 2019 — no Progress API to provide descriptions
}
#endif
///
/// Check if the C++ side was running an offscreen capture before domain reload.
/// If so, auto-resume pushing frames so the browser stream recovers seamlessly.
///
private static void TryRestoreOffscreenAfterReload()
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
// Safety net: close any orphaned floating capture window that
// survived the domain reload. OnBeforeAssemblyReload should
// have closed it, but if that failed (exception, forced
// reload, etc.), the saved instance ID lets us find it.
long orphanId = long.TryParse(
SessionState.GetString(kOrphanedFloatingWindowKey, "0"), out long parsedOrphanId)
? parsedOrphanId : 0L;
if (orphanId != 0)
{
SessionState.SetString(kOrphanedFloatingWindowKey, "0");
foreach (var win in Resources.FindObjectsOfTypeAll())
{
if (win != null && win.GetStableInstanceId() == orphanId)
{
try
{
win.Close();
CodelyLogger.Log("[NWB] Closed orphaned floating capture window after reload");
}
catch (Exception) { }
break;
}
}
}
try
{
// Restore client DPR so capture scaling continues after reload.
s_ClientDPR = SessionState.GetFloat(kClientDprKey, 0f);
SessionState.EraseFloat(kClientDprKey);
// Restore closed-docked-GameView flag so StopOffscreenCapture
// knows to reopen the GameView after streaming ends.
s_ClosedDockedGameViewForStreaming = SessionState.GetBool(kClosedDockedGameViewKey, false);
byte[] buf = new byte[256];
int len = NativeWindowBridgeAPI.NWB_GetActiveOffscreenWindowType(buf, buf.Length);
if (len <= 0) return;
string windowType = System.Text.Encoding.UTF8.GetString(buf, 0, len);
if (string.IsNullOrEmpty(windowType)) return;
if (windowType == "__composite__")
{
// Rediscover EditorWindows that survived domain reload
// via Unity serialization. Pre-populating s_CompositeSlots
// lets ApplyCompositeLayout reuse them instead of creating
// new windows (avoiding AddTab and tab-order issues).
PrePopulateCompositeSlots();
// Auto-resume composite streaming using saved request JSON.
// Without this, a deadlock occurs: C# waits for the browser
// to re-send the layout, while the browser's stall detector
// sees C++ heartbeats alive and never calls tryResumeOffscreen.
string savedJson = SessionState.GetString(kCompositeRestoreJsonKey, "");
int savedFps = SessionState.GetInt(kCompositeRestoreFpsKey, 30);
SessionState.EraseString(kCompositeRestoreJsonKey);
SessionState.EraseInt(kCompositeRestoreFpsKey);
if (!string.IsNullOrEmpty(savedJson))
{
CodelyLogger.Log("[NWB] Domain reload detected active composite offscreen; auto-resuming with saved layout...");
bool startOk = StartCompositeOffscreenCapture(savedJson, savedFps, 0, 0,
nativeAlreadyStarted: true, applyMode: CompositeLayoutApplyMode.PreserveExisting);
if (startOk)
{
CodelyLogger.Log("[NWB] Composite offscreen auto-resumed after domain reload");
s_PendingReloadRehidePasses = 5;
EditorApplication.update += ReloadRehideTick;
}
else
{
CodelyLogger.LogWarning("[NWB] Composite auto-resume failed, resetting native state");
ForceResetNativeOffscreenState();
}
}
else
{
CodelyLogger.Log("[NWB] Domain reload detected active composite offscreen; no saved layout, waiting for browser refresh.");
}
return;
}
// Only resume if C# is not already running offscreen (shouldn't be after reload).
if (s_OffscreenActive) return;
CodelyLogger.Log($"[NWB] Domain reload detected active offscreen: {windowType}, auto-resuming...");
// Native offscreen mode is already active in C++ (it survived reload),
// so pass nativeAlreadyStarted=true to skip re-initializing native side.
bool ok = StartOffscreenCapture(windowType, fps: 30, width: 0, height: 0,
nativeAlreadyStarted: true);
if (ok)
{
CodelyLogger.Log($"[NWB] Offscreen capture auto-resumed for {windowType} after domain reload");
// Play-mode entry can re-layout windows after MakeAllWindowsTransparent
// runs inside StartOffscreenCapture. Schedule extra re-hide passes over
// the next few frames to catch any late window position changes.
s_PendingReloadRehidePasses = 5;
EditorApplication.update += ReloadRehideTick;
}
else
{
// C# restore failed but native may still think offscreen is active — reset
// native state so the browser can start a fresh stream instead of hanging.
CodelyLogger.LogWarning($"[NWB] Failed to auto-resume offscreen capture for {windowType}, resetting native offscreen");
ForceResetNativeOffscreenState();
}
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB] TryRestoreOffscreenAfterReload error: {ex.Message}");
ForceResetNativeOffscreenState();
}
#endif
}
public static bool IsSupportedPlatform =>
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
true;
#else
false;
#endif
public static string GetAvailabilityReason() => availabilityReason;
///
/// Probe whether the native plugin is loadable. Cached after first success.
///
public static bool IsAvailable(bool forceRefresh = false)
{
if (!IsSupportedPlatform)
{
availabilityChecked = true;
available = false;
availabilityReason = "Platform not supported by NativeWindowBridge.";
return false;
}
if (availabilityChecked && (!forceRefresh || available))
{
return available;
}
availabilityChecked = true;
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
#if UNITY_EDITOR_WIN
EnsureWindowsDllSearchPath();
#elif UNITY_EDITOR_OSX
EnsureMacDylibLoaded();
#endif
_ = NativeWindowBridgeAPI.NWB_IsRunning();
available = true;
availabilityReason = "NativeWindowBridge library loaded successfully.";
#endif
}
catch (DllNotFoundException ex)
{
available = false;
availabilityReason = $"DllNotFoundException: {ex.Message}";
#if UNITY_EDITOR_OSX
if (macNativeDylibLoadAttempted)
{
availabilityReason += $" | mac preload: {macNativeDylibLoadDetail}";
}
#endif
}
catch (EntryPointNotFoundException ex)
{
available = false;
availabilityReason = $"EntryPointNotFoundException: {ex.Message}";
#if UNITY_EDITOR_OSX
if (macNativeDylibLoadAttempted)
{
availabilityReason += $" | mac preload: {macNativeDylibLoadDetail}";
}
#endif
}
catch (Exception ex)
{
available = false;
availabilityReason = $"Unexpected: {ex.GetType().Name}: {ex.Message}";
CodelyLogger.LogWarning($"[NWB] Availability check failed: {availabilityReason}");
}
return available;
}
#if UNITY_EDITOR_WIN
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool SetDllDirectory(string lpPathName);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr LoadLibrary(string lpFileName);
///
/// Ensure Windows can resolve transitive native dependencies (e.g. datachannel.dll)
/// when loading NativeWindowBridge.dll from the Unity package Plugins folder.
/// After domain reload Mono P/Invoke will load a new DLL instance from the new
/// package path. This is acceptable because OnBeforeAssemblyReload stops the old
/// instance's server, making the new instance the sole authority.
/// SetDllDirectory must still point to the correct folder so that transitive
/// dependencies (datachannel.dll, libssl, libcrypto) can be resolved.
///
private static void EnsureWindowsDllSearchPath()
{
if (windowsDllSearchPathConfigured)
{
return;
}
try
{
foreach (string folder in EnumerateNativeBridgeFolders())
{
if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder))
{
continue;
}
string nativeBridgePath = Path.Combine(folder, "NativeWindowBridge.dll");
if (!File.Exists(nativeBridgePath))
{
continue;
}
string dependencyPath = Path.Combine(folder, "datachannel.dll");
if (!File.Exists(dependencyPath))
{
CodelyLogger.LogWarning($"[NWB] datachannel.dll not found next to NativeWindowBridge.dll: {dependencyPath}");
}
if (SetDllDirectory(folder))
{
windowsDllSearchPathConfigured = true;
CodelyLogger.Log($"[NWB] SetDllDirectory configured: {folder}");
EnsureWindowsNativeDllLoaded(nativeBridgePath, dependencyPath);
if (windowsNativeDllLoaded)
{
return;
}
}
}
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB] EnsureWindowsDllSearchPath failed: {ex.Message}");
}
CodelyLogger.LogWarning("[NWB] Could not configure Windows DLL search path for NativeWindowBridge.dll.");
}
private static void EnsureWindowsNativeDllLoaded(string nativeBridgePath, string dependencyPath)
{
if (windowsNativeDllLoaded)
{
return;
}
// Load dependency first to avoid transitive resolution failure.
if (!string.IsNullOrEmpty(dependencyPath) && File.Exists(dependencyPath))
{
IntPtr dep = LoadLibrary(dependencyPath);
if (dep == IntPtr.Zero)
{
int depErr = Marshal.GetLastWin32Error();
CodelyLogger.LogWarning($"[NWB] LoadLibrary(datachannel.dll) failed. path={dependencyPath}, win32={depErr}");
}
}
IntPtr lib = LoadLibrary(nativeBridgePath);
if (lib == IntPtr.Zero)
{
int err = Marshal.GetLastWin32Error();
CodelyLogger.LogWarning($"[NWB] LoadLibrary(NativeWindowBridge.dll) failed. path={nativeBridgePath}, win32={err}");
return;
}
windowsNativeDllLoaded = true;
CodelyLogger.Log($"[NWB] LoadLibrary succeeded: {nativeBridgePath}");
}
private static IEnumerable EnumerateNativeBridgeFolders()
{
var folders = new HashSet(StringComparer.OrdinalIgnoreCase);
// Find imported DLL assets via AssetDatabase.
string[] guids = AssetDatabase.FindAssets("NativeWindowBridge t:DefaultAsset");
foreach (string guid in guids)
{
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
if (string.IsNullOrEmpty(assetPath) || !assetPath.EndsWith("NativeWindowBridge.dll", StringComparison.OrdinalIgnoreCase))
{
continue;
}
string fullPath = Path.GetFullPath(assetPath);
string dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
folders.Add(dir);
}
}
// Fallback: common package locations in the current Unity project.
string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
folders.Add(Path.Combine(projectRoot, "Packages", "cn.tuanjie.codely.bridge", "Plugins", "Win"));
string packageCacheRoot = Path.Combine(projectRoot, "Library", "PackageCache");
if (Directory.Exists(packageCacheRoot))
{
foreach (string dir in Directory.GetDirectories(packageCacheRoot, "cn.tuanjie.codely.bridge*"))
{
folders.Add(Path.Combine(dir, "Plugins", "Win"));
}
}
return folders;
}
#endif
#if UNITY_EDITOR_OSX
[DllImport("/usr/lib/libSystem.B.dylib")]
private static extern IntPtr dlopen(string path, int mode);
[DllImport("/usr/lib/libSystem.B.dylib")]
private static extern IntPtr dlerror();
private const int RtldNow = 2;
private const int RtldGlobal = 8;
///
/// Ensure macOS can resolve transitive dylib dependencies on Intel/Apple Silicon.
/// Some packaging flows produce bare-name linkage (e.g. libdatachannel.dylib),
/// so we proactively dlopen dependencies from known package folders.
///
private static void EnsureMacDylibLoaded()
{
if (macNativeDylibLoaded)
{
return;
}
macNativeDylibLoadAttempted = true;
foreach (string folder in EnumerateMacNativeBridgeFolders())
{
if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder))
{
continue;
}
string dependencyPath = Path.Combine(folder, "libdatachannel.dylib");
string nativeBridgePath = Path.Combine(folder, "libNativeWindowBridge.dylib");
if (!File.Exists(nativeBridgePath))
{
continue;
}
if (!TryDlopenMac(dependencyPath, out string depError))
{
CodelyLogger.LogWarning($"[NWB] macOS pre-load dependency failed: {depError}");
}
if (TryDlopenMac(nativeBridgePath, out string bridgeError))
{
macNativeDylibLoaded = true;
macNativeDylibLoadDetail = $"Loaded from {nativeBridgePath}";
return;
}
macNativeDylibLoadDetail = bridgeError;
CodelyLogger.LogWarning($"[NWB] macOS pre-load NativeWindowBridge failed: {bridgeError}");
}
if (!macNativeDylibLoaded)
{
CodelyLogger.LogWarning($"[NWB] macOS pre-load exhausted all candidate folders without success. detail={macNativeDylibLoadDetail}");
}
}
private static bool TryDlopenMac(string path, out string error)
{
if (string.IsNullOrEmpty(path))
{
error = "Path is empty.";
return false;
}
if (!File.Exists(path))
{
error = $"File not found: {path}";
return false;
}
IntPtr handle = dlopen(path, RtldNow | RtldGlobal);
if (handle != IntPtr.Zero)
{
error = string.Empty;
return true;
}
string dlError = ReadMacDlError();
error = $"dlopen failed: path={path}, detail={dlError}";
return false;
}
private static string ReadMacDlError()
{
IntPtr ptr = dlerror();
if (ptr == IntPtr.Zero)
{
return "unknown";
}
string msg = Marshal.PtrToStringAnsi(ptr);
return string.IsNullOrEmpty(msg) ? "unknown" : msg;
}
private static IEnumerable EnumerateMacNativeBridgeFolders()
{
var folders = new HashSet(StringComparer.OrdinalIgnoreCase);
// Find imported dylib assets via AssetDatabase.
string[] guids = AssetDatabase.FindAssets("libNativeWindowBridge t:DefaultAsset");
foreach (string guid in guids)
{
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
if (
string.IsNullOrEmpty(assetPath)
|| !assetPath.EndsWith("libNativeWindowBridge.dylib", StringComparison.OrdinalIgnoreCase)
)
{
continue;
}
string fullPath = Path.GetFullPath(assetPath);
string dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
folders.Add(dir);
}
}
// Fallback: common package locations in the current Unity project.
string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
folders.Add(Path.Combine(projectRoot, "Packages", "cn.tuanjie.codely.bridge", "Plugins", "macOS"));
string packageCacheRoot = Path.Combine(projectRoot, "Library", "PackageCache");
if (Directory.Exists(packageCacheRoot))
{
foreach (string dir in Directory.GetDirectories(packageCacheRoot, "cn.tuanjie.codely.bridge*"))
{
folders.Add(Path.Combine(dir, "Plugins", "macOS"));
}
}
return folders;
}
#endif
public static bool IsRunning()
{
if (!IsAvailable()) return false;
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NativeWindowBridgeAPI.NWB_IsRunning() == 1;
#else
return false;
#endif
}
catch { return false; }
}
///
/// Start or re-attach to the native HTTP streaming server.
/// Idempotent: if already running, returns the existing bound port.
///
public static bool StartOrAttach(int requestedPort, out int boundPort)
{
boundPort = 0;
// Force-refresh availability here so a previous transient load failure
// does not keep the stream server permanently unavailable.
if (!IsAvailable(forceRefresh: true)) return false;
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
// Register log callback BEFORE any other native call so we capture
// all C++ log output from this point forward.
RegisterNativeLogCallback();
int result = NativeWindowBridgeAPI.NWB_StartServer(requestedPort);
if (result <= 0)
{
availabilityReason = $"NWB_StartServer returned {result} for requested port {requestedPort}.";
return false;
}
boundPort = NativeWindowBridgeAPI.NWB_GetBoundPort();
if (boundPort <= 0) boundPort = result;
// Always register polling when the server is up. OnDomainReload may run
// before AutoStartStreamServer, leaving polling unregistered until restart.
RegisterOffscreenPolling();
return true;
#else
return false;
#endif
}
catch (Exception ex)
{
availabilityReason = $"StartOrAttach exception: {ex.GetType().Name}: {ex.Message}";
CodelyLogger.LogError($"[NWB] Start failed: {ex.Message}");
return false;
}
}
// Static reference prevents the delegate from being garbage-collected while
// the native DLL holds a pointer to it.
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
private static NativeWindowBridgeAPI.NWB_LogCallbackDelegate s_NativeLogCallback;
private static bool s_NativeLogCallbackRegistered;
#endif
private static void RegisterNativeLogCallback()
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
if (s_NativeLogCallbackRegistered) return;
try
{
s_NativeLogCallback = (level, message) =>
{
if (level >= 2) CodelyLogger.LogError(message);
else if (level == 1) CodelyLogger.LogWarning(message);
else CodelyLogger.Log(message);
};
NativeWindowBridgeAPI.NWB_SetLogCallback(s_NativeLogCallback);
s_NativeLogCallbackRegistered = true;
}
catch (EntryPointNotFoundException)
{
CodelyLogger.LogWarning("[NWB] NWB_SetLogCallback not found in native DLL — native logs will not appear in Unity Console");
}
#endif
}
///
/// Register EditorApplication.update polling for offscreen capture requests.
///
private static void RegisterOffscreenPolling()
{
if (pollingRegistered) return;
pollingRegistered = true;
EditorApplication.update += PollOffscreenRequests;
CodelyLogger.Log("[NWB] Offscreen polling registered on EditorApplication.update.");
}
///
/// Polled every editor frame. Checks if native has a pending offscreen start request.
/// Runs on Unity main thread so all EditorWindow APIs are safe to call.
///
private static readonly byte[] s_OffscreenWindowTypeBuf = new byte[256];
private static readonly byte[] s_CompositeRequestBuf = new byte[512 * 1024];
private static void PollOffscreenRequests()
{
if (!IsAvailable()) return;
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
try
{
// Check for pending offscreen start request.
// Always consume pending requests even when s_OffscreenActive is true.
// When the user switches between Game View and Scene View, the native
// server stores the new target type as a pending request. If we only
// check when !s_OffscreenActive, the C# side never learns about the
// new type and keeps pushing frames from the old window.
int hasPending = NativeWindowBridgeAPI.NWB_GetPendingOffscreenRequest(
s_OffscreenWindowTypeBuf, s_OffscreenWindowTypeBuf.Length,
out int offFps, out int offW, out int offH, out float offDpr);
if (hasPending == 1)
{
int len = Array.IndexOf(s_OffscreenWindowTypeBuf, (byte)0);
if (len < 0) len = s_OffscreenWindowTypeBuf.Length;
string windowType = Encoding.UTF8.GetString(s_OffscreenWindowTypeBuf, 0, len);
if (offDpr > 0f) s_ClientDPR = offDpr;
if (s_OffscreenActive)
{
string currentType = s_OffscreenTarget != null ? s_OffscreenTarget.GetType().FullName : "(null)";
CodelyLogger.Log($"[NWB] Offscreen switch request while active: {currentType} → {windowType} fps={offFps} w={offW} h={offH}");
}
else
{
CodelyLogger.Log($"[NWB] Offscreen request: type={windowType} fps={offFps} w={offW} h={offH}");
}
// Always call NWB_StartOffscreenCapture from C# to ensure the
// native side activates offscreen mode. The HTTP handler only
// stores the pending request; activation requires this explicit call.
bool ok = StartOffscreenCapture(windowType, offFps, offW, offH, nativeAlreadyStarted: false);
if (!ok)
{
CodelyLogger.LogError($"[NWB] StartOffscreenCapture failed for type={windowType} fps={offFps} w={offW} h={offH}");
}
}
int hasCompositePending = NativeWindowBridgeAPI.NWB_GetPendingCompositeRequest(
s_CompositeRequestBuf, s_CompositeRequestBuf.Length,
out int compositeFps, out int compositeW, out int compositeH, out float compositeDpr);
if (hasCompositePending == 1)
{
if (compositeDpr > 0f) s_ClientDPR = compositeDpr;
int len = Array.IndexOf(s_CompositeRequestBuf, (byte)0);
if (len < 0) len = s_CompositeRequestBuf.Length;
string json = Encoding.UTF8.GetString(s_CompositeRequestBuf, 0, len);
bool ok = StartCompositeOffscreenCapture(json, compositeFps, compositeW, compositeH);
if (!ok)
{
CodelyLogger.LogError($"[NWB] StartCompositeOffscreenCapture failed fps={compositeFps} w={compositeW} h={compositeH}");
}
}
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB] PollOffscreenRequests error: {ex.Message}\n{ex.StackTrace}");
}
#endif
}
public static void StopServer()
{
// Always clean up C# offscreen state first so window transparency
// (WS_EX_TRANSPARENT) and input interception are restored even if
// the native call below throws or the poll loop hasn't run yet.
if (s_OffscreenActive)
{
StopOffscreenCapture();
}
if (!IsAvailable()) return;
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
NativeWindowBridgeAPI.NWB_StopServer();
#endif
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB] Stop failed: {ex.Message}");
}
}
public static int GetBoundPort()
{
if (!IsAvailable()) return 0;
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NativeWindowBridgeAPI.NWB_GetBoundPort();
#else
return 0;
#endif
}
catch { return 0; }
}
///
/// Tell the native side the Unity Editor's OS PID so it can enumerate
/// OS-native windows (CGWindowID on macOS) for that process.
///
public static void SetUnityPid(int pid)
{
if (!IsAvailable()) return;
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
NativeWindowBridgeAPI.NWB_SetUnityPid(pid);
#endif
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB] SetUnityPid failed: {ex.Message}");
}
}
///
/// Push the current EditorWindow list JSON into native as metadata.
/// The /windows endpoint merges this with OS-native window enumeration.
///
public static bool UpdateWindowList(string windowsJson)
{
if (!IsAvailable() || string.IsNullOrEmpty(windowsJson)) return false;
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
return NativeWindowBridgeAPI.NWB_UpdateWindowList(windowsJson) == 1;
#else
return false;
#endif
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB] UpdateWindowList failed: {ex.Message}");
return false;
}
}
public static string GetWindowListJson()
{
if (!IsAvailable()) return "[]";
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
if (NativeWindowBridgeAPI.NWB_GetWindowListJson(
JsonBuffer, JsonBuffer.Length, out int bytes) == 1 &&
bytes > 0 && bytes <= JsonBuffer.Length)
{
return Encoding.UTF8.GetString(JsonBuffer, 0, bytes);
}
#endif
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB] GetWindowListJson failed: {ex.Message}");
}
return "[]";
}
public static string GetStreamStatusJson()
{
if (!IsAvailable()) return "{\"capturing\":false}";
try
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
if (NativeWindowBridgeAPI.NWB_GetStreamStatusJson(
JsonBuffer, JsonBuffer.Length, out int bytes) == 1 &&
bytes > 0 && bytes <= JsonBuffer.Length)
{
return Encoding.UTF8.GetString(JsonBuffer, 0, bytes);
}
#endif
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB] GetStreamStatusJson failed: {ex.Message}");
}
return "{\"capturing\":false}";
}
// =====================================================================
// Offscreen rendering: capture RenderTexture from EditorWindow, push
// BGRA pixels to native encoder, and poll input events from DataChannel.
// =====================================================================
private static bool s_OffscreenActive;
internal static bool IsOffscreenActive => s_OffscreenActive;
private static bool s_OffscreenLoopRegistered;
// Re-entrancy guard: prevents concurrent/nested StopOffscreenCapture calls
// from heartbeat-timeout, mask disconnect, StopServer, and tab-close paths.
private static bool s_IsStoppingOffscreen;
// Set to true during ReleaseCompositeState to tell ReleaseCompositeSlot
// to close ContainerWindows (not during ApplyCompositeLayout slot replacement).
private static bool s_IsReleasingComposite;
// Saved Application.runInBackground value before streaming.
// Streaming requires runInBackground=true so the player loop
// keeps running when the editor loses OS focus (user switches
// to the Codely streaming app).
private static bool s_SavedRunInBackground;
private static bool s_RunInBackgroundOverridden;
private static EditorWindow s_OffscreenTarget;
private static System.Type s_OffscreenTargetType;
// If true, we created a floating capture window from a docked target
// so we can resize it independently for streaming quality.
private static bool s_OffscreenAutoFloatingWindow;
private static EditorWindow s_OffscreenOriginalDockedTarget;
// True when we closed the editor's docked GameView(s) during streaming to
// prevent their size info from interfering with the floating streaming view.
private static bool s_ClosedDockedGameViewForStreaming;
private const string kClosedDockedGameViewKey = "NWB_ClosedDockedGameView";
private const string kClosedDockedGameViewSiblingKey = "NWB_ClosedDockedGameViewSibling";
// Keep applying frontend resize target for a few frames until GUIView catches up.
private static bool s_OffscreenResizeTargetActive;
private static int s_OffscreenResizeTargetWidth;
private static int s_OffscreenResizeTargetHeight;
private static int s_OffscreenResizeRetryFrames;
private const int kOffscreenResizeRetryMaxFrames = 20;
private static RenderTexture s_CaptureRT;
private static Texture2D s_ReadbackTex;
// Diagnostic: sample captured pixels to detect black-frame issues
// private static long s_GrabPixelsDiagCounter;
// private static Texture2D s_DiagTex;
// When GrabPixels consistently returns RGBA=(0,0,0,0), the SwapChain
// back-buffer is empty (typical on Unity 2019 Gamma color space where
// no AuxBackBufferManager intermediate RT exists, so DoPaint→Present
// clears the SwapChain before GrabPixels reads). Once detected, switch
// to the GameView's internal m_RenderTexture which survives Present.
//
// Detection: prefer PlayerSettings.colorSpace == Gamma (instant, no
// wasted frames), fall back to pixel-sampling if needed.
private static bool s_UseRenderTextureFallback;
// Unity 2019 (especially Gamma + PrintWindow path) has known tab-drag
// instability in DockArea.DragTab. Keep a narrow safety guard only for
// that version branch, while allowing tab-drag split on newer versions.
private static bool s_LoggedUnity2019TabDragGuard;
private static bool IsUnity2019Editor =>
Application.unityVersion.StartsWith("2019.", StringComparison.Ordinal);
// PrintWindow API cached resources for Unity 2019 Gamma fallback.
// Bypasses GrabPixels entirely by using DWM composition capture.
private static IntPtr s_PwDC;
private static IntPtr s_PwBitmap;
private static IntPtr s_PwOldBmp;
private static IntPtr s_PwBits;
private static int s_PwDibW, s_PwDibH;
private static Texture2D s_PwTex;
private static byte[] s_PwBuffer;
// This field is actively used on Win/macOS streaming paths, but Linux
// builds compile out those paths and can report CS0414 noise.
#pragma warning disable CS0414
private static int s_OffscreenFps = 30;
#pragma warning restore CS0414
private static double s_NextCaptureTime;
private static int s_OffscreenFrameCount;
private static readonly byte[] s_InputBuffer = new byte[4096];
private static bool s_DockAreaTabActivated;
// Saved DockArea state for restoration on stop.
private static object s_DockAreaInstance;
private static int s_OriginalDockAreaSelectedIndex = -1;
// Saved EditorWindow.position at streaming start for black-screen recovery.
private static Rect s_OriginalTargetPosition;
// Height difference between GUIView and EditorWindow (tab bar + border).
// Used for RT size calculation and GrabPixels sourceRect sizing.
// NOT used for coordinate adjustment since SendEvent uses GUIView coords.
private static float s_TabBarOffsetY;
///
/// Client devicePixelRatio received from the browser. 0 means not provided
/// (use local pixelsPerPoint only). Stored across domain reload via SessionState.
///
private static float s_ClientDPR;
///
/// Local pixelsPerPoint (min 1). On Windows this is the OS DPI scaling
/// factor; on macOS Metal handles backing scale internally so returns 1.
///
private static float LocalPP =>
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
Mathf.Max(EditorGUIUtility.pixelsPerPoint, 1f);
#else
1f;
#endif
///
/// Effective DPI scale: max(clientDPR, LocalPP). When the remote client
/// has a higher DPR than the local machine, capture at clientDPR so the
/// browser receives enough pixels for a sharp image.
///
private static float DPIScale =>
s_ClientDPR > 0 ? Mathf.Max(s_ClientDPR, LocalPP) : LocalPP;
///
/// Scale factor to convert frontend CSS pixels to Unity window logical points.
/// When DPIScale > LocalPP, the window must be larger in logical points
/// so the backbuffer (= windowLogical × LocalPP) matches the capture RT
/// (= CSS_size × DPIScale).
///
private static float DpiScaleFactor => DPIScale / LocalPP;
// During Windows offscreen GameView streaming, force-disable "Low Resolution"
// to avoid blurry output after toolbar/preset interactions.
#if UNITY_EDITOR_WIN
private static bool s_GameViewLowResOriginalCaptured;
private static bool s_GameViewLowResOriginalValue;
private static bool s_GameViewLowResForcedOff;
#endif
private static long s_OffscreenFloatingWindowInstanceId;
#if UNITY_EDITOR_WIN
private static int s_OffscreenForegroundHideDiagCount;
#endif
// ── macOS P/Invoke: direct Objective-C runtime calls ──
// Strategy: make all Unity windows transparent (alpha=0) with
// setIgnoresMouseEvents:YES so they remain on-screen but invisible.
// This avoids [NSApp hide:] which causes windowDidResignKey →
// GUIUtility.hotControl=0 (breaking drags) and lockFocusIfCanDraw=NO
// (preventing backing store updates for GrabPixels).
//
// SceneView.HandleClickAndDragToFocus() calls Focus() on every
// mouseDown on macOS → GUIView::Focus() → [NSApp activateIgnoringOtherApps:YES].
// With transparent windows, this activation is invisible to the user,
// and mouse events pass through to the browser via ignoresMouseEvents.
#if UNITY_EDITOR_OSX
[DllImport("libobjc.dylib", EntryPoint = "objc_getClass")]
private static extern IntPtr objc_getClass(string name);
[DllImport("libobjc.dylib", EntryPoint = "sel_registerName")]
private static extern IntPtr sel_registerName(string name);
[DllImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
private static extern IntPtr objc_msgSend_RetPtr(IntPtr receiver, IntPtr selector);
[DllImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_void_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg);
[DllImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_void(IntPtr receiver, IntPtr selector);
[DllImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_void_double(IntPtr receiver, IntPtr selector, double arg);
[DllImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_void_bool(IntPtr receiver, IntPtr selector, bool arg);
// Return an id (pointer) from a message with one IntPtr argument.
// Used for [NSArray objectAtIndex:].
[DllImport("libobjc.dylib", EntryPoint = "objc_msgSend")]
private static extern IntPtr objc_msgSend_RetPtr_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg);
private static IntPtr s_NSApp = IntPtr.Zero;
private static IntPtr s_HideSel = IntPtr.Zero;
private static IntPtr s_UnhideSel = IntPtr.Zero;
private static IntPtr s_SetAlphaValSel = IntPtr.Zero;
private static IntPtr s_SetIgnoresMouseSel = IntPtr.Zero;
private static IntPtr s_SetActivationPolicySel = IntPtr.Zero;
private static IntPtr s_ActivationPolicySel = IntPtr.Zero;
private static IntPtr s_WindowsSel = IntPtr.Zero;
private static IntPtr s_CountSel = IntPtr.Zero;
private static IntPtr s_ObjectAtIndexSel = IntPtr.Zero;
private static IntPtr s_ActivateIgnoringSel = IntPtr.Zero;
private static IntPtr s_MakeKeyWindowSel = IntPtr.Zero;
private static bool s_ObjCRefsInitialized;
private const long kNSApplicationActivationPolicyRegular = 0;
// Cached real NSWindow pointers obtained from [NSApp windows].
private static readonly List s_TransparentNSWindows = new List();
// NSWindow of the streaming mask window, excluded from alpha/ignore-mouse hiding.
private static IntPtr s_MacMaskNSWindow = IntPtr.Zero;
// NSWindow of the offscreen capture floating window. Used on macOS
// to call [nsWindow makeKeyWindow] before keyboard injection so
// GUIView.hasFocus returns true for GameView Play mode input.
private static IntPtr s_OffscreenTargetNSWindow = IntPtr.Zero;
// CGEvent P/Invoke for injecting native keyboard events on macOS.
// This is the Mac equivalent of Win32 PostMessageW — events go through
// the native input pipeline and update the Input Manager key state.
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
private static extern IntPtr CGEventCreateKeyboardEvent(IntPtr source, ushort virtualKey, bool keyDown);
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
private static extern void CGEventPost(uint tap, IntPtr cgEvent);
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
private static extern void CGEventSetFlags(IntPtr cgEvent, ulong flags);
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
private static extern void CFRelease(IntPtr cf);
// CGEvent mouse P/Invoke — Mac equivalent of Win32
// PostNativeMouseButtonMessage. Used to inject mouse button
// presses and movement so Input.GetMouseButton / Input.GetAxis
// work in Play mode during streaming.
[StructLayout(LayoutKind.Sequential)]
private struct CGPoint { public double x, y; }
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
private static extern IntPtr CGEventCreateMouseEvent(
IntPtr source, uint mouseType, CGPoint mouseCursorPosition, uint mouseButton);
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
private static extern void CGEventSetIntegerValueField(IntPtr evt, uint field, long value);
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
private static extern void CGEventSetDoubleValueField(IntPtr evt, uint field, double value);
// CGEvent mouse type constants
private const uint kCGEventLeftMouseDown = 1;
private const uint kCGEventLeftMouseUp = 2;
private const uint kCGEventRightMouseDown = 3;
private const uint kCGEventRightMouseUp = 4;
private const uint kCGEventMouseMoved = 5;
private const uint kCGEventLeftMouseDragged = 6;
private const uint kCGEventRightMouseDragged = 7;
private const uint kCGEventOtherMouseDown = 25;
private const uint kCGEventOtherMouseUp = 26;
private const uint kCGEventOtherMouseDragged = 27;
// CGEvent field IDs for mouse delta (Apple CGEventTypes.h values)
private const uint kCGMouseEventDeltaX = 4;
private const uint kCGMouseEventDeltaY = 5;
private const uint kCGSessionEventTap = 1;
// Mouse buttons currently held via PostNativeCGMouseButtonEvent.
// Flushed on stream disconnect to avoid ghost-held button states.
private static readonly HashSet s_MacHeldMouseButtons = new HashSet();
// Query physical key state at the hardware level. Used as an
// optimization for LOCAL Mac: while a physical key is held,
// skip deactivation so the native Input Manager keeps the key
// pressed. Returns false on remote Mac (no physical keyboard),
// which gracefully falls back to the DataChannel timeout path.
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
private static extern bool CGEventSourceKeyState(int stateID, ushort keycode);
private const int kCGEventSourceStateCombinedSessionState = 0;
// Whether Unity is currently activated for Play mode keyboard input.
// While true, CheckMacPlayKeyboardTimeout re-injects held keys and
// manages deactivation via a DataChannel-based timeout.
private static bool s_MacPlayKeyboardActive = false;
// Browser key names currently held (from DataChannel keydown/keyup).
// Primary key tracking — works on both local and remote Mac.
private static readonly HashSet s_MacHeldKeys = new HashSet();
// Timestamp of the last Play mode key event from DataChannel.
// Deactivation occurs when no DataChannel events arrive for
// kMacPlayKeyTimeoutSec AND no physical keys are held.
private static double s_MacPlayKeyLastTime = 0;
private const double kMacPlayKeyTimeoutSec = 0.3;
// Frame counter for re-injecting CGEvent keydowns after activation.
// macOS activateIgnoringOtherApps is asynchronous — CGEvents posted
// before activation completes are dispatched to the wrong app. By
// re-injecting for a few frames, we guarantee at least one CGEvent
// arrives after activation settles.
private static int s_MacKeyReinjectionFrames = 0;
private const int kMacKeyReinjectionMaxFrames = 5;
private static void EnsureObjCRefs()
{
if (s_ObjCRefsInitialized) return;
s_ObjCRefsInitialized = true;
try
{
IntPtr nsAppClass = objc_getClass("NSApplication");
IntPtr sharedSel = sel_registerName("sharedApplication");
s_NSApp = objc_msgSend_RetPtr(nsAppClass, sharedSel);
s_HideSel = sel_registerName("hide:");
s_UnhideSel = sel_registerName("unhideWithoutActivation");
s_SetAlphaValSel = sel_registerName("setAlphaValue:");
s_SetIgnoresMouseSel = sel_registerName("setIgnoresMouseEvents:");
s_SetActivationPolicySel = sel_registerName("setActivationPolicy:");
s_ActivationPolicySel = sel_registerName("activationPolicy");
s_WindowsSel = sel_registerName("windows");
s_CountSel = sel_registerName("count");
s_ObjectAtIndexSel = sel_registerName("objectAtIndex:");
s_ActivateIgnoringSel = sel_registerName("activateIgnoringOtherApps:");
s_MakeKeyWindowSel = sel_registerName("makeKeyWindow");
CodelyLogger.Log($"[NWB-ObjC] Initialized: NSApp={s_NSApp}");
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-ObjC] Init failed: {ex.Message}");
}
}
///
/// Map browser KeyboardEvent.key names to macOS virtual key codes.
/// Returns 0xFFFF if the key is not mapped.
///
private static ushort BrowserKeyToMacVK(string key)
{
if (string.IsNullOrEmpty(key)) return 0xFFFF;
if (key.Length == 1)
{
char c = char.ToLower(key[0]);
switch (c)
{
case 'a': return 0x00; case 's': return 0x01;
case 'd': return 0x02; case 'f': return 0x03;
case 'h': return 0x04; case 'g': return 0x05;
case 'z': return 0x06; case 'x': return 0x07;
case 'c': return 0x08; case 'v': return 0x09;
case 'b': return 0x0B; case 'q': return 0x0C;
case 'w': return 0x0D; case 'e': return 0x0E;
case 'r': return 0x0F; case 'y': return 0x10;
case 't': return 0x11; case 'o': return 0x1F;
case 'u': return 0x20; case 'i': return 0x22;
case 'p': return 0x23; case 'l': return 0x25;
case 'j': return 0x26; case 'k': return 0x28;
case 'n': return 0x2D; case 'm': return 0x2E;
case '1': return 0x12; case '2': return 0x13;
case '3': return 0x14; case '4': return 0x15;
case '5': return 0x17; case '6': return 0x16;
case '7': return 0x1A; case '8': return 0x1C;
case '9': return 0x19; case '0': return 0x1D;
case ' ': return 0x31; case '-': return 0x1B;
case '=': return 0x18; case '[': return 0x21;
case ']': return 0x1E; case ';': return 0x29;
case '\'': return 0x27; case ',': return 0x2B;
case '.': return 0x2F; case '/': return 0x2C;
case '\\': return 0x2A; case '`': return 0x32;
}
}
switch (key)
{
case "Enter": case "Return": return 0x24;
case "Tab": return 0x30;
case "Backspace": return 0x33;
case "Escape": return 0x35;
case "Delete": return 0x75;
case "ArrowUp": return 0x7E;
case "ArrowDown": return 0x7D;
case "ArrowLeft": return 0x7B;
case "ArrowRight": return 0x7C;
case "Home": return 0x73;
case "End": return 0x77;
case "PageUp": return 0x74;
case "PageDown": return 0x79;
case "Shift": return 0x38;
case "Control": return 0x3B;
case "Alt": return 0x3A;
case "Meta": return 0x37;
case "CapsLock": return 0x39;
case "F1": return 0x7A; case "F2": return 0x78;
case "F3": return 0x63; case "F4": return 0x76;
case "F5": return 0x60; case "F6": return 0x61;
case "F7": return 0x62; case "F8": return 0x64;
case "F9": return 0x65; case "F10": return 0x6D;
case "F11": return 0x67; case "F12": return 0x6F;
}
return 0xFFFF;
}
///
/// Post a native CGEvent keyboard event into the macOS session event tap.
/// This is the Mac equivalent of PostNativeKeyMessage (Win32 PostMessageW).
/// The event travels through NSApp's native event pipeline and updates
/// the Input Manager key state, making Input.GetKey work in Play mode.
///
private static void PostNativeCGKeyEvent(bool isDown, string browserKey)
{
ushort macVK = BrowserKeyToMacVK(browserKey);
if (macVK == 0xFFFF) return;
IntPtr cgEvent = IntPtr.Zero;
try
{
cgEvent = CGEventCreateKeyboardEvent(IntPtr.Zero, macVK, isDown);
if (cgEvent == IntPtr.Zero) return;
CGEventPost(kCGSessionEventTap, cgEvent);
}
finally
{
if (cgEvent != IntPtr.Zero)
CFRelease(cgEvent);
}
}
///
/// Post a native CGEvent mouse button event (down/up) into the macOS
/// session event tap. Mac equivalent of Win32 PostNativeMouseButtonMessage.
/// Makes Input.GetMouseButton(0/1/2) return true in Play mode.
///
/// The position uses the offscreen window center so the event lands
/// inside the GameView. Works for both local and remote streaming.
///
private static void PostNativeCGMouseButtonEvent(bool isDown, int button)
{
uint eventType;
uint cgButton;
switch (button)
{
case 0: eventType = isDown ? kCGEventLeftMouseDown : kCGEventLeftMouseUp;
cgButton = 0; break;
case 2: eventType = isDown ? kCGEventRightMouseDown : kCGEventRightMouseUp;
cgButton = 1; break;
case 1: eventType = isDown ? kCGEventOtherMouseDown : kCGEventOtherMouseUp;
cgButton = 2; break;
default: return;
}
if (isDown)
s_MacHeldMouseButtons.Add(button);
else
s_MacHeldMouseButtons.Remove(button);
// Use offscreen window center for the CGEvent position so the
// event targets the GameView. CG coordinates: origin top-left
// of primary display. macOS screen height = primary display height.
CGPoint pos = GetOffscreenWindowCGCenter();
IntPtr cgEvt = IntPtr.Zero;
try
{
cgEvt = CGEventCreateMouseEvent(IntPtr.Zero, eventType, pos, cgButton);
if (cgEvt == IntPtr.Zero) return;
CGEventPost(kCGSessionEventTap, cgEvt);
}
finally
{
if (cgEvt != IntPtr.Zero) CFRelease(cgEvt);
}
LogVerbose($"[NWB-MacMouseBtn] CGEvent 0x{eventType:X} down={isDown}" +
$" btn={button} cgBtn={cgButton} pos=({pos.x:F0},{pos.y:F0})" +
$" held=[{string.Join(",", s_MacHeldMouseButtons)}]");
}
///
/// Post a native CGEvent mouse move/drag event with delta values.
/// This feeds Input.GetAxisRaw("Mouse X") / Input.GetAxisRaw("Mouse Y")
/// through the macOS native event pipeline.
///
/// On Windows, Input.GetAxis delta comes from WM_INPUT (RIDEV_INPUTSINK)
/// which only works locally. On macOS, CGEvent delta works for BOTH
/// local and remote streaming because we inject the delta directly.
///
private static void PostNativeCGMouseMoveEvent(
float deltaX, float deltaY, int heldButton)
{
// Choose the correct event type based on which button is held.
uint eventType;
uint cgButton = 0;
if (s_MacHeldMouseButtons.Contains(0))
{
eventType = kCGEventLeftMouseDragged;
cgButton = 0;
}
else if (s_MacHeldMouseButtons.Contains(2))
{
eventType = kCGEventRightMouseDragged;
cgButton = 1;
}
else if (s_MacHeldMouseButtons.Contains(1))
{
eventType = kCGEventOtherMouseDragged;
cgButton = 2;
}
else
{
eventType = kCGEventMouseMoved;
}
CGPoint pos = GetOffscreenWindowCGCenter();
IntPtr cgEvt = IntPtr.Zero;
try
{
cgEvt = CGEventCreateMouseEvent(IntPtr.Zero, eventType, pos, cgButton);
if (cgEvt == IntPtr.Zero) return;
// Set delta fields so Input.GetAxisRaw("Mouse X/Y") picks
// up the movement through the native input pipeline.
CGEventSetIntegerValueField(cgEvt, kCGMouseEventDeltaX, (long)deltaX);
CGEventSetIntegerValueField(cgEvt, kCGMouseEventDeltaY, (long)deltaY);
CGEventPost(kCGSessionEventTap, cgEvt);
}
finally
{
if (cgEvt != IntPtr.Zero) CFRelease(cgEvt);
}
}
///
/// Compute the CG display-coordinate center of the offscreen window.
/// CG coordinates: origin at top-left of primary display.
/// EditorWindow.position: origin at top-left of primary display
/// (same as CG on macOS, already in points).
///
private static CGPoint GetOffscreenWindowCGCenter()
{
if (s_OffscreenTarget != null)
{
Rect winPos = s_OffscreenTarget.position;
return new CGPoint
{
x = winPos.x + winPos.width * 0.5,
y = winPos.y + winPos.height * 0.5
};
}
// Fallback: use a central point
return new CGPoint { x = 100, y = 100 };
}
///
/// Per-frame Play mode keyboard management.
/// Called from EnsureWindowsStayHiddenDuringOffscreen every frame.
///
/// Three responsibilities:
/// 1. Re-inject CGEvent keydowns during the grace period after
/// activation so the async activateIgnoringOtherApps has time
/// to settle before CGEvents are dispatched.
/// 2. Poll CGEventSourceKeyState for physically held keys (LOCAL
/// Mac optimization). While any key is physically held, keep
/// Unity active so the native Input Manager maintains the
/// pressed state for continuous movement.
/// 3. Deactivate Unity when no physical keys are held AND no
/// DataChannel events have arrived for kMacPlayKeyTimeoutSec.
/// On remote Mac, CGEventSourceKeyState always returns false,
/// so the timeout is the sole deactivation trigger.
///
private static void CheckMacPlayKeyboardTimeout()
{
if (!s_MacPlayKeyboardActive) return;
// Re-inject held keys during the grace period so at least one
// CGEvent arrives after macOS activation has settled.
if (s_MacKeyReinjectionFrames > 0 && s_MacHeldKeys.Count > 0)
{
s_MacKeyReinjectionFrames--;
EnsureObjCRefs();
if (s_NSApp != IntPtr.Zero && s_OffscreenTargetNSWindow != IntPtr.Zero)
{
objc_msgSend_void_bool(s_NSApp, s_ActivateIgnoringSel, true);
objc_msgSend_void(s_OffscreenTargetNSWindow, s_MakeKeyWindowSel);
}
foreach (var key in s_MacHeldKeys)
PostNativeCGKeyEvent(true, key);
}
// LOCAL optimization: poll physical key state. If any tracked
// key is still physically held, keep Unity active and refresh
// the timestamp so the timeout doesn't fire. On remote Mac
// (no physical keyboard), this always returns false and the
// code falls through to the timeout check below.
if (s_MacHeldKeys.Count > 0)
{
bool anyPhysicallyHeld = false;
foreach (var key in s_MacHeldKeys)
{
ushort vk = BrowserKeyToMacVK(key);
if (vk != 0xFFFF && CGEventSourceKeyState(
kCGEventSourceStateCombinedSessionState, vk))
{
anyPhysicallyHeld = true;
break;
}
}
if (anyPhysicallyHeld)
{
s_MacPlayKeyLastTime = EditorApplication.timeSinceStartup;
return;
}
}
// Deactivate when no physical keys are held AND no DataChannel
// events have arrived for the timeout period. The browser
// regains keyboard focus and immediately fires keydown if the
// user is still pressing a key → re-activate.
double elapsed = EditorApplication.timeSinceStartup - s_MacPlayKeyLastTime;
if (elapsed > kMacPlayKeyTimeoutSec)
{
foreach (var key in s_MacHeldKeys)
PostNativeCGKeyEvent(false, key);
s_MacHeldKeys.Clear();
s_MacPlayKeyboardActive = false;
s_MacKeyReinjectionFrames = 0;
DeactivateUnityKeepWindows();
ReapplyWindowTransparency();
}
}
#endif
// Count hide/transparency calls for throttled logging.
// Diagnostic counter used by hide/deactivate routines on supported platforms.
#pragma warning disable CS0414
private static int s_HideCallCount;
#pragma warning restore CS0414
// Track whether a remote mouse button is currently pressed.
// Used to avoid deactivation during drag operations.
private static bool s_RemoteMouseButtonDown;
// True when native capture has ended but the user has not clicked
// "Disconnect & Restore" yet. In this state Unity stays hidden and
// floating windows are preserved for an explicit restore action.
private static bool s_NativeOffscreenDisconnectedAwaitingRestore;
///
/// Collect all real NSWindow pointers via [NSApp windows].
/// This is safe because NSApp.windows returns actual Objective-C
/// NSWindow objects, unlike ContainerWindow.m_WindowPtr which
/// is a C++ ContainerWindow* and must NOT receive ObjC messages.
///
private static List GetAllNSWindows(bool includeMaskWindow = false)
{
var result = new List();
#if UNITY_EDITOR_OSX
EnsureObjCRefs();
if (s_NSApp == IntPtr.Zero || s_WindowsSel == IntPtr.Zero) return result;
try
{
IntPtr windowsArray = objc_msgSend_RetPtr(s_NSApp, s_WindowsSel);
if (windowsArray == IntPtr.Zero) return result;
IntPtr countRaw = objc_msgSend_RetPtr(windowsArray, s_CountSel);
int count = (int)(long)countRaw;
for (int i = 0; i < count; i++)
{
IntPtr nsWin = objc_msgSend_RetPtr_IntPtr(
windowsArray, s_ObjectAtIndexSel, (IntPtr)i);
if (nsWin != IntPtr.Zero && (includeMaskWindow || nsWin != s_MacMaskNSWindow))
result.Add(nsWin);
}
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-ObjC] GetAllNSWindows failed: {ex.Message}");
}
#endif
return result;
}
///
/// Make all NSWindows transparent (alpha=0) with mouse events
/// passing through. Windows remain on-screen so lockFocusIfCanDraw=YES
/// and the Metal backing store keeps updating for GrabPixels capture.
///
private static void MakeAllWindowsTransparent()
{
#if UNITY_EDITOR_OSX
EnsureObjCRefs();
if (s_SetAlphaValSel == IntPtr.Zero) return;
s_TransparentNSWindows.Clear();
var allWindows = GetAllNSWindows();
foreach (var nsWin in allWindows)
{
objc_msgSend_void_double(nsWin, s_SetAlphaValSel, 0.0);
objc_msgSend_void_bool(nsWin, s_SetIgnoresMouseSel, true);
s_TransparentNSWindows.Add(nsWin);
}
LogVerbose($"[NWB-ObjC] Made {allWindows.Count} NSWindows transparent (via [NSApp windows])");
#elif UNITY_EDITOR_WIN
try
{
s_TransparentHwnds.Clear();
s_OriginalExStyles.Clear();
s_OriginalWindowRects.Clear();
var hwnds = GetAllUnityHwnds();
foreach (var hwnd in hwnds)
{
int exStyle = GetWindowLongW(hwnd, GWL_EXSTYLE);
// DOMAIN-RELOAD SAFETY: After domain reload, C# statics
// are cleared but the HWND may already have WS_EX_TRANSPARENT
// from the previous session. Strip it before saving so that
// RestoreAllWindowsVisible can properly undo the modification.
int cleanStyle = exStyle & ~WS_EX_TRANSPARENT;
s_OriginalExStyles[hwnd] = cleanStyle;
// IMPORTANT: Do NOT use WS_EX_LAYERED. The Tuanjie C++
// GUIView::DoPaint checks the ContainerWindow HWND for
// WS_EX_LAYERED and sets m_Transparent=true (one-way latch
// with no reset path). m_Transparent=true causes
// SwapChain::Present() to be skipped in EndRendering(),
// permanently freezing the window's visual output.
SetWindowLongW(hwnd, GWL_EXSTYLE,
exStyle | WS_EX_TRANSPARENT);
if (s_UseRenderTextureFallback)
{
// PrintWindow mode: keep windows in-place so DWM
// continues to composite their content. Push behind
// the streaming mask window via HWND_BOTTOM.
SetWindowPos(hwnd, HWND_BOTTOM, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
}
else
{
// Normal mode: save original position then move
// off-screen to hide visually. GrabPixels still
// works on off-screen windows.
GetWindowRect(hwnd, out RECT origRect);
bool alreadyAtSentinel = IsAtOffscreenSentinel(origRect);
if (!alreadyAtSentinel)
{
s_OriginalWindowRects[hwnd] = origRect;
}
else
{
// Window already at sentinel — likely after domain reload
// cleared s_OriginalWindowRects. Try SessionState first,
// then fall back to the monitor work area starting at (0,0).
RestoreWindowRectsFromSessionState();
if (!s_OriginalWindowRects.ContainsKey(hwnd))
{
var fallback = GetFallbackRestoreRect(hwnd, origRect);
s_OriginalWindowRects[hwnd] = fallback;
CodelyLogger.LogWarning($"[NWB-Win] Window hwnd=0x{hwnd:X} at sentinel, no saved rect in SessionState, origRect=({origRect.Left},{origRect.Top},{origRect.Right},{origRect.Bottom}) w={origRect.Right-origRect.Left} h={origRect.Bottom-origRect.Top}, using fallback ({fallback.Left},{fallback.Top},{fallback.Right},{fallback.Bottom})");
}
}
int w = origRect.Right - origRect.Left;
int h = origRect.Bottom - origRect.Top;
SetWindowPos(hwnd, IntPtr.Zero, -32000, -32000, w, h,
SWP_NOACTIVATE | SWP_NOZORDER);
}
s_TransparentHwnds.Add(hwnd);
}
// After all windows have been processed (both real saves and
// sentinel restores), persist the complete s_OriginalWindowRects
// to SessionState. This ensures that windows saved earlier in
// the loop are available when later windows are at sentinel.
SaveWindowRectsToSessionState();
CodelyLogger.Log($"[NWB-Win] Made {hwnds.Count} HWNDs hidden (mode={( s_UseRenderTextureFallback ? "PrintWindow/HWND_BOTTOM" : "off-screen" )})");
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-Win] MakeAllWindowsTransparent failed: {ex.Message}");
}
#endif
}
///
/// Re-apply alpha=0 + ignoresMouseEvents=YES on all NSWindows.
/// Fetches a fresh list via [NSApp windows] so newly created
/// windows (e.g. popup ContainerWindows) are also made transparent.
/// Called after Focus() which may order windows front.
///
private static void ReapplyWindowTransparency()
{
#if UNITY_EDITOR_OSX
if (s_SetAlphaValSel == IntPtr.Zero) return;
var allWindows = GetAllNSWindows();
s_TransparentNSWindows.Clear();
foreach (var nsWin in allWindows)
{
objc_msgSend_void_double(nsWin, s_SetAlphaValSel, 0.0);
objc_msgSend_void_bool(nsWin, s_SetIgnoresMouseSel, true);
s_TransparentNSWindows.Add(nsWin);
}
#elif UNITY_EDITOR_WIN
try
{
// Re-enumerate to catch newly created windows (e.g. popup ContainerWindows).
var hwnds = GetAllUnityHwnds();
s_TransparentHwnds.Clear();
bool detachedHidden = s_NativeOffscreenDisconnectedAwaitingRestore;
foreach (var hwnd in hwnds)
{
if (!s_OriginalExStyles.ContainsKey(hwnd))
{
// Strip WS_EX_TRANSPARENT before saving — if a new window
// appeared after a domain reload it might already carry our
// transparent flag from ReapplyWindowTransparency.
int rawStyle = GetWindowLongW(hwnd, GWL_EXSTYLE);
s_OriginalExStyles[hwnd] = rawStyle & ~WS_EX_TRANSPARENT;
}
int exStyle = GetWindowLongW(hwnd, GWL_EXSTYLE);
SetWindowLongW(hwnd, GWL_EXSTYLE,
exStyle | WS_EX_TRANSPARENT);
bool useInPlaceFallback = s_UseRenderTextureFallback && !detachedHidden;
if (useInPlaceFallback)
{
// PrintWindow mode: HWND_BOTTOM keeps windows behind
// the streaming mask while DWM keeps compositing.
SetWindowPos(hwnd, HWND_BOTTOM, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
}
else
{
// Normal off-screen mode: re-enforce (-32000,-32000)
// position if Unity moved the window back on-screen.
if (!s_OriginalWindowRects.ContainsKey(hwnd))
{
GetWindowRect(hwnd, out RECT r);
bool atSentinel = (r.Left <= -30000 && r.Top <= -30000);
if (!atSentinel)
s_OriginalWindowRects[hwnd] = r;
}
GetWindowRect(hwnd, out RECT liveRect);
int w2 = Mathf.Max(liveRect.Right - liveRect.Left, 1);
int h2 = Mathf.Max(liveRect.Bottom - liveRect.Top, 1);
bool alreadyOffscreen = (liveRect.Left == -32000 && liveRect.Top == -32000);
if (!alreadyOffscreen)
{
SetWindowPos(hwnd, IntPtr.Zero, -32000, -32000, w2, h2,
SWP_NOACTIVATE | SWP_NOZORDER);
}
}
s_TransparentHwnds.Add(hwnd);
}
s_LastWindowTransparencyReapplyTime = EditorApplication.timeSinceStartup;
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-Win] ReapplyWindowTransparency failed: {ex.Message}");
}
#endif
}
///
/// macOS defensive recovery on domain reload.
/// If a previous offscreen session left NSApp in Accessory activation policy
/// (e.g. abnormal Cowork exit), the Dock icon disappears. This restores it
/// by forcing Regular policy and restoring window visibility. Only takes
/// action when policy is actually wrong — avoids disturbing macOS window
/// management when everything is already normal.
///
private static void EnsureMacEditorVisibilitySelfHeal()
{
#if UNITY_EDITOR_OSX
if (s_OffscreenTarget != null) return;
EnsureObjCRefs();
if (s_NSApp == IntPtr.Zero) return;
try
{
long currentPolicy = s_ActivationPolicySel != IntPtr.Zero
? (long)objc_msgSend_RetPtr(s_NSApp, s_ActivationPolicySel)
: -1;
if (s_SetActivationPolicySel != IntPtr.Zero)
objc_msgSend_void_IntPtr(s_NSApp, s_SetActivationPolicySel,
(IntPtr)kNSApplicationActivationPolicyRegular);
// RestoreAllWindowsVisible: alpha=1, ignoresMouseEvents=false,
// setActivationPolicy:Regular, activateIgnoringOtherApps:YES.
RestoreAllWindowsVisible();
CodelyLogger.Log($"[NWB-ObjC] Self-heal: policy was {currentPolicy}, called RestoreAllWindowsVisible.");
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-ObjC] Self-heal failed: {ex.Message}");
}
#endif
}
///
/// Restore all NSWindows to full visibility.
/// Uses [NSApp windows] for a fresh list in case windows were
/// created or destroyed since MakeAllWindowsTransparent was called.
///
private static void RestoreAllWindowsVisible(IntPtr excludeNSWindow = default)
{
#if UNITY_EDITOR_OSX
EnsureObjCRefs();
if (s_SetAlphaValSel == IntPtr.Zero) return;
var allWindows = GetAllNSWindows();
int skipped = 0;
foreach (var nsWin in allWindows)
{
// Skip NSWindows that are being closed. On macOS
// [nsWindow close] doesn't immediately remove the
// pointer from [NSApp windows], so we must not
// restore alpha on a window we just closed.
if (excludeNSWindow != IntPtr.Zero && nsWin == excludeNSWindow)
{
skipped++;
continue;
}
objc_msgSend_void_double(nsWin, s_SetAlphaValSel, 1.0);
objc_msgSend_void_bool(nsWin, s_SetIgnoresMouseSel, false);
}
// Restore Regular activation policy and unhide before activation so
// a previous hide:/Accessory state cannot leave the editor invisible.
if (s_NSApp != IntPtr.Zero && s_SetActivationPolicySel != IntPtr.Zero)
{
objc_msgSend_void_IntPtr(s_NSApp, s_SetActivationPolicySel,
(IntPtr)kNSApplicationActivationPolicyRegular);
}
if (s_NSApp != IntPtr.Zero && s_UnhideSel != IntPtr.Zero)
objc_msgSend_void(s_NSApp, s_UnhideSel);
if (s_NSApp != IntPtr.Zero && s_ActivateIgnoringSel != IntPtr.Zero)
{
objc_msgSend_void_bool(s_NSApp, s_ActivateIgnoringSel, true);
}
CodelyLogger.Log($"[NWB-ObjC] Restored {allWindows.Count - skipped} NSWindows to visible (Regular + activate){(skipped > 0 ? $", skipped {skipped} closing" : "")}");
s_TransparentNSWindows.Clear();
#elif UNITY_EDITOR_WIN
try
{
var visibleHwnds = GetAllUnityHwnds();
var hwndsSet = new HashSet(visibleHwnds);
foreach (var trackedHwnd in s_OriginalExStyles.Keys)
{
if (!hwndsSet.Contains(trackedHwnd) && IsWindow(trackedHwnd)
&& trackedHwnd != s_MaskWindowHwnd)
hwndsSet.Add(trackedHwnd);
}
var hwnds = new List(hwndsSet);
// Restore original extended styles (remove WS_EX_TRANSPARENT).
// ALWAYS strip WS_EX_TRANSPARENT as a safety net — even if the
// saved "original" style contains it (e.g. domain reload
// corrupted the saved state), Unity editor windows should
// never have WS_EX_TRANSPARENT in normal operation.
foreach (var hwnd in hwnds)
{
int curStyle = GetWindowLongW(hwnd, GWL_EXSTYLE);
int restoreStyle;
if (s_OriginalExStyles.TryGetValue(hwnd, out int origStyle))
{
restoreStyle = origStyle & ~WS_EX_TRANSPARENT;
}
else
{
restoreStyle = curStyle & ~WS_EX_TRANSPARENT;
}
SetWindowLongW(hwnd, GWL_EXSTYLE, restoreStyle);
int afterStyle = GetWindowLongW(hwnd, GWL_EXSTYLE);
LogVerbose($"[NWB-Win] Restore hwnd=0x{hwnd:X}: before=0x{curStyle:X8} orig=0x{(s_OriginalExStyles.ContainsKey(hwnd) ? s_OriginalExStyles[hwnd] : -1):X8} target=0x{restoreStyle:X8} after=0x{afterStyle:X8} transparent={(afterStyle & WS_EX_TRANSPARENT) != 0}");
}
// Bring windows back: restore Z-order and position.
// SWP_FRAMECHANGED ensures DWM refreshes the hit-test
// region after removing WS_EX_TRANSPARENT.
foreach (var hwnd in hwnds)
{
ShowWindow(hwnd, SW_SHOW);
if (s_UseRenderTextureFallback)
{
// PrintWindow mode: windows stayed in-place,
// only need Z-order + visibility restore.
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW | SWP_FRAMECHANGED);
}
else if (s_OriginalWindowRects.TryGetValue(hwnd, out RECT r))
{
// Off-screen mode: move back to saved position.
bool savedIsOffscreen = IsAtOffscreenSentinel(r);
if (savedIsOffscreen)
{
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW | SWP_FRAMECHANGED);
}
else
{
int w = r.Right - r.Left;
int h = r.Bottom - r.Top;
SetWindowPos(hwnd, HWND_TOP, r.Left, r.Top, w, h,
SWP_SHOWWINDOW | SWP_FRAMECHANGED);
}
LogVerbose($"[NWB-Win] Restore pos hwnd=0x{hwnd:X}: saved=({r.Left},{r.Top},{r.Right},{r.Bottom}) savedIsOffscreen={savedIsOffscreen}");
}
else
{
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW | SWP_FRAMECHANGED);
LogVerbose($"[NWB-Win] Restore pos hwnd=0x{hwnd:X}: no saved rect, used SWP_NOMOVE");
}
}
// Force a full Win32 redraw so the restored windows display
// their current content immediately.
foreach (var hwnd in hwnds)
{
RedrawWindow(hwnd, IntPtr.Zero, IntPtr.Zero,
RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN | RDW_ERASE | RDW_FRAME);
}
// Activate the first Unity HWND (main editor window) so it
// receives keyboard/mouse input focus immediately.
if (hwnds.Count > 0)
{
ForceForegroundWindow(hwnds[0]);
}
CodelyLogger.Log($"[NWB-Win] Restored {hwnds.Count} HWNDs to visible + Z-order + focus");
s_TransparentHwnds.Clear();
s_OriginalExStyles.Clear();
s_OriginalWindowRects.Clear();
// Clear SessionState — streaming has ended, window positions
// are no longer needed for domain reload recovery.
SessionState.EraseString(kWindowRectsSaveKey);
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-Win] RestoreAllWindowsVisible failed: {ex.Message}");
}
#endif
}
#if UNITY_EDITOR_WIN
///
/// Returns true when the RECT sits at the off-screen sentinel position
/// (-32000, -32000 physical) used by
/// to hide windows during streaming.
///
private static bool IsAtOffscreenSentinel(RECT rect)
{
return rect.Left <= -30000 || rect.Top <= -30000;
}
///
/// If is stuck at the off-screen sentinel,
/// force it to an on-screen position using the monitor work area.
///
/// true when a repair was performed.
private static bool RepairOffscreenSentinel(IntPtr hwnd, string logContext)
{
GetWindowRect(hwnd, out RECT rect);
if (!IsAtOffscreenSentinel(rect)) return false;
var fb = GetFallbackRestoreRect(hwnd, rect);
ShowWindow(hwnd, SW_SHOW);
SetWindowPos(hwnd, HWND_TOP, fb.Left, fb.Top, fb.Right - fb.Left, fb.Bottom - fb.Top,
SWP_SHOWWINDOW | SWP_FRAMECHANGED);
RedrawWindow(hwnd, IntPtr.Zero, IntPtr.Zero,
RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN | RDW_ERASE | RDW_FRAME);
CodelyLogger.LogWarning(
$"[NWB-Win] {logContext}: hwnd=0x{hwnd:X} at ({rect.Left},{rect.Top}), forced to ({fb.Left},{fb.Top},{fb.Right - fb.Left},{fb.Bottom - fb.Top})");
return true;
}
///
/// Get a reasonable on-screen fallback rect for a window whose
/// original position is unknown (e.g. lost during domain reload).
/// Uses the monitor's work area (taskbar-excluded) that the HWND
/// is nearest to. Falls back to (0,0,1920,1080) if Win32 APIs fail.
///
private static RECT GetFallbackRestoreRect(IntPtr hwnd, RECT currentRect)
{
try
{
IntPtr hMon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if (hMon != IntPtr.Zero)
{
var mi = new MONITORINFO { cbSize = System.Runtime.InteropServices.Marshal.SizeOf() };
if (GetMonitorInfo(hMon, ref mi))
return mi.rcWork;
}
}
catch { }
return new RECT { Left = 0, Top = 0, Right = 1920, Bottom = 1080 };
}
///
///
/// Persist s_OriginalWindowRects to SessionState.
/// Called once from MakeAllWindowsTransparent when windows are still
/// on-screen (real positions). All rects at this point are real
/// (non-sentinel, non-fallback), so no filtering is needed.
///
private static void SaveWindowRectsToSessionState()
{
if (s_OriginalWindowRects == null || s_OriginalWindowRects.Count == 0)
{
// Don't erase — keep existing data for domain reload recovery.
return;
}
var sb = new StringBuilder();
foreach (var kvp in s_OriginalWindowRects)
{
if (sb.Length > 0) sb.Append(';');
sb.Append(kvp.Key.ToInt64());
sb.Append(',');
sb.Append(kvp.Value.Left);
sb.Append(',');
sb.Append(kvp.Value.Top);
sb.Append(',');
sb.Append(kvp.Value.Right);
sb.Append(',');
sb.Append(kvp.Value.Bottom);
}
SessionState.SetString(kWindowRectsSaveKey, sb.ToString());
}
///
/// Restore s_OriginalWindowRects from SessionState after domain reload.
///
private static void RestoreWindowRectsFromSessionState()
{
string saved = SessionState.GetString(kWindowRectsSaveKey, "");
if (string.IsNullOrEmpty(saved)) return;
int count = 0;
foreach (var entry in saved.Split(';'))
{
var parts = entry.Split(',');
if (parts.Length != 5) continue;
if (!long.TryParse(parts[0], out long hwndLong)) continue;
IntPtr hwnd = new IntPtr(hwndLong);
if (!IsWindow(hwnd)) continue;
if (!int.TryParse(parts[1], out int l) ||
!int.TryParse(parts[2], out int t) ||
!int.TryParse(parts[3], out int r) ||
!int.TryParse(parts[4], out int b)) continue;
s_OriginalWindowRects[hwnd] = new RECT { Left = l, Top = t, Right = r, Bottom = b };
count++;
}
}
#endif
#if UNITY_EDITOR_OSX
///
/// Show the streaming mask window after hiding all Unity editor windows.
/// Detects the mask NSWindow by diffing [NSApp windows] before and after
/// creation, then excludes it from subsequent transparency passes.
///
private static void ShowStreamingMaskWindow()
{
try
{
var beforeWindows = new HashSet(GetAllNSWindows(includeMaskWindow: true));
foreach (var nsWin in s_TransparentNSWindows) beforeWindows.Add(nsWin);
StreamingMaskWindow.ShowMask();
s_MacMaskNSWindow = IntPtr.Zero;
var afterWindows = GetAllNSWindows(includeMaskWindow: true);
foreach (var nsWin in afterWindows)
{
if (!beforeWindows.Contains(nsWin))
{
s_MacMaskNSWindow = nsWin;
break;
}
}
if (s_MacMaskNSWindow != IntPtr.Zero)
{
LogVerbose($"[NWB-ObjC] Streaming mask window shown, nsWindow={s_MacMaskNSWindow}");
}
else
{
CodelyLogger.LogWarning("[NWB-ObjC] Streaming mask window shown but NSWindow not detected");
}
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-ObjC] ShowStreamingMaskWindow failed: {ex.Message}");
}
}
///
/// Hide the streaming mask window and clear the tracked NSWindow.
///
private static void HideStreamingMaskWindow()
{
try
{
StreamingMaskWindow.HideMask();
s_MacMaskNSWindow = IntPtr.Zero;
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-ObjC] HideStreamingMaskWindow failed: {ex.Message}");
}
}
#endif
///
/// Force a window to the foreground, bypassing the Win32 restriction
/// that only the foreground process can call SetForegroundWindow.
/// Uses AttachThreadInput to merge input queues with the current
/// foreground window's thread, then calls SetForegroundWindow.
///
private static void ForceForegroundWindow(IntPtr targetHwnd)
{
#if UNITY_EDITOR_WIN
try
{
IntPtr fgHwnd = GetForegroundWindow();
if (fgHwnd == targetHwnd)
{
LogVerbose("[NWB-Win] Target is already foreground");
return;
}
uint curThreadId = GetCurrentThreadId();
uint fgThreadId = GetWindowThreadProcessId(fgHwnd, out _);
bool attached = false;
if (curThreadId != fgThreadId)
{
attached = AttachThreadInput(curThreadId, fgThreadId, true);
}
BringWindowToTop(targetHwnd);
ShowWindow(targetHwnd, SW_SHOW);
bool result = SetForegroundWindow(targetHwnd);
LogVerbose($"[NWB-Win] ForceForegroundWindow: attached={attached} result={result} hwnd=0x{targetHwnd:X}");
if (attached)
{
AttachThreadInput(curThreadId, fgThreadId, false);
}
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-Win] ForceForegroundWindow failed: {ex.Message}");
}
#endif
}
///
/// Deactivate Unity without permanently hiding windows.
/// Sequence: [NSApp hide:] (deactivates, hides windows) →
/// [NSApp unhideWithoutActivation] (re-shows windows without activating).
/// Windows come back at alpha=0 (preserved), so they remain invisible.
/// The previously active app (browser) regains activation.
///
private static void DeactivateUnityKeepWindows()
{
#if UNITY_EDITOR_OSX
EnsureObjCRefs();
if (s_NSApp == IntPtr.Zero) return;
try
{
// hide: deactivates Unity, orders all windows out.
objc_msgSend_void_IntPtr(s_NSApp, s_HideSel, IntPtr.Zero);
// unhideWithoutActivation: re-shows windows without activating.
// Windows retain their alpha=0 setting from MakeAllWindowsTransparent.
objc_msgSend_void(s_NSApp, s_UnhideSel);
s_HideCallCount++;
if (s_HideCallCount <= 3 || s_HideCallCount % 200 == 0)
LogVerbose($"[NWB-ObjC] deactivate+unhide #{s_HideCallCount}");
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-ObjC] DeactivateUnityKeepWindows failed: {ex.Message}");
}
#elif UNITY_EDITOR_WIN
try
{
// Re-apply transparency (covers newly created popup windows) then
// push all Unity windows behind other windows so the browser stays
// in front. Unlike SW_MINIMIZE, this keeps windows renderable so
// GrabPixels continues to work.
ReapplyWindowTransparency();
foreach (var hwnd in s_TransparentHwnds)
{
SetWindowPos(hwnd, HWND_BOTTOM, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
}
s_HideCallCount++;
if (s_HideCallCount <= 3 || s_HideCallCount % 200 == 0)
LogVerbose($"[NWB-Win] deactivate+push-back #{s_HideCallCount}");
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-Win] DeactivateUnityKeepWindows failed: {ex.Message}");
}
#endif
}
///
/// Schedule a delayed deactivation for the next editor frame.
/// Used after closing native popup windows, as the popup's
/// ContainerWindow.Close() can trigger additional window ordering
/// that re-activates Unity after our synchronous deactivation.
///
private static void RequestDelayedDeactivation()
{
if (!s_OffscreenActive) return;
EditorApplication.delayCall += () =>
{
if (!s_OffscreenActive) return;
ReapplyWindowTransparency();
DeactivateUnityKeepWindows();
};
}
///
/// While offscreen streaming is active, keep Unity hidden even if
/// the OS or internal code re-activates/re-shows windows (e.g. play
/// mode transitions, taskbar clicks).
///
private static void EnsureWindowsStayHiddenDuringOffscreen()
{
#if UNITY_EDITOR_WIN
if (!s_OffscreenActive) return;
try
{
// Snapshot FG before re-hiding: taskbar restore moves an
// off-screen HWND on-screen, and ReapplyWindowTransparency
// would shove it back to -32000 before we can detect the click.
IntPtr fgBeforeReapply = GetForegroundWindow();
bool restoredFromOffscreen = false;
if (fgBeforeReapply != IntPtr.Zero
&& fgBeforeReapply != s_MaskWindowHwnd
&& !s_UseRenderTextureFallback
&& s_TransparentHwnds.Contains(fgBeforeReapply))
{
GetWindowRect(fgBeforeReapply, out RECT fgRect);
restoredFromOffscreen = fgRect.Left > -30000 && fgRect.Top > -30000;
}
// Re-apply WS_EX_TRANSPARENT + hiding strategy on all HWNDs.
// PrintWindow mode uses HWND_BOTTOM in-place; normal mode
// uses off-screen (-32000,-32000). EditorWindow.position
// setter or Unity internal layout may change styles/Z-order.
double now = EditorApplication.timeSinceStartup;
if (now - s_LastWindowTransparencyReapplyTime >= kWindowTransparencyReapplyIntervalSec)
{
s_LastWindowTransparencyReapplyTime = now;
ReapplyWindowTransparency();
}
IntPtr fg = fgBeforeReapply;
if (fg != IntPtr.Zero)
{
// Skip if the foreground window is the streaming mask window —
// it is intentionally visible and should not be pushed back.
if (fg == s_MaskWindowHwnd)
return;
// Skip foreground reclaim while Play mode keys are held
// OR within the grace period after the last SetFocus.
//
// s_WinHeldKeys tracks DataChannel keydown/keyup state. It
// stays non-empty for the entire key hold — including the
// initial key-repeat delay (~500ms) that the short grace
// period cannot cover. The grace period (0.15s) handles
// the brief SetFocus effect AFTER all keys are released.
//
// Without this, the initial key-repeat delay causes a
// stutter: grace expires → DeactivateUnityKeepWindows +
// SetForegroundWindow(mask) → hasFocus=false → movement
// stops until the first key repeat arrives.
if (s_WinHeldKeys.Count > 0)
return;
double focusElapsed = EditorApplication.timeSinceStartup - s_WinPlayKeyLastFocusTime;
if (focusElapsed < kWinPlayKeyFocusGraceSec)
return;
GetWindowThreadProcessId(fg, out uint fgPid);
uint curPid = (uint)System.Diagnostics.Process.GetCurrentProcess().Id;
double nowSec = EditorApplication.timeSinceStartup;
if (fgPid != 0 && fgPid != curPid)
{
// Another app holds FG (browser / Codely). Remember how long.
if (s_OtherProcessFgSince < 0)
s_OtherProcessFgSince = nowSec;
}
else if (fgPid == curPid)
{
// Only treat as taskbar click if another process held FG long enough.
// Stream-start Unity FG churn never satisfies this, so the mask
// stays hidden until the user deliberately activates Tuanjie.
bool taskbarActivate = s_OtherProcessFgSince >= 0
&& (nowSec - s_OtherProcessFgSince) >= kTaskbarActivateMinOtherFgSec;
if (!taskbarActivate && restoredFromOffscreen)
taskbarActivate = true;
s_OtherProcessFgSince = -1;
s_OffscreenForegroundHideDiagCount++;
// In composite streaming mode, the floating ContainerWindow
// is resized larger than its original docked size. Any
// DeactivateUnityKeepWindows() → SetWindowPos(HWND_BOTTOM)
// sends WM_SIZE which resets ContainerWindow.position back
// to the original (smaller) size — the position setter
// doesn't stick for offscreen windows at (-32000,-32000).
// This causes a per-frame resize loop → Screen dimensions
// oscillate → Camera.aspect oscillates → vertical shaking.
// Skip DeactivateUnityKeepWindows() entirely in composite mode
// unless the user explicitly clicked the taskbar.
bool skipDeactivate = (s_CompositeActive && !taskbarActivate)
|| (s_IsRemoteConnection && !taskbarActivate);
if (!skipDeactivate)
{
if (s_OffscreenForegroundHideDiagCount <= 20 || s_OffscreenForegroundHideDiagCount % 120 == 0)
{
CodelyLogger.LogWarning($"[NWB-Win] Foreground reclaimed by Unity during offscreen, forcing hidden again #{s_OffscreenForegroundHideDiagCount} (taskbarActivate={taskbarActivate})");
}
DeactivateUnityKeepWindows();
}
else if (s_OffscreenForegroundHideDiagCount == 1)
{
LogVerbose($"[NWB-Win] Foreground reclaimed by Unity during offscreen, skipping deactivate (composite={s_CompositeActive} remote={s_IsRemoteConnection} taskbarActivate={taskbarActivate})");
}
if (taskbarActivate)
EnsureStreamingMaskShownForUser();
}
}
}
catch (Exception ex)
{
if (s_OffscreenForegroundHideDiagCount <= 5 || s_OffscreenForegroundHideDiagCount % 120 == 0)
{
CodelyLogger.LogWarning($"[NWB-Win] EnsureWindowsStayHiddenDuringOffscreen failed: {ex.Message}");
}
}
#elif UNITY_EDITOR_OSX
if (!s_OffscreenActive) return;
// Check whether Play mode keyboard activation should time out.
CheckMacPlayKeyboardTimeout();
// Re-apply alpha=0 + ignoresMouseEvents on all NSWindows every
// frame. Play mode transitions or internal Unity code may
// re-show or re-activate windows; this ensures they stay hidden.
ReapplyWindowTransparency();
#endif
}
#if UNITY_EDITOR_WIN
[DllImport("user32.dll")]
private static extern int GetWindowLongW(IntPtr hWnd, int nIndex);
[DllImport("user32.dll")]
private static extern int SetWindowLongW(IntPtr hWnd, int nIndex, int dwNewLong);
[DllImport("user32.dll")]
private static extern bool EnumWindows(EnumWindowsCallback lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[DllImport("user32.dll")]
private static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
int X, int Y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("kernel32.dll")]
private static extern uint GetCurrentThreadId();
[DllImport("user32.dll")]
private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
[DllImport("user32.dll")]
private static extern bool BringWindowToTop(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern bool RedrawWindow(IntPtr hWnd, IntPtr lprcUpdate,
IntPtr hrgnUpdate, uint flags);
// RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN | RDW_ERASE | RDW_FRAME
private const uint RDW_INVALIDATE = 0x0001;
private const uint RDW_UPDATENOW = 0x0100;
private const uint RDW_ALLCHILDREN = 0x0080;
private const uint RDW_ERASE = 0x0004;
private const uint RDW_FRAME = 0x0400;
[DllImport("user32.dll")]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
private static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern IntPtr SetFocus(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);
private const uint GW_CHILD = 5;
private const uint GW_HWNDNEXT = 2;
[DllImport("user32.dll")]
private static extern IntPtr GetParent(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags);
private const uint GA_ROOT = 2;
[DllImport("user32.dll")]
private static extern IntPtr GetDesktopWindow();
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr FindWindowW(string lpClassName, string lpWindowName);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr FindWindowExW(IntPtr hwndParent, IntPtr hwndChildAfter, string lpszClass, string lpszWindow);
[DllImport("user32.dll")]
private static extern bool EndMenu();
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int GetClassNameW(IntPtr hWnd, System.Text.StringBuilder lpClassName, int nMaxCount);
[StructLayout(LayoutKind.Sequential)]
private struct RECT { public int Left, Top, Right, Bottom; }
[DllImport("user32.dll")]
private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll")]
private static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll")]
private static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint);
[DllImport("user32.dll")]
private static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint nFlags);
[DllImport("gdi32.dll")]
private static extern IntPtr CreateCompatibleDC(IntPtr hdc);
[DllImport("gdi32.dll")]
private static extern bool DeleteDC(IntPtr hdc);
[DllImport("gdi32.dll")]
private static extern IntPtr SelectObject(IntPtr hdc, IntPtr h);
[DllImport("gdi32.dll")]
private static extern bool DeleteObject(IntPtr ho);
[DllImport("gdi32.dll")]
private static extern IntPtr CreateDIBSection(
IntPtr hdc, ref BITMAPINFOHEADER lpbmi, uint usage,
out IntPtr ppvBits, IntPtr hSection, uint offset);
[StructLayout(LayoutKind.Sequential)]
private struct BITMAPINFOHEADER
{
public int biSize;
public int biWidth;
public int biHeight;
public short biPlanes;
public short biBitCount;
public int biCompression;
public int biSizeImage;
public int biXPelsPerMeter;
public int biYPelsPerMeter;
public int biClrUsed;
public int biClrImportant;
}
private const uint PW_RENDERFULLCONTENT = 2;
[DllImport("user32.dll")]
private static extern bool PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern uint MapVirtualKeyW(uint uCode, uint uMapType);
private const uint WM_KEYDOWN = 0x0100;
private const uint WM_KEYUP = 0x0101;
private const uint WM_CANCELMODE = 0x001F;
private const uint WM_LBUTTONDOWN = 0x0201;
private const uint WM_LBUTTONUP = 0x0202;
private const uint WM_RBUTTONDOWN = 0x0204;
private const uint WM_RBUTTONUP = 0x0205;
private const uint WM_MBUTTONDOWN = 0x0207;
private const uint WM_MBUTTONUP = 0x0208;
private const uint MK_LBUTTON = 0x0001;
private const uint MK_RBUTTON = 0x0002;
private const uint MK_MBUTTON = 0x0010;
private const uint MAPVK_VK_TO_VSC = 0;
// SendInput for injecting synthetic mouse movement when RIDEV_INPUTSINK
// doesn't provide hardware delta (remote streaming scenario).
[DllImport("user32.dll", SetLastError = true)]
private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
[StructLayout(LayoutKind.Sequential)]
private struct INPUT
{
public uint type;
public MOUSEINPUT mi;
// Pad to sizeof(INPUT)=40 on x64. KEYBDINPUT and HARDWAREINPUT
// are smaller, but union size = max member size = MOUSEINPUT(32).
}
[StructLayout(LayoutKind.Sequential)]
private struct MOUSEINPUT
{
public int dx;
public int dy;
public uint mouseData;
public uint dwFlags;
public uint time;
public IntPtr dwExtraInfo;
}
private const uint INPUT_MOUSE = 0;
private const uint MOUSEEVENTF_MOVE = 0x0001;
[DllImport("user32.dll")]
private static extern bool ReleaseCapture();
// SendMessage is synchronous: it directly invokes the WndProc on
// the calling thread and returns only after the WndProc finishes.
// This lets us update the InputManager's mouse button state (which
// happens inside WndProc for WM_RBUTTONDOWN) and IMMEDIATELY call
// ReleaseCapture() before any message pump processing occurs.
// PostMessage was asynchronous, leaving SetCapture active across
// frames, causing editor freezes.
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern IntPtr SendMessage(
IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
// RegisterRawInputDevices — re-register mouse raw input with
// RIDEV_INPUTSINK so the offscreen GameView receives WM_INPUT
// even when the Unity Editor is not the foreground process.
// Without this, Input.GetAxisRaw("Mouse X/Y") returns 0 because
// the delta is sourced exclusively from WM_INPUT / RawInput, not
// from WM_MOUSEMOVE (Tuanjie InputManager architecture).
[StructLayout(LayoutKind.Sequential)]
private struct RAWINPUTDEVICE
{
public ushort usUsagePage;
public ushort usUsage;
public uint dwFlags;
public IntPtr hwndTarget;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool RegisterRawInputDevices(
RAWINPUTDEVICE[] pRawInputDevices, uint uiNumDevices, uint cbSize);
private const uint RIDEV_INPUTSINK = 0x00000100;
private const uint RIDEV_REMOVE = 0x00000001;
private const ushort HID_USAGE_PAGE_GENERIC = 0x01;
private const ushort HID_USAGE_GENERIC_MOUSE = 0x02;
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int X, Y; }
[DllImport("user32.dll")]
private static extern bool GetCursorPos(out POINT lpPoint);
[DllImport("user32.dll")]
private static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")]
private static extern bool ClipCursor(ref RECT lpRect);
[DllImport("user32.dll")]
private static extern bool ClipCursor(IntPtr lpRect);
private const int SW_SHOW = 5;
private const int SW_HIDE = 0;
private const int SW_SHOWNOACTIVATE = 8;
[DllImport("user32.dll")]
private static extern bool DestroyWindow(IntPtr hWnd);
[StructLayout(LayoutKind.Sequential)]
private struct MONITORINFO
{
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public uint dwFlags;
}
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
private const uint MONITOR_DEFAULTTONEAREST = 2;
[UnmanagedFunctionPointer(CallingConvention.Winapi)]
private delegate bool EnumWindowsCallback(IntPtr hWnd, IntPtr lParam);
private const int GWL_EXSTYLE = -20;
private const int WS_EX_LAYERED = 0x00080000;
private const int WS_EX_TRANSPARENT = 0x00000020;
private const uint SWP_NOMOVE = 0x0002;
private const uint SWP_NOSIZE = 0x0001;
private const uint SWP_NOACTIVATE = 0x0010;
private const uint SWP_FRAMECHANGED = 0x0020;
private const uint SWP_SHOWWINDOW = 0x0040;
private const uint SWP_NOZORDER = 0x0004;
private static readonly IntPtr HWND_BOTTOM = new IntPtr(1);
private static readonly IntPtr HWND_TOP = IntPtr.Zero;
// Cached list of Unity editor HWNDs that were made transparent.
private static readonly List s_TransparentHwnds = new List();
// Original WS_EX styles per HWND for proper restoration.
private static readonly Dictionary s_OriginalExStyles = new Dictionary();
// Original window positions saved before moving off-screen.
// Used by the normal (non-PrintWindow) hiding strategy to restore
// windows to their original positions when streaming stops.
private static readonly Dictionary s_OriginalWindowRects = new Dictionary();
// HWND of the streaming mask window, excluded from hide/push operations.
private static IntPtr s_MaskWindowHwnd = IntPtr.Zero;
// Foreground belonged to another process starting at this time (-1 = Unity/unknown).
// Used so we only show the mask on a real taskbar activate (other app → Unity),
// not on stream-start Unity FG churn.
private static double s_OtherProcessFgSince = -1;
private const double kTaskbarActivateMinOtherFgSec = 0.4;
// Throttle the hidden-state refresh so we do not enumerate HWNDs on
// every editor frame while offscreen streaming is active.
private static double s_LastWindowTransparencyReapplyTime = 0;
private const double kWindowTransparencyReapplyIntervalSec = 0.25;
// HWND of the offscreen capture floating ContainerWindow and its
// GUIView child. Used by PostNativeKeyMessage and SetFocus to route
// keyboard events to the correct GameView for Input.GetKey support.
private static IntPtr s_OffscreenTargetContainerHwnd = IntPtr.Zero;
private static IntPtr s_OffscreenTargetGUIViewHwnd = IntPtr.Zero;
// Timestamp of the last Play mode SetFocus call on Windows.
// SetFocus briefly makes the HWND's parent the foreground window,
// which triggers EnsureWindowsStayHiddenDuringOffscreen to reclaim
// and show the mask window. During this grace period the foreground
// check is skipped so the mask doesn't keep flashing.
private static double s_WinPlayKeyLastFocusTime = 0;
private const double kWinPlayKeyFocusGraceSec = 0.15;
// Throttle SetFocus during key auto-repeat to reduce movement jitter.
private const double kWinPlayKeyRepeatRefocusSec = 0.25;
// Browser key names currently held on Windows (from DataChannel
// keydown/keyup). Used to flush WM_KEYUP on mouseup so the Input
// Manager does not retain stale pressed states (ghost movement).
private static readonly HashSet s_WinHeldKeys = new HashSet();
// Mouse buttons currently held via PostNativeMouseButtonMessage.
// Flushed on stream disconnect to avoid ghost-held button states.
private static readonly HashSet s_WinHeldMouseButtons = new HashSet();
// Set after posting WM_xBUTTONDOWN; cleared by OffscreenUpdateLoop
// calling ReleaseCapture to undo SetMouseCapture that the WndProc
// applies in response, preventing potential frame stalls.
private static bool s_NeedReleaseMouseCapture = false;
// Tracks whether RIDEV_INPUTSINK is currently registered for the
// GameView HWND and the last registration time (for throttling).
private static bool s_RawInputSinkRegistered = false;
private static double s_RawInputSinkLastRegTime = 0;
private static IntPtr s_RawInputSinkHwnd = IntPtr.Zero;
///
/// Register the GameView HWND for mouse raw input with RIDEV_INPUTSINK.
/// This allows the offscreen GameView to receive WM_INPUT messages even
/// when the Unity Editor is not the foreground process. Without this,
/// Input.GetAxisRaw("Mouse X/Y") returns 0 because delta is sourced
/// exclusively from WM_INPUT/RawInput (not from WM_MOUSEMOVE).
/// Tuanjie's InputSetWindow (called on WM_SETFOCUS) re-registers without
/// RIDEV_INPUTSINK, so we must periodically re-register.
///
private static void EnsureRawInputSinkRegistered()
{
IntPtr hwnd = s_OffscreenTargetGUIViewHwnd != IntPtr.Zero
? s_OffscreenTargetGUIViewHwnd
: s_OffscreenTargetContainerHwnd;
if (hwnd == IntPtr.Zero) return;
double now = EditorApplication.timeSinceStartup;
// Re-register every 0.5s to counteract Tuanjie's own RegisterRawInputDevices
// (triggered by SetFocus → WM_SETFOCUS → InputSetWindow → ToggleFullscreen)
if (s_RawInputSinkRegistered && s_RawInputSinkHwnd == hwnd
&& (now - s_RawInputSinkLastRegTime) < 0.5)
return;
var rid = new RAWINPUTDEVICE[]
{
new RAWINPUTDEVICE
{
usUsagePage = HID_USAGE_PAGE_GENERIC,
usUsage = HID_USAGE_GENERIC_MOUSE,
dwFlags = RIDEV_INPUTSINK,
hwndTarget = hwnd
}
};
bool ok = RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf(typeof(RAWINPUTDEVICE)));
if (ok)
{
if (!s_RawInputSinkRegistered)
LogVerbose($"[NWB-RawInput] Registered RIDEV_INPUTSINK for hwnd=0x{hwnd.ToInt64():X}");
s_RawInputSinkRegistered = true;
s_RawInputSinkHwnd = hwnd;
s_RawInputSinkLastRegTime = now;
}
else if (!s_RawInputSinkRegistered)
{
CodelyLogger.LogWarning($"[NWB-RawInput] Failed to register RIDEV_INPUTSINK for hwnd=0x{hwnd.ToInt64():X}");
}
}
// Enumerate top-level windows belonging to the current process using
// GetWindow instead of EnumWindows. EnumWindows internally invokes
// callbacks that can load third-party shell extension DLLs (e.g.
// BaiduNetdisk YunShellExt, OneDrive FileSyncShell, Adobe CoreSync)
// whose DllMain may fail, causing STATUS_DLL_INIT_FAILED (0xC0000142)
// — a native crash that C# try-catch cannot intercept.
// GetWindow walks the window manager's internal linked list without
// triggering any shell extension loading.
private static List GetAllUnityHwnds()
{
var result = new List();
uint currentPid = (uint)System.Diagnostics.Process.GetCurrentProcess().Id;
IntPtr hwnd = GetWindow(GetDesktopWindow(), GW_CHILD);
int safety = 0;
while (hwnd != IntPtr.Zero && safety < 4096)
{
try
{
if (IsWindow(hwnd) && IsWindowVisible(hwnd))
{
GetWindowThreadProcessId(hwnd, out uint pid);
if (pid == currentPid && hwnd != s_MaskWindowHwnd)
result.Add(hwnd);
}
}
catch
{
// Keep iteration resilient even if a single HWND becomes invalid.
}
hwnd = GetWindow(hwnd, GW_HWNDNEXT);
safety++;
}
return result;
}
///
/// Defer creating the streaming mask until the user activates Unity via the
/// taskbar (see EnsureStreamingMaskShownForUser). Showing it at stream start
/// steals Host mouse focus and skews GameView Play PathD.
///
private static void DeferStreamingMaskWindow()
{
s_MaskWindowHwnd = IntPtr.Zero;
s_OtherProcessFgSince = -1;
CodelyLogger.Log("[NWB-Win] Streaming mask deferred until taskbar activate (bottom-right when shown)");
}
///
/// Create/show the mask at bottom-right and bring it to the foreground.
/// Called when the user clicks the Editor taskbar button during offscreen streaming.
///
private static void EnsureStreamingMaskShownForUser()
{
try
{
StreamingMaskWindow.ShowMask();
IntPtr hwnd = ResolveStreamingMaskContainerHwnd();
if (hwnd == IntPtr.Zero)
{
// ShowUtility may bind the native handle on the next editor tick.
EditorApplication.delayCall += () =>
{
if (!s_OffscreenActive) return;
IntPtr delayed = ResolveStreamingMaskContainerHwnd();
if (delayed == IntPtr.Zero)
{
CodelyLogger.LogWarning("[NWB-Win] EnsureStreamingMaskShownForUser: HWND not detected");
return;
}
s_MaskWindowHwnd = delayed;
BringStreamingMaskToFront(delayed);
};
return;
}
s_MaskWindowHwnd = hwnd;
BringStreamingMaskToFront(hwnd);
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-Win] EnsureStreamingMaskShownForUser failed: {ex.Message}");
}
}
private static IntPtr ResolveStreamingMaskContainerHwnd()
{
var win = StreamingMaskWindow.TryGetInstance();
if (win == null) return IntPtr.Zero;
IntPtr handle = Cn.Tuanjie.Codely.Editor.EditorWindowNativeHandleHelper.GetGUIViewHandle(win);
if (handle == IntPtr.Zero || !IsWindow(handle))
handle = FindWindowW(null, StreamingMaskWindow.WindowTitle);
if (handle == IntPtr.Zero || !IsWindow(handle))
return IntPtr.Zero;
IntPtr root = GetAncestor(handle, GA_ROOT);
return root != IntPtr.Zero ? root : handle;
}
private static void BringStreamingMaskToFront(IntPtr hwnd)
{
if (hwnd == IntPtr.Zero || !IsWindow(hwnd)) return;
s_TransparentHwnds.Remove(hwnd);
int exStyle = GetWindowLongW(hwnd, GWL_EXSTYLE);
SetWindowLongW(hwnd, GWL_EXSTYLE, exStyle & ~WS_EX_TRANSPARENT);
StreamingMaskWindow.ApplyBottomRightPosition(StreamingMaskWindow.TryGetInstance());
ShowWindow(hwnd, SW_SHOW);
SetForegroundWindow(hwnd);
LogVerbose($"[NWB-Win] Streaming mask shown for user, hwnd=0x{hwnd.ToInt64():X}");
}
///
/// Hide the streaming mask window and clear the tracked HWND.
///
private static void HideStreamingMaskWindow()
{
try
{
StreamingMaskWindow.HideMask();
s_MaskWindowHwnd = IntPtr.Zero;
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-Win] HideStreamingMaskWindow failed: {ex.Message}");
}
}
#endif
///
/// Ensure the target EditorWindow is the active tab in its DockArea.
/// When multiple windows share a tab group (e.g. GameView + SceneView),
/// GrabPixels and SendEvent only work on the currently selected tab.
/// This uses reflection to set the DockArea.selected index without
/// calling Focus() which would bring Unity to the foreground.
///
private static void EnsureActiveTabInDockArea(EditorWindow window)
{
if (window == null || s_DockAreaTabActivated) return;
try
{
FieldInfo parentField = typeof(EditorWindow).GetField("m_Parent",
BindingFlags.Instance | BindingFlags.NonPublic);
if (parentField == null) return;
object dockArea = parentField.GetValue(window);
if (dockArea == null) return;
System.Type dockAreaType = dockArea.GetType();
// If not a DockArea (e.g. standalone window), compute offset from
// the GUIView and mark as done.
if (dockAreaType.Name != "DockArea")
{
ComputeTabBarOffset(window, dockArea, dockAreaType);
s_DockAreaTabActivated = true;
return;
}
// Get the list of panes (EditorWindows) in this DockArea.
FieldInfo panesField = dockAreaType.GetField("m_Panes",
BindingFlags.Instance | BindingFlags.NonPublic);
if (panesField == null) return;
var panes = panesField.GetValue(dockArea) as System.Collections.IList;
if (panes == null || panes.Count <= 1)
{
// Even with a single tab, DockArea still has a tab bar that offsets
// the content area. Must compute the offset before returning.
ComputeTabBarOffset(window, dockArea, dockAreaType);
s_DockAreaTabActivated = true;
return;
}
int targetIndex = -1;
for (int i = 0; i < panes.Count; i++)
{
if ((panes[i] as UnityEngine.Object) == window) { targetIndex = i; break; }
}
if (targetIndex < 0) return;
// Get current selected index and save it for restoration on stop.
PropertyInfo selectedProp = dockAreaType.GetProperty("selected",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (selectedProp == null)
{
FieldInfo selectedField = dockAreaType.GetField("m_Selected",
BindingFlags.Instance | BindingFlags.NonPublic);
if (selectedField != null)
{
int cur = (int)selectedField.GetValue(dockArea);
if (cur != targetIndex)
{
s_DockAreaInstance = dockArea;
s_OriginalDockAreaSelectedIndex = cur;
selectedField.SetValue(dockArea, targetIndex);
LogVerbose($"[NWB-Offscreen] Activated tab {targetIndex} in DockArea (field), saved original={cur}");
}
}
}
else
{
int cur = (int)selectedProp.GetValue(dockArea);
if (cur != targetIndex)
{
s_DockAreaInstance = dockArea;
s_OriginalDockAreaSelectedIndex = cur;
selectedProp.SetValue(dockArea, targetIndex);
LogVerbose($"[NWB-Offscreen] Activated tab {targetIndex} in DockArea (prop), saved original={cur}");
}
}
// Calculate Y offset: the tab bar height between GUIView top and
// EditorWindow content top. GrabPixels captures from GUIView top,
// but SendEvent mousePosition is relative to EditorWindow content.
ComputeTabBarOffset(window, dockArea, dockAreaType);
s_DockAreaTabActivated = true;
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-Offscreen] EnsureActiveTab failed: {ex.Message}");
}
}
///
/// Restore the DockArea selected tab if we changed it during offscreen capture,
/// then ensure the target window has proper focus for user interaction.
///
private static void RestoreDockAreaTab()
{
EditorWindow target = s_OffscreenTarget;
// If we changed the DockArea tab, restore the original selection.
if (s_DockAreaInstance != null && s_OriginalDockAreaSelectedIndex >= 0)
{
try
{
var dockAreaType = s_DockAreaInstance.GetType();
PropertyInfo selectedProp = dockAreaType.GetProperty("selected",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (selectedProp != null)
{
selectedProp.SetValue(s_DockAreaInstance, s_OriginalDockAreaSelectedIndex);
LogVerbose($"[NWB-Offscreen] Restored DockArea tab to original index={s_OriginalDockAreaSelectedIndex}");
}
else
{
FieldInfo selectedField = dockAreaType.GetField("m_Selected",
BindingFlags.Instance | BindingFlags.NonPublic);
if (selectedField != null)
{
selectedField.SetValue(s_DockAreaInstance, s_OriginalDockAreaSelectedIndex);
LogVerbose($"[NWB-Offscreen] Restored DockArea tab to original index={s_OriginalDockAreaSelectedIndex} (field)");
}
}
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-Offscreen] RestoreDockAreaTab failed: {ex.Message}");
}
}
else
{
LogVerbose("[NWB-Offscreen] No DockArea tab change to restore");
}
s_DockAreaInstance = null;
s_OriginalDockAreaSelectedIndex = -1;
}
///
/// Release UIElements pointer and mouse capture on the target window's panel.
/// During streaming, SendEvent injection passes through ProcessRetainedMode which
/// lets UIElements process pointer events. If a MouseDown sets pointer capture on
/// a VisualElement but the corresponding MouseUp fails to release it (e.g. because
/// the stream was stopped mid-interaction), PointerCaptureDispatchingStrategy will
/// intercept ALL subsequent OS mouse events with stopDispatch=true and
/// propagateToIMGUI=false, completely blocking IMGUI toolbar interaction.
///
private static void ReleaseUIElementsCapture(EditorWindow target)
{
try
{
// Method 1: Call GUIUtility.releaseCapture delegate which triggers
// PointerCaptureHelper.ReleaseEditorMouseCapture() on the editor dispatcher.
var releaseCaptureField = typeof(GUIUtility).GetField("releaseCapture",
BindingFlags.Static | BindingFlags.NonPublic);
if (releaseCaptureField != null)
{
var releaseAction = releaseCaptureField.GetValue(null) as Action;
if (releaseAction != null)
{
releaseAction.Invoke();
LogVerbose("[NWB-Offscreen] Released UIElements editor mouse capture via GUIUtility.releaseCapture");
}
}
// Method 2: Also release capture on the target window's panel directly.
// This covers cases where the panel-local capture differs from the
// editor-global dispatcher's capture state.
if (target != null && target.rootVisualElement != null)
{
var panel = target.rootVisualElement.panel;
if (panel != null)
{
// IPanel.ReleasePointer is an extension method in PointerCaptureHelper.
// Use reflection: panel.dispatcher.pointerState.ReleasePointer(0)
// PointerId.mousePointerId == 0
var dispatcherProp = panel.GetType().GetProperty("dispatcher",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
object dispatcher = dispatcherProp?.GetValue(panel);
if (dispatcher != null)
{
var pointerStateProp = dispatcher.GetType().GetProperty("pointerState",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
object pointerState = pointerStateProp?.GetValue(dispatcher);
if (pointerState != null)
{
// Check current capturing element for diagnostics
var getCaptMethod = pointerState.GetType().GetMethod("GetCapturingElement",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
object capElement = getCaptMethod?.Invoke(pointerState, new object[] { 0 });
if (capElement != null)
{
LogVerbose($"[NWB-Offscreen] Panel has pointer capture on: {capElement.GetType().Name} — releasing");
}
// ReleasePointer(int pointerId)
var releaseMethod = pointerState.GetType().GetMethod("ReleasePointer",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
null, new System.Type[] { typeof(int) }, null);
releaseMethod?.Invoke(pointerState, new object[] { 0 });
// ProcessPointerCapture to commit the release
var processMethod = pointerState.GetType().GetMethod("ProcessPointerCapture",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
null, new System.Type[] { typeof(int) }, null);
processMethod?.Invoke(pointerState, new object[] { 0 });
if (capElement != null)
{
LogVerbose("[NWB-Offscreen] Panel pointer capture released and processed");
}
}
}
}
}
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-Offscreen] ReleaseUIElementsCapture failed: {ex.Message}");
}
}
///
/// Repair GUIView native properties that may block input after offscreen streaming.
/// SendEvent injection can leave disableInputEvents=true or mouseRayInvisible=true,
/// which prevents the DockArea from receiving any mouse events.
///
private static void RepairGUIViewNativeState(EditorWindow target)
{
if (target == null) return;
try
{
FieldInfo parentField = typeof(EditorWindow).GetField("m_Parent",
BindingFlags.Instance | BindingFlags.NonPublic);
if (parentField == null) return;
object guiView = parentField.GetValue(target);
if (guiView == null) return;
var gvType = guiView.GetType();
var disableInputProp = gvType.GetProperty("disableInputEvents",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (disableInputProp != null)
{
bool val = (bool)disableInputProp.GetValue(guiView);
if (val)
{
disableInputProp.SetValue(guiView, false);
LogVerbose("[NWB-Offscreen] FIXED: disableInputEvents was true, reset to false");
}
}
var mouseRayInvisProp = gvType.GetProperty("mouseRayInvisible",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (mouseRayInvisProp != null)
{
bool val = (bool)mouseRayInvisProp.GetValue(guiView);
if (val)
{
mouseRayInvisProp.SetValue(guiView, false);
LogVerbose("[NWB-Offscreen] FIXED: mouseRayInvisible was true, reset to false");
}
}
var clearKbdMethod = gvType.GetMethod("ClearKeyboardControl",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (clearKbdMethod != null)
clearKbdMethod.Invoke(guiView, null);
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-Offscreen] RepairGUIViewNativeState failed: {ex.Message}");
}
}
///
/// Restore visual rendering state of the target EditorWindow after
/// offscreen streaming.
///
/// Root cause: during streaming, the GUIView's IMGUI state can become
/// stale — toolbar toggle icons stop updating visually even though the
/// underlying values toggle correctly (confirmed by PostStop diagnostic
/// logs showing audioMasterMute correctly flipping on each click).
///
/// The issue is that after stop, the GUIView's native rendering pipeline
/// does not trigger a full on-screen redraw. Simply calling Repaint()
/// schedules an async repaint, but RepaintImmediately() forces a
/// synchronous full redraw through the native pipeline (including the
/// platform buffer swap). We also install a short-lived EditorApplication
/// .update callback that forces Repaint every frame for a few frames, to
/// ensure any deferred state (GUILayout caches, hover state, etc.) gets
/// fully flushed to screen.
///
private static int s_RepairRepaintFramesLeft;
private static EditorWindow s_RepairTarget;
///
/// Fix black-screen caused by EditorWindow.m_Pos being displaced to
/// extreme off-screen coordinates during offscreen streaming. When the
/// HWND is moved off-screen for hiding, Unity's ContainerWindow may
/// internally shift m_Pos far off-screen (e.g. -31741,-31970). After
/// restoring the HWND, m_Pos remains stale and the DockArea renders
/// nothing for this EditorWindow, causing the "black screen" effect.
///
private static void RepairWindowPosition(EditorWindow target, Rect savedPos)
{
if (target == null) return;
try
{
Rect curPos = target.position;
// Detect abnormal position: x or y far off-screen (< -10000)
if (curPos.x < -10000 || curPos.y < -10000)
{
// Try to recover a sane position from the GUIView (parent)
// which tracks the actual native window rect.
FieldInfo parentField = typeof(EditorWindow).GetField("m_Parent",
BindingFlags.Instance | BindingFlags.NonPublic);
object guiView = parentField?.GetValue(target);
Rect fixedPos = curPos;
if (guiView != null)
{
PropertyInfo posProp = null;
var tt = guiView.GetType();
while (tt != null && posProp == null)
{
posProp = tt.GetProperty("position",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
tt = tt.BaseType;
}
if (posProp != null)
{
Rect gvPos = (Rect)posProp.GetValue(guiView);
// EditorWindow.position is relative to screen;
// GUIView.position is relative to the ContainerWindow.
// Use the GUIView rect as a base — the EditorWindow
// sits inside the GUIView at a small offset (tab bar).
fixedPos = new Rect(gvPos.x + 1, gvPos.y + 19,
curPos.width, curPos.height);
}
}
// If we saved a valid position before stop, prefer that.
if (savedPos.x > -10000 && savedPos.y > -10000 &&
savedPos.width > 0 && savedPos.height > 0)
{
fixedPos = savedPos;
}
LogVerbose($"[NWB-Repair] Fixing abnormal window position: " +
$"was=({curPos.x},{curPos.y}) fixed=({fixedPos.x},{fixedPos.y}) " +
$"size={curPos.width}x{curPos.height}");
// Set m_Pos directly to avoid EditorWindow.position setter
// side-effects (undocking, etc.)
var mPosField = typeof(EditorWindow).GetField("m_Pos",
BindingFlags.Instance | BindingFlags.NonPublic);
if (mPosField != null)
mPosField.SetValue(target, fixedPos);
else
target.position = fixedPos;
target.Repaint();
}
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-Repair] RepairWindowPosition failed: {ex.Message}");
}
}
private static void RepairVisualState(EditorWindow target)
{
if (target == null) return;
try
{
FieldInfo parentField = typeof(EditorWindow).GetField("m_Parent",
BindingFlags.Instance | BindingFlags.NonPublic);
if (parentField == null) return;
object guiView = parentField.GetValue(target);
if (guiView == null) return;
var gvType = guiView.GetType();
// Diagnostic: dump critical IMGUI/GUIView state BEFORE repair.
bool hasFocus = false;
try
{
PropertyInfo hasFocusProp = null;
var tt = gvType;
while (tt != null && hasFocusProp == null)
{
hasFocusProp = tt.GetProperty("hasFocus",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
tt = tt.BaseType;
}
if (hasFocusProp != null)
hasFocus = (bool)hasFocusProp.GetValue(guiView);
}
catch (Exception) { }
Rect rootBound = Rect.zero;
try
{
if (target.rootVisualElement != null)
rootBound = target.rootVisualElement.worldBound;
}
catch (Exception) { }
PropertyInfo posProp = null;
Rect gvPos = Rect.zero;
try
{
var tt = gvType;
while (tt != null && posProp == null)
{
posProp = tt.GetProperty("position",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
tt = tt.BaseType;
}
if (posProp != null)
gvPos = (Rect)posProp.GetValue(guiView);
}
catch (Exception) { }
LogVerbose($"[NWB-Repair] PRE-STATE: hotControl={GUIUtility.hotControl} " +
$"kbControl={GUIUtility.keyboardControl} hasFocus={hasFocus} " +
$"focusedWin={EditorWindow.focusedWindow?.GetType().Name ?? "null"} " +
$"rootVE.worldBound={rootBound} gvPos={gvPos} " +
$"targetPos={target.position} targetType={target.GetType().Name}");
// CRITICAL: Clear hotControl. If streaming left a non-zero
// hotControl, GetEventTypeForControl() converts all mouse events
// to Ignore for any control whose id != hotControl — completely
// blocking toolbar toggle/button interaction.
if (GUIUtility.hotControl != 0)
{
LogVerbose($"[NWB-Repair] Clearing residual hotControl={GUIUtility.hotControl}");
GUIUtility.hotControl = 0;
}
// Force a synchronous full redraw through the native pipeline.
MethodInfo repaintImmediate = null;
var t = gvType;
while (t != null && repaintImmediate == null)
{
repaintImmediate = t.GetMethod("RepaintImmediately",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
null, System.Type.EmptyTypes, null);
t = t.BaseType;
}
if (repaintImmediate != null)
{
try { repaintImmediate.Invoke(guiView, null); }
catch (Exception) { }
}
// Also trigger a deferred Repaint for safety.
target.Repaint();
// Install a short-lived update callback that forces Repaint every
// frame for several frames. This ensures toolbar toggle icons,
// hover effects, and GUILayout caches are fully refreshed even if
// the first RepaintImmediately runs before all state is committed.
s_RepairTarget = target;
s_RepairRepaintFramesLeft = 30;
EditorApplication.update -= RepairRepaintTick;
EditorApplication.update += RepairRepaintTick;
LogVerbose("[NWB-Offscreen] RepairVisualState: RepaintImmediately + 30-frame repaint installed");
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[NWB-Offscreen] RepairVisualState failed: {ex.Message}");
}
}
private static void RepairRepaintTick()
{
if (s_RepairTarget == null || s_RepairRepaintFramesLeft <= 0)
{
EditorApplication.update -= RepairRepaintTick;
s_RepairTarget = null;
return;
}
s_RepairRepaintFramesLeft--;
// Request a deferred repaint on the target window and its parent
// GUIView (DockArea) to refresh both content and tab bar.
s_RepairTarget.Repaint();
try
{
FieldInfo parentField = typeof(EditorWindow).GetField("m_Parent",
BindingFlags.Instance | BindingFlags.NonPublic);
object guiView = parentField?.GetValue(s_RepairTarget);
if (guiView != null)
{
MethodInfo repaintMethod = guiView.GetType().GetMethod("Repaint",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
null, System.Type.EmptyTypes, null);
repaintMethod?.Invoke(guiView, null);
}
}
catch (Exception) { }
}
///
/// Compute the pixel offset between the top of GUIView (what GrabPixels captures)
/// and the top of EditorWindow content area (what SendEvent uses).
/// This is typically the DockArea tab bar height (~18-21 pixels).
///
private static void ComputeTabBarOffset(EditorWindow window, object dockArea, System.Type dockAreaType)
{
s_TabBarOffsetY = 0f;
try
{
float borderTop = -1f;
float screenDiff = -1f;
// Method 1: Use DockArea.borderSize.top (only the tab strip itself).
PropertyInfo borderProp = dockAreaType.GetProperty("borderSize",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (borderProp != null)
{
object border = borderProp.GetValue(dockArea);
if (border != null)
{
PropertyInfo topProp = border.GetType().GetProperty("top");
if (topProp != null)
borderTop = (float)(int)topProp.GetValue(border);
}
}
// Method 2 (preferred): The difference in height between GUIView
// (which includes tab bar) and EditorWindow (content only) gives
// the exact pixel offset. This is robust regardless of coordinate origin.
PropertyInfo viewPosProp = dockAreaType.GetProperty("screenPosition",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (viewPosProp != null)
{
Rect viewPos = (Rect)viewPosProp.GetValue(dockArea);
Rect winPos = window.position;
float heightDiff = viewPos.height - winPos.height;
float yDiff = winPos.y - viewPos.y;
screenDiff = heightDiff;
LogVerbose($"[NWB-Offscreen] Tab offset debug: borderSize.top={borderTop} " +
$"viewPos=({viewPos.x},{viewPos.y},{viewPos.width},{viewPos.height}) " +
$"winPos=({winPos.x},{winPos.y},{winPos.width},{winPos.height}) " +
$"heightDiff={heightDiff} yDiff={yDiff}");
}
// Prefer the height diff if valid, otherwise fall back to borderSize.
if (screenDiff > 0 && screenDiff < 100)
{
s_TabBarOffsetY = screenDiff;
LogVerbose($"[NWB-Offscreen] Tab bar offset (height diff): {s_TabBarOffsetY}px");
}
else if (borderTop >= 0)
{
s_TabBarOffsetY = borderTop;
LogVerbose($"[NWB-Offscreen] Tab bar offset (borderSize.top): {s_TabBarOffsetY}px");
}
else
{
s_TabBarOffsetY = 19f;
LogVerbose($"[NWB-Offscreen] Tab bar offset (default): {s_TabBarOffsetY}px");
}
}
catch (Exception ex)
{
s_TabBarOffsetY = 19f;
CodelyLogger.LogWarning($"[NWB-Offscreen] ComputeTabBarOffset failed: {ex.Message}");
}
}
///
/// Start offscreen capture for the given EditorWindow type (e.g. "UnityEditor.GameView").
/// The window does not need to be visible or undocked.
/// When nativeAlreadyStarted=true (browser HTTP path), native offscreen mode is already active.
/// When nativeAlreadyStarted=false (CLI path), this method activates native offscreen mode.
///
public static bool StartOffscreenCapture(string windowTypeName, int fps = 30, int width = 0, int height = 0,
bool nativeAlreadyStarted = false)
{
if (!IsAvailable()) return false;
// If already in offscreen mode (tab switch), clean up the old target
// without restoring window visibility — keeps windows hidden throughout.
bool isSwitching = s_OffscreenTarget != null;
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
bool reuseDetachedTarget = false;
#endif
if (isSwitching)
{
if (s_NativeOffscreenDisconnectedAwaitingRestore &&
s_OffscreenTarget != null &&
string.Equals(s_OffscreenTarget.GetType().FullName, windowTypeName, StringComparison.Ordinal))
{
// Reconnect path: keep the existing floating target alive so
// stream restart does not destroy/recreate the capture window.
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
reuseDetachedTarget = true;
#endif
LogVerbose($"[NWB-Offscreen] Reusing detached target: {windowTypeName}");
}
else
{
LogVerbose($"[NWB-Offscreen] Switching target: {s_OffscreenTarget.GetType().FullName} → {windowTypeName}");
if (s_OffscreenAutoFloatingWindow)
{
try { s_OffscreenTarget.Close(); }
catch (Exception ex) { CodelyLogger.LogWarning($"[NWB-Offscreen] Close old floating failed: {ex.Message}"); }
}
RestoreKeyFocusAfterOffscreen();
s_OffscreenTarget = null;
s_OffscreenAutoFloatingWindow = false;
s_OffscreenOriginalDockedTarget = null;
s_OffscreenFloatingWindowInstanceId = 0;
}
}
// Ensure no stale popup/panel state from a previous capture session.
ClearFrontendPopupState();
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
bool hidEditorWindowsForOffscreen = false;
try
{
// Resolve the target EditorWindow type.
// First try standard resolution, then search all loaded assemblies
// (needed for third-party plugin window types).
System.Type windowType = System.Type.GetType(windowTypeName);
if (windowType == null)
{
windowType = System.Type.GetType(windowTypeName + ",UnityEditor");
}
if (windowType == null)
{
foreach (var asm in System.AppDomain.CurrentDomain.GetAssemblies())
{
windowType = asm.GetType(windowTypeName);
if (windowType != null) break;
}
}
if (windowType == null)
{
// Fallback: try matching by FullName in already-open EditorWindows.
foreach (var w in Resources.FindObjectsOfTypeAll())
{
if (w != null && w.GetType().FullName == windowTypeName)
{
windowType = w.GetType();
break;
}
}
if (windowType == null)
{
CodelyLogger.LogError($"[NWB-Offscreen] Window type not found: {windowTypeName}");
return false;
}
}
EditorWindow target = null;
foreach (var w in Resources.FindObjectsOfTypeAll())
{
if (w != null && w.GetType() == windowType)
{
target = w;
break;
}
}
if (reuseDetachedTarget &&
s_OffscreenTarget != null &&
s_OffscreenTarget.GetType() == windowType)
{
target = s_OffscreenTarget;
}
if (target == null)
{
// Try to create one if not found (e.g. GameView may not be open).
target = EditorWindow.GetWindow(windowType, false, null, false);
}
if (target == null)
{
CodelyLogger.LogError($"[NWB-Offscreen] Could not find/create window: {windowTypeName}");
return false;
}
// Always create a dedicated floating capture window so:
// 1) We never disturb the user's existing window layout (docked or floating).
// 2) Resize requests can actually change source resolution.
// 3) Stop always closes our window, leaving the user's windows intact.
if (!reuseDetachedTarget)
{
s_OffscreenAutoFloatingWindow = false;
s_OffscreenOriginalDockedTarget = null;
s_OffscreenFloatingWindowInstanceId = 0;
try
{
EditorWindow referenceTarget = target;
// Save reference target's position and docked state BEFORE
// closing it, because CloseExistingGameViewsForStreaming
// will destroy the docked GameView instance.
Rect savedRefPos = referenceTarget.position;
bool refWasDocked = IsWindowDocked(referenceTarget);
// Close existing docked GameView(s) BEFORE creating the floating
// window. The docked GameView's size info (PanelSettings.
// GetGameViewResolution) interferes with the floating view,
// causing UI compression if still present during creation.
if (windowType.Name == "GameView" && !s_ClosedDockedGameViewForStreaming)
{
CloseExistingGameViewsForStreaming(null);
}
EditorWindow floating = ScriptableObject.CreateInstance(windowType) as EditorWindow;
if (floating == null)
{
CodelyLogger.LogError("[NWB-Offscreen] Failed to create dedicated floating capture window");
return false;
}
// Frontend sends CSS pixels which map 1:1 to Unity logical
// points (DIPs). Do NOT divide by pixelsPerPoint — that
// shrinks the window on HiDPI displays (e.g. Windows 200%),
// making captured content appear zoomed/magnified.
Rect pos = savedRefPos;
float desiredW = Mathf.Max(width > 0 ? (float)width * DpiScaleFactor : pos.width, 320f * DpiScaleFactor);
float desiredH = Mathf.Max(height > 0 ? ((float)height - s_TabBarOffsetY) * DpiScaleFactor : pos.height, 240f * DpiScaleFactor);
Rect desiredRect = new Rect(pos.x + 20f, pos.y + 20f, desiredW, desiredH);
floating.position = desiredRect;
#if UNITY_EDITOR_WIN
// Snapshot HWNDs before Show() so we can detect the new
// ContainerWindow HWND created for this floating window.
var beforeShowHwnds = new HashSet(GetAllUnityHwnds());
#endif
#if UNITY_EDITOR_OSX
// Snapshot NSWindow list before Show() so we can detect
// the new NSWindow created for the floating window.
var beforeShowNSWindows = new HashSet(GetAllNSWindows());
#endif
floating.Show();
// Re-apply position AFTER Show() because Unity's window
// management may override the pre-Show position with saved
// layout values. This ensures the floating window starts at
// the requested capture resolution immediately.
floating.position = desiredRect;
floating.Focus();
#if UNITY_EDITOR_WIN
// Detect the newly created ContainerWindow HWND and its
// GUIView child for keyboard input injection targeting.
s_OffscreenTargetContainerHwnd = IntPtr.Zero;
s_OffscreenTargetGUIViewHwnd = IntPtr.Zero;
var afterShowHwnds = GetAllUnityHwnds();
foreach (var h in afterShowHwnds)
{
if (!beforeShowHwnds.Contains(h))
{
s_OffscreenTargetContainerHwnd = h;
IntPtr child = GetWindow(h, GW_CHILD);
if (child != IntPtr.Zero)
s_OffscreenTargetGUIViewHwnd = child;
LogVerbose($"[NWB-Win] Captured floating window HWND=0x{h.ToInt64():X}, GUIView child=0x{(child != IntPtr.Zero ? child.ToInt64() : 0):X}");
break;
}
}
#endif
#if UNITY_EDITOR_OSX
// Detect the newly created NSWindow for keyboard focus
// injection (makeKeyWindow) during GameView Play mode.
s_OffscreenTargetNSWindow = IntPtr.Zero;
var afterShowNSWindows = GetAllNSWindows();
foreach (var nsw in afterShowNSWindows)
{
if (!beforeShowNSWindows.Contains(nsw))
{
s_OffscreenTargetNSWindow = nsw;
LogVerbose($"[NWB-ObjC] Captured floating window NSWindow={nsw}");
break;
}
}
#endif
target = floating;
s_OffscreenAutoFloatingWindow = true;
s_OffscreenOriginalDockedTarget = referenceTarget;
s_OffscreenFloatingWindowInstanceId = floating.GetStableInstanceId();
Rect actualPos = floating.position;
CodelyLogger.Log($"[NWB-Offscreen] Created dedicated floating capture window (ref was {(refWasDocked ? "docked" : "floating")}) " +
$"desired=({desiredRect.width:F0}x{desiredRect.height:F0}) actual=({actualPos.width:F0}x{actualPos.height:F0})");
}
catch (Exception ex)
{
CodelyLogger.LogError($"[NWB-Offscreen] Failed to create floating capture window: {ex.Message}");
return false;
}
}
else
{
LogVerbose($"[NWB-Offscreen] Reuse existing floating capture window instanceId={target.GetStableInstanceId()}");
}
// Ensure the window has rendered at least once so the RT is populated.
target.Repaint();
s_OffscreenTarget = target;
s_OffscreenTargetType = windowType;
s_OffscreenFps = Mathf.Max(1, fps);
s_OriginalTargetPosition = target.position;
s_DockAreaTabActivated = false;
s_DockAreaInstance = null;
s_OriginalDockAreaSelectedIndex = -1;
s_TabBarOffsetY = 0f;
s_OffscreenFrameCount = 0;
s_NextCaptureTime = 0;
s_OffscreenResizeTargetActive = false;
s_OffscreenResizeTargetWidth = 0;
s_OffscreenResizeTargetHeight = 0;
s_OffscreenResizeRetryFrames = 0;
#if UNITY_EDITOR_WIN
s_GameViewLowResOriginalCaptured = false;
s_GameViewLowResOriginalValue = false;
s_GameViewLowResForcedOff = false;
s_OffscreenForegroundHideDiagCount = 0;
s_OtherProcessFgSince = -1;
#endif
// Compute tab bar offset early so RT size includes the full GUIView.
EnsureActiveTabInDockArea(target);
TryPrepareGameViewLowResolutionForOffscreen(target);
// Determine resolution: use GUIView.screenPosition (includes tab bar)
// to get the full pixel size that GrabPixels will capture.
if (width <= 0 || height <= 0)
{
try
{
FieldInfo parentField = typeof(EditorWindow).GetField("m_Parent",
BindingFlags.Instance | BindingFlags.NonPublic);
if (parentField != null)
{
object guiView = parentField.GetValue(target);
if (guiView != null)
{
PropertyInfo screenPosProp = guiView.GetType().GetProperty("screenPosition",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (screenPosProp != null)
{
Rect screenPos = (Rect)screenPosProp.GetValue(guiView);
width = Mathf.Max((int)screenPos.width, 320);
height = Mathf.Max((int)screenPos.height, 240);
}
}
}
}
catch (Exception) { /* fallback below */ }
if (width <= 0 || height <= 0)
{
Rect pos = target.position;
width = Mathf.Max((int)pos.width, 320);
height = Mathf.Max((int)(pos.height + s_TabBarOffsetY), 240);
}
}
// Round capture dimensions to even values so that H.264/VP8
// encoders do not need to pad, which avoids a subtle frame-size
// mismatch between what Unity captures and what the browser decodes.
width = (width + 1) & ~1;
height = (height + 1) & ~1;
// On Windows, scale to physical pixels for HiDPI displays. GrabPixels
// operates on the GPU backbuffer which is at physical resolution.
// Without this, at 150% DPI (ppp=1.5) only the top-left ~67% of
// content is captured, making the stream appear zoomed/magnified.
// On macOS, Metal handles backing scale internally so use logical size.
int physWidth = (Mathf.RoundToInt(width * DPIScale) + 1) & ~1;
int physHeight = (Mathf.RoundToInt(height * DPIScale) + 1) & ~1;
// Create capture resources sized to the full GUIView (including tab bar).
if (s_CaptureRT != null) { s_CaptureRT.Release(); UnityEngine.Object.DestroyImmediate(s_CaptureRT); }
if (s_ReadbackTex != null) UnityEngine.Object.DestroyImmediate(s_ReadbackTex);
// Platform-specific color space for capture RT and readback texture.
//
// Windows (D3D11): GrabPixels writes sRGB-encoded content into the RT.
// Using an sRGB-aware RT would cause the GPU to apply an extra
// linear→sRGB curve on write, double-encoding gamma (washed-out colors).
// Linear RT avoids this by treating writes as raw bytes.
//
// macOS (Metal): GrabPixels already handles color space correctly.
// Using Linear RT strips the sRGB encoding, making colors too dark.
// Default (sRGB) RT preserves correct gamma on Mac.
#if UNITY_EDITOR_WIN
var rtReadWrite = RenderTextureReadWrite.Linear;
bool texLinear = true;
#else
var rtReadWrite = RenderTextureReadWrite.Default;
bool texLinear = false;
#endif
s_CaptureRT = new RenderTexture(physWidth, physHeight, 0, RenderTextureFormat.BGRA32, rtReadWrite)
{
name = "__NWB_OffscreenRT__",
useMipMap = false,
autoGenerateMips = false
};
s_CaptureRT.Create();
s_ReadbackTex = new Texture2D(physWidth, physHeight, TextureFormat.BGRA32, false, texLinear);
target.Repaint();
// Activate native offscreen mode. The HTTP handler only stores
// a pending request; NWB_StartOffscreenCapture must be called
// to actually enable frame reception in the C++ encoder.
// When nativeAlreadyStarted=true (domain reload restore), skip
// this because the C++ singleton already has offscreen active.
if (!nativeAlreadyStarted)
{
int result = NativeWindowBridgeAPI.NWB_StartOffscreenCapture(fps, physWidth, physHeight);
if (result != 1)
{
CodelyLogger.LogError("[NWB-Offscreen] Native StartOffscreenCapture failed");
return false;
}
}
s_OffscreenActive = true;
s_NativeOffscreenDisconnectedAwaitingRestore = false;
// Predict whether GrabPixels will fail: in Gamma color
// space, Unity has no AuxBackBufferManager intermediate RT,
// so DoPaint→Present clears the SwapChain before
// GrabPixels can read it. Enable fallback immediately to
// avoid 3 frames of black.
s_UseRenderTextureFallback =
(PlayerSettings.colorSpace == ColorSpace.Gamma);
if (s_UseRenderTextureFallback)
CodelyLogger.Log("[NWB-Diag] Gamma color space detected — using m_RenderTexture fallback (no AuxBackBufferManager)");
s_HasFrontendHeartbeat = false;
s_LastFrontendHeartbeatTime = EditorApplication.timeSinceStartup;
RegisterOffscreenLoop();
// Minimize non-target utility windows so that macOS popup
// window activation doesn't make them visible on the desktop.
MinimizeNonTargetUtilityWindows();
#if UNITY_EDITOR_WIN
// Force Layout+Repaint cycles at the correct capture resolution
// BEFORE hiding windows. A Repaint-only SendEvent does NOT trigger
// GameView.ConfigureTargetTexture() — that happens in the Layout
// pass. Without Layout, m_TargetTexture keeps its old size from the
// docked view, and game cameras render at the wrong aspect ratio
// (e.g. 1411x505 instead of 1092x810), causing horizontal
// compression of buildings.
//
// Two full Layout+Repaint cycles are needed:
// Pass 1: Layout recalculates targetRenderSize and calls
// ConfigureTargetTexture to recreate m_TargetTexture at
// the new size. Repaint renders — but the game player
// loop may still use stale Screen.width/height values.
// Pass 2: Layout is a no-op (size unchanged). Repaint renders
// with the correctly sized m_TargetTexture and updated
// Screen dimensions. This produces the first correctly
// proportioned frame.
try
{
// Ensure window is at the correct capture size before DoPaint.
// width/height are CSS pixels = Unity logical points (DIPs).
float wantW = Mathf.Max((float)width * DpiScaleFactor, 320f * DpiScaleFactor);
float wantH = Mathf.Max((float)height * DpiScaleFactor - s_TabBarOffsetY, 240f * DpiScaleFactor);
Rect curPos = target.position;
if (Mathf.Abs(curPos.width - wantW) > 2 || Mathf.Abs(curPos.height - wantH) > 2)
{
target.position = new Rect(curPos.x, curPos.y, wantW, wantH);
LogVerbose($"[NWB-Offscreen] Pre-DoPaint resize: was ({curPos.width:F0}x{curPos.height:F0}), now ({wantW:F0}x{wantH:F0})");
}
ForceDoubleLayoutRepaint(target);
LogVerbose($"[NWB-Offscreen] Forced initial Layout+Repaint x2 at capture resolution ({width}x{height})");
}
catch (Exception) { }
#endif
// When switching targets, windows are already hidden — skip re-hiding
// to avoid a visible flash (hide → unhide → re-hide cycle).
if (!isSwitching)
{
MakeAllWindowsTransparent();
hidEditorWindowsForOffscreen = true;
DeactivateUnityKeepWindows();
#if UNITY_EDITOR_OSX
ShowStreamingMaskWindow();
#endif
#if UNITY_EDITOR_WIN
DeferStreamingMaskWindow();
#endif
}
else
{
hidEditorWindowsForOffscreen = true;
}
// Force runInBackground so the player loop keeps running
// when the user switches to the Codely streaming app and
// the editor loses OS focus. Without this, Time.frameCount
// freezes and game logic (Update, coroutines, scene loads)
// never executes.
if (!s_RunInBackgroundOverridden)
{
s_SavedRunInBackground = Application.runInBackground;
Application.runInBackground = true;
s_RunInBackgroundOverridden = true;
}
float ppp = EditorGUIUtility.pixelsPerPoint;
Rect winPos = target.position;
CodelyLogger.Log($"[NWB-Offscreen] Started: {windowTypeName} RT={physWidth}x{physHeight} logical={width}x{height} @{fps}fps " +
$"winPos=({winPos.x},{winPos.y},{winPos.width},{winPos.height}) " +
$"tabOff={s_TabBarOffsetY} ppp={ppp} " +
$"runInBG={Application.runInBackground} (was {s_SavedRunInBackground})");
return true;
}
catch (Exception ex)
{
// Fail-safe cleanup: if start fails after visibility changes,
// restore Unity UI state immediately.
if (hidEditorWindowsForOffscreen)
{
#if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN
HideStreamingMaskWindow();
#endif
RestoreAllWindowsVisible();
}
CodelyLogger.LogError($"[NWB-Offscreen] Start failed: {ex.Message}\n{ex.StackTrace}");
return false;
}
#else
return false;
#endif
}
private static bool ShouldReuseExistingCompositeWindow(string windowTypeName)
{
switch (windowTypeName)
{
case "UnityEditor.GameView":
case "UnityEditor.SceneView":
case "UnityEditor.InspectorWindow":
case "UnityEditor.ProjectBrowser":
case "UnityEditor.ConsoleWindow":
case "UnityEditor.SceneHierarchyWindow":
return false;
default:
return true;
}
}
private static System.Type ResolveEditorWindowType(string windowTypeName)
{
if (string.IsNullOrEmpty(windowTypeName)) return null;
System.Type windowType = System.Type.GetType(windowTypeName) ??
System.Type.GetType(windowTypeName + ",UnityEditor");
if (windowType != null) return windowType;
foreach (var asm in System.AppDomain.CurrentDomain.GetAssemblies())
{
windowType = asm.GetType(windowTypeName);
if (windowType != null) return windowType;
}
foreach (var w in Resources.FindObjectsOfTypeAll())
{
if (w != null && w.GetType().FullName == windowTypeName)
return w.GetType();
}
return null;
}
private static Rect ClampCompositeRect(CompositeRect rect)
{
if (rect == null)
return new Rect(0f, 0f, 1f, 1f);
float x = Mathf.Clamp01(rect.x);
float y = Mathf.Clamp01(rect.y);
float w = Mathf.Clamp(rect.w, 0.05f, 1f);
float h = Mathf.Clamp(rect.h, 0.05f, 1f);
if (x + w > 1f) w = 1f - x;
if (y + h > 1f) h = 1f - y;
return new Rect(x, y, Mathf.Max(w, 0.05f), Mathf.Max(h, 0.05f));
}
///
/// Re-acquire platform handles (HWND/NSWindow) for a composite slot
/// whose EditorWindow survived domain reload. After reload C# statics
/// are wiped but the Win32/Cocoa windows still exist.
///
private static void ReacquireCompositeSlotHandles(CompositeCaptureSlot slot)
{
if (slot == null || slot.Window == null) return;
#if UNITY_EDITOR_WIN
IntPtr previousContainer = slot.ContainerHwnd;
IntPtr previousGUIView = slot.GUIViewHwnd;
bool containerChanged = false;
bool guiViewChanged = false;
// Always re-query the current ContainerWindow HWND from the live EditorWindow.
// Dock/undock/re-tab operations can migrate the window to a different
// ContainerWindow while the slot keeps an old cached HWND.
IntPtr latestContainer = Cn.Tuanjie.Codely.Editor.NativeWindowHelper.GetHWND(slot.Window);
if (latestContainer != IntPtr.Zero)
{
bool currentInvalid = slot.ContainerHwnd == IntPtr.Zero || !IsWindow(slot.ContainerHwnd);
bool candidateChanged = slot.ContainerHwnd != latestContainer;
bool allowUpdate = currentInvalid || !candidateChanged || IsContainerHwndCompatibleWithCompositeSlot(slot, latestContainer);
if (!allowUpdate)
{
LogVerbose($"[NWB-Composite] Skip suspicious handle switch for slot '{slot.SlotId}': " +
$"0x{slot.ContainerHwnd.ToInt64():X} -> 0x{latestContainer.ToInt64():X}");
}
else if (slot.ContainerHwnd == IntPtr.Zero || slot.ContainerHwnd != latestContainer || !IsWindow(slot.ContainerHwnd))
{
slot.ContainerHwnd = latestContainer;
containerChanged = slot.ContainerHwnd != previousContainer;
IntPtr child = GetWindow(latestContainer, GW_CHILD);
if (child != IntPtr.Zero)
{
slot.GUIViewHwnd = child;
guiViewChanged = slot.GUIViewHwnd != previousGUIView;
}
}
}
else if (slot.ContainerHwnd != IntPtr.Zero && !IsWindow(slot.ContainerHwnd))
{
slot.ContainerHwnd = IntPtr.Zero;
containerChanged = slot.ContainerHwnd != previousContainer;
}
if (slot.GUIViewHwnd == IntPtr.Zero || !IsWindow(slot.GUIViewHwnd) || containerChanged)
{
IntPtr reflected = Cn.Tuanjie.Codely.Editor.EditorWindowNativeHandleHelper.GetGUIViewHandle(slot.Window);
if (reflected != IntPtr.Zero)
{
slot.GUIViewHwnd = reflected;
guiViewChanged = slot.GUIViewHwnd != previousGUIView;
}
}
if (slot.ContainerHwnd != IntPtr.Zero)
{
int style = GetWindowLongW(slot.ContainerHwnd, GWL_EXSTYLE);
if ((style & WS_EX_LAYERED) != 0)
SetWindowLongW(slot.ContainerHwnd, GWL_EXSTYLE, style & ~WS_EX_LAYERED);
}
if (containerChanged || guiViewChanged)
{
LogVerbose($"[NWB-Composite] Refreshed slot '{slot.SlotId}' handles: " +
$"container 0x{previousContainer.ToInt64():X} -> 0x{slot.ContainerHwnd.ToInt64():X}, " +
$"guiView 0x{previousGUIView.ToInt64():X} -> 0x{slot.GUIViewHwnd.ToInt64():X}");
}
#endif
#if UNITY_EDITOR_OSX
if (slot.NSWindow == IntPtr.Zero)
{
foreach (CompositeCaptureSlot other in s_CompositeSlots.Values)
{
if (other != null && other != slot && other.NSWindow != IntPtr.Zero)
{
slot.NSWindow = other.NSWindow;
break;
}
}
}
#endif
}
#if UNITY_EDITOR_WIN
///
/// Validate candidate container HWND against the slot's live parent view size.
/// This prevents transient layout states from rebinding a slot to an unrelated
/// narrow container (which later causes PrintWindow crop/stretch artifacts).
///
private static bool IsContainerHwndCompatibleWithCompositeSlot(CompositeCaptureSlot slot, IntPtr candidateContainerHwnd)
{
if (slot == null || slot.Window == null || candidateContainerHwnd == IntPtr.Zero)
return false;
if (!GetClientRect(candidateContainerHwnd, out RECT clientRect))
return true;
object parentView = GetParentView(slot.Window);
if (parentView == null)
return true;
Rect viewRect = GetViewScreenPosition(parentView);
if (viewRect.width <= 1f || viewRect.height <= 1f)
return true;
float expectedW = Mathf.Max(viewRect.width * LocalPP, 1f);
float expectedH = Mathf.Max(viewRect.height * LocalPP, 1f);
float actualW = Mathf.Max(clientRect.Right, 1f);
float actualH = Mathf.Max(clientRect.Bottom, 1f);
float widthDelta = Mathf.Abs(actualW - expectedW);
float heightDelta = Mathf.Abs(actualH - expectedH);
float widthTolerance = Mathf.Max(96f, expectedW * 0.25f);
float heightTolerance = Mathf.Max(96f, expectedH * 0.25f);
return widthDelta <= widthTolerance && heightDelta <= heightTolerance;
}
#endif
///
/// Get the type name of the currently active window in the composite
/// DockArea. Used to save the active tab before Play mode.
///
private static string GetCompositeActiveWindowTypeName()
{
foreach (CompositeCaptureSlot slot in s_CompositeSlots.Values)
{
if (slot?.Window == null) continue;
try
{
FieldInfo parentField = typeof(EditorWindow).GetField("m_Parent",
BindingFlags.Instance | BindingFlags.NonPublic);
if (parentField == null) break;
object dockArea = parentField.GetValue(slot.Window);
if (dockArea == null || dockArea.GetType().Name != "DockArea") break;
System.Type dockAreaType = dockArea.GetType();
PropertyInfo selectedProp = dockAreaType.GetProperty("selected",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
FieldInfo panesField = dockAreaType.GetField("m_Panes",
BindingFlags.Instance | BindingFlags.NonPublic);
if (selectedProp == null || panesField == null) break;
int sel = (int)selectedProp.GetValue(dockArea);
var panes = panesField.GetValue(dockArea) as System.Collections.IList;
if (panes == null || sel < 0 || sel >= panes.Count) break;
var activeWin = panes[sel] as EditorWindow;
if (activeWin != null)
return activeWin.GetType().FullName;
}
catch (Exception) { }
break;
}
return null;
}
///
/// Activate the GameView (PlayModeView) tab in the composite DockArea.
/// Mirrors Tuanjie's InitPlaymodeLayout → playModeView.Focus() behavior.
///
private static void ActivateCompositeGameViewTab()
{
foreach (CompositeCaptureSlot slot in s_CompositeSlots.Values)
{
if (slot?.Window == null) continue;
string typeName = slot.Window.GetType().FullName;
if (typeName == "UnityEditor.GameView"
|| typeof(EditorWindow).IsAssignableFrom(slot.Window.GetType())
&& slot.Window.GetType().Name == "GameView")
{
slot.Window.ShowTab();
LogVerbose($"[NWB-Composite] Activated GameView tab on Play mode enter");
return;
}
}
}
///
/// Restore a composite tab by its type name. Used when exiting Play
/// mode to switch back to the window the user had active before Play.
/// Mirrors Tuanjie's TryGetLastFocusedWindowInSameDock → ShowTab().
///
private static void RestoreCompositeTabByTypeName(string typeName)
{
if (string.IsNullOrEmpty(typeName)) return;
foreach (CompositeCaptureSlot slot in s_CompositeSlots.Values)
{
if (slot?.Window == null) continue;
if (slot.Window.GetType().FullName == typeName)
{
slot.Window.ShowTab();
LogVerbose($"[NWB-Composite] Restored pre-play tab '{typeName}' on Edit mode enter");
return;
}
}
}
///
/// Send two rounds of Layout+Repaint to an EditorWindow so it
/// initializes its IMGUI state and GPU AuxBackBuffer. Pass 1
/// triggers ConfigureTargetTexture; pass 2 renders with the
/// correctly sized m_TargetTexture. Silently catches exceptions
/// from internal IMGUI assertions in off-screen windows.
///
private static void ForceDoubleLayoutRepaint(EditorWindow window)
{
if (window == null) return;
try
{
window.SendEvent(new Event { type = EventType.Layout });
window.SendEvent(new Event { type = EventType.Repaint });
window.SendEvent(new Event { type = EventType.Layout });
window.SendEvent(new Event { type = EventType.Repaint });
}
catch (Exception) { }
}
private static void ResizeCompositeSlot(CompositeCaptureSlot slot, int logicalW, int logicalH, CompositeRect rect)
{
if (slot == null || slot.Window == null) return;
Rect nr = ClampCompositeRect(rect);
float slotW = Mathf.Max(logicalW * DpiScaleFactor, 320f * DpiScaleFactor);
float slotH = Mathf.Max(logicalH * DpiScaleFactor - s_TabBarOffsetY, 240f * DpiScaleFactor);
Rect pos = slot.Window.position;
object currentHostWindow = GetCompositeHostWindow();
object slotHostWindow = GetViewWindow(GetParentView(slot.Window));
bool isDockedInCompositeWorkspace = currentHostWindow != null && object.ReferenceEquals(currentHostWindow, slotHostWindow);
if (!isDockedInCompositeWorkspace && (Mathf.Abs(pos.width - slotW) > 2f || Mathf.Abs(pos.height - slotH) > 2f))
slot.Window.position = new Rect(pos.x, pos.y, slotW, slotH);
int physW = (Mathf.RoundToInt(slotW * LocalPP) + 1) & ~1;
int physH = (Mathf.RoundToInt((slotH + s_TabBarOffsetY) * LocalPP) + 1) & ~1;
#if UNITY_EDITOR_WIN
var rtReadWrite = RenderTextureReadWrite.Linear;
#else
var rtReadWrite = RenderTextureReadWrite.Default;
#endif
if (slot.SourceRT == null || slot.SourceRT.width != physW || slot.SourceRT.height != physH)
{
if (slot.SourceRT != null)
{
slot.SourceRT.Release();
UnityEngine.Object.DestroyImmediate(slot.SourceRT);
}
slot.SourceRT = new RenderTexture(physW, physH, 0, RenderTextureFormat.BGRA32, rtReadWrite)
{
name = "__NWB_CompositeSlotRT__",
useMipMap = false,
autoGenerateMips = false
};
slot.SourceRT.Create();
}
slot.NormalizedRect = nr;
}
private static void ReleaseCompositeSlot(CompositeCaptureSlot slot)
{
if (slot == null) return;
if (slot.SourceRT != null)
{
if (RenderTexture.active == slot.SourceRT)
RenderTexture.active = null;
slot.SourceRT.Release();
UnityEngine.Object.DestroyImmediate(slot.SourceRT);
slot.SourceRT = null;
}
if ((slot.OwnsWindow || slot.CloseWindowOnRelease) && slot.Window != null)
{
if (slot.OwnsWindow && s_IsReleasingComposite)
{
// During ReleaseCompositeState (stop streaming), close the entire
// ContainerWindow to ensure the Win32 HWND is destroyed. This
// unifies behavior across all layout paths — EditorWindow.Close()
// alone may leave the ContainerWindow's HWND alive as a zombie
// when the DockArea's parent structure doesn't trigger KillIfEmpty.
// During ApplyCompositeLayout (slot replacement), s_IsReleasingComposite
// is false, so only EditorWindow.Close() is used.
try
{
object parentView = GetParentView(slot.Window);
object containerWindow = parentView != null ? GetViewWindow(parentView) : null;
if (containerWindow != null)
{
MethodInfo closeMethod = containerWindow.GetType().GetMethod("Close",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (closeMethod != null)
closeMethod.Invoke(containerWindow, null);
else
slot.Window.Close();
}
else
{
slot.Window.Close();
}
}
catch (Exception)
{
try { slot.Window.Close(); } catch (Exception) { }
}
}
else
{
try { slot.Window.Close(); }
catch (Exception) { }
}
}
slot.Window = null;
}
private static object GetParentView(EditorWindow window)
{
if (window == null) return null;
FieldInfo parentField = typeof(EditorWindow).GetField("m_Parent",
BindingFlags.Instance | BindingFlags.NonPublic);
return parentField?.GetValue(window);
}
private static object GetViewWindow(object view)
{
if (view == null) return null;
PropertyInfo windowProp = view.GetType().GetProperty("window",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
return windowProp?.GetValue(view);
}
private static Rect GetViewScreenPosition(object view)
{
if (view == null) return Rect.zero;
PropertyInfo screenPosProp = view.GetType().GetProperty("screenPosition",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (screenPosProp == null) return Rect.zero;
return (Rect)screenPosProp.GetValue(view);
}
private static string JsonFloat(float value)
{
if (float.IsNaN(value) || float.IsInfinity(value)) value = 0f;
return value.ToString("0.######", CultureInfo.InvariantCulture);
}
private static Rect NormalizeCompositeViewRect(Rect viewRect, Rect bounds)
{
if (bounds.width <= 1f || bounds.height <= 1f)
return new Rect(0f, 0f, 1f, 1f);
return new Rect(
Mathf.Clamp01((viewRect.xMin - bounds.xMin) / bounds.width),
Mathf.Clamp01((viewRect.yMin - bounds.yMin) / bounds.height),
Mathf.Clamp01(viewRect.width / bounds.width),
Mathf.Clamp01(viewRect.height / bounds.height));
}
private static void SendCompositeLayoutSnapshot()
{
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
if (!s_CompositeActive || s_CompositeSlots == null || s_CompositeSlots.Count == 0)
{
SendDataChannelMessage("{\"type\":\"composite_layout_snapshot\",\"slots\":[]}");
return;
}
try
{
var slotViews = new Dictionary();
var visitedViews = new HashSet