#if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN using System; using System.IO; using System.Net; using System.Net.Sockets; using System.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; using Unity.CodeEditor; using UnityEditor; using UnityEditorInternal; using UnityEngine; using UnityTcp.Editor.Helpers; namespace Cn.Tuanjie.Codely.Editor { /// /// IPC 消息类型 /// public static class IpcMessageType { public const string ShowLines = "showLines"; public const string OpenFile = "openFile"; public const string SelectAsset = "selectAsset"; public const string SetColors = "setColors"; public const string GetColors = "getColors"; public const string Recompile = "recompile"; public const string AddContexts = "addContexts"; public const string DebugUnityConsole = "debugUnityConsole"; public const string FocusInputWithNewSession = "focusInputWithNewSession"; public const string SetEmbedMode = "setEmbedMode"; /// Tauri switched workspace; close Codely EditorWindow if in detach mode (handled in CodelyWindow). public const string CloseCodelyEditorOnWorkspaceSwitch = "closeCodelyEditorOnWorkspaceSwitch"; public const string DrillCompleted = "drillCompleted"; public const string FocusWindow = "focusWindow"; public const string UpdateRestart = "updateRestart"; public const string TrackEvent = "trackEvent"; } /// /// IPC 消息结构 /// [Serializable] public class IpcMessage { public string messageType; public string filepath; public int startLine; public int endLine; // For setColors message public string data; // JSON string containing theme colors } /// /// Codely IPC 客户端 - Unity 作为客户端连接到 Tauri 服务器 /// public class CodelyIpcClient : IDisposable { public const string PIPE_NAME_PREFIX = "CodelyUnityIpc"; private static int s_drillCompletedPending; internal static bool ConsumePendingDrillCompleted() { return Interlocked.Exchange(ref s_drillCompletedPending, 0) != 0; } #if UNITY_EDITOR_WIN // Native named-pipe client (NIC_*) replaces System.IO.Pipes.NamedPipeClientStream. // Unity 2019's Mono 5.x has known bugs around pipe I/O cancellation that surface as // hangs and leaked threads on assembly reload; the native singleton owns the worker // thread and exposes a non-blocking dequeue API the managed side polls. private const string NIC_DLL = "NativeIpcClient"; [DllImport(NIC_DLL, EntryPoint = "NIC_StartClient", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] private static extern int NIC_StartClient([MarshalAs(UnmanagedType.LPStr)] string pipeName); [DllImport(NIC_DLL, EntryPoint = "NIC_StopClient", CallingConvention = CallingConvention.Cdecl)] private static extern void NIC_StopClient(); [DllImport(NIC_DLL, EntryPoint = "NIC_IsConnected", CallingConvention = CallingConvention.Cdecl)] private static extern int NIC_IsConnected(); [DllImport(NIC_DLL, EntryPoint = "NIC_IsRunning", CallingConvention = CallingConvention.Cdecl)] private static extern int NIC_IsRunning(); [DllImport(NIC_DLL, EntryPoint = "NIC_TryDequeueMessage", CallingConvention = CallingConvention.Cdecl)] private static extern int NIC_TryDequeueMessage(byte[] buffer, int bufferCapacity, out int outBytes); [DllImport(NIC_DLL, EntryPoint = "NIC_SendMessage", CallingConvention = CallingConvention.Cdecl)] private static extern int NIC_SendMessage(byte[] payload, int payloadBytes); [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] private static extern bool BringWindowToTop(IntPtr hWnd); [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); [DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId(); [DllImport("user32.dll")] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); [DllImport("user32.dll")] private static extern bool InvalidateRect(IntPtr hWnd, IntPtr lpRect, bool bErase); [DllImport("user32.dll")] private static extern bool EnumChildWindows(IntPtr parent, EnumChildWindowsProc cb, IntPtr param); [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetClassNameW")] private static extern int GetClassNameW(IntPtr hwnd, [Out] char[] buf, int max); private delegate bool EnumChildWindowsProc(IntPtr hwnd, IntPtr param); private const int SW_RESTORE = 9; private const string GUI_VIEW_CLASS = "UnityGUIViewWndClass"; // Static delegate to keep the EnumChildWindows callback alive across calls. private static readonly EnumChildWindowsProc s_invalidateGUIViewsProc = InvalidateIfGUIView; private static readonly char[] s_classBuf = new char[256]; [AOT.MonoPInvokeCallback(typeof(EnumChildWindowsProc))] private static bool InvalidateIfGUIView(IntPtr hwnd, IntPtr param) { try { int n = GetClassNameW(hwnd, s_classBuf, s_classBuf.Length); if (n > 0) { // 注:s_classBuf 在 IPC 后台线程间复用,但 ProcessMessage / NotifyConnectionChanged // 在同一 IPC client task 上顺序执行,不会并发——足够安全。 string cls = new string(s_classBuf, 0, n); if (cls == GUI_VIEW_CLASS) { InvalidateRect(hwnd, IntPtr.Zero, false); } } } catch { /* enumeration must continue even if one window fails */ } return true; } #endif /// /// 从后台线程将 Unity Editor 进程窗口提到前台(不依赖主线程 update 循环)。 /// Windows: AttachThreadInput 绕过前台切换限制,再调用 SetForegroundWindow。 /// macOS: osascript 通过 PID 激活进程。 /// private static void BringUnityToFront() { try { #if UNITY_EDITOR_WIN var hwnd = System.Diagnostics.Process.GetCurrentProcess().MainWindowHandle; if (hwnd != IntPtr.Zero) { ShowWindow(hwnd, SW_RESTORE); // Windows 限制后台进程直接调用 SetForegroundWindow(会降级为任务栏闪烁)。 // AttachThreadInput 将当前线程的输入队列附加到前台窗口线程, // 使 OS 认为本次调用来自"有资格"的线程,从而绕过该限制。 var foregroundHwnd = GetForegroundWindow(); uint foregroundThreadId = GetWindowThreadProcessId(foregroundHwnd, out _); uint currentThreadId = GetCurrentThreadId(); bool attached = false; if (foregroundThreadId != 0 && foregroundThreadId != currentThreadId) attached = AttachThreadInput(currentThreadId, foregroundThreadId, true); try { BringWindowToTop(hwnd); SetForegroundWindow(hwnd); } finally { if (attached) AttachThreadInput(currentThreadId, foregroundThreadId, false); } CodelyLogger.Log("Codely IPC: BringUnityToFront - SetForegroundWindow called"); } else { CodelyLogger.LogWarning("Codely IPC: BringUnityToFront - MainWindowHandle is zero"); } #elif UNITY_EDITOR_OSX int pid = System.Diagnostics.Process.GetCurrentProcess().Id; System.Diagnostics.Process.Start("osascript", $"-e 'tell application \"System Events\" to set frontmost of first process whose unix id is {pid} to true'"); CodelyLogger.Log($"Codely IPC: BringUnityToFront - osascript activated pid {pid}"); #endif } catch (Exception ex) { CodelyLogger.LogWarning($"Codely IPC: BringUnityToFront failed: {ex.Message}"); } } /// /// 从后台线程"唤醒"Unity 主线程消息循环,使其在失焦下也跑一次 EditorWindow.OnGUI。 /// /// 背景:Unity Editor 失焦时 EditorApplication.update / delayCall 都被节流到几乎为零, /// server_ready → OnTauriServerReady → TryInitializeWebView2 整条事件链会被卡住,导致 /// CodelyWindow / DrillWindow 的 WebView2 一直停留在 "Loading ..." 占位,直到用户点回 Unity。 /// /// 修复思路:让所有 UnityGUIViewWndClass 子窗口 InvalidateRect。InvalidateRect 跨线程 /// 安全,会标记区域无效并通知目标线程;Win32 产生的 WM_PAINT 唤醒目标线程的 GetMessage, /// Unity 必须处理 WM_PAINT 且会跑一次 EditorWindow.OnGUI——这条路径在失焦下仍然可用 /// (用户能看到 "Loading ..." 占位证明 OnGUI 能跑)。配合 OnGUI 内部的初始化 backstop, /// server_ready 一到 CodelyWindow / DrillWindow 就能在下一次 paint 里直接推进 /// TryInitializeWebView2,而不必依赖被节流的 editor update loop。 /// /// macOS / Linux: 无对应低开销机制,留作 no-op;当前已知问题只在 Windows 复现。 /// private static void WakeUnityMainThread() { try { #if UNITY_EDITOR_WIN var hwnd = System.Diagnostics.Process.GetCurrentProcess().MainWindowHandle; if (hwnd != IntPtr.Zero) { EnumChildWindows(hwnd, s_invalidateGUIViewsProc, IntPtr.Zero); } #endif } catch (Exception ex) { CodelyLogger.LogWarning($"Codely IPC: WakeUnityMainThread failed: {ex.Message}"); } } private CancellationTokenSource _cancellationTokenSource; private Task _clientTask; private bool _isDisposed = false; private bool _isConnected = false; private readonly object _lockObj = new object(); #if UNITY_EDITOR_WIN // Native NIC_* singleton owns the actual pipe handle; we just hold the name + state. private readonly string _pipeName; // Windows: Named pipe name #else private Socket _socket; // macOS/Linux: Unix Domain Socket private readonly string _socketPath; // macOS/Linux: Socket file path private sealed class Unity2019UnixSocketEndPoint : EndPoint { private const int NativePathOffset = 2; private readonly string _path; public Unity2019UnixSocketEndPoint(string path) { if (string.IsNullOrEmpty(path)) throw new ArgumentException("Unix socket path must not be empty", nameof(path)); _path = path; } public override AddressFamily AddressFamily => AddressFamily.Unix; public override SocketAddress Serialize() { byte[] pathBytes = Encoding.UTF8.GetBytes(_path); var socketAddress = new SocketAddress(AddressFamily.Unix, NativePathOffset + pathBytes.Length + 1); for (int i = 0; i < pathBytes.Length; i++) { socketAddress[NativePathOffset + i] = pathBytes[i]; } socketAddress[NativePathOffset + pathBytes.Length] = 0; return socketAddress; } public override EndPoint Create(SocketAddress socketAddress) { return this; } } #endif /// /// 当 Tauri 服务器就绪时触发 /// public event Action OnServerReady; /// /// 当收到 ShowLines 消息时触发 /// public event Action OnShowLines; /// /// 当收到 OpenFile 消息时触发 /// public event Action OnOpenFile; /// /// 当收到 SelectAsset 消息时触发 /// public event Action OnSelectAsset; /// /// 当收到 SetEmbedMode 消息时触发,参数为 embedMode 布尔值 /// public event Action OnSetEmbedMode; /// /// Tauri 切换工作区前在旧 IPC 上发送;CodelyWindow 仅在 detach 模式下关闭自身。 /// public event Action OnCloseCodelyEditorOnWorkspaceSwitch; /// /// 当收到 DrillCompleted 消息时触发(演练完成,应关闭 DrillWindow 并打开 CodelyWindow) /// public event Action OnDrillCompleted; /// /// 连接状态改变时触发 /// public event Action OnConnectionChanged; /// /// 当收到 UpdateRestart 消息时触发(Tauri 即将更新重启) /// public event Action OnUpdateRestart; /// /// 是否已连接到 Tauri 服务器 /// public bool IsConnected { get { lock (_lockObj) { return _isConnected; } } } /// /// 构造函数:根据项目路径生成 pipe/socket 名称 /// public CodelyIpcClient() { #if UNITY_EDITOR_WIN _pipeName = GeneratePipeName(); #else _socketPath = GenerateSocketPath(); #endif } #if UNITY_EDITOR_WIN /// /// 根据项目路径生成唯一的 pipe 名称 (Windows) /// private static string GeneratePipeName() { try { string projectPath = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); using (var sha256 = System.Security.Cryptography.SHA256.Create()) { byte[] hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(projectPath)); string hash = BitConverter.ToString(hashBytes).Replace("-", "").Substring(0, 8).ToLower(); return $"{PIPE_NAME_PREFIX}-{hash}"; } } catch (Exception ex) { CodelyLogger.LogWarning($"Codely IPC: Failed to generate pipe name hash, using default: {ex.Message}"); return PIPE_NAME_PREFIX; } } #else /// /// 根据项目路径生成唯一的 socket 路径 (macOS/Linux) /// private static string GenerateSocketPath() { try { string projectPath = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); using (var sha256 = System.Security.Cryptography.SHA256.Create()) { byte[] hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(projectPath)); string hash = BitConverter.ToString(hashBytes).Replace("-", "").Substring(0, 8).ToLower(); // Use TMPDIR environment variable or /tmp as fallback string tmpDir = Environment.GetEnvironmentVariable("TMPDIR"); if (string.IsNullOrEmpty(tmpDir)) { tmpDir = "/tmp"; } tmpDir = tmpDir.TrimEnd('/'); return $"{tmpDir}/{PIPE_NAME_PREFIX}-{hash}.sock"; } } catch (Exception ex) { CodelyLogger.LogWarning($"Codely IPC: Failed to generate socket path hash, using default: {ex.Message}"); string tmpDir = Environment.GetEnvironmentVariable("TMPDIR") ?? "/tmp"; return $"{tmpDir.TrimEnd('/')}/{PIPE_NAME_PREFIX}.sock"; } } #endif /// /// 启动 IPC 客户端并连接到 Tauri 服务器 /// public void Start() { lock (_lockObj) { if (_clientTask != null && !_clientTask.IsCompleted) { CodelyLogger.Log("Codely IPC: Client already running"); return; } _cancellationTokenSource = new CancellationTokenSource(); _clientTask = Task.Run(() => RunClientLoop(_cancellationTokenSource.Token)); #if UNITY_EDITOR_WIN CodelyLogger.Log($"Codely IPC: Client started, attempting to connect to Tauri server on named pipe '{_pipeName}'"); #else CodelyLogger.Log($"Codely IPC: Client started, attempting to connect to Tauri server on Unix socket '{_socketPath}'"); #endif } } /// /// 停止 IPC 客户端 /// public void Stop() { CancellationTokenSource cts = null; Task clientTask = null; #if !UNITY_EDITOR_WIN Socket socketToDispose = null; #endif lock (_lockObj) { // Allow Stop to be called multiple times and also from Dispose #if UNITY_EDITOR_WIN if (_cancellationTokenSource == null && _clientTask == null) #else if (_cancellationTokenSource == null && _clientTask == null && _socket == null) #endif { return; } cts = _cancellationTokenSource; _cancellationTokenSource = null; clientTask = _clientTask; _clientTask = null; _isConnected = false; #if !UNITY_EDITOR_WIN // Detach current connection reference so SendMessage won't use a disposed instance socketToDispose = _socket; _socket = null; #endif } // Cancel outside lock to avoid deadlocks if (cts != null) { try { cts.Cancel(); } catch { // ignore } finally { cts.Dispose(); } } // Wait for client loop to exit (best effort) if (clientTask != null) { try { clientTask.Wait(2000); } catch { // ignore } } #if UNITY_EDITOR_WIN // Tear down the native worker thread and pipe handle. Idempotent on the native side. try { NIC_StopClient(); } catch (Exception ex) { CodelyLogger.LogWarning($"Codely IPC: NIC_StopClient failed: {ex.Message}"); } #else if (socketToDispose != null) { try { socketToDispose.Close(); socketToDispose.Dispose(); } catch { // ignore } } #endif CodelyLogger.Log("Codely IPC: Client stopped"); } private async Task RunClientLoop(CancellationToken cancellationToken) { #if UNITY_EDITOR_WIN // Hand off connect/reconnect/read to the native singleton, then poll its inbound // queue from this managed task. ProcessMessage runs on this thread (matching the // legacy behavior) so focusWindow can call BringUnityToFront() without waiting on // the editor update loop, which is exactly the case where focus is missing. await RunNativeClientLoopWin(cancellationToken); return; #else int retryDelayMs = 100; // Start with 100ms int maxRetryDelayMs = 2000; // Max 2 seconds while (!cancellationToken.IsCancellationRequested) { Socket socket = null; try { // 创建 Unix Domain Socket socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); var endpoint = new Unity2019UnixSocketEndPoint(_socketPath); CodelyLogger.Log("Codely IPC: Attempting to connect to Tauri server via Unix socket..."); // 连接到服务器(使用异步连接) await Task.Run(() => socket.Connect(endpoint), cancellationToken); if (cancellationToken.IsCancellationRequested) break; // 连接成功,重置退化延迟 retryDelayMs = 100; // 连接成功 lock (_lockObj) { _isConnected = true; _socket = socket; } NotifyConnectionChanged(true); CodelyLogger.Log("Codely IPC: Successfully connected to Tauri server via Unix socket!"); // 读取消息循环 byte[] buffer = new byte[4096]; StringBuilder messageBuffer = new StringBuilder(); while (!cancellationToken.IsCancellationRequested && socket.Connected) { try { int bytesRead = await Task.Run(() => socket.Receive(buffer), cancellationToken); if (bytesRead == 0) { // 连接断开 break; } string data = Encoding.UTF8.GetString(buffer, 0, bytesRead); messageBuffer.Append(data); // 处理完整的消息(以换行符分隔) string messages = messageBuffer.ToString(); int newlineIndex; while ((newlineIndex = messages.IndexOf('\n')) >= 0) { string message = messages.Substring(0, newlineIndex).Trim(); messages = messages.Substring(newlineIndex + 1); if (!string.IsNullOrWhiteSpace(message)) { ProcessMessage(message); } } messageBuffer.Clear(); messageBuffer.Append(messages); } catch (Exception ex) { CodelyLogger.LogWarning($"Codely IPC: Error reading message: {ex.Message}"); break; } } } catch (OperationCanceledException) { // 正常取消,退出循环 break; } catch (Exception ex) { if (!cancellationToken.IsCancellationRequested) { CodelyLogger.Log($"Codely IPC: Connection error: {ex.Message}. Retrying in {retryDelayMs}ms..."); } } finally { if (socket != null) { try { socket.Close(); socket.Dispose(); } catch { // ignore } } bool shouldNotify = false; lock (_lockObj) { _isConnected = false; if (_socket == socket) { _socket = null; } shouldNotify = !_isDisposed; } if (shouldNotify) { NotifyConnectionChanged(false); } } // 如果连接断开,使用退化延迟后重试 if (!cancellationToken.IsCancellationRequested) { await Task.Delay(retryDelayMs, cancellationToken); // 指数退避,每次重试延迟翻倍,直到达到最大值 retryDelayMs = Math.Min(retryDelayMs * 2, maxRetryDelayMs); } } #endif } #if UNITY_EDITOR_WIN // Polls the native NIC_* singleton's inbound queue and forwards lines to ProcessMessage. // The native worker handles connect/reconnect; we only own state-transition notifications // and message dispatch. Buffer grows on demand if the native side reports a message // larger than capacity (this is the documented contract — outBytes carries required size). private async Task RunNativeClientLoopWin(CancellationToken cancellationToken) { try { if (NIC_StartClient(_pipeName) == 0) { CodelyLogger.LogError($"Codely IPC: NIC_StartClient failed for pipe '{_pipeName}'"); return; } } catch (DllNotFoundException ex) { CodelyLogger.LogError( $"Codely IPC: native plugin '{NIC_DLL}' not found ({ex.Message}). " + "Build codely-bridge-native/tcp-bridge-native and copy NativeIpcClient.dll to Plugins/Win/."); return; } catch (Exception ex) { CodelyLogger.LogError($"Codely IPC: NIC_StartClient threw: {ex.Message}"); return; } CodelyLogger.Log($"Codely IPC: native client started for pipe '{_pipeName}', polling inbound queue"); byte[] buffer = new byte[8192]; bool prevConnected = false; try { while (!cancellationToken.IsCancellationRequested) { bool nowConnected; try { nowConnected = NIC_IsConnected() != 0; } catch { nowConnected = false; } if (nowConnected != prevConnected) { lock (_lockObj) { _isConnected = nowConnected; } NotifyConnectionChanged(nowConnected); if (nowConnected) CodelyLogger.Log("Codely IPC: Successfully connected to Tauri server!"); else if (prevConnected) CodelyLogger.Log("Codely IPC: Disconnected, native worker will retry"); prevConnected = nowConnected; } // Drain everything currently queued before sleeping. while (true) { int outBytes = 0; int rc; try { rc = NIC_TryDequeueMessage(buffer, buffer.Length, out outBytes); } catch (Exception ex) { CodelyLogger.LogWarning($"Codely IPC: NIC_TryDequeueMessage threw: {ex.Message}"); rc = 0; outBytes = 0; } if (rc == 1) { if (outBytes > 0) { string line = Encoding.UTF8.GetString(buffer, 0, outBytes); if (!string.IsNullOrWhiteSpace(line)) { ProcessMessage(line); } } continue; } // rc == 0: either empty queue (outBytes == 0) or buffer too small // (outBytes carries required size and the message is NOT popped). if (outBytes > buffer.Length) { int needed = outBytes; int newCap = buffer.Length; while (newCap < needed) newCap *= 2; buffer = new byte[newCap]; continue; // retry dequeue with the larger buffer } break; // queue is empty } try { await Task.Delay(50, cancellationToken); } catch (OperationCanceledException) { break; } } } finally { bool wasConnected; lock (_lockObj) { wasConnected = _isConnected; _isConnected = false; } if (wasConnected) NotifyConnectionChanged(false); } } #endif private void NotifyConnectionChanged(bool connected) { EditorApplication.delayCall += () => { lock (_lockObj) { if (_isDisposed) { return; } } OnConnectionChanged?.Invoke(connected); }; } private void ProcessMessage(string messageJson) { try { lock (_lockObj) { if (_isDisposed || _cancellationTokenSource == null) { return; } } CodelyLogger.Log($"Codely IPC: ProcessMessage raw: {messageJson}"); // Check if this is a server_ready message (simple JSON check) if (messageJson.Contains("\"type\":\"server_ready\"") || messageJson.Contains("\"type\": \"server_ready\"")) { // Extract URL from JSON manually int urlStart = messageJson.IndexOf("\"url\""); if (urlStart >= 0) { int colonPos = messageJson.IndexOf(":", urlStart); int quoteStart = messageJson.IndexOf("\"", colonPos + 1); int quoteEnd = messageJson.IndexOf("\"", quoteStart + 1); if (quoteStart >= 0 && quoteEnd > quoteStart) { string url = messageJson.Substring(quoteStart + 1, quoteEnd - quoteStart - 1); CodelyLogger.Log($"Codely IPC: Received server_ready message with URL: {url}"); // 在 delayCall 入队前先写入 thread-safe 静态字段:失焦启动时主线程 update loop // 被节流,OnTauriServerReady 不会及时跑;OnGUI 的 backstop 直接读这个 URL // 解出端口,调 TryInitializeWebView2,绕过 delayCall 事件链。 CodelyIpcManager.SetLastServerReadyUrl(url); EditorApplication.delayCall += () => { OnServerReady?.Invoke(url); }; // InvalidateRect GUIView 子窗口,强制走一次 WM_PAINT → OnGUI → backstop。 WakeUnityMainThread(); return; } } } var message = JsonUtility.FromJson(messageJson); if (message == null) { CodelyLogger.LogWarning($"Codely IPC: Failed to parse message: {messageJson}"); return; } CodelyLogger.Log($"Codely IPC: Received message type '{message.messageType}', filepath: {message.filepath}"); // focusWindow must bring Unity to the foreground immediately on this background // thread via OS-level APIs, because EditorApplication.delayCall won't run until // the editor update loop is active — which requires Unity to already have focus. if (message.messageType == IpcMessageType.FocusWindow) { CodelyLogger.Log("Codely IPC: focusWindow received on background thread — calling BringUnityToFront()"); BringUnityToFront(); } else if (message.messageType == IpcMessageType.DrillCompleted) { Interlocked.Exchange(ref s_drillCompletedPending, 1); } // 在主线程中处理消息(Unity API 只能在主线程调用) EditorApplication.delayCall += () => { switch (message.messageType) { case IpcMessageType.ShowLines: HandleShowLines(message.filepath, message.startLine, message.endLine); break; case IpcMessageType.OpenFile: HandleOpenFile(message.filepath); break; case IpcMessageType.SelectAsset: HandleSelectAsset(message.filepath); break; case IpcMessageType.GetColors: HandleGetColors(); break; case IpcMessageType.Recompile: // used when bridge is exported HandleRecompile(); break; case IpcMessageType.SetEmbedMode: HandleSetEmbedMode(message.data); break; case IpcMessageType.CloseCodelyEditorOnWorkspaceSwitch: OnCloseCodelyEditorOnWorkspaceSwitch?.Invoke(); break; case IpcMessageType.DrillCompleted: // Prefer event-driven transition first. If there is no listener on this // IPC client, fallback to closing DrillWindow to avoid stuck walkthrough UI. var drillCompletedHandler = OnDrillCompleted; if (drillCompletedHandler != null) { drillCompletedHandler.Invoke(); } else { ConsumePendingDrillCompleted(); foreach (var window in Resources.FindObjectsOfTypeAll()) { if (window == null) continue; try { window.Close(); } catch (Exception ex) { CodelyLogger.LogWarning($"Codely IPC: Failed to close DrillWindow: {ex.Message}"); } } } break; case IpcMessageType.FocusWindow: HandleFocusWindow(); break; case IpcMessageType.UpdateRestart: HandleUpdateRestart(message.data); break; default: CodelyLogger.LogWarning($"Codely IPC: Unknown message type: {message.messageType}"); break; } }; if (message.messageType == IpcMessageType.DrillCompleted) { WakeUnityMainThread(); } } catch (Exception ex) { CodelyLogger.LogError($"Codely IPC: Error processing message: {ex.Message}\nMessage: {messageJson}"); } } private void HandleShowLines(string filepath, int startLine, int endLine) { CodelyLogger.Log($"Codely IPC: ShowLines - {filepath} ({startLine}-{endLine})"); // 触发事件 OnShowLines?.Invoke(filepath, startLine, endLine); // 选中文件并在 Project 窗口中高亮 SelectAndPingAsset(filepath); // 打开文件并跳转到指定行 OpenFileAtLine(filepath, startLine); } private void HandleOpenFile(string filepath) { CodelyLogger.Log($"Codely IPC: OpenFile - {filepath}"); OnOpenFile?.Invoke(filepath); OpenFileAtLine(filepath, 0); } private void HandleSelectAsset(string filepath) { CodelyLogger.Log($"Codely IPC: SelectAsset - {filepath}"); OnSelectAsset?.Invoke(filepath); SelectAndPingAsset(filepath); } private void HandleGetColors() { try { // Unity Editor theme mode (dark/light) string themeMode = EditorGUIUtility.isProSkin ? "dark" : "light"; CodelyLogger.Log($"Codely IPC: GetColors - responding with theme mode: {themeMode}"); // Send theme mode back to Tauri. Rust side will map mode -> colors. SendMessage(IpcMessageType.SetColors, themeMode); } catch (Exception ex) { CodelyLogger.LogError($"Codely IPC: Error handling GetColors: {ex.Message}"); } } private void HandleRecompile() { try { bool executed = EditorApplication.ExecuteMenuItem("Assets/Refresh"); if (!executed) { AssetDatabase.Refresh(); AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); } } catch (Exception ex) { CodelyLogger.LogError($"Codely IPC: Error handling Recompile: {ex.Message}"); } } private void HandleFocusWindow() { try { CodelyLogger.Log("Codely IPC: HandleFocusWindow called"); var windows = Resources.FindObjectsOfTypeAll(); if (windows.Length > 0) { CodelyLogger.Log($"Codely IPC: FocusWindow - found {windows.Length} CodelyWindow(s), calling Focus()"); windows[0].Focus(); CodelyLogger.Log("Codely IPC: FocusWindow - brought Codely window to foreground"); } else { CodelyLogger.LogWarning("Codely IPC: FocusWindow - no CodelyWindow instance found"); } } catch (Exception ex) { CodelyLogger.LogError($"Codely IPC: Error handling FocusWindow: {ex.Message}"); } } private void HandleUpdateRestart(string data) { try { OnUpdateRestart?.Invoke(data); } catch (Exception ex) { CodelyLogger.LogError($"Codely IPC: Error handling UpdateRestart: {ex.Message}"); } } private void HandleSetEmbedMode(string data) { bool embedMode = string.Equals(data, "true", StringComparison.OrdinalIgnoreCase); CodelyLogger.Log($"Codely IPC: SetEmbedMode - embedMode={embedMode}"); OnSetEmbedMode?.Invoke(embedMode); } /// /// 在 Project 窗口中选中并高亮文件 /// private void SelectAndPingAsset(string filepath) { try { // 将绝对路径转换为相对路径(相对于 Assets 目录) string relativePath = ConvertToAssetPath(filepath); if (string.IsNullOrEmpty(relativePath)) { CodelyLogger.LogWarning($"Codely IPC: Cannot convert path to asset path: {filepath}"); return; } // 加载资源 var asset = AssetDatabase.LoadAssetAtPath(relativePath); if (asset == null) { CodelyLogger.LogWarning($"Codely IPC: Asset not found at path: {relativePath}"); return; } // 选中资源 Selection.activeObject = asset; // 在 Project 窗口中高亮显示(Ping) EditorGUIUtility.PingObject(asset); CodelyLogger.Log($"Codely IPC: Selected and pinged asset: {relativePath}"); } catch (Exception ex) { CodelyLogger.LogError($"Codely IPC: Error selecting asset: {ex.Message}"); } } public static bool OpenWithCurrentEditor(string filePath, int line = -1, int column = -1) { // Use CurrentEditorInstallation (available since CodeEditor package was introduced) // rather than CurrentEditorPath, which doesn't exist on Unity 2020. string editorPath = CodeEditor.CurrentEditorInstallation; // Guard against an unconfigured / invalid external editor path. On macOS, passing an // empty or non-existent path into CodeEditor.OSOpenFile makes the native // LaunchOrReuseApp build a nil NSURL and crash with an uncatchable // NSInternalInconsistencyException (url != nil). Fall back to the OS default app, // which safely handles a missing handler instead of crashing the editor. if (string.IsNullOrEmpty(editorPath) || !EditorPathExists(editorPath)) { CodelyLogger.LogWarning( $"Codely IPC: External editor path is not configured or invalid ('{editorPath}'); " + $"opening with default app: {filePath}"); EditorUtility.OpenWithDefaultApp(filePath); return true; } string argumentsTemplate = GetEditorArguments(editorPath); string arguments = CodeEditor.ParseArgument(argumentsTemplate, filePath, line, column); return CodeEditor.OSOpenFile(editorPath, arguments); } /// /// 判断外部编辑器路径是否实际存在。macOS 上编辑器是 .app 包(目录), /// 其它平台是可执行文件,因此目录或文件存在都视为有效。 /// private static bool EditorPathExists(string editorPath) { return File.Exists(editorPath) || Directory.Exists(editorPath); } /// /// 获取编辑器的参数配置 /// private static string GetEditorArguments(string editorPath) { const string k_ArgumentKey = "kScriptEditorArgs"; const string k_DefaultArgument = "$(File)"; // 对于 macOS,有特殊的 key if (Application.platform == RuntimePlatform.OSXEditor) { string oldMacKey = "kScriptEditorArgs_" + editorPath; string oldMac = EditorPrefs.GetString(oldMacKey); if (!string.IsNullOrEmpty(oldMac)) { EditorPrefs.SetString(k_ArgumentKey + editorPath, oldMac); } } return EditorPrefs.GetString(k_ArgumentKey + editorPath, k_DefaultArgument); } /// /// 打开文件并跳转到指定行 /// private void OpenFileAtLine(string filepath, int line) { try { // 将绝对路径转换为相对路径 string relativePath = ConvertToAssetPath(filepath); if (string.IsNullOrEmpty(relativePath)) { // 如果不是 Assets 目录下的文件,尝试使用 Unity 默认的外部编辑器打开 if (File.Exists(filepath)) { OpenWithCurrentEditor(filepath); } return; } // 加载资源 var asset = AssetDatabase.LoadAssetAtPath(relativePath); if (asset != null) { // 使用 Unity 的 API 打开文件 AssetDatabase.OpenAsset(asset, line); CodelyLogger.Log($"Codely IPC: Opened asset at line {line}: {relativePath}"); } } catch (Exception ex) { CodelyLogger.LogError($"Codely IPC: Error opening file: {ex.Message}"); } } /// /// 将绝对路径转换为 Unity 资源路径(相对于项目根目录) /// private string ConvertToAssetPath(string absolutePath) { if (string.IsNullOrEmpty(absolutePath)) return null; if (absolutePath.StartsWith("file:///", StringComparison.OrdinalIgnoreCase)) { absolutePath = absolutePath.Substring(8); // 去掉 "file:///" } else if (absolutePath.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) { absolutePath = absolutePath.Substring(7); // 去掉 "file://" } // 标准化路径分隔符 absolutePath = absolutePath.Replace('\\', '/'); // 获取项目根目录 string projectPath = Path.GetFullPath(Path.Combine(Application.dataPath, "..")).Replace('\\', '/'); // 检查路径是否在项目目录内 if (absolutePath.StartsWith(projectPath, StringComparison.OrdinalIgnoreCase)) { // 获取相对路径 string relativePath = absolutePath.Substring(projectPath.Length).TrimStart('/'); // 确保以 Assets/ 开头或 Packages/ 开头 if (relativePath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) || relativePath.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)) { return relativePath; } } // 检查是否已经是相对路径 if (absolutePath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) || absolutePath.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)) { return absolutePath; } return null; } public void Dispose() { if (_isDisposed) return; _isDisposed = true; Stop(); } /// /// Send a message to Tauri server /// public bool SendMessage(string messageType, string data = null) { lock (_lockObj) { if (!_isConnected) { // Avoid noisy logs during domain reload / startup. return false; } try { // Create message var message = new IpcMessage { messageType = messageType, filepath = "", startLine = 0, endLine = 0, data = data }; // Serialize to JSON string json = JsonUtility.ToJson(message); #if UNITY_EDITOR_WIN // Native side appends '\n' before writing — pass raw JSON bytes only. byte[] payloadBytes = Encoding.UTF8.GetBytes(json); int sent; try { sent = NIC_SendMessage(payloadBytes, payloadBytes.Length); } catch (Exception ex) { CodelyLogger.LogWarning($"Codely IPC: NIC_SendMessage threw: {ex.Message}"); return false; } if (sent == 1) { CodelyLogger.Log($"Codely IPC: Sent message to Tauri: {messageType}"); return true; } return false; #else // Send to Tauri server (write to Unix socket) if (_socket != null && _socket.Connected) { byte[] messageBytes = Encoding.UTF8.GetBytes(json + "\n"); _socket.Send(messageBytes); CodelyLogger.Log($"Codely IPC: Sent message to Tauri: {messageType}"); return true; } else { return false; } #endif } catch (Exception ex) { CodelyLogger.LogError($"Codely IPC: Error sending message: {ex.Message}"); return false; } } } } } #endif