```
feat(gameplay): 更新单位属性和材质配置 - 调整多个单位的基础生命值和攻击力数值平衡 - 修改CoreBattleGround材质的纹理缩放比例 - 更新地砖材质颜色为白色 - 优化纹理资源设置,调整最大纹理尺寸并添加PlayableAds平台支持 - 修复弓箭手技能目标查找逻辑,改用统一的敌人检测方法 - 为水晶矿添加中立阵营设置和光球视觉效果同步 - 调整预制体中的全局缩放参数 - 为狼人和石巨人添加NavMeshAgent组件支持 - 优化水晶矿血条显示位置计算方式 ```
This commit is contained in:
1 parent
0e7de5f885
commit
53d95802e5
448 files changed
+133333
-472
No files matched your search
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Cn.Tuanjie.Codely.Editor",
|
||||
"rootNamespace": "Cn.Tuanjie.Codely.Editor",
|
||||
"references": [
|
||||
"Codely.Common"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 61a431ba3ea762c4188c02deb2865a68
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
#if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Unity.InternalBridge;
|
||||
using UnityEditor;
|
||||
using UnityTcp.Editor.Helpers;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Cn.Tuanjie.Codely.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Analytics event tracker. Sends events from Unity to the Tauri backend via
|
||||
/// HTTP invoke (<c>unity/trackEvent</c>), which forwards them to the remote server.
|
||||
/// </summary>
|
||||
public static class CodelyEventTracker
|
||||
{
|
||||
public const string InvokeTrackEvent = "unity/trackEvent";
|
||||
|
||||
public const string WelcomeWindowShown = "welcome_window_shown";
|
||||
public const string WelcomeWindowClosed = "welcome_window_closed";
|
||||
public const string WelcomeButtonClick = "welcome_button_clicked";
|
||||
public const string CodelyPanelOpened = "codely_panel_opened";
|
||||
public const string CodelyPanelClosed = "codely_panel_closed";
|
||||
|
||||
private static readonly HashSet<string> s_eventsRequiringUserId =
|
||||
new HashSet<string>
|
||||
{
|
||||
WelcomeWindowShown,
|
||||
WelcomeWindowClosed,
|
||||
WelcomeButtonClick,
|
||||
CodelyPanelOpened,
|
||||
CodelyPanelClosed,
|
||||
};
|
||||
|
||||
private static readonly HttpClient s_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10),
|
||||
};
|
||||
|
||||
private static readonly Queue<string> s_pendingTrackPayloads =
|
||||
new Queue<string>();
|
||||
private const int MaxPendingTrackPayloads = 50;
|
||||
|
||||
public static bool HasPending => s_pendingTrackPayloads.Count > 0;
|
||||
|
||||
private static bool? s_isTuanjie;
|
||||
private static bool s_suppressNextPanelClose;
|
||||
|
||||
/// <summary>
|
||||
/// Skip the next <c>codely_panel_closed</c> event. Use when closing only to
|
||||
/// immediately reopen (e.g. setup UI → main panel, force reload).
|
||||
/// </summary>
|
||||
public static void SuppressNextPanelClose()
|
||||
{
|
||||
s_suppressNextPanelClose = true;
|
||||
}
|
||||
|
||||
/// <returns>True when the close event should be skipped.</returns>
|
||||
public static bool ConsumeSuppressNextPanelClose()
|
||||
{
|
||||
if (!s_suppressNextPanelClose)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
s_suppressNextPanelClose = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flush track events queued before the Tauri HTTP port was available.
|
||||
/// </summary>
|
||||
public static void FlushPending(int tauriServerPort = -1)
|
||||
{
|
||||
if (!ShouldTrack())
|
||||
{
|
||||
s_pendingTrackPayloads.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
while (s_pendingTrackPayloads.Count > 0)
|
||||
{
|
||||
string payload = s_pendingTrackPayloads.Peek();
|
||||
if (!SendTrackInvoke(payload, tauriServerPort, synchronous: false))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
s_pendingTrackPayloads.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Track(string eventType, string data = null, int tauriServerPort = -1)
|
||||
{
|
||||
if (!ShouldTrack())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
FlushPending(tauriServerPort);
|
||||
|
||||
string userId = s_eventsRequiringUserId.Contains(eventType)
|
||||
? UnityConnectSession.GetUserId()
|
||||
: null;
|
||||
string payload = BuildPayload(eventType, data, userId);
|
||||
|
||||
if (SendTrackInvoke(payload, tauriServerPort, synchronous: false))
|
||||
{
|
||||
CodelyLogger.Log($"Codely EventTracker: {eventType}");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (s_pendingTrackPayloads.Count < MaxPendingTrackPayloads)
|
||||
{
|
||||
s_pendingTrackPayloads.Enqueue(payload);
|
||||
CodelyLogger.LogWarning(
|
||||
$"Codely EventTracker: {eventType} buffered (Tauri port not ready)");
|
||||
}
|
||||
else
|
||||
{
|
||||
CodelyLogger.LogWarning(
|
||||
$"Codely EventTracker: {eventType} dropped (pending queue full)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogWarning($"Codely EventTracker error: {ex.Message}");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Track a lifecycle close event via HTTP invoke. Uses a synchronous request so
|
||||
/// the event is sent before the Editor window tears down Tauri connectivity.
|
||||
/// </summary>
|
||||
public static void TryTrackClose(
|
||||
string eventType,
|
||||
int tauriServerPort,
|
||||
ref bool alreadySent,
|
||||
bool synchronous = true)
|
||||
{
|
||||
if (alreadySent || !ShouldTrack())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
FlushPending(tauriServerPort);
|
||||
|
||||
string userId = s_eventsRequiringUserId.Contains(eventType)
|
||||
? UnityConnectSession.GetUserId()
|
||||
: null;
|
||||
string payload = BuildPayload(eventType, data: null, userId);
|
||||
|
||||
if (SendTrackInvoke(payload, tauriServerPort, synchronous))
|
||||
{
|
||||
alreadySent = true;
|
||||
CodelyLogger.Log($"Codely EventTracker: {eventType}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogWarning($"Codely EventTracker error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldTrack()
|
||||
{
|
||||
if (!s_isTuanjie.HasValue)
|
||||
{
|
||||
string editorType = TauriUtils.GetEditorType();
|
||||
s_isTuanjie = editorType == "tuanjie";
|
||||
}
|
||||
|
||||
return s_isTuanjie.Value;
|
||||
}
|
||||
|
||||
private static bool SendTrackInvoke(
|
||||
string trackPayloadJson,
|
||||
int tauriServerPort,
|
||||
bool synchronous)
|
||||
{
|
||||
return SendHttpInvoke(
|
||||
InvokeTrackEvent,
|
||||
tauriServerPort,
|
||||
synchronous,
|
||||
trackPayloadJson);
|
||||
}
|
||||
|
||||
private static bool SendHttpInvoke(
|
||||
string messageType,
|
||||
int tauriServerPort,
|
||||
bool synchronous,
|
||||
string dataJson = null)
|
||||
{
|
||||
int port = ResolveTauriPort(tauriServerPort);
|
||||
if (port <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string workspaceDir = TauriUtils.GetWorkspaceDirectory();
|
||||
string editorType = TauriUtils.GetEditorType();
|
||||
string messageId = Guid.NewGuid().ToString();
|
||||
string payload = BuildInvokePayload(
|
||||
messageType,
|
||||
messageId,
|
||||
workspaceDir,
|
||||
editorType,
|
||||
dataJson);
|
||||
string url = $"http://127.0.0.1:{port}/api/tauri/invoke";
|
||||
var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = content };
|
||||
AddInvokeRoutingHeaders(request, workspaceDir, editorType);
|
||||
|
||||
if (synchronous)
|
||||
{
|
||||
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)))
|
||||
{
|
||||
using (var response = s_httpClient.SendAsync(request, cts.Token).GetAwaiter().GetResult())
|
||||
{
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = s_httpClient.SendAsync(request);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogWarning($"Codely EventTracker: {messageType} invoke failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static int ResolveTauriPort(int tauriServerPort)
|
||||
{
|
||||
if (tauriServerPort > 0)
|
||||
{
|
||||
return tauriServerPort;
|
||||
}
|
||||
|
||||
return SessionState.GetInt(TauriUtils.SESSION_STATE_TAURI_PORT_KEY, -1);
|
||||
}
|
||||
|
||||
private static void AddInvokeRoutingHeaders(
|
||||
HttpRequestMessage request,
|
||||
string workspaceDir,
|
||||
string editorType)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(workspaceDir))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
"X-Workspace-Dir",
|
||||
Uri.EscapeDataString(workspaceDir));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(editorType))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Editor-Type", editorType);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildInvokePayload(
|
||||
string messageType,
|
||||
string messageId,
|
||||
string workspaceDir,
|
||||
string editorType,
|
||||
string dataJson = null)
|
||||
{
|
||||
string typeEscaped = EscapeJson(messageType);
|
||||
string idEscaped = EscapeJson(messageId);
|
||||
string dirEscaped = EscapeJson(workspaceDir ?? string.Empty);
|
||||
string editorEscaped = EscapeJson(editorType ?? "unity");
|
||||
string dataField = string.IsNullOrEmpty(dataJson) ? "null" : dataJson;
|
||||
return $"{{\"message\":{{\"messageType\":\"{typeEscaped}\",\"messageId\":\"{idEscaped}\",\"data\":{dataField}}},\"workspaceDir\":\"{dirEscaped}\",\"editorType\":\"{editorEscaped}\"}}";
|
||||
}
|
||||
|
||||
private static string BuildPayload(string eventType, string data, string userId = null)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("{\"event_type\":\"").Append(EscapeJson(eventType)).Append("\"");
|
||||
sb.Append(",\"timestamp\":\"").Append(DateTimeOffset.UtcNow.ToString("o")).Append("\"");
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
sb.Append(",\"user_id\":\"").Append(EscapeJson(userId)).Append("\"");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(data))
|
||||
{
|
||||
sb.Append(",\"data\":\"").Append(EscapeJson(data)).Append("\"");
|
||||
}
|
||||
sb.Append("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string EscapeJson(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return string.Empty;
|
||||
var sb = new StringBuilder(s.Length);
|
||||
foreach (char c in s)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case '"': sb.Append("\\\""); break;
|
||||
case '\\': sb.Append("\\\\"); break;
|
||||
case '\n': sb.Append("\\n"); break;
|
||||
case '\r': sb.Append("\\r"); break;
|
||||
case '\t': sb.Append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) sb.AppendFormat("\\u{0:x4}", (int)c);
|
||||
else sb.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 08da39896c9b4f46a430c8d9f7914da6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
#if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
|
||||
using UnityTcp.Editor.Helpers;
|
||||
namespace Cn.Tuanjie.Codely.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Global IPC manager shared by Codely-related Editor windows.
|
||||
/// Manages one CodelyIpcClient instance and reference-counted owners.
|
||||
/// </summary>
|
||||
internal static class CodelyIpcManager
|
||||
{
|
||||
private static readonly object _lockObj = new object();
|
||||
private static readonly HashSet<string> _owners = new HashSet<string>();
|
||||
private static CodelyIpcClient _client;
|
||||
|
||||
// Messages sent before the IPC client connects (e.g. trackEvent in
|
||||
// OnEnable before AcquireIpcClient) are buffered here and flushed
|
||||
// once the connection is established.
|
||||
private static readonly Queue<KeyValuePair<string, string>> _pendingMessages = new Queue<KeyValuePair<string, string>>();
|
||||
private const int MAX_PENDING_MESSAGES = 50;
|
||||
|
||||
public static event Action<string> OnServerReady;
|
||||
public static event Action<bool> OnSetEmbedMode;
|
||||
public static event Action OnCloseCodelyEditorOnWorkspaceSwitch;
|
||||
public static event Action<bool> OnConnectionChanged;
|
||||
public static event Action<string> OnUpdateRestart;
|
||||
public static event Action OnDrillCompleted;
|
||||
|
||||
// 最近一次 server_ready 携带的 URL。后台线程在 delayCall 入队前先写入此字段,
|
||||
// 让 CodelyWindow / DrillWindow 的 OnGUI backstop 能在主线程 update loop
|
||||
// (失焦下被节流)尚未跑通时直接拾起此 URL 完成 WebView2 初始化。
|
||||
// 字符串赋值在引用大小上原子,volatile 保证发布可见性,无需额外锁。
|
||||
private static volatile string _lastServerReadyUrl;
|
||||
public static string LastServerReadyUrl => _lastServerReadyUrl;
|
||||
internal static void SetLastServerReadyUrl(string url) => _lastServerReadyUrl = url;
|
||||
|
||||
public static void Acquire(string owner)
|
||||
{
|
||||
if (string.IsNullOrEmpty(owner))
|
||||
{
|
||||
throw new ArgumentException("IPC owner must be non-empty", nameof(owner));
|
||||
}
|
||||
|
||||
lock (_lockObj)
|
||||
{
|
||||
_owners.Add(owner);
|
||||
EnsureClientStartedLocked();
|
||||
}
|
||||
}
|
||||
|
||||
public static void Release(string owner)
|
||||
{
|
||||
if (string.IsNullOrEmpty(owner))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_lockObj)
|
||||
{
|
||||
_owners.Remove(owner);
|
||||
if (_owners.Count == 0)
|
||||
{
|
||||
StopClientLocked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void StopAll()
|
||||
{
|
||||
lock (_lockObj)
|
||||
{
|
||||
_owners.Clear();
|
||||
StopClientLocked();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TrySend(string messageType, string data = null)
|
||||
{
|
||||
lock (_lockObj)
|
||||
{
|
||||
if (_client == null || !_client.IsConnected)
|
||||
{
|
||||
// Buffer the message so it can be delivered once the IPC
|
||||
// client connects. Without this, trackEvent calls made in
|
||||
// OnEnable (before AcquireIpcClient) are silently lost.
|
||||
if (_pendingMessages.Count < MAX_PENDING_MESSAGES)
|
||||
{
|
||||
_pendingMessages.Enqueue(new KeyValuePair<string, string>(messageType, data));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return _client.SendMessage(messageType, data);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drain the pending message queue. Must be called under _lockObj.
|
||||
/// </summary>
|
||||
private static void FlushPendingMessagesLocked()
|
||||
{
|
||||
while (_pendingMessages.Count > 0)
|
||||
{
|
||||
if (_client == null || !_client.IsConnected)
|
||||
break;
|
||||
|
||||
var msg = _pendingMessages.Dequeue();
|
||||
_client.SendMessage(msg.Key, msg.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsConnected
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lockObj)
|
||||
{
|
||||
return _client != null && _client.IsConnected;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureClientStartedLocked()
|
||||
{
|
||||
if (_client != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_client = new CodelyIpcClient();
|
||||
_client.OnServerReady += HandleServerReady;
|
||||
_client.OnSetEmbedMode += HandleSetEmbedMode;
|
||||
_client.OnCloseCodelyEditorOnWorkspaceSwitch += HandleCloseCodelyEditorOnWorkspaceSwitch;
|
||||
_client.OnConnectionChanged += HandleConnectionChanged;
|
||||
_client.OnUpdateRestart += HandleUpdateRestart;
|
||||
_client.OnDrillCompleted += HandleDrillCompleted;
|
||||
_client.Start();
|
||||
CodelyLogger.Log("Codely IPC Manager: client started");
|
||||
}
|
||||
|
||||
private static void StopClientLocked()
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FlushPendingMessagesLocked();
|
||||
|
||||
var clientToDispose = _client;
|
||||
_client = null;
|
||||
|
||||
try
|
||||
{
|
||||
clientToDispose.OnServerReady -= HandleServerReady;
|
||||
clientToDispose.OnSetEmbedMode -= HandleSetEmbedMode;
|
||||
clientToDispose.OnCloseCodelyEditorOnWorkspaceSwitch -= HandleCloseCodelyEditorOnWorkspaceSwitch;
|
||||
clientToDispose.OnConnectionChanged -= HandleConnectionChanged;
|
||||
clientToDispose.OnUpdateRestart -= HandleUpdateRestart;
|
||||
clientToDispose.OnDrillCompleted -= HandleDrillCompleted;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogWarning($"Codely IPC Manager: stop failed: {ex.Message}");
|
||||
}
|
||||
|
||||
Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
clientToDispose.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogWarning($"Codely IPC Manager: async dispose failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
CodelyLogger.Log("Codely IPC Manager: client stop scheduled");
|
||||
}
|
||||
|
||||
private static void HandleServerReady(string url) => OnServerReady?.Invoke(url);
|
||||
private static void HandleSetEmbedMode(bool embedMode) => OnSetEmbedMode?.Invoke(embedMode);
|
||||
private static void HandleCloseCodelyEditorOnWorkspaceSwitch() => OnCloseCodelyEditorOnWorkspaceSwitch?.Invoke();
|
||||
private static void HandleConnectionChanged(bool connected)
|
||||
{
|
||||
if (connected)
|
||||
{
|
||||
lock (_lockObj)
|
||||
{
|
||||
FlushPendingMessagesLocked();
|
||||
}
|
||||
}
|
||||
OnConnectionChanged?.Invoke(connected);
|
||||
}
|
||||
private static void HandleUpdateRestart(string payload) => OnUpdateRestart?.Invoke(payload);
|
||||
private static void HandleDrillCompleted()
|
||||
{
|
||||
var handler = OnDrillCompleted;
|
||||
if (handler != null)
|
||||
{
|
||||
handler.Invoke();
|
||||
}
|
||||
else
|
||||
{
|
||||
CodelyIpcClient.ConsumePendingDrillCompleted();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 13bdb41f354364663bfe59333d1a881a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+1312
File diff suppressed because it is too large.
Load diff
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d107f4d21b4069f4abad96e077c6a00c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
using UnityTcp.Editor.Helpers;
|
||||
namespace Cn.Tuanjie.Codely.Editor
|
||||
{
|
||||
[InitializeOnLoad]
|
||||
internal static class CodelyLayoutInstaller
|
||||
{
|
||||
private const string LayoutFileName = "Tuanjie AI Mode.wlt";
|
||||
private const string OldLayoutFileName = "Codely Mode.wlt";
|
||||
private const string PackageLayoutAssetPath = "Packages/cn.tuanjie.codely.bridge/Editor/Layout/" + LayoutFileName;
|
||||
|
||||
static CodelyLayoutInstaller()
|
||||
{
|
||||
EditorApplication.delayCall += InstallCodelyLayout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the legacy "Codely Mode.wlt" entry from the user's layouts directory
|
||||
/// so it does not show up alongside the new "Tuanjie AI Mode" in the Window > Layouts menu.
|
||||
/// </summary>
|
||||
private static void RemoveLegacyLayout(string layoutsDir)
|
||||
{
|
||||
if (string.IsNullOrEmpty(layoutsDir)) return;
|
||||
var legacy = Path.Combine(layoutsDir, OldLayoutFileName);
|
||||
if (!File.Exists(legacy)) return;
|
||||
try { File.Delete(legacy); }
|
||||
catch (Exception e) { CodelyLogger.LogWarning($"[Codely] Could not remove legacy layout '{legacy}': {e.Message}"); }
|
||||
}
|
||||
|
||||
private static void InstallCodelyLayout()
|
||||
{
|
||||
var assetPath = ResolveLayoutAssetPath();
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
{
|
||||
CodelyLogger.LogWarning($"[Codely] Could not resolve {LayoutFileName} (check package path or asset import).");
|
||||
return;
|
||||
}
|
||||
|
||||
var srcFull = ToAbsoluteProjectPath(assetPath);
|
||||
if (string.IsNullOrEmpty(srcFull) || !File.Exists(srcFull))
|
||||
{
|
||||
CodelyLogger.LogWarning($"[Codely] {LayoutFileName} not found on disk for asset path \"{assetPath}\" (resolved: {srcFull ?? "(null)"}).");
|
||||
return;
|
||||
}
|
||||
|
||||
var layoutsDir = GetLayoutsModePreferencesPath();
|
||||
if (string.IsNullOrEmpty(layoutsDir))
|
||||
{
|
||||
CodelyLogger.LogWarning("[Codely] Could not get editor layouts path.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(layoutsDir))
|
||||
{
|
||||
CodelyLogger.LogWarning($"[Codely] Layouts directory does not exist: {layoutsDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
var dstFull = Path.Combine(layoutsDir, LayoutFileName);
|
||||
try
|
||||
{
|
||||
File.Copy(srcFull, dstFull, true);
|
||||
RemoveLegacyLayout(layoutsDir);
|
||||
ReloadWindowLayoutMenu();
|
||||
|
||||
// Only auto-switch when the Editor is still sitting on the legacy "Codely Mode"
|
||||
// layout — otherwise reloading would wipe window customisations on every
|
||||
// domain reload.
|
||||
if (IsLayoutActive(LegacyLayoutNames[0]) || IsLayoutActive(LegacyLayoutNames[1]))
|
||||
{
|
||||
ApplyCodelyLayout();
|
||||
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
CodelyLogger.LogWarning($"[Codely] Could not install Tuanjie AI Layout: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy values of `m_LastLoadedLayoutName` to migrate from (no .wlt extension).
|
||||
private static readonly string[] LegacyLayoutNames = { "Codely Mode", "Codely" };
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> when Unity's active layout snapshot records
|
||||
/// <c>m_LastLoadedLayoutName</c> as one of the legacy Codely names. The snapshot
|
||||
/// lives at <c>WindowLayout.GetCurrentLayoutPath()</c>, which resolves to
|
||||
/// <c><UserPrefs>\Layouts\CurrentLayout-<mode>.dwlt</c> (or
|
||||
/// <c>CurrentMaximizeLayout.dwlt</c>) — NOT under the project's Library/.
|
||||
/// </summary>
|
||||
private static bool IsLayoutActive(string layoutName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dwltPath = GetCurrentLayoutDwltPath();
|
||||
if (string.IsNullOrEmpty(dwltPath) || !File.Exists(dwltPath))
|
||||
return false;
|
||||
|
||||
using (var reader = new StreamReader(dwltPath))
|
||||
{
|
||||
string line;
|
||||
while ((line = reader.ReadLine()) != null)
|
||||
{
|
||||
var trimmed = line.TrimStart();
|
||||
if (!trimmed.StartsWith("m_LastLoadedLayoutName:", StringComparison.Ordinal))
|
||||
continue;
|
||||
var colon = trimmed.IndexOf(':');
|
||||
if (colon < 0) continue;
|
||||
var name = trimmed.Substring(colon + 1).Trim();
|
||||
if (string.Equals(name, layoutName, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
CodelyLogger.LogWarning($"[Codely] IsLayoutActive failed: {e.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the absolute path of Unity's currently-active layout snapshot
|
||||
/// (<c>CurrentLayout-<mode>.dwlt</c>). Prefers
|
||||
/// <c>UnityEditor.WindowLayout.GetCurrentLayoutPath()</c>; falls back to scanning
|
||||
/// <c>WindowLayout.layoutsPreferencesPath</c> (the user-prefs Layouts root) for the
|
||||
/// freshest matching dwlt so it works on Unity versions that lack the method.
|
||||
/// </summary>
|
||||
private static string GetCurrentLayoutDwltPath()
|
||||
{
|
||||
var windowLayoutType = typeof(EditorWindow).Assembly.GetType("UnityEditor.WindowLayout");
|
||||
if (windowLayoutType == null) return null;
|
||||
|
||||
const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public;
|
||||
|
||||
// 1) Direct API when present (Unity 2020.2+).
|
||||
var method = windowLayoutType.GetMethod("GetCurrentLayoutPath", flags, null, Type.EmptyTypes, null);
|
||||
if (method != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = method.Invoke(null, null) as string;
|
||||
if (!string.IsNullOrEmpty(path) && File.Exists(path))
|
||||
return path;
|
||||
}
|
||||
catch { /* fall through to manual probe */ }
|
||||
}
|
||||
|
||||
// 2) Scan the user-prefs Layouts root for the freshest CurrentLayout*.dwlt.
|
||||
var layoutsRootProp = windowLayoutType.GetProperty("layoutsPreferencesPath", flags);
|
||||
var layoutsRoot = layoutsRootProp?.GetValue(null) as string;
|
||||
if (string.IsNullOrEmpty(layoutsRoot) || !Directory.Exists(layoutsRoot))
|
||||
return null;
|
||||
|
||||
string freshest = null;
|
||||
DateTime newest = DateTime.MinValue;
|
||||
foreach (var p in Directory.GetFiles(layoutsRoot, "CurrentLayout*.dwlt", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
var m = File.GetLastWriteTimeUtc(p);
|
||||
if (m > newest) { newest = m; freshest = p; }
|
||||
}
|
||||
return freshest;
|
||||
}
|
||||
|
||||
internal static void RemoveInstalledLayout()
|
||||
{
|
||||
var layoutsDir = GetLayoutsModePreferencesPath();
|
||||
if (string.IsNullOrEmpty(layoutsDir))
|
||||
return;
|
||||
|
||||
var dstFull = Path.Combine(layoutsDir, LayoutFileName);
|
||||
if (!File.Exists(dstFull))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(dstFull);
|
||||
ReloadWindowLayoutMenu();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
CodelyLogger.LogWarning($"[Codely] Could not remove installed layout: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switches the Editor to the Codely layout. Re-installs the .wlt from the package
|
||||
/// if it's missing from the user's layouts directory. Returns true on success.
|
||||
/// Note: loading a layout destroys all current EditorWindows.
|
||||
/// </summary>
|
||||
internal static bool ApplyCodelyLayout()
|
||||
{
|
||||
if (IsLayoutActive("Tuanjie AI Mode") || IsLayoutActive("Tuanjie AI"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var layoutsDir = GetLayoutsModePreferencesPath();
|
||||
if (string.IsNullOrEmpty(layoutsDir))
|
||||
{
|
||||
CodelyLogger.LogWarning("[Codely] ApplyCodelyLayout: could not resolve layouts directory.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var dstFull = Path.Combine(layoutsDir, LayoutFileName);
|
||||
if (!File.Exists(dstFull))
|
||||
{
|
||||
InstallCodelyLayout();
|
||||
if (!File.Exists(dstFull))
|
||||
{
|
||||
CodelyLogger.LogWarning($"[Codely] ApplyCodelyLayout: layout file missing after install attempt: {dstFull}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return LoadWindowLayoutFile(dstFull);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
CodelyLogger.LogWarning($"[Codely] ApplyCodelyLayout failed: {e.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool LoadWindowLayoutFile(string fullPath)
|
||||
{
|
||||
var windowLayoutType = typeof(EditorWindow).Assembly.GetType("UnityEditor.WindowLayout");
|
||||
if (windowLayoutType == null)
|
||||
return false;
|
||||
|
||||
var signature = new[] { typeof(string), typeof(bool) };
|
||||
const BindingFlags flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
|
||||
// Prefer TryLoadWindowLayout when present (newer Unity); fall back to LoadWindowLayout.
|
||||
var method = windowLayoutType.GetMethod("TryLoadWindowLayout", flags, null, signature, null)
|
||||
?? windowLayoutType.GetMethod("LoadWindowLayout", flags, null, signature, null);
|
||||
|
||||
if (method == null)
|
||||
{
|
||||
CodelyLogger.LogWarning("[Codely] LoadWindowLayoutFile: UnityEditor.WindowLayout.LoadWindowLayout not found.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = method.Invoke(null, new object[] { fullPath, false });
|
||||
return result is bool ok ? ok : true;
|
||||
}
|
||||
|
||||
private static string ResolveLayoutAssetPath()
|
||||
{
|
||||
if (File.Exists(ToAbsoluteProjectPath(PackageLayoutAssetPath)))
|
||||
return PackageLayoutAssetPath;
|
||||
|
||||
foreach (var guid in AssetDatabase.FindAssets(LayoutFileName))
|
||||
{
|
||||
var path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
if (path.EndsWith("/" + LayoutFileName, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith("\\" + LayoutFileName, StringComparison.OrdinalIgnoreCase))
|
||||
return path;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ToAbsoluteProjectPath(string assetPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
return null;
|
||||
|
||||
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
|
||||
if (string.IsNullOrEmpty(projectRoot))
|
||||
return null;
|
||||
|
||||
return Path.GetFullPath(Path.Combine(projectRoot, assetPath.Replace('/', Path.DirectorySeparatorChar)));
|
||||
}
|
||||
|
||||
private static string GetLayoutsModePreferencesPath()
|
||||
{
|
||||
var windowLayoutType = typeof(EditorWindow).Assembly.GetType("UnityEditor.WindowLayout");
|
||||
if (windowLayoutType == null)
|
||||
return null;
|
||||
|
||||
var property = windowLayoutType.GetProperty(
|
||||
"layoutsModePreferencesPath",
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
|
||||
return property?.GetValue(null) as string;
|
||||
}
|
||||
|
||||
private static void ReloadWindowLayoutMenu()
|
||||
{
|
||||
var windowLayoutType = typeof(EditorWindow).Assembly.GetType("UnityEditor.WindowLayout");
|
||||
if (windowLayoutType == null)
|
||||
return;
|
||||
|
||||
var method = windowLayoutType.GetMethod(
|
||||
"ReloadWindowLayoutMenu",
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
method?.Invoke(null, null);
|
||||
|
||||
var updateMenusMethod = typeof(EditorUtility).GetMethod(
|
||||
"Internal_UpdateAllMenus",
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
updateMenusMethod?.Invoke(null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ff9d2af41843c42b08c5c5fe74859472
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
#if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN
|
||||
#if UNITY_6000_3_OR_NEWER
|
||||
using Cn.Tuanjie.Codely.Editor;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Toolbars;
|
||||
using UnityEngine;
|
||||
|
||||
namespace UnityTcp.Editor.Windows
|
||||
{
|
||||
public static class CodelyToolbarUnity6
|
||||
{
|
||||
private const string IconBase = "Packages/cn.tuanjie.codely.bridge/Editor/Bridge/Icons/";
|
||||
|
||||
[MainToolbarElement("Tuanjie AI/Tuanjie AI Bridge", defaultDockPosition = MainToolbarDockPosition.Left)]
|
||||
public static MainToolbarElement CreateButton()
|
||||
{
|
||||
|
||||
var icon = AssetDatabase.LoadAssetAtPath<Texture2D>(
|
||||
EditorGUIUtility.isProSkin
|
||||
? IconBase + "toolbar.png"
|
||||
: IconBase + "toolbar_light_theme.png");
|
||||
var content = new MainToolbarContent(string.Empty, icon, "AI");
|
||||
return new MainToolbarButton(content, CodelyWindow.ToggleCodely);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#elif UNITY_2020_1_OR_NEWER
|
||||
|
||||
using System.Reflection;
|
||||
using Cn.Tuanjie.Codely.Editor;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace UnityTcp.Editor.Windows
|
||||
{
|
||||
[InitializeOnLoad]
|
||||
public static class CodelyToolbar
|
||||
{
|
||||
private const string ButtonName = "CodelyToolbarBtn";
|
||||
|
||||
static CodelyToolbar()
|
||||
{
|
||||
EditorApplication.delayCall += AttachToToolbar;
|
||||
EditorApplication.update += EnsureAttached;
|
||||
}
|
||||
|
||||
private static void EnsureAttached()
|
||||
{
|
||||
var toolbarType = typeof(UnityEditor.Editor).Assembly.GetType("UnityEditor.Toolbar");
|
||||
if (toolbarType == null) return;
|
||||
|
||||
var toolbars = Resources.FindObjectsOfTypeAll(toolbarType);
|
||||
if (toolbars.Length == 0) return;
|
||||
|
||||
var rootField = toolbarType.GetField("m_Root", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (rootField == null) return;
|
||||
|
||||
var root = rootField.GetValue(toolbars[0]) as VisualElement;
|
||||
var leftZone = root?.Q("ToolbarZoneLeftAlign");
|
||||
if (leftZone == null) return;
|
||||
|
||||
if (leftZone.Q(ButtonName) == null)
|
||||
{
|
||||
AttachToToolbar();
|
||||
}
|
||||
}
|
||||
|
||||
private static void AttachToToolbar()
|
||||
{
|
||||
var toolbarType = typeof(UnityEditor.Editor).Assembly.GetType("UnityEditor.Toolbar");
|
||||
if (toolbarType == null) return;
|
||||
|
||||
var toolbars = Resources.FindObjectsOfTypeAll(toolbarType);
|
||||
if (toolbars.Length == 0)
|
||||
{
|
||||
EditorApplication.delayCall += AttachToToolbar;
|
||||
return;
|
||||
}
|
||||
|
||||
var rootField = toolbarType.GetField("m_Root", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (rootField == null) return;
|
||||
|
||||
var root = rootField.GetValue(toolbars[0]) as VisualElement;
|
||||
if (root == null)
|
||||
{
|
||||
EditorApplication.delayCall += AttachToToolbar;
|
||||
return;
|
||||
}
|
||||
|
||||
var leftZone = root.Q("ToolbarZoneLeftAlign");
|
||||
if (leftZone == null)
|
||||
{
|
||||
EditorApplication.delayCall += AttachToToolbar;
|
||||
return;
|
||||
}
|
||||
|
||||
if (leftZone.Q(ButtonName) != null) return;
|
||||
|
||||
const string iconBase = "Packages/cn.tuanjie.codely.bridge/Editor/Bridge/Icons/";
|
||||
var icon = AssetDatabase.LoadAssetAtPath<Texture2D>(
|
||||
EditorGUIUtility.isProSkin
|
||||
? iconBase + "toolbar.png"
|
||||
: iconBase + "toolbar_light_theme.png");
|
||||
|
||||
var button = new ToolbarButton(CodelyWindow.ToggleCodely)
|
||||
{
|
||||
name = ButtonName,
|
||||
tooltip = "AI"
|
||||
};
|
||||
|
||||
button.style.paddingTop = 2;
|
||||
button.style.paddingBottom = 2;
|
||||
button.style.paddingLeft = 6;
|
||||
button.style.paddingRight = 6;
|
||||
button.style.marginTop = 0;
|
||||
button.style.marginBottom = -1;
|
||||
button.style.alignItems = Align.Center;
|
||||
button.style.justifyContent = Justify.Center;
|
||||
button.style.flexDirection = FlexDirection.Row;
|
||||
|
||||
if (icon != null)
|
||||
{
|
||||
var iconImage = new Image
|
||||
{
|
||||
image = icon,
|
||||
scaleMode = ScaleMode.ScaleToFit,
|
||||
pickingMode = PickingMode.Ignore,
|
||||
};
|
||||
iconImage.style.width = 12;
|
||||
iconImage.style.height = 12;
|
||||
button.Add(iconImage);
|
||||
}
|
||||
|
||||
var label = new Label("AI")
|
||||
{
|
||||
pickingMode = PickingMode.Ignore,
|
||||
};
|
||||
label.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
label.style.marginLeft = icon != null ? 4 : 0;
|
||||
button.Add(label);
|
||||
|
||||
leftZone.Add(button);
|
||||
|
||||
leftZone.RegisterCallback<DetachFromPanelEvent>(_ =>
|
||||
EditorApplication.delayCall += AttachToToolbar);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b71b883158cd3f342b34b4155a254531
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+4646
File diff suppressed because it is too large.
Load diff
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6f947d6846c52fe4493bda3b19fc76be
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
using UnityTcp.Editor.Helpers;
|
||||
namespace Cn.Tuanjie.Codely.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Reflection helpers that wrap an EditorWindow in a fresh DockArea and splice it in
|
||||
/// as the leftmost child of the main editor's horizontal SplitView. Used so opening
|
||||
/// the Codely window does not require a destructive .wlt layout swap.
|
||||
/// </summary>
|
||||
internal static class CodelyWindowDocker
|
||||
{
|
||||
private const BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
private const BindingFlags StaticFlags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
|
||||
// ContainerWindow.m_ShowMode value for the main editor window. ShowMode.MainWindow == 4
|
||||
// in every Unity revision we ship against; see ContainerWindow.ShowMode in UnityCsReference.
|
||||
private const int ShowMode_MainWindow = 4;
|
||||
|
||||
// width <= 0 means "auto": dock claims half of the main editor's working width.
|
||||
public static bool TryDockLeftmost(EditorWindow window, int width = 0)
|
||||
{
|
||||
if (window == null) return false;
|
||||
|
||||
object newDockArea = null;
|
||||
try
|
||||
{
|
||||
var asm = typeof(EditorWindow).Assembly;
|
||||
var containerWindowType = asm.GetType("UnityEditor.ContainerWindow");
|
||||
var splitViewType = asm.GetType("UnityEditor.SplitView");
|
||||
var dockAreaType = asm.GetType("UnityEditor.DockArea");
|
||||
var viewType = asm.GetType("UnityEditor.View");
|
||||
if (containerWindowType == null || splitViewType == null || dockAreaType == null || viewType == null)
|
||||
return false;
|
||||
|
||||
var mainContainer = FindMainContainerWindow(containerWindowType);
|
||||
if (mainContainer == null) return false;
|
||||
|
||||
var rootViewProp = containerWindowType.GetProperty("rootView", InstanceFlags);
|
||||
var rootView = rootViewProp?.GetValue(mainContainer);
|
||||
if (rootView == null) return false;
|
||||
|
||||
var mainSplit = FindOutermostHorizontalSplit(rootView, splitViewType, viewType);
|
||||
if (mainSplit == null) return false;
|
||||
|
||||
var setPosition = viewType.GetMethod("SetPosition", InstanceFlags, null, new[] { typeof(Rect) }, null);
|
||||
var positionProp = viewType.GetProperty("position", InstanceFlags);
|
||||
if (setPosition == null || positionProp == null) return false;
|
||||
|
||||
var splitRect = (Rect)positionProp.GetValue(mainSplit);
|
||||
|
||||
int effectiveWidth = width > 0
|
||||
? width
|
||||
: Math.Max(100, (int)(splitRect.width));
|
||||
|
||||
newDockArea = ScriptableObject.CreateInstance(dockAreaType);
|
||||
if (newDockArea == null) return false;
|
||||
|
||||
if (!InvokeAddTab(dockAreaType, newDockArea, window))
|
||||
return false;
|
||||
|
||||
// Seed the new dock's width before splice so SplitView.SetupSplitState reads
|
||||
// effectiveWidth (not 0) when it rebuilds splitState from children's current rects.
|
||||
setPosition.Invoke(newDockArea, new object[] { new Rect(0, 0, effectiveWidth, splitRect.height) });
|
||||
|
||||
var addChild = viewType.GetMethod("AddChild", InstanceFlags, null, new[] { viewType, typeof(int) }, null);
|
||||
if (addChild == null)
|
||||
return false;
|
||||
addChild.Invoke(mainSplit, new[] { newDockArea, 0 });
|
||||
|
||||
// SplitView nulled splitState in AddChild; re-applying its own rect triggers
|
||||
// SetupSplitState (seeds from the children's positions, including our seeded
|
||||
// width hint) followed by SetupRectsFromSplitState.
|
||||
setPosition.Invoke(mainSplit, new object[] { splitRect });
|
||||
|
||||
// Final pass: clamp leftmost child to exactly `effectiveWidth` and absorb the
|
||||
// delta from existing siblings proportionally. Without this, Unity's
|
||||
// SplitterState can snap to even shares when total > parent width.
|
||||
EnforceLeftmostWidth(mainSplit, splitViewType, viewType, effectiveWidth, splitRect, setPosition);
|
||||
|
||||
newDockArea = null;
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
CodelyLogger.LogWarning($"[Codely] TryDockLeftmost failed: {e.Message}");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (newDockArea is UnityEngine.Object orphan)
|
||||
UnityEngine.Object.DestroyImmediate(orphan);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnforceLeftmostWidth(
|
||||
object split, Type splitViewType, Type viewType,
|
||||
int targetWidth, Rect splitRect, MethodInfo setPosition)
|
||||
{
|
||||
var splitStateField = splitViewType.GetField("splitState", InstanceFlags);
|
||||
var splitState = splitStateField?.GetValue(split);
|
||||
if (splitState == null) return;
|
||||
|
||||
var realSizesField = splitState.GetType().GetField("realSizes", InstanceFlags);
|
||||
var sizes = realSizesField?.GetValue(splitState) as int[];
|
||||
if (sizes == null || sizes.Length < 2) return;
|
||||
|
||||
int parentWidth = (int)splitRect.width;
|
||||
if (parentWidth <= 0) return;
|
||||
|
||||
// Reserve at least 200px total for the rest of the docks so we never starve them.
|
||||
int minRest = Math.Max(200, 100 * (sizes.Length - 1));
|
||||
int clamped = Math.Min(targetWidth, Math.Max(100, parentWidth - minRest));
|
||||
|
||||
int oldRest = 0;
|
||||
for (int i = 1; i < sizes.Length; i++) oldRest += sizes[i];
|
||||
|
||||
int newRest = parentWidth - clamped;
|
||||
sizes[0] = clamped;
|
||||
|
||||
if (oldRest > 0)
|
||||
{
|
||||
int accum = 0;
|
||||
for (int i = 1; i < sizes.Length - 1; i++)
|
||||
{
|
||||
int scaled = (int)((float)sizes[i] / oldRest * newRest);
|
||||
sizes[i] = Math.Max(100, scaled);
|
||||
accum += sizes[i];
|
||||
}
|
||||
sizes[sizes.Length - 1] = Math.Max(100, newRest - accum);
|
||||
}
|
||||
else
|
||||
{
|
||||
int each = newRest / (sizes.Length - 1);
|
||||
for (int i = 1; i < sizes.Length; i++) sizes[i] = each;
|
||||
}
|
||||
|
||||
setPosition.Invoke(split, new object[] { splitRect });
|
||||
}
|
||||
|
||||
private static object FindMainContainerWindow(Type containerWindowType)
|
||||
{
|
||||
var windowsProp = containerWindowType.GetProperty("windows", StaticFlags);
|
||||
var all = windowsProp?.GetValue(null) as IEnumerable;
|
||||
if (all == null) return null;
|
||||
|
||||
var showModeField = containerWindowType.GetField("m_ShowMode", InstanceFlags);
|
||||
if (showModeField == null) return null;
|
||||
|
||||
foreach (var cw in all)
|
||||
{
|
||||
if (Convert.ToInt32(showModeField.GetValue(cw)) == ShowMode_MainWindow)
|
||||
return cw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static object FindOutermostHorizontalSplit(object view, Type splitViewType, Type viewType)
|
||||
{
|
||||
var allChildrenProp = viewType.GetProperty("allChildren", InstanceFlags);
|
||||
var children = allChildrenProp?.GetValue(view) as Array;
|
||||
if (children == null) return null;
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
if (splitViewType.IsInstanceOfType(child) && !IsVertical(child, splitViewType))
|
||||
return child;
|
||||
}
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
if (viewType.IsInstanceOfType(child))
|
||||
{
|
||||
var nested = FindOutermostHorizontalSplit(child, splitViewType, viewType);
|
||||
if (nested != null) return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsVertical(object splitView, Type splitViewType)
|
||||
{
|
||||
var verticalField = splitViewType.GetField("vertical", InstanceFlags);
|
||||
if (verticalField == null) return false;
|
||||
var value = verticalField.GetValue(splitView);
|
||||
return value is bool b && b;
|
||||
}
|
||||
|
||||
private static bool InvokeAddTab(Type dockAreaType, object dockArea, EditorWindow window)
|
||||
{
|
||||
var oneArg = dockAreaType.GetMethod("AddTab", InstanceFlags, null, new[] { typeof(EditorWindow) }, null);
|
||||
if (oneArg != null) { oneArg.Invoke(dockArea, new object[] { window }); return true; }
|
||||
|
||||
var oneArgBool = dockAreaType.GetMethod("AddTab", InstanceFlags, null, new[] { typeof(EditorWindow), typeof(bool) }, null);
|
||||
if (oneArgBool != null) { oneArgBool.Invoke(dockArea, new object[] { window, true }); return true; }
|
||||
|
||||
var indexedBool = dockAreaType.GetMethod("AddTab", InstanceFlags, null, new[] { typeof(int), typeof(EditorWindow), typeof(bool) }, null);
|
||||
if (indexedBool != null) { indexedBool.Invoke(dockArea, new object[] { 0, window, true }); return true; }
|
||||
|
||||
var indexed = dockAreaType.GetMethod("AddTab", InstanceFlags, null, new[] { typeof(int), typeof(EditorWindow) }, null);
|
||||
if (indexed != null) { indexed.Invoke(dockArea, new object[] { 0, window }); return true; }
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c16430b46b29b1f47bdcf9de21327e59
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+1164
File diff suppressed because it is too large.
Load diff
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1070a2c5b274df599c2ccd6620a7e20
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
#if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using UnityEditor;
|
||||
|
||||
namespace Cn.Tuanjie.Codely.Editor
|
||||
{
|
||||
public static class EditorWindowNativeHandleHelper
|
||||
{
|
||||
private const BindingFlags InstanceFlags =
|
||||
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
|
||||
|
||||
public static IntPtr GetGUIViewHandle(EditorWindow window)
|
||||
{
|
||||
if (window == null)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
var parentField = typeof(EditorWindow).GetField("m_Parent", InstanceFlags);
|
||||
object guiView = parentField?.GetValue(window);
|
||||
if (guiView == null)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
IntPtr nativeHandle = TryGetNativeHandleProperty(guiView);
|
||||
if (nativeHandle != IntPtr.Zero)
|
||||
{
|
||||
return nativeHandle;
|
||||
}
|
||||
|
||||
return TryGetViewPtrField(guiView);
|
||||
}
|
||||
|
||||
private static IntPtr TryGetNativeHandleProperty(object guiView)
|
||||
{
|
||||
var nativeHandleProperty = guiView.GetType().GetProperty("nativeHandle", InstanceFlags);
|
||||
if (nativeHandleProperty == null)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
return TryConvertToIntPtr(nativeHandleProperty.GetValue(guiView, null));
|
||||
}
|
||||
|
||||
private static IntPtr TryGetViewPtrField(object guiView)
|
||||
{
|
||||
var viewPtrField = GetFieldInTypeHierarchy(guiView.GetType(), "m_ViewPtr");
|
||||
if (viewPtrField == null)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
return TryConvertToIntPtr(viewPtrField.GetValue(guiView));
|
||||
}
|
||||
|
||||
private static IntPtr TryConvertToIntPtr(object value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
if (value is IntPtr ptr)
|
||||
{
|
||||
return ptr;
|
||||
}
|
||||
|
||||
Type valueType = value.GetType();
|
||||
foreach (string memberName in new[] { "m_IntPtr", "m_Ptr", "m_Value", "value" })
|
||||
{
|
||||
var field = GetFieldInTypeHierarchy(valueType, memberName);
|
||||
if (field != null && field.FieldType == typeof(IntPtr))
|
||||
{
|
||||
return (IntPtr)field.GetValue(value);
|
||||
}
|
||||
|
||||
var property = valueType.GetProperty(memberName, InstanceFlags);
|
||||
if (property != null && property.PropertyType == typeof(IntPtr))
|
||||
{
|
||||
return (IntPtr)property.GetValue(value, null);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var field in valueType.GetFields(InstanceFlags))
|
||||
{
|
||||
if (field.FieldType == typeof(IntPtr))
|
||||
{
|
||||
return (IntPtr)field.GetValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
private static FieldInfo GetFieldInTypeHierarchy(Type type, string name)
|
||||
{
|
||||
while (type != null)
|
||||
{
|
||||
var field = type.GetField(name, InstanceFlags);
|
||||
if (field != null)
|
||||
{
|
||||
return field;
|
||||
}
|
||||
|
||||
type = type.BaseType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8b574ff6979b141aaa27ac6328321e46
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 133d731ca2e94174da722477a50f950a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ce8760ade6ed247428bf6f909f6f528a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
|
||||
using System;
|
||||
using System.Reflection;
|
||||
|
||||
// ReSharper disable once CheckNamespace
|
||||
namespace Unity.InternalBridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Reflection-based wrapper around UnityEditor.Connect.UnityConnect (an internal type).
|
||||
/// Using reflection avoids depending on the magic
|
||||
/// `Unity.InternalAPIEditorBridge.<contract>` asmdef whose contract number changes
|
||||
/// between Unity major versions (001 in 2019, 010 in 2020, 016/017 in 2021+, ...).
|
||||
/// </summary>
|
||||
public static class UnityConnectSession
|
||||
{
|
||||
private static readonly Type s_Type;
|
||||
private static readonly object s_Instance;
|
||||
private static readonly MethodInfo s_GetAccessToken;
|
||||
private static readonly MethodInfo s_GetUserId;
|
||||
private static readonly MethodInfo s_GetUserName;
|
||||
private static readonly PropertyInfo s_LoggedIn;
|
||||
private static readonly MethodInfo s_RefreshAccessToken;
|
||||
private static readonly MethodInfo s_ShowLogin;
|
||||
|
||||
static UnityConnectSession()
|
||||
{
|
||||
try
|
||||
{
|
||||
var editorAssembly = typeof(UnityEditor.Editor).Assembly;
|
||||
s_Type = editorAssembly.GetType("UnityEditor.Connect.UnityConnect");
|
||||
if (s_Type == null) return;
|
||||
|
||||
var instanceProp = s_Type.GetProperty("instance",
|
||||
BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic);
|
||||
if (instanceProp == null) return;
|
||||
s_Instance = instanceProp.GetValue(null);
|
||||
if (s_Instance == null) return;
|
||||
|
||||
const BindingFlags inst = BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
s_GetAccessToken = s_Type.GetMethod("GetAccessToken", inst, null, Type.EmptyTypes, null);
|
||||
s_GetUserId = s_Type.GetMethod("GetUserId", inst, null, Type.EmptyTypes, null);
|
||||
s_GetUserName = s_Type.GetMethod("GetUserName", inst, null, Type.EmptyTypes, null);
|
||||
s_LoggedIn = s_Type.GetProperty("loggedIn", inst);
|
||||
s_RefreshAccessToken = s_Type.GetMethod("RefreshAccessToken", inst, null, new[] { typeof(Action<bool>) }, null);
|
||||
s_ShowLogin = s_Type.GetMethod("ShowLogin", inst, null, Type.EmptyTypes, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Reflection bind failure: leave fields null; methods will return safe defaults.
|
||||
}
|
||||
}
|
||||
|
||||
private static string InvokeString(MethodInfo mi)
|
||||
{
|
||||
if (mi == null || s_Instance == null) return string.Empty;
|
||||
try { return mi.Invoke(s_Instance, null) as string ?? string.Empty; }
|
||||
catch { return string.Empty; }
|
||||
}
|
||||
|
||||
public static string GetAccessToken() => InvokeString(s_GetAccessToken);
|
||||
public static string GetUserId() => InvokeString(s_GetUserId);
|
||||
public static string GetUserName() => InvokeString(s_GetUserName);
|
||||
|
||||
public static bool IsLoggedIn()
|
||||
{
|
||||
if (s_LoggedIn == null || s_Instance == null) return false;
|
||||
try
|
||||
{
|
||||
var v = s_LoggedIn.GetValue(s_Instance);
|
||||
return v is bool b && b;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public static void RefreshAccessToken(Action<bool> onRefreshed)
|
||||
{
|
||||
if (s_RefreshAccessToken == null || s_Instance == null)
|
||||
{
|
||||
onRefreshed?.Invoke(false);
|
||||
return;
|
||||
}
|
||||
try { s_RefreshAccessToken.Invoke(s_Instance, new object[] { onRefreshed }); }
|
||||
catch { onRefreshed?.Invoke(false); }
|
||||
}
|
||||
|
||||
public static void ShowLogin()
|
||||
{
|
||||
if (s_ShowLogin == null || s_Instance == null) return;
|
||||
try { s_ShowLogin.Invoke(s_Instance, null); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 35a1b76bb29253344a7289b78ef4aece
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+556
@@ -0,0 +1,556 @@
|
||||
#if UNITY_EDITOR_OSX
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using UnityEngine;
|
||||
|
||||
using UnityTcp.Editor.Helpers;
|
||||
namespace Cn.Tuanjie.Codely.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// macOS WKWebView integration wrapper
|
||||
/// Provides a platform-agnostic interface for WebView operations
|
||||
/// </summary>
|
||||
public class MacOSWebViewBridge
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
#region Native Plugin Imports
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern IntPtr WebView_Create(IntPtr guiViewPtr, string url, float x, float y, float w, float h);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern IntPtr WebView_CreateByWindowTitle(string windowTitle, string url, float x, float y, float w, float h);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern void WebView_Destroy(IntPtr handle);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern void WebView_UpdateFrame(IntPtr handle, float x, float y, float w, float h);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern void WebView_UpdateParentAndFrame(IntPtr handle, IntPtr guiViewPtr, float x, float y, float w, float h);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern void WebView_LoadURL(IntPtr handle, string url);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern void WebView_GoBack(IntPtr handle);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern void WebView_GoForward(IntPtr handle);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern void WebView_Reload(IntPtr handle);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern void WebView_ExecuteJavaScript(IntPtr handle, string script);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern void WebView_SetHidden(IntPtr handle, bool hidden);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern void WebView_ShowInspector(IntPtr handle);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern void WebView_Focus(IntPtr handle);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern bool WebView_ValidateHandle(IntPtr handle);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern bool WebView_HasValidContent(IntPtr handle);
|
||||
|
||||
// Unity Drag Gateway imports (now in WebViewPlugin)
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern IntPtr InstallUnityDragGateway(IntPtr wkWebViewPtr, IntPtr guiRenderViewPtr);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern void UninstallUnityDragGateway(IntPtr gatewayPtr);
|
||||
|
||||
[DllImport("WebViewPlugin")]
|
||||
private static extern void UpdateDragGatewayFrame(IntPtr gatewayPtr, float x, float y, float w, float h);
|
||||
#endregion
|
||||
#endif
|
||||
|
||||
private IntPtr _webViewHandle = IntPtr.Zero;
|
||||
private bool _isInitialized = false;
|
||||
private IntPtr _dragGatewayHandle = IntPtr.Zero;
|
||||
|
||||
public bool IsInitialized => _isInitialized;
|
||||
public IntPtr Handle => _webViewHandle;
|
||||
|
||||
|
||||
public bool RestoreFromHandle(IntPtr handle)
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (handle == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Verify the handle is still valid using the native registry
|
||||
if (!WebView_ValidateHandle(handle))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_webViewHandle = handle;
|
||||
_isInitialized = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Exception while restoring handle: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if the WebView has valid content loaded (not blank/white screen)
|
||||
/// </summary>
|
||||
public bool HasValidContent()
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (_webViewHandle == IntPtr.Zero || !_isInitialized)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return WebView_HasValidContent(_webViewHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Exception checking content validity: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create and initialize the WebView using window title (recommended)
|
||||
/// </summary>
|
||||
public bool CreateByWindowTitle(string windowTitle, string url, float x, float y, float w, float h)
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
try
|
||||
{
|
||||
if (_isInitialized)
|
||||
{
|
||||
CodelyLogger.LogWarning("MacOSWebViewBridge: WebView already initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(windowTitle))
|
||||
{
|
||||
CodelyLogger.LogError("MacOSWebViewBridge: Invalid window title");
|
||||
return false;
|
||||
}
|
||||
|
||||
_webViewHandle = WebView_CreateByWindowTitle(windowTitle, url, x, y, w, h);
|
||||
|
||||
if (_webViewHandle != IntPtr.Zero)
|
||||
{
|
||||
_isInitialized = true;
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Failed to create WebView for window '{windowTitle}' (returned null handle)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Exception creating WebView: {ex.Message}\n{ex.StackTrace}");
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
CodelyLogger.LogWarning("MacOSWebViewBridge: WebView is only supported on macOS");
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create and initialize the WebView
|
||||
/// </summary>
|
||||
public bool Create(IntPtr guiViewHandle, string url, float x, float y, float w, float h)
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
try
|
||||
{
|
||||
if (_isInitialized)
|
||||
{
|
||||
CodelyLogger.LogWarning("MacOSWebViewBridge: WebView already initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (guiViewHandle == IntPtr.Zero)
|
||||
{
|
||||
CodelyLogger.LogError("MacOSWebViewBridge: Invalid GUIView handle");
|
||||
return false;
|
||||
}
|
||||
|
||||
_webViewHandle = WebView_Create(guiViewHandle, url, x, y, w, h);
|
||||
|
||||
if (_webViewHandle != IntPtr.Zero)
|
||||
{
|
||||
_isInitialized = true;
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
CodelyLogger.LogError("MacOSWebViewBridge: Failed to create WebView (returned null handle)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Exception creating WebView: {ex.Message}\n{ex.StackTrace}");
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
CodelyLogger.LogWarning("MacOSWebViewBridge: WebView is only supported on macOS");
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destroy the WebView
|
||||
/// </summary>
|
||||
public void Destroy()
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
// Uninstall drag gateway first
|
||||
UninstallDragGateway();
|
||||
|
||||
if (_webViewHandle != IntPtr.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
WebView_Destroy(_webViewHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Error destroying WebView: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_webViewHandle = IntPtr.Zero;
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update WebView frame (position and size)
|
||||
/// </summary>
|
||||
public void UpdateFrame(float x, float y, float w, float h)
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (_webViewHandle != IntPtr.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
WebView_UpdateFrame(_webViewHandle, x, y, w, h);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Error updating frame: {ex.Message}");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update parent view and frame (when window/tab changes)
|
||||
/// </summary>
|
||||
public void UpdateParentAndFrame(IntPtr guiViewPtr, float x, float y, float w, float h)
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (_webViewHandle != IntPtr.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
WebView_UpdateParentAndFrame(_webViewHandle, guiViewPtr, x, y, w, h);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Error updating parent and frame: {ex.Message}");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load a URL
|
||||
/// </summary>
|
||||
public void LoadURL(string url)
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (_webViewHandle != IntPtr.Zero && !string.IsNullOrEmpty(url))
|
||||
{
|
||||
try
|
||||
{
|
||||
WebView_LoadURL(_webViewHandle, url);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Error loading URL: {ex.Message}");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigate back
|
||||
/// </summary>
|
||||
public void GoBack()
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (_webViewHandle != IntPtr.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
WebView_GoBack(_webViewHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Error going back: {ex.Message}");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigate forward
|
||||
/// </summary>
|
||||
public void GoForward()
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (_webViewHandle != IntPtr.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
WebView_GoForward(_webViewHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Error going forward: {ex.Message}");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload the page
|
||||
/// </summary>
|
||||
public void Reload()
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (_webViewHandle != IntPtr.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
WebView_Reload(_webViewHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Error reloading: {ex.Message}");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute JavaScript in the WebView
|
||||
/// </summary>
|
||||
public void ExecuteJavaScript(string script)
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (_webViewHandle != IntPtr.Zero && !string.IsNullOrEmpty(script))
|
||||
{
|
||||
try
|
||||
{
|
||||
WebView_ExecuteJavaScript(_webViewHandle, script);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Error executing JavaScript: {ex.Message}");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set WebView visibility (hide/show)
|
||||
/// </summary>
|
||||
public void SetHidden(bool hidden)
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (_webViewHandle != IntPtr.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
WebView_SetHidden(_webViewHandle, hidden);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Error setting visibility: {ex.Message}");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show Web Inspector for debugging
|
||||
/// </summary>
|
||||
public void ShowInspector()
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (_webViewHandle != IntPtr.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
WebView_ShowInspector(_webViewHandle);
|
||||
CodelyLogger.Log("MacOSWebViewBridge: Opening Web Inspector...");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Error showing inspector: {ex.Message}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
CodelyLogger.LogWarning("MacOSWebViewBridge: Cannot show inspector - WebView not initialized");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Move keyboard focus to the embedded WKWebView.
|
||||
/// </summary>
|
||||
public void Focus()
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (_webViewHandle != IntPtr.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
WebView_Focus(_webViewHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Error focusing WebView: {ex.Message}");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Install Unity Drag Gateway to enable GameObject drag and drop
|
||||
/// Must be called after WebView is created and guiRenderView is available
|
||||
/// </summary>
|
||||
public bool InstallDragGateway(IntPtr guiRenderViewPtr)
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (_webViewHandle == IntPtr.Zero)
|
||||
{
|
||||
CodelyLogger.LogError("MacOSWebViewBridge: Cannot install drag gateway - WebView not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (guiRenderViewPtr == IntPtr.Zero)
|
||||
{
|
||||
CodelyLogger.LogError("MacOSWebViewBridge: Cannot install drag gateway - GUIRenderView is null");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_dragGatewayHandle != IntPtr.Zero)
|
||||
{
|
||||
CodelyLogger.LogWarning("MacOSWebViewBridge: Drag gateway already installed");
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_dragGatewayHandle = InstallUnityDragGateway(_webViewHandle, guiRenderViewPtr);
|
||||
|
||||
if (_dragGatewayHandle != IntPtr.Zero)
|
||||
{
|
||||
CodelyLogger.Log("MacOSWebViewBridge: Drag gateway installed successfully");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
CodelyLogger.LogError("MacOSWebViewBridge: Failed to install drag gateway");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Exception installing drag gateway: {ex.Message}\n{ex.StackTrace}");
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uninstall Unity Drag Gateway
|
||||
/// </summary>
|
||||
public void UninstallDragGateway()
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (_dragGatewayHandle != IntPtr.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
UninstallUnityDragGateway(_dragGatewayHandle);
|
||||
CodelyLogger.Log("MacOSWebViewBridge: Drag gateway uninstalled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Error uninstalling drag gateway: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_dragGatewayHandle = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update drag gateway frame to match WebView frame
|
||||
/// Should be called whenever WebView frame changes
|
||||
/// </summary>
|
||||
public void UpdateDragGatewayFrame(float x, float y, float w, float h)
|
||||
{
|
||||
#if UNITY_EDITOR_OSX
|
||||
if (_dragGatewayHandle != IntPtr.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
UpdateDragGatewayFrame(_dragGatewayHandle, x, y, w, h);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"MacOSWebViewBridge: Error updating drag gateway frame: {ex.Message}");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 58b0c80efb83eda45b6a8bf5703947fb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
#if UNITY_EDITOR_WIN
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Cn.Tuanjie.Codely.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// NativeBrowser.dll 的 P/Invoke 声明。
|
||||
/// DLL 内部管理 STA 线程、WebView2 COM 生命周期和 WndProcHook,
|
||||
/// C# 侧只需持有不透明句柄并调用 API。
|
||||
/// </summary>
|
||||
internal static class NativeBrowserAPI
|
||||
{
|
||||
const string DLL = "NativeBrowser";
|
||||
|
||||
// ── 回调委托(必须 stdcall + 持有强引用,防止 GC 回收)────────────────
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Unicode)]
|
||||
public delegate void NavigationCallback(string url, IntPtr ctx);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
public delegate void LoadCompletedCallback(bool success, IntPtr ctx);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Unicode)]
|
||||
public delegate void TitleChangedCallback(string title, IntPtr ctx);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Unicode)]
|
||||
public delegate void MessageCallback(string json, IntPtr ctx);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
public delegate void DestroyedCallback(IntPtr ctx);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct Callbacks
|
||||
{
|
||||
public NavigationCallback onNavigationStarting;
|
||||
public LoadCompletedCallback onLoadCompleted;
|
||||
public TitleChangedCallback onTitleChanged;
|
||||
public MessageCallback onWebMessageReceived;
|
||||
public DestroyedCallback onParentDestroyed;
|
||||
public IntPtr ctx;
|
||||
}
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
[DllImport(DLL, CharSet = CharSet.Unicode)]
|
||||
public static extern IntPtr NB_Create(
|
||||
IntPtr parentHwnd,
|
||||
string userDataFolder,
|
||||
string initialUrl,
|
||||
ref Callbacks callbacks);
|
||||
|
||||
[DllImport(DLL)]
|
||||
public static extern void NB_Destroy(IntPtr handle);
|
||||
|
||||
/// <summary>
|
||||
/// 同步关闭所有活跃 BrowserInstance(总耗时最长 timeoutMs)。
|
||||
/// 在调用线程 pump 消息以驱动 WebView2 跨 apartment marshaling,避免父 HWND
|
||||
/// 所属线程被阻塞导致 Close 死锁。仅 PrepareShutdown + FinalizeShutdown,
|
||||
/// 不释放内存(进程即将退出由 OS 回收)。必须在 EditorApplication.quitting 调用一次。
|
||||
/// </summary>
|
||||
[DllImport(DLL)]
|
||||
public static extern void NB_ShutdownAll(uint timeoutMs);
|
||||
|
||||
/// <summary>
|
||||
/// 替换 BrowserInstance 内的托管回调函数指针。
|
||||
/// Unity Domain Reload 后旧委托已失效,必须在 OnEnable 中调用此函数注册新委托。
|
||||
/// </summary>
|
||||
[DllImport(DLL)]
|
||||
public static extern void NB_SetCallbacks(IntPtr handle, ref Callbacks callbacks);
|
||||
|
||||
/// <summary>
|
||||
/// 判断 handle 是否仍指向一个活跃的 BrowserInstance(不解引用指针,安全)。
|
||||
/// 用于 Domain Reload 后验证序列化的句柄是否仍然有效。
|
||||
/// </summary>
|
||||
[DllImport(DLL)]
|
||||
public static extern bool NB_IsHandleValid(IntPtr handle);
|
||||
|
||||
// ── 导航 ──────────────────────────────────────────────────────────────
|
||||
[DllImport(DLL, CharSet = CharSet.Unicode)]
|
||||
public static extern void NB_Navigate(IntPtr handle, string url);
|
||||
|
||||
[DllImport(DLL)] public static extern void NB_GoBack (IntPtr handle);
|
||||
[DllImport(DLL)] public static extern void NB_GoForward(IntPtr handle);
|
||||
[DllImport(DLL)] public static extern void NB_Reload (IntPtr handle);
|
||||
[DllImport(DLL)] public static extern void NB_Stop (IntPtr handle);
|
||||
|
||||
// ── 布局 ──────────────────────────────────────────────────────────────
|
||||
[DllImport(DLL)] public static extern void NB_Resize (IntPtr handle, int x, int y, int w, int h);
|
||||
[DllImport(DLL)] public static extern void NB_SetVisible (IntPtr handle, bool visible);
|
||||
[DllImport(DLL)] public static extern void NB_SetTopInset(IntPtr handle, int topPx);
|
||||
|
||||
// ── 脚本 ──────────────────────────────────────────────────────────────
|
||||
[DllImport(DLL, CharSet = CharSet.Unicode)]
|
||||
public static extern void NB_ExecuteScript(IntPtr handle, string js);
|
||||
|
||||
[DllImport(DLL, CharSet = CharSet.Unicode)]
|
||||
public static extern void NB_PostWebMessage(IntPtr handle, string json);
|
||||
|
||||
// ── 焦点 ──────────────────────────────────────────────────────────────
|
||||
// 将键盘焦点移入 WebView2 内容(ICoreWebView2Controller::MoveFocus PROGRAMMATIC)。
|
||||
// 嵌入 Unity 后导航完成时焦点仍在父窗口,autofocus/element.focus() 不生效,需显式调用。
|
||||
[DllImport(DLL)] public static extern void NB_MoveFocus(IntPtr handle);
|
||||
|
||||
// ── 调试 ──────────────────────────────────────────────────────────────
|
||||
[DllImport(DLL)] public static extern void NB_OpenDevTools(IntPtr handle);
|
||||
|
||||
// ── Runtime 检测 ──────────────────────────────────────────────────────
|
||||
[DllImport(DLL)] public static extern bool NB_IsRuntimeAvailable();
|
||||
|
||||
[DllImport(DLL, CharSet = CharSet.Unicode)]
|
||||
public static extern IntPtr NB_GetRuntimeVersion(); // 返回 DLL 内部静态字符串
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7fe7cfd97c8c9084a8274036d00ea15e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+581
@@ -0,0 +1,581 @@
|
||||
#if UNITY_EDITOR_WIN
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
using UnityTcp.Editor.Helpers;
|
||||
namespace Cn.Tuanjie.Codely.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Locate the Win32 HWND that backs a Unity EditorWindow.
|
||||
///
|
||||
/// EditorWindow.position is in DPI-scaled "points" while Win32 reports
|
||||
/// physical pixels, and Unity's point↔pixel mapping for monitors other
|
||||
/// than the primary is not a single linear factor. A purely absolute
|
||||
/// rect comparison therefore only works reliably on the primary monitor —
|
||||
/// on secondary monitors it used to never match and the embedded panel
|
||||
/// hung on "Loading" forever. The lookup below layers several strategies,
|
||||
/// most reliable first:
|
||||
///
|
||||
/// 1. Reflection: the host GUIView's internal nativeHandle. If it is our
|
||||
/// process's UnityGUIViewWndClass window, done — coordinate-free.
|
||||
/// 2. If reflection yields a valid window of ours with a different class
|
||||
/// (e.g. the ContainerWindow), restrict the search to that top-level
|
||||
/// window; a sole GUIView child there is accepted outright (typical
|
||||
/// for floating panels).
|
||||
/// 3. Rect search over UnityGUIViewWndClass children, testing containment
|
||||
/// under several coordinate hypotheses per candidate:
|
||||
/// (a) legacy: window rect × EditorGUIUtility.pixelsPerPoint versus
|
||||
/// physical pixels — exact on the primary monitor.
|
||||
/// (b) pure scale: candidate physical rect ÷ its own GetDpiForWindow
|
||||
/// factor versus the window rect in points.
|
||||
/// (d) monitor-anchored: like (b) but preserving the monitor's
|
||||
/// physical origin (points = monOrigin + (phys − monOrigin)/scale).
|
||||
/// (c) container-relative: compare the candidate's offset within its
|
||||
/// top-level window against the view's offset within its
|
||||
/// ContainerWindow (both in points). Relative offsets never cross
|
||||
/// monitors, so this holds under ANY per-monitor mapping.
|
||||
/// Smallest matching candidate wins (the dock host's GUIView fully
|
||||
/// contains its hosted EditorWindow's content rect; slack absorbs the
|
||||
/// tab strip / scrollbar offset).
|
||||
/// 4. When everything fails, a throttled diagnostic dump of every
|
||||
/// candidate is logged so field reports pin down the actual mapping.
|
||||
///
|
||||
/// IMPORTANT: the EnumWindowsProc delegates are stored in static fields so
|
||||
/// they're allocated once. Repeatedly creating fresh delegate instances on
|
||||
/// every editor tick crashes Mono's delegate_hash_table when the marshal
|
||||
/// cache fills up (mono-2.0-bdwgc:marshal.c delegate_hash_table_add assert).
|
||||
/// </summary>
|
||||
public static class NativeWindowHelper
|
||||
{
|
||||
// ── Win32 ──────────────────────────────────────────────────────────
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern bool EnumWindows(EnumWindowsProc cb, IntPtr param);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern bool EnumChildWindows(IntPtr parent, EnumWindowsProc cb, IntPtr param);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetClassNameW")]
|
||||
static extern int GetClassNameW(IntPtr hwnd, [Out] char[] buf, int max);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint processId);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern bool GetWindowRect(IntPtr hwnd, out RECT rect);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern bool GetClientRect(IntPtr hwnd, out RECT rect);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern bool ClientToScreen(IntPtr hwnd, ref POINT pt);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern bool IsWindow(IntPtr hwnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern IntPtr GetAncestor(IntPtr hwnd, uint flags);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint flags);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
static extern bool GetMonitorInfoW(IntPtr hMonitor, ref MONITORINFO mi);
|
||||
|
||||
// Windows 10 1607+; call sites guard with s_dpiApiAvailable.
|
||||
[DllImport("user32.dll")]
|
||||
static extern uint GetDpiForWindow(IntPtr hwnd);
|
||||
|
||||
const uint GA_ROOT = 2;
|
||||
const uint MONITOR_DEFAULTTONEAREST = 2;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct RECT
|
||||
{
|
||||
public int Left, Top, Right, Bottom;
|
||||
public int Width => Right - Left;
|
||||
public int Height => Bottom - Top;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct POINT
|
||||
{
|
||||
public int X, Y;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct MONITORINFO
|
||||
{
|
||||
public int cbSize;
|
||||
public RECT rcMonitor;
|
||||
public RECT rcWork;
|
||||
public uint dwFlags;
|
||||
}
|
||||
|
||||
delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr param);
|
||||
|
||||
const string kGUIViewClass = "UnityGUIViewWndClass";
|
||||
|
||||
// tab strip / scrollbar / window chrome can make the host GUIView several
|
||||
// dozen pixels larger than the EditorWindow's inner content rect — give
|
||||
// enough slack on each side to absorb that. Used as physical pixels in
|
||||
// hypothesis (a) and as points elsewhere; both stay well below the size
|
||||
// of a real dock area.
|
||||
const int Slack = 64;
|
||||
|
||||
static readonly int s_currentPid = System.Diagnostics.Process.GetCurrentProcess().Id;
|
||||
|
||||
// Static delegate instances. Created once at type init, kept alive forever,
|
||||
// so EnumWindows / EnumChildWindows always see the same managed callback.
|
||||
static readonly EnumWindowsProc s_outerProc = OuterEnumProc;
|
||||
static readonly EnumWindowsProc s_innerProc = InnerEnumProc;
|
||||
|
||||
// Per-call state. Safe to use static state because GetHWND is only invoked
|
||||
// from the editor main thread and EnumWindows is synchronous.
|
||||
static readonly char[] s_classBuf = new char[256];
|
||||
static int s_targetLeft, s_targetTop, s_targetRight, s_targetBottom; // physical px, hypothesis (a)
|
||||
static float s_targetPtLeft, s_targetPtTop, s_targetPtRight, s_targetPtBottom; // points, hypotheses (b)/(d)
|
||||
static bool s_haveContainer; // hypothesis (c) inputs resolved
|
||||
static bool s_relIsExact; // rel rect comes from the GUIView itself → near-equality match
|
||||
static float s_containerW, s_containerH; // container size, points
|
||||
static float s_relLeft, s_relTop, s_relRight, s_relBottom; // rect relative to container, points
|
||||
static IntPtr s_best;
|
||||
static long s_bestArea;
|
||||
static int s_candidateCount;
|
||||
static IntPtr s_firstCandidate;
|
||||
|
||||
// Flipped to false on pre-1607 Windows where GetDpiForWindow is missing;
|
||||
// per-monitor hypotheses are then skipped and behavior matches the legacy search.
|
||||
static bool s_dpiApiAvailable = true;
|
||||
|
||||
// Diagnostics: dump all candidates on the first failure and every 32nd
|
||||
// one after that, so a stuck editor produces a conclusive log without
|
||||
// flooding the console (retries run about twice per second).
|
||||
static int s_failCount;
|
||||
static StringBuilder s_diag;
|
||||
static IntPtr s_diagReflected;
|
||||
static string s_diagReflectedClass = "";
|
||||
|
||||
// Cached reflection lookups for hypothesis (c). Resolved once; any miss
|
||||
// simply disables that hypothesis.
|
||||
static bool s_reflectionResolved;
|
||||
static FieldInfo s_fiParent; // EditorWindow.m_Parent
|
||||
static PropertyInfo s_piWindow; // View.window → ContainerWindow
|
||||
static PropertyInfo s_piScreenPosition; // View.screenPosition (points)
|
||||
static PropertyInfo s_piContainerPosition; // ContainerWindow.position (points)
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────
|
||||
|
||||
public static IntPtr GetHWND(EditorWindow win)
|
||||
{
|
||||
if (win == null) return IntPtr.Zero;
|
||||
|
||||
try
|
||||
{
|
||||
// Strategy 1/2: reflected native handle, exact or as search scope.
|
||||
IntPtr searchRoot;
|
||||
IntPtr reflected = TryReflectedHandle(win, out searchRoot);
|
||||
if (reflected != IntPtr.Zero)
|
||||
{
|
||||
s_failCount = 0;
|
||||
return reflected;
|
||||
}
|
||||
|
||||
Rect pos = win.position;
|
||||
if (pos.width < 1f || pos.height < 1f) return IntPtr.Zero;
|
||||
|
||||
PrepareTargets(win, pos);
|
||||
|
||||
bool collectDiag = s_failCount == 0 || (s_failCount % 32) == 0;
|
||||
s_diag = collectDiag ? new StringBuilder() : null;
|
||||
|
||||
IntPtr found = IntPtr.Zero;
|
||||
if (searchRoot != IntPtr.Zero)
|
||||
{
|
||||
if (s_diag != null) s_diag.AppendLine($"-- scoped pass under root=0x{searchRoot.ToInt64():X} --");
|
||||
found = SearchScoped(searchRoot);
|
||||
}
|
||||
if (found == IntPtr.Zero)
|
||||
{
|
||||
if (s_diag != null) s_diag.AppendLine("-- global pass --");
|
||||
found = SearchGlobal();
|
||||
}
|
||||
|
||||
if (found != IntPtr.Zero)
|
||||
{
|
||||
s_failCount = 0;
|
||||
s_diag = null;
|
||||
return found;
|
||||
}
|
||||
|
||||
s_failCount++;
|
||||
if (s_diag != null)
|
||||
{
|
||||
LogDiagnostics(win, pos, searchRoot);
|
||||
s_diag = null;
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogWarning($"[NativeWindowHelper] GetHWND exception: {ex.Message}");
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strategy 1/2: reflection ───────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Read GUIView.nativeHandle through the shared reflection helper. If it
|
||||
/// is our process's UnityGUIViewWndClass window, return it. If it is a
|
||||
/// valid window of ours with a different class (some editor builds hand
|
||||
/// out the container window instead), expose its top-level ancestor via
|
||||
/// <paramref name="searchRoot"/> so the rect search can be scoped to the
|
||||
/// right container. Anything else (zero, foreign, not a window at all —
|
||||
/// e.g. a native object pointer) is ignored.
|
||||
/// </summary>
|
||||
static IntPtr TryReflectedHandle(EditorWindow win, out IntPtr searchRoot)
|
||||
{
|
||||
searchRoot = IntPtr.Zero;
|
||||
s_diagReflected = IntPtr.Zero;
|
||||
s_diagReflectedClass = "";
|
||||
try
|
||||
{
|
||||
IntPtr handle = EditorWindowNativeHandleHelper.GetGUIViewHandle(win);
|
||||
s_diagReflected = handle;
|
||||
if (handle == IntPtr.Zero || !IsWindow(handle)) return IntPtr.Zero;
|
||||
|
||||
GetWindowThreadProcessId(handle, out uint pid);
|
||||
if ((int)pid != s_currentPid) return IntPtr.Zero;
|
||||
|
||||
string cls = ClassNameOf(handle);
|
||||
s_diagReflectedClass = cls;
|
||||
if (cls == kGUIViewClass) return handle;
|
||||
|
||||
searchRoot = GetAncestor(handle, GA_ROOT);
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Reflection target changed shape or the native view is mid-teardown.
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strategy 3: rect search ────────────────────────────────────────
|
||||
|
||||
static IntPtr SearchScoped(IntPtr root)
|
||||
{
|
||||
ResetSearchState();
|
||||
EnumChildWindows(root, s_innerProc, IntPtr.Zero);
|
||||
if (s_best != IntPtr.Zero) return s_best;
|
||||
|
||||
// The scope IS the window's own container; if it hosts exactly one
|
||||
// GUIView there is nothing to disambiguate — take it even when no
|
||||
// coordinate hypothesis matched (typical floating panel).
|
||||
if (s_candidateCount == 1) return s_firstCandidate;
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
static IntPtr SearchGlobal()
|
||||
{
|
||||
ResetSearchState();
|
||||
EnumWindows(s_outerProc, IntPtr.Zero);
|
||||
return s_best;
|
||||
}
|
||||
|
||||
static void ResetSearchState()
|
||||
{
|
||||
s_best = IntPtr.Zero;
|
||||
s_bestArea = long.MaxValue;
|
||||
s_candidateCount = 0;
|
||||
s_firstCandidate = IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>Fill the per-call matching targets for all hypotheses.</summary>
|
||||
static void PrepareTargets(EditorWindow win, Rect pos)
|
||||
{
|
||||
Rect target = ScaleToPixels(pos);
|
||||
s_targetLeft = (int)target.x;
|
||||
s_targetTop = (int)target.y;
|
||||
s_targetRight = (int)(target.x + target.width);
|
||||
s_targetBottom = (int)(target.y + target.height);
|
||||
|
||||
s_targetPtLeft = pos.x;
|
||||
s_targetPtTop = pos.y;
|
||||
s_targetPtRight = pos.x + pos.width;
|
||||
s_targetPtBottom = pos.y + pos.height;
|
||||
|
||||
// Hypothesis (c): the window's rect relative to its ContainerWindow,
|
||||
// in points. Prefer the host GUIView's own screenPosition — it should
|
||||
// equal the candidate HWND's rect, allowing a near-equality match.
|
||||
s_haveContainer = false;
|
||||
s_relIsExact = false;
|
||||
try
|
||||
{
|
||||
ResolveReflection();
|
||||
object parent = s_fiParent?.GetValue(win);
|
||||
if (parent == null) return;
|
||||
|
||||
object container = s_piWindow?.GetValue(parent, null);
|
||||
if (container == null || s_piContainerPosition == null) return;
|
||||
|
||||
Rect containerPos = (Rect)s_piContainerPosition.GetValue(container, null);
|
||||
if (containerPos.width < 1f || containerPos.height < 1f) return;
|
||||
|
||||
Rect basis = pos;
|
||||
if (s_piScreenPosition != null)
|
||||
{
|
||||
Rect sp = (Rect)s_piScreenPosition.GetValue(parent, null);
|
||||
if (sp.width >= 1f && sp.height >= 1f)
|
||||
{
|
||||
basis = sp;
|
||||
s_relIsExact = true;
|
||||
}
|
||||
}
|
||||
|
||||
s_containerW = containerPos.width;
|
||||
s_containerH = containerPos.height;
|
||||
s_relLeft = basis.x - containerPos.x;
|
||||
s_relTop = basis.y - containerPos.y;
|
||||
s_relRight = s_relLeft + basis.width;
|
||||
s_relBottom = s_relTop + basis.height;
|
||||
s_haveContainer = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
s_haveContainer = false;
|
||||
}
|
||||
}
|
||||
|
||||
static void ResolveReflection()
|
||||
{
|
||||
if (s_reflectionResolved) return;
|
||||
s_reflectionResolved = true;
|
||||
try
|
||||
{
|
||||
const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
|
||||
s_fiParent = typeof(EditorWindow).GetField("m_Parent", flags);
|
||||
|
||||
Type viewType = s_fiParent?.FieldType; // HostView (derives from GUIView/View)
|
||||
s_piWindow = viewType?.GetProperty("window", flags);
|
||||
s_piScreenPosition = viewType?.GetProperty("screenPosition", flags);
|
||||
|
||||
Type containerType = s_piWindow?.PropertyType; // ContainerWindow
|
||||
s_piContainerPosition = containerType?.GetProperty("position", flags);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Editor internals changed shape; hypothesis (c) stays disabled.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internals ──────────────────────────────────────────────────────
|
||||
|
||||
// EditorWindow.position is in DPI-scaled "points"; Win32 GetWindowRect
|
||||
// returns physical pixels. Convert before comparing. Note this uses the
|
||||
// CALLER's pixelsPerPoint, which only matches the window's monitor when
|
||||
// both share a DPI scale — the other hypotheses cover the rest.
|
||||
static Rect ScaleToPixels(Rect r)
|
||||
{
|
||||
float dpi;
|
||||
try { dpi = EditorGUIUtility.pixelsPerPoint; }
|
||||
catch { dpi = 1f; }
|
||||
if (dpi <= 0f) dpi = 1f;
|
||||
return new Rect(r.x * dpi, r.y * dpi, r.width * dpi, r.height * dpi);
|
||||
}
|
||||
|
||||
static string ClassNameOf(IntPtr hwnd)
|
||||
{
|
||||
int clen = GetClassNameW(hwnd, s_classBuf, s_classBuf.Length);
|
||||
return clen > 0 ? new string(s_classBuf, 0, clen) : "";
|
||||
}
|
||||
|
||||
[AOT.MonoPInvokeCallback(typeof(EnumWindowsProc))]
|
||||
static bool OuterEnumProc(IntPtr topHwnd, IntPtr param)
|
||||
{
|
||||
try
|
||||
{
|
||||
GetWindowThreadProcessId(topHwnd, out uint pid);
|
||||
if ((int)pid != s_currentPid) return true;
|
||||
EnumChildWindows(topHwnd, s_innerProc, IntPtr.Zero);
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
[AOT.MonoPInvokeCallback(typeof(EnumWindowsProc))]
|
||||
static bool InnerEnumProc(IntPtr childHwnd, IntPtr param)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ClassNameOf(childHwnd) != kGUIViewClass) return true;
|
||||
|
||||
if (!GetWindowRect(childHwnd, out RECT wr)) return true;
|
||||
|
||||
s_candidateCount++;
|
||||
if (s_candidateCount == 1) s_firstCandidate = childHwnd;
|
||||
|
||||
float scale = s_dpiApiAvailable ? GetWindowScale(childHwnd) : 0f;
|
||||
|
||||
// (a) physical-pixel comparison with the caller-context scale
|
||||
bool a =
|
||||
wr.Left - Slack <= s_targetLeft &&
|
||||
wr.Top - Slack <= s_targetTop &&
|
||||
wr.Right + Slack >= s_targetRight &&
|
||||
wr.Bottom + Slack >= s_targetBottom;
|
||||
|
||||
// (b) point-space comparison with the candidate's own monitor scale
|
||||
bool b = false;
|
||||
if (!a && scale > 0f)
|
||||
{
|
||||
b =
|
||||
wr.Left / scale - Slack <= s_targetPtLeft &&
|
||||
wr.Top / scale - Slack <= s_targetPtTop &&
|
||||
wr.Right / scale + Slack >= s_targetPtRight &&
|
||||
wr.Bottom / scale + Slack >= s_targetPtBottom;
|
||||
}
|
||||
|
||||
// (d) like (b) but anchored at the candidate's monitor origin
|
||||
bool d = false;
|
||||
if (!a && !b && scale > 0f)
|
||||
{
|
||||
var mi = new MONITORINFO { cbSize = Marshal.SizeOf(typeof(MONITORINFO)) };
|
||||
IntPtr hmon = MonitorFromWindow(childHwnd, MONITOR_DEFAULTTONEAREST);
|
||||
if (hmon != IntPtr.Zero && GetMonitorInfoW(hmon, ref mi))
|
||||
{
|
||||
float mx = mi.rcMonitor.Left, my = mi.rcMonitor.Top;
|
||||
d =
|
||||
mx + (wr.Left - mx) / scale - Slack <= s_targetPtLeft &&
|
||||
my + (wr.Top - my) / scale - Slack <= s_targetPtTop &&
|
||||
mx + (wr.Right - mx) / scale + Slack >= s_targetPtRight &&
|
||||
my + (wr.Bottom - my) / scale + Slack >= s_targetPtBottom;
|
||||
}
|
||||
}
|
||||
|
||||
// (c) container-relative comparison — mapping-independent
|
||||
bool c = false;
|
||||
if (!a && !b && !d && scale > 0f && s_haveContainer)
|
||||
{
|
||||
c = MatchesContainerRelative(childHwnd, wr, scale);
|
||||
}
|
||||
|
||||
if (s_diag != null)
|
||||
{
|
||||
// Extra geometry so a single failed dump reconstructs the mapping.
|
||||
string monStr = "?", rootStr = "?";
|
||||
var dmi = new MONITORINFO { cbSize = Marshal.SizeOf(typeof(MONITORINFO)) };
|
||||
IntPtr dmon = MonitorFromWindow(childHwnd, MONITOR_DEFAULTTONEAREST);
|
||||
if (dmon != IntPtr.Zero && GetMonitorInfoW(dmon, ref dmi))
|
||||
monStr = $"({dmi.rcMonitor.Left},{dmi.rcMonitor.Top})";
|
||||
IntPtr droot = GetAncestor(childHwnd, GA_ROOT);
|
||||
if (droot != IntPtr.Zero && GetClientRect(droot, out RECT drc))
|
||||
{
|
||||
var dorigin = new POINT();
|
||||
if (ClientToScreen(droot, ref dorigin))
|
||||
rootStr = $"0x{droot.ToInt64():X}@({dorigin.X},{dorigin.Y}) client={drc.Right}x{drc.Bottom}";
|
||||
}
|
||||
s_diag.AppendLine(
|
||||
$" cand=0x{childHwnd.ToInt64():X} phys=({wr.Left},{wr.Top})-({wr.Right},{wr.Bottom}) " +
|
||||
$"scale={scale:F2} mon={monStr} root={rootStr} a={a} b={b} d={d} c={c}");
|
||||
}
|
||||
|
||||
if (!(a || b || d || c)) return true;
|
||||
|
||||
long area = (long)wr.Width * wr.Height;
|
||||
if (area < s_bestArea)
|
||||
{
|
||||
s_bestArea = area;
|
||||
s_best = childHwnd;
|
||||
}
|
||||
}
|
||||
catch { /* window may have closed mid-enumeration */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool MatchesContainerRelative(IntPtr childHwnd, RECT wr, float scale)
|
||||
{
|
||||
IntPtr root = GetAncestor(childHwnd, GA_ROOT);
|
||||
if (root == IntPtr.Zero) return false;
|
||||
if (!GetClientRect(root, out RECT rc)) return false;
|
||||
var origin = new POINT();
|
||||
if (!ClientToScreen(root, ref origin)) return false;
|
||||
|
||||
// The right top-level window has (about) the ContainerWindow's size.
|
||||
// Unity's ContainerWindow.position may describe the client area or the
|
||||
// full frame depending on chrome — accept either within slack.
|
||||
float clientW = rc.Right / scale, clientH = rc.Bottom / scale;
|
||||
bool sizeOk =
|
||||
Math.Abs(clientW - s_containerW) <= Slack &&
|
||||
Math.Abs(clientH - s_containerH) <= Slack;
|
||||
if (!sizeOk && GetWindowRect(root, out RECT rwr))
|
||||
{
|
||||
sizeOk =
|
||||
Math.Abs(rwr.Width / scale - s_containerW) <= Slack &&
|
||||
Math.Abs(rwr.Height / scale - s_containerH) <= Slack;
|
||||
}
|
||||
if (!sizeOk) return false;
|
||||
|
||||
float relLeft = (wr.Left - origin.X) / scale;
|
||||
float relTop = (wr.Top - origin.Y) / scale;
|
||||
float relRight = (wr.Right - origin.X) / scale;
|
||||
float relBottom = (wr.Bottom - origin.Y) / scale;
|
||||
|
||||
if (s_relIsExact)
|
||||
{
|
||||
// Comparing the GUIView's own rect — edges should line up.
|
||||
return
|
||||
Math.Abs(relLeft - s_relLeft) <= Slack &&
|
||||
Math.Abs(relTop - s_relTop) <= Slack &&
|
||||
Math.Abs(relRight - s_relRight) <= Slack &&
|
||||
Math.Abs(relBottom - s_relBottom) <= Slack;
|
||||
}
|
||||
|
||||
// Comparing the hosted EditorWindow content rect — the GUIView contains it.
|
||||
return
|
||||
relLeft - Slack <= s_relLeft &&
|
||||
relTop - Slack <= s_relTop &&
|
||||
relRight + Slack >= s_relRight &&
|
||||
relBottom + Slack >= s_relBottom;
|
||||
}
|
||||
|
||||
static float GetWindowScale(IntPtr hwnd)
|
||||
{
|
||||
try
|
||||
{
|
||||
uint dpi = GetDpiForWindow(hwnd);
|
||||
return dpi > 0 ? dpi / 96f : 0f;
|
||||
}
|
||||
catch (EntryPointNotFoundException)
|
||||
{
|
||||
s_dpiApiAvailable = false;
|
||||
return 0f;
|
||||
}
|
||||
}
|
||||
|
||||
static void LogDiagnostics(EditorWindow win, Rect pos, IntPtr searchRoot)
|
||||
{
|
||||
try
|
||||
{
|
||||
float ppp;
|
||||
try { ppp = EditorGUIUtility.pixelsPerPoint; } catch { ppp = -1f; }
|
||||
|
||||
var header = new StringBuilder();
|
||||
header.AppendLine($"[NativeWindowHelper] diagnose (fail #{s_failCount}): no GUIView matched {win.GetType().Name}");
|
||||
header.AppendLine($" win.position=({pos.x:F1},{pos.y:F1},{pos.width:F1},{pos.height:F1}) pixelsPerPoint={ppp:F2} dpiApi={s_dpiApiAvailable}");
|
||||
header.AppendLine($" reflected=0x{s_diagReflected.ToInt64():X} class='{s_diagReflectedClass}' searchRoot=0x{searchRoot.ToInt64():X}");
|
||||
header.AppendLine(s_haveContainer
|
||||
? $" container={s_containerW:F1}x{s_containerH:F1} rel=({s_relLeft:F1},{s_relTop:F1})-({s_relRight:F1},{s_relBottom:F1}) exact={s_relIsExact}"
|
||||
: " container=<unavailable>");
|
||||
header.Append(s_diag);
|
||||
CodelyLogger.LogWarning(header.ToString());
|
||||
}
|
||||
catch { /* diagnostics must never break the lookup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2b3cb472569f81e45a33db606ae9f580
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,453 @@
|
||||
#if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
using UnityTcp.Editor.Helpers;
|
||||
namespace Cn.Tuanjie.Codely.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared static utilities used by both <see cref="CodelyWindow"/> and <see cref="DrillWindow"/>.
|
||||
/// Centralises SessionState keys, port management, lock-file resolution, and executable discovery.
|
||||
/// </summary>
|
||||
internal static class TauriUtils
|
||||
{
|
||||
#region SessionState Keys & Port Constants
|
||||
|
||||
/// <summary>SessionState key for the running Tauri process PID.</summary>
|
||||
internal const string SESSION_STATE_TAURI_PID_KEY = "Codely_TauriPID";
|
||||
|
||||
/// <summary>SessionState key for the running Tauri HTTP server port.</summary>
|
||||
internal const string SESSION_STATE_TAURI_PORT_KEY = "Codely_TauriPort";
|
||||
|
||||
internal const int DEFAULT_TAURI_PORT = 31415;
|
||||
internal const int MAX_PORT_ATTEMPTS = 100;
|
||||
|
||||
internal const string TAURI_ICONS_BASE_PATH = "Packages/cn.tuanjie.codely.bridge/Editor/Tauri/Icons/";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Automation / Headless Detection
|
||||
|
||||
/// <summary>
|
||||
/// True when the editor is running unattended — batch mode or with a null graphics device
|
||||
/// (CI / QA automation harnesses). The auto-open windows must stay closed in these
|
||||
/// environments: creating an <see cref="UnityEditor.EditorWindow"/> forces native window +
|
||||
/// graphics-surface creation, which fails with a <c>SUCCEEDED(hr)</c> native assertion that
|
||||
/// flips otherwise-passing automated test runs to an overall FAIL.
|
||||
/// </summary>
|
||||
internal static bool IsAutomatedEditorRun()
|
||||
=> Application.isBatchMode
|
||||
|| SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Workspace & Lock-File Utilities
|
||||
|
||||
/// <summary>Returns the Unity project root directory (one level above Application.dataPath).</summary>
|
||||
internal static string GetWorkspaceDirectory()
|
||||
=> Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath, ".."));
|
||||
|
||||
/// <summary>
|
||||
/// Returns the per-project lock directory: <c>~/.codely/projects/<sha256_16hex></c>.
|
||||
/// The hash algorithm matches the Rust <c>lock_dir_for_workspace</c> function:
|
||||
/// SHA256 of the project path with forward slashes, first 16 hex chars.
|
||||
/// </summary>
|
||||
internal static string GetLockDirForWorkspace(string workspaceDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (string.IsNullOrEmpty(home)) return null;
|
||||
|
||||
// Forward-slash normalisation is required so the hash is identical across platforms.
|
||||
string projectPath = Path.GetFullPath(workspaceDir).Replace('\\', '/');
|
||||
using (var sha256 = SHA256.Create())
|
||||
{
|
||||
byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(projectPath));
|
||||
string hashHex = BitConverter.ToString(hashBytes).Replace("-", "").Substring(0, 16).ToLowerInvariant();
|
||||
return Path.Combine(home, ".codely", "projects", hashHex);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogWarning($"Codely: GetLockDirForWorkspace failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the global app meta file path: <c>~/.codely/app.lock.meta.json</c>.
|
||||
/// Matches the Rust <c>write_global_app_meta</c> location. The file is
|
||||
/// written by any running single-instance APP so external processes
|
||||
/// can discover the APP's HTTP port.
|
||||
/// </summary>
|
||||
internal static string GetGlobalAppMetaPath()
|
||||
{
|
||||
try
|
||||
{
|
||||
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (string.IsNullOrEmpty(home)) return null;
|
||||
return Path.Combine(home, ".codely", "app.lock.meta.json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogWarning($"Codely: GetGlobalAppMetaPath failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>~/.codely/data.json</c> and returns whether the drill walkthrough
|
||||
/// has been completed. Missing file or unparseable JSON returns <c>false</c>
|
||||
/// (treat as not-yet-completed so the walkthrough can open); read errors return
|
||||
/// <c>true</c> so we fall back to the main window instead of looping on a broken file.
|
||||
/// </summary>
|
||||
internal static bool IsDrillCompleted()
|
||||
{
|
||||
try
|
||||
{
|
||||
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
string dataPath = Path.Combine(home, ".codely", "data.json");
|
||||
if (!File.Exists(dataPath))
|
||||
return false;
|
||||
string json = File.ReadAllText(dataPath);
|
||||
var match = Regex.Match(json, "\"drillCompleted\"\\s*:\\s*(true|false)");
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Groups[1].Value == "true";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogWarning($"Codely: Failed to read drill status from data.json: {ex.Message}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Executable Discovery
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the <c>cowork</c> / <c>cowork.exe</c> runnable path.
|
||||
/// Reads <c>CODELY_APP_HOME</c> from the process environment or OS-level store, then
|
||||
/// falls back to the default installation location. Returns <c>null</c> if not found.
|
||||
/// </summary>
|
||||
internal static string GetCodelyRunnable()
|
||||
{
|
||||
// Read CODELY_APP_HOME from the OS-level store (registry / login shell) every call
|
||||
// so the Editor picks up installer updates without a restart.
|
||||
string home = ReadOsEnvVar("CODELY_APP_HOME");
|
||||
|
||||
#if UNITY_EDITOR_WIN
|
||||
if (!string.IsNullOrEmpty(home) && !File.Exists(Path.Combine(home, "cowork.exe")))
|
||||
{
|
||||
CodelyLogger.LogWarning($"Codely: CODELY_APP_HOME='{home}' does not contain cowork.exe, falling back to default install location.");
|
||||
home = null;
|
||||
}
|
||||
if (string.IsNullOrEmpty(home))
|
||||
{
|
||||
string tuanjieHome = Environment.ExpandEnvironmentVariables(@"%LOCALAPPDATA%\programs\Tuanjie Cowork");
|
||||
string codelyHome = Environment.ExpandEnvironmentVariables(@"%LOCALAPPDATA%\programs\Codely Cowork");
|
||||
home = File.Exists(Path.Combine(tuanjieHome, "cowork.exe")) ? tuanjieHome : codelyHome;
|
||||
}
|
||||
|
||||
string exePath = Path.Combine(home, "cowork.exe");
|
||||
#elif UNITY_EDITOR_OSX
|
||||
if (!string.IsNullOrEmpty(home) && !File.Exists(Path.Combine(home, "cowork")))
|
||||
{
|
||||
CodelyLogger.LogWarning($"Codely: CODELY_APP_HOME='{home}' does not contain cowork, falling back to default install location.");
|
||||
home = null;
|
||||
}
|
||||
if (string.IsNullOrEmpty(home))
|
||||
{
|
||||
const string tuanjieHome = "/Applications/Tuanjie Cowork.app/Contents/MacOS";
|
||||
const string codelyHome = "/Applications/Codely Cowork.app/Contents/MacOS";
|
||||
home = File.Exists(Path.Combine(tuanjieHome, "cowork")) ? tuanjieHome : codelyHome;
|
||||
}
|
||||
|
||||
string exePath = Path.Combine(home, "cowork");
|
||||
#else
|
||||
if (string.IsNullOrEmpty(home)) return null;
|
||||
string exePath = Path.Combine(home, "cowork");
|
||||
#endif
|
||||
|
||||
if (!File.Exists(exePath))
|
||||
{
|
||||
CodelyLogger.LogError($"Codely: cowork runnable not found at '{exePath}'. Make sure cowork is installed.");
|
||||
return null;
|
||||
}
|
||||
return Path.GetFullPath(exePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the <c>codely</c> / <c>codely.exe</c> CLI path. Mirrors
|
||||
/// <see cref="GetCodelyRunnable"/>'s OS-level env-var reading so installer updates take
|
||||
/// effect without restarting the Editor:
|
||||
/// <list type="number">
|
||||
/// <item><c>CODELY_HOME</c>: look directly for <c>codely[.exe]</c> at that root.</item>
|
||||
/// <item><c>CODELY_APP_HOME</c>: look under <c>cli\bin\win32-x64</c> (Windows) or
|
||||
/// <c>cli/bin/darwin-arm64</c> / <c>cli/bin/darwin-x64</c> (macOS).</item>
|
||||
/// <item>Fallback to the default Tuanjie Cowork / Codely Cowork install dir with the
|
||||
/// same cli subpath.</item>
|
||||
/// </list>
|
||||
/// Returns <c>null</c> if none of these resolve.
|
||||
/// </summary>
|
||||
internal static string GetCodelyCliRunnable()
|
||||
{
|
||||
#if UNITY_EDITOR_WIN
|
||||
const string cliExe = "codely.exe";
|
||||
string[] cliSubPaths = { @"cli\bin\win32-x64" };
|
||||
string[] fallbackRoots =
|
||||
{
|
||||
Environment.ExpandEnvironmentVariables(@"%LOCALAPPDATA%\programs\Tuanjie Cowork"),
|
||||
Environment.ExpandEnvironmentVariables(@"%LOCALAPPDATA%\programs\Codely Cowork"),
|
||||
};
|
||||
#elif UNITY_EDITOR_OSX
|
||||
const string cliExe = "codely";
|
||||
// Apple Silicon first, then x64 — try both since the Editor's process arch is not
|
||||
// always a reliable indicator of which CLI binary the user has installed.
|
||||
string[] cliSubPaths = { "cli/bin/darwin-arm64", "cli/bin/darwin-x64" };
|
||||
string[] fallbackRoots =
|
||||
{
|
||||
"/Applications/Tuanjie Cowork.app/Contents/MacOS",
|
||||
"/Applications/Codely Cowork.app/Contents/MacOS",
|
||||
};
|
||||
#else
|
||||
return null;
|
||||
#endif
|
||||
|
||||
// 1) CODELY_HOME — direct codely[.exe] at the root.
|
||||
string codelyHome = ReadOsEnvVar("CODELY_HOME");
|
||||
if (!string.IsNullOrEmpty(codelyHome))
|
||||
{
|
||||
string direct = Path.Combine(codelyHome, cliExe);
|
||||
if (File.Exists(direct)) return Path.GetFullPath(direct);
|
||||
CodelyLogger.LogWarning($"Codely: CODELY_HOME='{codelyHome}' does not contain {cliExe}, falling back to CODELY_APP_HOME.");
|
||||
}
|
||||
|
||||
// 2) CODELY_APP_HOME + cli subpath.
|
||||
string appHome = ReadOsEnvVar("CODELY_APP_HOME");
|
||||
if (!string.IsNullOrEmpty(appHome))
|
||||
{
|
||||
string found = TryFindCli(appHome, cliSubPaths, cliExe);
|
||||
if (!string.IsNullOrEmpty(found)) return found;
|
||||
CodelyLogger.LogWarning($"Codely: CODELY_APP_HOME='{appHome}' does not contain {cliExe} under the expected cli/bin path, falling back to default install location.");
|
||||
}
|
||||
|
||||
// 3) Fallback: default install locations (Tuanjie Cowork preferred, then Codely Cowork).
|
||||
foreach (var root in fallbackRoots)
|
||||
{
|
||||
string found = TryFindCli(root, cliSubPaths, cliExe);
|
||||
if (!string.IsNullOrEmpty(found)) return found;
|
||||
}
|
||||
|
||||
CodelyLogger.LogError("Codely: codely CLI runnable not found. Make sure Codely is installed.");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string TryFindCli(string root, string[] subPaths, string exeName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(root)) return null;
|
||||
foreach (var sub in subPaths)
|
||||
{
|
||||
string candidate = Path.Combine(root, sub, exeName);
|
||||
if (File.Exists(candidate)) return Path.GetFullPath(candidate);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an environment variable from the OS-level store (Windows registry HKCU/HKLM
|
||||
/// Environment, or macOS login shell), bypassing the Unity Editor process's cached env.
|
||||
/// Returns <c>null</c> if not set or unavailable. Windows values are
|
||||
/// <see cref="Environment.ExpandEnvironmentVariables"/>-expanded before return.
|
||||
/// </summary>
|
||||
private static string ReadOsEnvVar(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
#if UNITY_EDITOR_WIN
|
||||
var registryLocations = new (Microsoft.Win32.RegistryKey root, string subKey)[]
|
||||
{
|
||||
(Microsoft.Win32.Registry.CurrentUser, @"Environment"),
|
||||
(Microsoft.Win32.Registry.LocalMachine, @"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
|
||||
};
|
||||
foreach (var (root, subKey) in registryLocations)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = root.OpenSubKey(subKey, writable: false))
|
||||
{
|
||||
string value = key?.GetValue(name, null,
|
||||
Microsoft.Win32.RegistryValueOptions.DoNotExpandEnvironmentNames) as string;
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
return Environment.ExpandEnvironmentVariables(value);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogWarning($"Codely: Failed to read {name} from registry ({subKey}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
#elif UNITY_EDITOR_OSX
|
||||
try
|
||||
{
|
||||
string shell = Environment.GetEnvironmentVariable("SHELL");
|
||||
if (string.IsNullOrEmpty(shell)) shell = "/bin/zsh";
|
||||
for (int pass = 0; pass < 2; pass++)
|
||||
{
|
||||
string flags = pass == 0 ? "-l" : "-i -l";
|
||||
var psi = new ProcessStartInfo(shell, $"{flags} -c \"echo ${name}\"")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
using (var proc = Process.Start(psi))
|
||||
{
|
||||
string value = proc?.StandardOutput.ReadLine()?.Trim();
|
||||
proc?.WaitForExit(3000);
|
||||
if (!string.IsNullOrEmpty(value)) return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogWarning($"Codely: Failed to read {name} from login shell: {ex.Message}");
|
||||
}
|
||||
return null;
|
||||
#else
|
||||
return null;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>"tuanjie"</c> if the project uses the Tuanjie engine, otherwise <c>"unity"</c>.
|
||||
/// </summary>
|
||||
internal static string GetEditorType()
|
||||
{
|
||||
try
|
||||
{
|
||||
string versionTxt = Path.GetFullPath(Path.Combine(
|
||||
UnityEngine.Application.dataPath, "..", "ProjectSettings", "ProjectVersion.txt"));
|
||||
if (!File.Exists(versionTxt))
|
||||
return "unity";
|
||||
string content = File.ReadAllText(versionTxt);
|
||||
// m_TuanjieEditorVersion is the primary signal; m_EditorVersion with a
|
||||
// 't' suffix (e.g. 2022.3.62t1) is the fallback for Tuanjie projects
|
||||
// that don't yet write the dedicated field.
|
||||
if (content.Contains("m_TuanjieEditorVersion"))
|
||||
return "tuanjie";
|
||||
var match = Regex.Match(content, @"m_EditorVersion:\s*\d+\.\d+\.\d+(\w+)");
|
||||
if (match.Success && match.Groups[1].Value.Contains("t"))
|
||||
return "tuanjie";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CodelyLogger.LogError($"Codely: Error reading ProjectVersion.txt: {ex.Message}");
|
||||
}
|
||||
return "unity";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Port Utilities
|
||||
|
||||
internal static bool IsPortAvailable(int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, port);
|
||||
listener.Start();
|
||||
listener.Stop();
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
internal static int FindAvailablePort()
|
||||
{
|
||||
for (int port = DEFAULT_TAURI_PORT; port < DEFAULT_TAURI_PORT + MAX_PORT_ATTEMPTS; port++)
|
||||
{
|
||||
if (IsPortAvailable(port)) return port;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes inherited pkg bootstrap variables before launching cowork/Tauri.
|
||||
/// If these variables leak from the editor process into cowork/core, core
|
||||
/// startup can fail inside the editor because pkg mis-detects its entrypoint.
|
||||
/// </summary>
|
||||
internal static void SanitizePkgEnvironment(ProcessStartInfo startInfo)
|
||||
{
|
||||
if (startInfo == null || startInfo.UseShellExecute)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
startInfo.EnvironmentVariables.Remove("PKG_EXECPATH");
|
||||
startInfo.EnvironmentVariables.Remove("PKG_INVOKE_NODEJS");
|
||||
startInfo.EnvironmentVariables.Remove("PKG_INVOKE_ENTRYPOINT");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region File & JSON Utilities
|
||||
|
||||
/// <summary>
|
||||
/// Reads a text file with retry on <see cref="IOException"/>.
|
||||
/// Returns <c>null</c> on permanent failure or if the file does not exist.
|
||||
/// </summary>
|
||||
internal static string TryReadFileWithRetry(string filePath, int retryCount, int retryDelayMs)
|
||||
{
|
||||
for (int attempt = 1; attempt <= retryCount; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(filePath)) return null;
|
||||
return File.ReadAllText(filePath);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
if (attempt >= retryCount)
|
||||
{
|
||||
CodelyLogger.LogWarning($"Codely: Failed to read file after {retryCount} attempts. path={filePath}, error={ex.Message}");
|
||||
return null;
|
||||
}
|
||||
Thread.Sleep(retryDelayMs);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
CodelyLogger.LogWarning($"Codely: No permission to read file. path={filePath}, error={ex.Message}");
|
||||
return null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts an integer field from a simple flat JSON string using regex.
|
||||
/// Returns <c>-1</c> if the field is absent or cannot be parsed.
|
||||
/// </summary>
|
||||
internal static int ExtractIntFromJson(string json, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(fieldName)) return -1;
|
||||
var match = Regex.Match(json, $"\"{Regex.Escape(fieldName)}\"\\s*:\\s*(\\d+)");
|
||||
return match.Success && int.TryParse(match.Groups[1].Value, out int v) ? v : -1;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9f079a534247b4b78b376a943f72095f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
#if UNITY_EDITOR_WIN
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using UnityEngine;
|
||||
using static Cn.Tuanjie.Codely.Editor.NativeBrowserAPI;
|
||||
|
||||
using UnityTcp.Editor.Helpers;
|
||||
namespace Cn.Tuanjie.Codely.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// WebView2 嵌入式浏览器宿主(Windows)。
|
||||
///
|
||||
/// 所有线程安全、STA 消息泵、WebView2 COM 生命周期均由 NativeBrowser.dll 内部处理,
|
||||
/// 本类只负责 P/Invoke 调用和委托 GC 保护。
|
||||
/// </summary>
|
||||
internal sealed class WebView2Host : IDisposable
|
||||
{
|
||||
IntPtr m_handle;
|
||||
|
||||
// 委托实例必须持有强引用,否则 GC 回收后 native 调用函数指针会崩溃
|
||||
NavigationCallback m_onNav;
|
||||
LoadCompletedCallback m_onLoad;
|
||||
TitleChangedCallback m_onTitle;
|
||||
MessageCallback m_onMessage;
|
||||
DestroyedCallback m_onDestroyed;
|
||||
|
||||
public event Action OnParentDestroyed;
|
||||
public event Action<string> OnTitleChanged;
|
||||
public event Action<string> OnWebMessageReceived;
|
||||
public event Action<bool> OnLoadCompleted;
|
||||
public event Action<string> OnNavigationStarting;
|
||||
|
||||
public bool IsReady => m_handle != IntPtr.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// 原生 BrowserInstance 指针,用于序列化跨 Domain Reload 保活。
|
||||
/// </summary>
|
||||
public IntPtr Handle => m_handle;
|
||||
|
||||
// ── Domain Reload 恢复 ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 从序列化的原生句柄恢复,复用已存活的 BrowserInstance。
|
||||
/// 替换旧的(已失效的)托管回调指针为新委托,使 WebView2 继续正常工作。
|
||||
/// </summary>
|
||||
/// <returns>恢复成功返回 true;handle 无效或已被销毁返回 false。</returns>
|
||||
public bool RestoreFromHandle(IntPtr handle)
|
||||
{
|
||||
if (handle == IntPtr.Zero) return false;
|
||||
if (!NB_IsHandleValid(handle)) return false;
|
||||
|
||||
m_handle = handle;
|
||||
|
||||
// 重新构造委托实例(旧域的委托已失效,必须在新域中重新创建)
|
||||
m_onNav = (url, _) => OnNavigationStarting?.Invoke(url);
|
||||
m_onLoad = (ok, _) => OnLoadCompleted?.Invoke(ok);
|
||||
m_onTitle = (t, _) => OnTitleChanged?.Invoke(t);
|
||||
m_onMessage = (json, _) => OnWebMessageReceived?.Invoke(json);
|
||||
m_onDestroyed = (_) => OnParentDestroyed?.Invoke();
|
||||
|
||||
var cb = new Callbacks
|
||||
{
|
||||
onNavigationStarting = m_onNav,
|
||||
onLoadCompleted = m_onLoad,
|
||||
onTitleChanged = m_onTitle,
|
||||
onWebMessageReceived = m_onMessage,
|
||||
onParentDestroyed = m_onDestroyed,
|
||||
ctx = IntPtr.Zero,
|
||||
};
|
||||
|
||||
NB_SetCallbacks(handle, ref cb);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 初始化(同步返回,WebView2 在 native STA 线程异步初始化)──────────
|
||||
|
||||
public void Init(IntPtr parentHwnd, string initialUrl = "about:blank")
|
||||
{
|
||||
if (!NB_IsRuntimeAvailable())
|
||||
throw new InvalidOperationException(
|
||||
"[WebView2Host] WebView2 Runtime not installed. " +
|
||||
"Please install Microsoft Edge or WebView2 Evergreen Runtime.");
|
||||
|
||||
string version = Marshal.PtrToStringUni(NB_GetRuntimeVersion());
|
||||
CodelyLogger.Log($"[WebView2Host] Runtime version: {version}");
|
||||
|
||||
string dataFolder = Path.Combine(
|
||||
Application.temporaryCachePath,
|
||||
"WebView2_" + parentHwnd.ToInt64().ToString("X"))
|
||||
.Replace('/', '\\');
|
||||
|
||||
// 构造回调结构,成员委托持有到 Dispose
|
||||
m_onNav = (url, _) => OnNavigationStarting?.Invoke(url);
|
||||
m_onLoad = (ok, _) => OnLoadCompleted?.Invoke(ok);
|
||||
m_onTitle = (t, _) => OnTitleChanged?.Invoke(t);
|
||||
m_onMessage = (json, _) => OnWebMessageReceived?.Invoke(json);
|
||||
m_onDestroyed = (_) => OnParentDestroyed?.Invoke();
|
||||
|
||||
var cb = new Callbacks
|
||||
{
|
||||
onNavigationStarting = m_onNav,
|
||||
onLoadCompleted = m_onLoad,
|
||||
onTitleChanged = m_onTitle,
|
||||
onWebMessageReceived = m_onMessage,
|
||||
onParentDestroyed = m_onDestroyed,
|
||||
ctx = IntPtr.Zero,
|
||||
};
|
||||
|
||||
m_handle = NB_Create(parentHwnd, dataFolder, initialUrl, ref cb);
|
||||
|
||||
if (m_handle == IntPtr.Zero)
|
||||
throw new Exception("[WebView2Host] NB_Create failed (check native logs)");
|
||||
|
||||
CodelyLogger.Log($"[WebView2Host] Created, handle=0x{m_handle:X}, url={initialUrl}");
|
||||
}
|
||||
|
||||
// ── 公开 API(线程安全,native 内部转发到 STA 线程)──────────────────
|
||||
|
||||
public void Navigate(string url)
|
||||
{
|
||||
if (IsReady && !string.IsNullOrEmpty(url))
|
||||
NB_Navigate(m_handle, url);
|
||||
}
|
||||
|
||||
public void GoBack() { if (IsReady) NB_GoBack(m_handle); }
|
||||
public void GoForward() { if (IsReady) NB_GoForward(m_handle); }
|
||||
public void Reload() { if (IsReady) NB_Reload(m_handle); }
|
||||
|
||||
public void ExecuteScript(string js)
|
||||
{
|
||||
if (IsReady && !string.IsNullOrEmpty(js))
|
||||
NB_ExecuteScript(m_handle, js);
|
||||
}
|
||||
|
||||
public void PostWebMessage(string json)
|
||||
{
|
||||
if (IsReady && !string.IsNullOrEmpty(json))
|
||||
NB_PostWebMessage(m_handle, json);
|
||||
}
|
||||
|
||||
public void SetTopInset(int topPx)
|
||||
{
|
||||
if (IsReady) NB_SetTopInset(m_handle, topPx);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set WebView bounds in parent client coordinates (pixels).
|
||||
/// Use this to match the exact content area from Unity layout (embedRect).
|
||||
/// </summary>
|
||||
public void Resize(int x, int y, int w, int h)
|
||||
{
|
||||
if (IsReady && w > 0 && h > 0) NB_Resize(m_handle, x, y, w, h);
|
||||
}
|
||||
|
||||
public void SetVisible(bool visible)
|
||||
{
|
||||
if (IsReady) NB_SetVisible(m_handle, visible);
|
||||
}
|
||||
|
||||
public void ShowDevTools()
|
||||
{
|
||||
if (IsReady) NB_OpenDevTools(m_handle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将键盘焦点移入 WebView2 内容。嵌入 Unity GUIView 后,导航完成时父窗口仍持有
|
||||
/// Win32 焦点,HTML 的 autofocus / element.focus() 不会显示光标也收不到键盘输入;
|
||||
/// 调用本方法把焦点交给内容(等价于浏览器中窗口被激活时内容自动获得焦点)。
|
||||
/// </summary>
|
||||
public void MoveFocus()
|
||||
{
|
||||
if (IsReady) NB_MoveFocus(m_handle);
|
||||
}
|
||||
|
||||
// ── 清理 ──────────────────────────────────────────────────────────────
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (m_handle != IntPtr.Zero)
|
||||
{
|
||||
NB_Destroy(m_handle);
|
||||
m_handle = IntPtr.Zero;
|
||||
}
|
||||
// 清空委托引用,让 GC 可以回收(各类型不同,不能链式赋值)
|
||||
m_onNav = null;
|
||||
m_onLoad = null;
|
||||
m_onTitle = null;
|
||||
m_onMessage = null;
|
||||
m_onDestroyed = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0db0a318e6c5e4b41961d58b36d39a2f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
|
||||
using System;
|
||||
|
||||
namespace Cn.Tuanjie.Codely.Editor
|
||||
{
|
||||
public readonly struct WindowHostStabilityDecision
|
||||
{
|
||||
public WindowHostStabilityDecision(bool shouldApply, IntPtr nextStableHandle, IntPtr nextPendingHandle)
|
||||
{
|
||||
ShouldApply = shouldApply;
|
||||
NextStableHandle = nextStableHandle;
|
||||
NextPendingHandle = nextPendingHandle;
|
||||
}
|
||||
|
||||
public bool ShouldApply { get; }
|
||||
public IntPtr NextStableHandle { get; }
|
||||
public IntPtr NextPendingHandle { get; }
|
||||
}
|
||||
|
||||
public static class WindowHostStabilityPolicy
|
||||
{
|
||||
public static WindowHostStabilityDecision Evaluate(IntPtr currentHandle, IntPtr stableHandle, IntPtr pendingHandle)
|
||||
{
|
||||
if (currentHandle == IntPtr.Zero)
|
||||
{
|
||||
return new WindowHostStabilityDecision(
|
||||
shouldApply: false,
|
||||
nextStableHandle: stableHandle,
|
||||
nextPendingHandle: IntPtr.Zero);
|
||||
}
|
||||
|
||||
if (stableHandle == IntPtr.Zero)
|
||||
{
|
||||
if (pendingHandle == currentHandle)
|
||||
{
|
||||
return new WindowHostStabilityDecision(
|
||||
shouldApply: true,
|
||||
nextStableHandle: currentHandle,
|
||||
nextPendingHandle: IntPtr.Zero);
|
||||
}
|
||||
|
||||
return new WindowHostStabilityDecision(
|
||||
shouldApply: false,
|
||||
nextStableHandle: IntPtr.Zero,
|
||||
nextPendingHandle: currentHandle);
|
||||
}
|
||||
|
||||
if (currentHandle == stableHandle)
|
||||
{
|
||||
return new WindowHostStabilityDecision(
|
||||
shouldApply: true,
|
||||
nextStableHandle: stableHandle,
|
||||
nextPendingHandle: IntPtr.Zero);
|
||||
}
|
||||
|
||||
if (pendingHandle == currentHandle)
|
||||
{
|
||||
return new WindowHostStabilityDecision(
|
||||
shouldApply: true,
|
||||
nextStableHandle: currentHandle,
|
||||
nextPendingHandle: IntPtr.Zero);
|
||||
}
|
||||
|
||||
return new WindowHostStabilityDecision(
|
||||
shouldApply: false,
|
||||
nextStableHandle: stableHandle,
|
||||
nextPendingHandle: currentHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a5f5cde6a8b4cc9b89d5b6c6d6a4389
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX
|
||||
namespace Cn.Tuanjie.Codely.Editor
|
||||
{
|
||||
public static class WindowResourcePreservationPolicy
|
||||
{
|
||||
public static bool ShouldPreserveSharedResources(bool detachMode, int liveWindowCount)
|
||||
{
|
||||
return detachMode || liveWindowCount > 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 52ef4286202a477798f99050f01294fc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in new issue
Block a user