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

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

No files matched your search

@@ -0,0 +1,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
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 61a431ba3ea762c4188c02deb2865a68
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 08da39896c9b4f46a430c8d9f7914da6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 13bdb41f354364663bfe59333d1a881a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d107f4d21b4069f4abad96e077c6a00c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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>&lt;UserPrefs&gt;\Layouts\CurrentLayout-&lt;mode&gt;.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-&lt;mode&gt;.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);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ff9d2af41843c42b08c5c5fe74859472
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b71b883158cd3f342b34b4155a254531
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6f947d6846c52fe4493bda3b19fc76be
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c16430b46b29b1f47bdcf9de21327e59
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c1070a2c5b274df599c2ccd6620a7e20
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -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:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ce8760ade6ed247428bf6f909f6f528a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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.&lt;contract&gt;` 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
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 35a1b76bb29253344a7289b78ef4aece
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 58b0c80efb83eda45b6a8bf5703947fb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7fe7cfd97c8c9084a8274036d00ea15e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -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 &amp; 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 &amp; 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/&lt;sha256_16hex&gt;</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 &amp; 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
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9f079a534247b4b78b376a943f72095f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0db0a318e6c5e4b41961d58b36d39a2f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7a5f5cde6a8b4cc9b89d5b6c6d6a4389
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 52ef4286202a477798f99050f01294fc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: