#if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Unity.InternalBridge; using UnityEditor; using UnityEditor.PackageManager; using UnityEngine; using UnityTcp.Editor.Helpers; namespace Cn.Tuanjie.Codely.Editor { /// /// 用于序列化选中对象信息 /// [Serializable] public class DropItemData { public string path; public string type; public string name; } [Serializable] public class DropItemsPayload { public DropItemData[] items; } public class CodelyWindow : EditorWindow { private static bool s_reloadEventsRegistered = false; private const string TAURI_ICONS_BASE_PATH = TauriUtils.TAURI_ICONS_BASE_PATH; [InitializeOnLoadMethod] private static void RegisterReloadEvents() { if (s_reloadEventsRegistered) { return; } s_reloadEventsRegistered = true; AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; EditorApplication.quitting += OnEditorQuitting; EditorApplication.playModeStateChanged += OnPlayModeStateChanged; #if UNITY_2020_1_OR_NEWER Events.registeringPackages += OnPackagesRegistering; #endif TryAutoOpenAfterInstall(); } /// /// Called when packages are about to change (before domain reload). /// Only react when *this* package is being installed, removed, or upgraded; do not kill on theme change / script recompile / play mode. /// #if UNITY_2020_1_OR_NEWER private static void OnPackagesRegistering(PackageRegistrationEventArgs args) { foreach (var package in args.added) { if (package.name == "cn.tuanjie.codely.bridge") { SessionState.SetBool(SESSION_STATE_AUTO_OPEN_AFTER_INSTALL_KEY, true); break; } } bool packageUpgradeDetected = false; foreach (var package in args.changedTo) { if (package.name == "cn.tuanjie.codely.bridge") { packageUpgradeDetected = true; break; } } if (packageUpgradeDetected) { foreach (var package in args.changedFrom) { if (package.name == "cn.tuanjie.codely.bridge") { CleanupForPackageRemovalOrUpgrade(); break; } } } foreach (var package in args.removed) { if (package.name == "cn.tuanjie.codely.bridge") { CleanupForPackageRemovalOrUpgrade(); break; } } } #endif private static void CleanupForPackageRemovalOrUpgrade() { #if UNITY_EDITOR_WIN // Synchronously shut down ALL active WebView2 BrowserInstances before // domain reload. NB_ShutdownAll pumps messages while waiting for every // STA thread to exit, so when it returns all native resources are fully // cleaned up. Using per-window NB_Destroy (async, detached thread) here // would leave cleanup threads running while the old NativeBrowser.dll is // unloaded during domain reload, causing a native crash. try { NativeBrowserAPI.NB_ShutdownAll(5000); } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: NB_ShutdownAll failed during package upgrade: {ex.Message}"); } // Clear all C# handle references so the subsequent per-window // PrepareForPackageRemovalOrUpgrade calls won't attempt NB_Destroy // on already-finalized instances (same pattern as OnEditorQuitting). foreach (var window in Resources.FindObjectsOfTypeAll()) { if (window == null) continue; window._serializedWebView2Handle = 0; window._serializedWebView2Valid = false; window._webView2Host = null; } foreach (var window in Resources.FindObjectsOfTypeAll()) window?.ClearWebView2OnQuit(); #endif foreach (var window in Resources.FindObjectsOfTypeAll()) window?.PrepareForPackageRemovalOrUpgrade(); foreach (var window in Resources.FindObjectsOfTypeAll()) window?.PrepareForPackageRemovalOrUpgrade(); // The Tauri process outlives the package: it is never killed from // Unity. Removing/upgrading the package only tears down this // Editor's IPC client and its own SessionState. ReleaseTauriSessionState(); CodelyLayoutInstaller.RemoveInstalledLayout(); foreach (var window in Resources.FindObjectsOfTypeAll()) window?.Close(); foreach (var window in Resources.FindObjectsOfTypeAll()) window?.Close(); } internal void PrepareForPackageRemovalOrUpgrade() { ReleaseIpcClient(); _preserveTauriProcessOnDestroy = false; #if UNITY_EDITOR_WIN if (_serializedWebView2Valid && _serializedWebView2Handle != 0) { NativeBrowserAPI.NB_Destroy(new IntPtr(_serializedWebView2Handle)); } _serializedWebView2Handle = 0; _serializedWebView2Valid = false; DisposeWebView2Host(); #endif #if UNITY_EDITOR_OSX if (_webViewBridge != null && _webViewBridge.IsInitialized) { _webViewBridge.Destroy(); } _webViewBridge = null; _webViewInitialized = false; _serializedWebViewHandle = 0; _serializedWebViewInitialized = false; #endif } private static void OnBeforeAssemblyReload() { // Switching editor theme triggers a domain reload. We must stop background IPC threads // before the domain is torn down to prevent native/mono crashes. StopIpcClientStatic(); } private static void OnEditorQuitting() { #if UNITY_EDITOR_WIN // 在 Unity 卸载 native DLL / 销毁窗口之前同步关闭所有 WebView2 实例: // 1) m_controller->Close() 必须在父 HWND 被销毁前完成,否则 msedgewebview2.exe 子进程残留; // 2) 父 HWND 销毁时需要 STA 线程已退出,否则归 STA 所有的 WebView2 子窗口无法整理 → 主线程挂起。 // ShutdownAll 内部 pump 消息以驱动 WebView2 的跨 apartment marshaling。 try { NativeBrowserAPI.NB_ShutdownAll(3000); } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: NB_ShutdownAll failed: {ex.Message}"); } // ShutdownAll 已 Finalize 但未 delete 实例;清空所有 C# 持有的 handle, // 避免后续每个窗口的 OnDestroy 再次调用 NB_Destroy 触发后台 delete 线程。 foreach (var window in Resources.FindObjectsOfTypeAll()) { if (window == null) continue; window._serializedWebView2Handle = 0; window._serializedWebView2Valid = false; window._webView2Host = null; } foreach (var window in Resources.FindObjectsOfTypeAll()) window?.ClearWebView2OnQuit(); #endif // The Tauri app has its own lifetime and is shared across every // Editor, so quitting this one only tears down its IPC client and // clears its own SessionState; the process keeps running. ReleaseTauriSessionState(); } /// /// Called when Play Mode state changes. /// WebView handle survives domain reload; visibility follows so a hidden tab does not cover other docks. /// private static void OnPlayModeStateChanged(PlayModeStateChange state) { #if UNITY_EDITOR_OSX var windows = Resources.FindObjectsOfTypeAll(); foreach (var window in windows) { if (window == null) continue; switch (state) { case PlayModeStateChange.EnteredPlayMode: case PlayModeStateChange.EnteredEditMode: if (window._webViewBridge != null && window._webViewBridge.IsInitialized) { window._webViewBridge.SetHidden(!window._isWindowVisible); window.UpdateWebViewFrame(); } break; } } #endif } private static void StopIpcClientStatic() { CodelyIpcManager.StopAll(); } /// /// Drops this Editor's claim on the Tauri process: stops the IPC client and clears the /// SessionState describing it, leaving the process itself running. Uses SessionState only, /// so it works when no EditorWindow instance is available (package removal, editor quit). /// private static void ReleaseTauriSessionState() { SessionState.EraseInt(SESSION_STATE_TAURI_PID_KEY); SessionState.EraseString(SESSION_STATE_TAURI_WINDOW_HANDLE_KEY); SessionState.EraseInt(SESSION_STATE_TAURI_PORT_KEY); SessionState.EraseString(SESSION_STATE_TAURI_SINGLE_INSTANCE_KEY); StopIpcClientStatic(); } #if UNITY_EDITOR_WIN #region Windows API Declarations [DllImport("user32.dll")] private static extern short GetAsyncKeyState(int vKey); [DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT lpPoint); [DllImport("user32.dll")] private static extern bool IsWindow(IntPtr hWnd); [DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr hWnd); [DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); // ── Screenshot capture (PrintWindow + DIB section) ───────────────────── // Used by CaptureDragSnapshot() to freeze the WebView2 content as a // Texture2D right before SetVisible(false) hides it, so DrawDropOverlay // can render the snapshot as the overlay background. [DllImport("user32.dll")] private static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint nFlags); [DllImport("user32.dll")] private static extern IntPtr GetDC(IntPtr hWnd); [DllImport("user32.dll")] private static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC); [DllImport("gdi32.dll")] private static extern IntPtr CreateCompatibleDC(IntPtr hdc); [DllImport("gdi32.dll")] private static extern bool DeleteDC(IntPtr hdc); [DllImport("gdi32.dll")] private static extern IntPtr CreateDIBSection( IntPtr hdc, ref BITMAPINFO pbmi, uint usage, out IntPtr ppvBits, IntPtr hSection, uint offset); [DllImport("gdi32.dll")] private static extern IntPtr SelectObject(IntPtr hdc, IntPtr hgdiobj); [DllImport("gdi32.dll")] private static extern bool DeleteObject(IntPtr hObject); [StructLayout(LayoutKind.Sequential)] private struct POINT { public int X; public int Y; } [StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left, Top, Right, Bottom; } [StructLayout(LayoutKind.Sequential)] private struct BITMAPINFOHEADER { public uint biSize; public int biWidth; public int biHeight; public ushort biPlanes; public ushort biBitCount; public uint biCompression; public uint biSizeImage; public int biXPelsPerMeter; public int biYPelsPerMeter; public uint biClrUsed; public uint biClrImportant; } [StructLayout(LayoutKind.Sequential)] private struct BITMAPINFO { public BITMAPINFOHEADER bmiHeader; // BI_RGB at 32 bpp has no color table; one RGBQUAD slot keeps the // struct layout valid for CreateDIBSection's signature. public uint bmiColors0; } private const uint PW_RENDERFULLCONTENT = 0x00000002; // captures DirectComposition / WebView2 content private const uint DIB_RGB_COLORS = 0; private const uint BI_RGB = 0; private const int VK_LBUTTON = 0x01; #endregion #elif UNITY_EDITOR_OSX #region macOS CoreGraphics [DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")] private static extern bool CGEventSourceButtonState(uint state, uint button); private const uint kCGEventSourceStateHIDSystemState = 1; private const uint kCGMouseButtonLeft = 0; private static bool IsPrimaryMouseButtonDownOsx() { return CGEventSourceButtonState(kCGEventSourceStateHIDSystemState, kCGMouseButtonLeft); } #endregion #endif #region Constants // Shared keys — canonical values live in TauriUtils; aliases kept to avoid touching all call sites. private const string SESSION_STATE_TAURI_PID_KEY = TauriUtils.SESSION_STATE_TAURI_PID_KEY; private const string SESSION_STATE_TAURI_PORT_KEY = TauriUtils.SESSION_STATE_TAURI_PORT_KEY; private const int DEFAULT_TAURI_PORT = TauriUtils.DEFAULT_TAURI_PORT; private const int MAX_PORT_ATTEMPTS = TauriUtils.MAX_PORT_ATTEMPTS; // CodelyWindow-specific keys private const string SESSION_STATE_TAURI_WINDOW_HANDLE_KEY = "Codely_TauriWindowHandle"; private const string SESSION_STATE_AUTO_OPEN_AFTER_INSTALL_KEY = "Codely_AutoOpenAfterInstall"; private const string SESSION_STATE_WINDOW_WAS_OPEN_KEY = "Codely_WindowWasOpen"; private const string SESSION_STATE_UPDATE_VERSION_KEY = "Codely_UpdateVersion"; // Cached capability advertised by `/api/tauri/status`: "1" → the Tauri process is // single-instance and shared across every Unity Editor in this user session. // Informational only — Unity never kills the Tauri process regardless of this flag. // Stored in SessionState so it survives domain reloads and is readable from static handlers. private const string SESSION_STATE_TAURI_SINGLE_INSTANCE_KEY = "Codely_TauriSingleInstance"; #endregion private Process _tauriProcess; #if UNITY_EDITOR_WIN private WebView2Host _webView2Host; private IntPtr _currentGUIViewHWND = IntPtr.Zero; private bool _webView2Initializing = false; private bool _webView2Initialized = false; // When a freshly created WebView2 finishes its first navigation we push // focus into the content once (so the page's autofocus input gets a // caret on open, matching browser behaviour). Guarded so SPA reloads / // later navigations don't steal focus from whatever window the user is // currently in. private bool _webView2DidInitialFocus = false; private int _lastTopInsetPx = -1; // Persists across host disposal/recreation. Used to seed the native // m_topInset before the first SyncBounds runs, avoiding a frame where // the WebView2 covers the tab bar. Updated whenever OnGUI computes a // valid inset; never reset (unlike _lastTopInsetPx). private int _cachedTopInsetPx = -1; // Domain Reload 跨域保活:序列化 BrowserInstance 指针,OnEnable 时恢复复用 [SerializeField] private long _serializedWebView2Handle = 0; [SerializeField] private bool _serializedWebView2Valid = false; #endif private float _searchStartTime; private IntPtr _lastWindowParentHandle = IntPtr.Zero; /// /// Whether this EditorWindow tab is currently visible (not covered by another tab in the same dock). /// Serialized so Domain Reload preserves it; otherwise WebView restore wrongly calls SetVisible(true) and covers other tabs. /// [SerializeField] private bool _isWindowVisible = true; private int _tauriServerPort = -1; private bool _serverReady = false; private bool _isDragOverlay = false; private bool _isDragExitInThisFrame = false; #if UNITY_EDITOR_WIN // Snapshot of the WebView2 content captured the instant the drag overlay // activates (before SetVisible(false) hides the webview). Rendered as the // background of DrawDropOverlay so the user keeps seeing their page under // a translucent green frame instead of an opaque colored rectangle. // Disposed in RestoreTauriFromDragOverlay / OnBecameInvisible / OnDisable. private Texture2D _dragSnapshotTex; // Top inset (in physical pixels) at the moment the snapshot was captured, // so DrawDropOverlay can sample only the WebView2 portion of the GUIView // bitmap (skipping the tab strip above it). private int _dragSnapshotTopInsetPx; #endif // Cached overlay textures + styles for the rounded "Release to upload" // panel. Built lazily on first DrawDropOverlay call and rebuilt only when // the editor skin (pro/light) changes. Disposed in OnDisable. private Texture2D _overlayFrameTex; // 32×32, 8 px corner, 2 px #35C9A9 border, 10% green fill private Texture2D _overlayCardTex; // 40×40, 12 px corner, surface-card fill, no border private Texture2D _overlayChipTex; // 16×16, 4 px corner, chip bg + 1 px border private Texture2D _overlayUploadIcon; // 64×64 anti-aliased upload glyph (document + up arrow) private GUIStyle _overlayFrameStyle; private GUIStyle _overlayCardStyle; private GUIStyle _overlayChipStyle; private bool _overlayResourcesIsProSkin; private bool _overlayResourcesBuilt; #if UNITY_EDITOR_OSX // macOS WebView support private MacOSWebViewBridge _webViewBridge; private Vector2 _lastWindowSize; private bool _webViewInitialized = false; private IntPtr _pendingStableGUIViewHandle = IntPtr.Zero; private bool _webViewFrameSyncPending = false; [SerializeField] private long _serializedWebViewHandle = 0; [SerializeField] private bool _serializedWebViewInitialized = false; // Set in OnEnable when we restore a serialized WebView but the Tauri server // is NOT the same live instance it was loaded against (it was killed and // relaunched, or we re-attached to a fresh one). The restored page is then // bound to a dead/replaced backend and sits on a white screen — its // EventSource silently reconnects but the SPA never re-runs its load // handshake. Consumed by OnEditorUpdate once the new server is HTTP-ready. private bool _pendingWebViewReloadAfterRelaunch = false; // HTTP-readiness gate for the embedded WebView. _tauriServerPort is assigned // the instant we pick the port — long before the spawned Tauri process binds // it — so loading on "port > 0" alone races the server and lands on a // permanent connection-refused white screen (WKWebView never retries a failed // navigation). We only create/reload the WebView after /api/tauri/status // actually answers on that port. _serverHttpReady and _serverReadyProbeRunning // are written from the probe's background task, so they are volatile. private volatile bool _serverHttpReady = false; private volatile bool _serverReadyProbeRunning = false; private int _serverReadyProbePort = -1; #endif private string _ipcOwnerId; private bool _ipcAcquired; private bool _ipcEventsSubscribed; [SerializeField] private bool _eventTrackedOnEnable = false; private bool _panelClosedEventSent; private bool _panelOpenedMetricQueued; /// /// True for throwaway windows discarded during /// fallback; suppresses open/close lifecycle metrics. /// private bool _suppressPanelLifecycleMetrics; // Theme tracking private bool _lastObservedIsProSkin; private string _lastSentThemeMode = null; private bool _themeModeDirty = true; // If true, we should keep trying to send the theme until it succeeds private bool _isDetachMode = false; /// When true, clears Unity-side state but does not kill the Tauri process (e.g. workspace switch from Tauri). private bool _preserveTauriProcessOnDestroy = false; // Setup UI (when cowork not found): show install guide inside this window instead of CodelySetupWindow private bool _showSetupUI = false; private const string SetupDownloadUrl = "https://codely.tuanjie.cn/download"; private const float SetupLogoTopMargin = 16f; // 顶部 Logo+文字 距窗口顶端的距离 private const string SetupIconsPath = "Packages/cn.tuanjie.codely.bridge/Editor/Bridge/Icons/"; private const string SetupFontsPath = "Packages/cn.tuanjie.codely.bridge/Editor/Bridge/fonts/"; private static readonly Color SetupPrimaryGreen = new Color(1f / 255f, 167f / 255f, 127f / 255f, 1f); // #01A77F private static readonly Color SetupPrimaryBtnNormal = new Color(1f / 255f, 167f / 255f, 127f / 255f, 1f); // #01A77F private static readonly Color SetupPrimaryBtnHover = new Color(77f / 255f, 193f / 255f, 165f / 255f, 1f); // #4dc1a5 private static readonly Color SetupPrimaryBtnPress = new Color(1f / 255f, 136f / 255f, 102f / 255f, 1f); // #018866 private static readonly Color SetupTextPrimary = new Color(0.95f, 0.95f, 0.95f, 1f); private static readonly Color SetupTextSecondary = new Color(0.60f, 0.60f, 0.65f, 1f); private static readonly Color SetupLinkNormal = new Color(107f / 255f, 114f / 255f, 128f / 255f, 1f); // #6B7280 private static readonly Color SetupLinkHover = new Color(1f, 1f, 1f, 1f); // #FFFFFF private static readonly Color SetupLinkPress = new Color(156f / 255f, 163f / 255f, 175f / 255f, 1f); // #9CA3AF private static readonly Color SetupMainTitleColor = new Color(1f, 1f, 1f, 1f); // #FFFFFF private static readonly Color SetupSubtitleColor = new Color(156f / 255f, 163f / 255f, 175f / 255f, 1f); // #9CA3AF private bool _setupStylesBuilt; private GUIStyle _setupLogoNameStyle; private GUIStyle _setupMainTitleStyle; private GUIStyle _setupSubtitleStyle; private GUIStyle _setupPrimaryBtnStyle; private GUIStyle _setupLinkStyle; private Texture2D _setupGreenTex; private Texture2D _setupPrimaryBtnNormalTex; private Texture2D _setupPrimaryBtnHoverTex; private Texture2D _setupPrimaryBtnPressTex; private Texture2D _setupLogoTex; private Texture2D _setupIllustrationTex; private Texture2D _setupDownloadIconTex; private Rect _setupIllustrationRect; private static bool _setupLinkPressed; private bool _isAttachingToEditor = false; // Loading UI 状态 private bool _showUpdateLoadingUI = false; private string _updateVersion = ""; private float _loadingAnimationTime = 0f; private bool _isExpectingTauriRestart = false; private DateTime _suppressCloseUntilUtc = DateTime.MinValue; private const double UPDATE_RESTART_DISCONNECT_GRACE_SECONDS = 8d; private static readonly HttpClient _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(3), }; public static void ShowWindow() { // 已存在实例时,直接 focus,保留用户当前布局 if (HasOpenInstances()) { foreach (var existing in Resources.FindObjectsOfTypeAll()) { if (existing == null) continue; existing.minSize = new Vector2(800, 600); existing.ApplyTitleIcon(); existing.Show(); existing.Focus(); return; } } // 新建实例,作为 main editor 最左侧的 dock panel 插入(结构参考 // Editor/Layout/Tuanjie AI Mode.wlt)。其他已存在的 dock 不会被销毁, // 只会按 SplitView 比例收缩让出空间。 var window = CreateInstance(); window._suppressPanelLifecycleMetrics = true; window.titleContent = new GUIContent(" Tuanjie AI"); window.minSize = new Vector2(800, 600); if (CodelyWindowDocker.TryDockLeftmost(window)) { window._suppressPanelLifecycleMetrics = false; window.ApplyTitleIcon(); window.Show(); window.Focus(); return; } // Fallback:反射失败时退回原有的 tab dock 行为,至少能把窗口打开 DestroyImmediate(window); var editorAssembly = typeof(UnityEditor.Editor).Assembly; var inspectorType = editorAssembly.GetType("UnityEditor.InspectorWindow"); var sceneViewType = typeof(UnityEditor.SceneView); var gameViewType = editorAssembly.GetType("UnityEditor.GameView"); var consoleType = editorAssembly.GetType("UnityEditor.ConsoleWindow"); var hierarchyType = editorAssembly.GetType("UnityEditor.SceneHierarchyWindow"); var desiredDockNextTo = new List(); if (hierarchyType != null) desiredDockNextTo.Add(hierarchyType); if (inspectorType != null) desiredDockNextTo.Add(inspectorType); desiredDockNextTo.Add(sceneViewType); if (gameViewType != null) desiredDockNextTo.Add(gameViewType); if (consoleType != null) desiredDockNextTo.Add(consoleType); var fallback = GetWindow("Tuanjie AI", true, desiredDockNextTo.ToArray()); fallback.minSize = new Vector2(800, 600); fallback.ApplyTitleIcon(); fallback.Show(); } /// /// If a CodelyWindow instance is open, focus it; otherwise open one. /// public static void FocusOrOpenWindow() { if (HasOpenInstances()) { var windows = Resources.FindObjectsOfTypeAll(); if (windows.Length > 0 && windows[0] != null) { windows[0].Focus(); return; } } ShowWindow(); } [MenuItem("AI/Open Assistant %l", priority = 995)] public static void ToggleCodely() { if (string.IsNullOrEmpty(GetCodelyRunnable())) { ShowWindow(); return; } // If drill walkthrough has not been completed, open DrillWindow instead if (!TauriUtils.IsDrillCompleted()) { DrillWindow.OpenWalkThrough(); return; } var windows = Resources.FindObjectsOfTypeAll(); // 判断主 Codely 窗口是否打开 if (HasOpenInstances()) { var readyWindow = GetReadyWindowOrNull(windows); if (readyWindow == null) { ShowWindow(); CodelyLogger.Log("Codely: Window exists but not initialized, reopening"); return; } foreach (var window in windows) { if (window != null && window.GetEditorWindowHandle() != IntPtr.Zero) { window.Show(); window.Focus(); } } } else { ShowWindow(); CodelyLogger.Log("Codely: Window opened via Ctrl+L"); } } [MenuItem("AI/Open CLI", priority = 996)] public static void OpenCodelyTerminal() { string cliPath = DrillWindow.GetCodelyCliRunnable(); if (string.IsNullOrEmpty(cliPath)) { EditorUtility.DisplayDialog("Tuanjie AI CLI Not Found", "codely CLI not found. Set CODELY_HOME (containing codely[.exe]) or CODELY_APP_HOME (containing cli/bin//codely[.exe]).", "OK"); return; } try { CodelyLogger.Log("Codely: Opening CLI in terminal: " + cliPath); #if UNITY_EDITOR_WIN Process.Start(new ProcessStartInfo { FileName = "cmd.exe", Arguments = $"/k \"{cliPath}\"", UseShellExecute = true, }); #elif UNITY_EDITOR_OSX // AppleScript-escape: backslash first, then double quote. // `quoted form of` then shell-quotes the path at AppleScript runtime, // so spaces in paths like "/Applications/Codely Cowork.app/..." survive // into Terminal's shell as a single argument. string asEscaped = cliPath.Replace("\\", "\\\\").Replace("\"", "\\\""); Process.Start(new ProcessStartInfo { FileName = "osascript", // `do script` launches the CLI in a new Terminal tab; a follow-up // `activate` brings Terminal to the foreground so the user can type // straight away instead of clicking back to the new window. Arguments = $"-e 'tell application \"Terminal\" to do script (quoted form of \"{asEscaped}\")' -e 'tell application \"Terminal\" to activate'", UseShellExecute = true, }); #else Process.Start(new ProcessStartInfo { FileName = cliPath, UseShellExecute = true, }); #endif } catch (Exception ex) { CodelyLogger.LogError($"Codely: Failed to open CLI in terminal: {ex.Message}"); EditorUtility.DisplayDialog("Tuanjie AI", $"Failed to open terminal: {ex.Message}", "OK"); } } [MenuItem("AI/Force Reload", priority = 999)] public static void ForceReloadCodely() { var window = GetWindow(); if (window != null) { // Clean up the Tauri process window.CleanupTauriProcess(); CodelyEventTracker.SuppressNextPanelClose(); // Close the Codely window window.Close(); } EditorApplication.delayCall += () => { ToggleCodely(); }; } // [MenuItem("Window/Codely/Force Quit", priority = 1010)] // public static void QuitCodely() // { // // Get the current Codely window // var window = GetWindow(); // if (window != null) // { // // Clean up the Tauri process // window.CleanupTauriProcess(); // // Close the Codely window // window.Close(); // CodelyLogger.Log("Codely: Quit Codely - Tauri process cleaned up and window closed"); // } // } [MenuItem("Assets/AI/Add to context", false, 110)] private static void AddSelectedAssetsToCodely() { CodelyLogger.Log("AddSelectedAssetsToCodely"); SendSelectedItemsToTauriInternal("Assets"); } private static bool _pendingGameObjectSelection = false; private static List _pendingContextItems = null; // IPC owner used by the static "Add to context" path so it can start the // shared IPC client without an open CodelyWindow. Released once the queued // items are delivered (a live window's own owner keeps the client alive). private const string STATIC_CONTEXT_IPC_OWNER_ID = "CodelyWindow:StaticContextSender"; private static bool _staticContextIpcAcquired = false; [MenuItem("GameObject/AI/Add to context", false, 110)] private static void AddSelectedGameObjectsToCodely() { if (_pendingGameObjectSelection) return; _pendingGameObjectSelection = true; EditorApplication.delayCall += () => { _pendingGameObjectSelection = false; CodelyLogger.Log("AddSelectedGameObjectsToCodely"); SendSelectedItemsToTauriInternal("GameObject"); }; } // [MenuItem("Window/Codely/Add Console to Context %&d", priority = 1001)] // private static void AddUnityConsoleToContext() // { // SendDebugUnityConsoleMessageStatic(); // } // [MenuItem("Window/Codely/New Session %&n", priority = 1000)] // private static void FocusInputWithNewSession() // { // SendFocusInputWithNewSessionMessageStatic(); // } // #if UNITY_EDITOR_OSX // [MenuItem("Window/Codely/Open DevTools", priority = 1002)] // private static void OpenDevTools() // { // var windows = Resources.FindObjectsOfTypeAll(); // var readyWindow = GetReadyWindowOrNull(windows); // if (readyWindow != null && readyWindow._webViewBridge != null) // { // readyWindow._webViewBridge.ShowInspector(); // CodelyLogger.Log("Codely: Opening Web Inspector..."); // } // else // { // CodelyLogger.LogWarning("Codely: No Codely window ready or WebView not initialized"); // } // } // [MenuItem("Window/Codely/Open DevTools", true, priority = 1002)] // private static bool ValidateOpenDevTools() // { // var windows = Resources.FindObjectsOfTypeAll(); // var readyWindow = GetReadyWindowOrNull(windows); // return readyWindow != null && readyWindow._webViewBridge != null && readyWindow._webViewBridge.IsInitialized; // } // #elif UNITY_EDITOR_WIN // [MenuItem("Window/Codely/Open DevTools", priority = 1002)] // private static void OpenDevTools() // { // var windows = Resources.FindObjectsOfTypeAll(); // var readyWindow = GetReadyWindowOrNull(windows); // if (readyWindow?._webView2Host != null && readyWindow._webView2Host.IsReady) // { // readyWindow._webView2Host.ShowDevTools(); // CodelyLogger.Log("Codely: Opening WebView2 DevTools..."); // } // else // { // CodelyLogger.LogWarning("Codely: No Codely window ready or WebView2 not initialized"); // } // } // [MenuItem("Window/Codely/Open DevTools", true, priority = 1002)] // private static bool ValidateOpenDevTools() // { // var windows = Resources.FindObjectsOfTypeAll(); // var readyWindow = GetReadyWindowOrNull(windows); // return readyWindow?._webView2Host != null && readyWindow._webView2Host.IsReady; // } // #endif private static void SendSelectedItemsToTauriInternal(string source) { // Capture selection immediately — it may change once focus moves. var dropItems = BuildDropItemsFromSelection(); if (dropItems == null) return; if (CodelyIpcManager.IsConnected) { SendContextItemsViaIpc(dropItems); return; } // IPC isn't connected yet. Make sure the shared IPC client is started // (it may not be, if no CodelyWindow is open) so the queued items can be // delivered once it connects. Do NOT open the CodelyWindow here — adding // context must not force the assistant window open. EnsureStaticContextIpcAcquired(); _pendingContextItems = dropItems; // Guard against double-subscription if triggered multiple times before IPC connects CodelyIpcManager.OnConnectionChanged -= OnIpcConnectionChangedForPendingContext; CodelyIpcManager.OnConnectionChanged += OnIpcConnectionChangedForPendingContext; } private static List BuildDropItemsFromSelection() { var selectedObjects = Selection.objects; if (selectedObjects == null || selectedObjects.Length == 0) return null; var dropItems = new List(); foreach (var obj in selectedObjects) { if (obj == null) continue; var itemData = new DropItemData { name = obj.name, type = obj.GetType().Name }; string assetPath = AssetDatabase.GetAssetPath(obj); if (!string.IsNullOrEmpty(assetPath)) itemData.path = Path.GetFullPath(assetPath); else if (obj is UnityEngine.GameObject go) itemData.path = GetGameObjectPath(go); else itemData.path = obj.name; dropItems.Add(itemData); } return dropItems.Count > 0 ? dropItems : null; } private static void SendContextItemsViaIpc(List dropItems) { var payload = new DropItemsPayload { items = dropItems.ToArray() }; string json = JsonUtility.ToJson(payload); CodelyIpcManager.TrySend(IpcMessageType.AddContexts, json); } private static void OnIpcConnectionChangedForPendingContext(bool connected) { if (!connected) return; CodelyIpcManager.OnConnectionChanged -= OnIpcConnectionChangedForPendingContext; var items = _pendingContextItems; _pendingContextItems = null; if (items != null && items.Count > 0) SendContextItemsViaIpc(items); // Items delivered (SendMessage is synchronous): drop our standalone IPC // owner so the shared client can stop when no CodelyWindow keeps it alive. ReleaseStaticContextIpcIfHeld(); } /// /// Start the shared IPC client on behalf of the static "Add to context" path /// when no CodelyWindow is open. Idempotent; the window's own owner (if any) /// is independent and unaffected. /// private static void EnsureStaticContextIpcAcquired() { if (_staticContextIpcAcquired) return; CodelyIpcManager.Acquire(STATIC_CONTEXT_IPC_OWNER_ID); _staticContextIpcAcquired = true; } private static void ReleaseStaticContextIpcIfHeld() { if (!_staticContextIpcAcquired) return; _staticContextIpcAcquired = false; CodelyIpcManager.Release(STATIC_CONTEXT_IPC_OWNER_ID); } private static CodelyWindow GetReadyWindowOrNull(CodelyWindow[] windows) { if (windows == null || windows.Length == 0) { return null; } foreach (var window in windows) { if (window == null) continue; #if UNITY_EDITOR_WIN if (window._webView2Initialized || window.GetEditorWindowHandle() != IntPtr.Zero) return window; #else if (window.GetEditorWindowHandle() != IntPtr.Zero) return window; #endif } return null; } private static void TryAutoOpenAfterInstall() { // Skip auto-open during unattended editor runs (batch mode / -nographics CI / QA // automation). Creating the window there triggers a native 'SUCCEEDED(hr)' assertion // that flips otherwise-passing test runs to FAIL. See TauriUtils.IsAutomatedEditorRun. if (TauriUtils.IsAutomatedEditorRun()) { return; } if (!SessionState.GetBool(SESSION_STATE_AUTO_OPEN_AFTER_INSTALL_KEY, false)) { return; } SessionState.EraseBool(SESSION_STATE_AUTO_OPEN_AFTER_INSTALL_KEY); EditorApplication.delayCall += () => { if (!HasOpenInstances()) { ToggleCodely(); CodelyLogger.Log("Codely: Auto-opened via ToggleCodely after package install"); } }; } private void OnEnable() { ApplyTitleIcon(); if (string.IsNullOrEmpty(GetCodelyRunnable())) { CodelyLogger.LogError("Codely: cowork runnable not found. Make sure cowork is installed."); _showSetupUI = true; return; } _lastWindowParentHandle = GetEditorWindowHandle(); _lastObservedIsProSkin = EditorGUIUtility.isProSkin; _lastSentThemeMode = null; _themeModeDirty = true; #if UNITY_EDITOR_OSX if (_webViewBridge == null) { _webViewBridge = new MacOSWebViewBridge(); _webViewInitialized = false; } // Try to restore from serialized handle if (_serializedWebViewInitialized && _serializedWebViewHandle != 0) { IntPtr savedHandle = new IntPtr(_serializedWebViewHandle); try { if (_webViewBridge.RestoreFromHandle(savedHandle)) { _webViewInitialized = true; _webViewBridge.SetHidden(!_isWindowVisible); QueueWebViewFrameSync(); // Clear serialized data after successful restore to prevent accumulation _serializedWebViewHandle = 0; _serializedWebViewInitialized = false; // Schedule validation check after a short delay to ensure content is loaded EditorApplication.delayCall += () => { System.Threading.Tasks.Task.Delay(2000).ContinueWith(_ => { EditorApplication.delayCall += () => ValidateRestoredWebView(); }); }; } else { CodelyLogger.Log("Codely: WebView handle invalid, will recreate"); _serializedWebViewHandle = 0; _serializedWebViewInitialized = false; _webViewInitialized = false; } } catch (Exception ex) { CodelyLogger.LogError($"Codely: Exception while restoring WebView: {ex.Message}"); _serializedWebViewHandle = 0; _serializedWebViewInitialized = false; _webViewInitialized = false; } } else if (_webViewBridge.IsInitialized) { _webViewInitialized = true; _webViewBridge.SetHidden(!_isWindowVisible); QueueWebViewFrameSync(); } bool tauriRestoredOSX = TryRestoreTauriProcess(); // Only a successful TryRestoreTauriProcess() proves the *same* Tauri // process the embedded WebView was loaded against is still alive — the // one case its page is guaranteed not stale. Attaching via the project // lock or relaunching means the backend is a fresh instance, so a // WebView restored from the serialized handle must be reloaded once the // new server connects (see _pendingWebViewReloadAfterRelaunch). bool restoredSameTauriInstance = tauriRestoredOSX; if (!tauriRestoredOSX) { tauriRestoredOSX = TryAttachToExistingProjectTauriFromLock(); } if (_webViewInitialized && !restoredSameTauriInstance) { _pendingWebViewReloadAfterRelaunch = true; // The backend changed; force a fresh readiness probe before the // restored WebView is reloaded (the new server may reuse the same // port, so the stale "ready" must be cleared explicitly). ResetServerReadyGate(); } AcquireIpcClient(); if (!tauriRestoredOSX && !_isDetachMode) { LaunchTauriApplication(); } #else // Windows: 使用 WebView2 内嵌浏览器,Tauri 以 server-only 模式运行 // 先判断是否需要新启动,若需要则先杀掉残留进程再连接 IPC, // 避免 IPC 连接到旧进程导致管道冲突和端口竞态。 bool tauriRestored = TryRestoreTauriProcess(); if (!tauriRestored) { tauriRestored = TryAttachToExistingProjectTauriFromLock(); } AcquireIpcClient(); if (!tauriRestored && !_isDetachMode) { LaunchTauriApplication(); } // 尝试从序列化 handle 恢复 WebView2(Domain Reload 后复用已存活的 BrowserInstance) bool webView2Restored = false; bool hasAnotherLiveWindow = HasAnotherLiveCodelyWindow(); if (!hasAnotherLiveWindow && _serializedWebView2Valid && _serializedWebView2Handle != 0) { IntPtr savedHandle = new IntPtr(_serializedWebView2Handle); try { var host = new WebView2Host(); if (host.RestoreFromHandle(savedHandle)) { _webView2Host = host; _webView2Initialized = true; _currentGUIViewHWND = NativeWindowHelper.GetHWND(this); host.SetVisible(_isWindowVisible); webView2Restored = true; CodelyLogger.Log("[Codely] WebView2 restored from handle after Domain Reload"); Repaint(); } else { CodelyLogger.LogWarning("[Codely] WebView2 handle invalid after Domain Reload, will recreate"); } } catch (Exception ex) { CodelyLogger.LogError($"[Codely] Exception restoring WebView2: {ex.Message}"); } finally { _serializedWebView2Handle = 0; _serializedWebView2Valid = false; } } if (!webView2Restored) { _webView2Initialized = false; _currentGUIViewHWND = IntPtr.Zero; } #endif // Report after Tauri restore/launch so HTTP port is available when possible. TrackPanelOpenedIfNeeded(); EditorApplication.update += OnEditorUpdate; } private static Texture2D LoadIcon(string fileName) => AssetDatabase.LoadAssetAtPath(TAURI_ICONS_BASE_PATH + fileName); private void ApplyTitleIcon() { var icon = EditorGUIUtility.isProSkin ? LoadIcon("title_icon.png") : LoadIcon("title_icon_light_theme.png"); if (icon == null) return; titleContent = new GUIContent(" Tuanjie AI", icon); } private void OnDisable() { EditorApplication.update -= OnEditorUpdate; ReleaseIpcClient(); #if UNITY_EDITOR_WIN DisposeDragSnapshot(); #endif DisposeOverlayResources(); if (_setupGreenTex != null) { DestroyImmediate(_setupGreenTex); _setupGreenTex = null; } if (_setupPrimaryBtnNormalTex != null) { DestroyImmediate(_setupPrimaryBtnNormalTex); _setupPrimaryBtnNormalTex = null; } if (_setupPrimaryBtnHoverTex != null) { DestroyImmediate(_setupPrimaryBtnHoverTex); _setupPrimaryBtnHoverTex = null; } if (_setupPrimaryBtnPressTex != null) { DestroyImmediate(_setupPrimaryBtnPressTex); _setupPrimaryBtnPressTex = null; } _setupStylesBuilt = false; #if UNITY_EDITOR_WIN // Domain Reload 保活:序列化 handle,让 WebView2 在 Reload 期间保持存活, // OnEnable 中恢复复用,避免重建导致的白屏闪烁。可见性由 _isWindowVisible(序列化)在恢复时决定。 if (_webView2Host != null && _webView2Host.IsReady) { // 先将回调清零:Domain Reload 后旧委托失效,清零可防止 Reload 期间意外触发崩溃 var emptyCallbacks = new NativeBrowserAPI.Callbacks { ctx = IntPtr.Zero }; NativeBrowserAPI.NB_SetCallbacks(_webView2Host.Handle, ref emptyCallbacks); _serializedWebView2Handle = _webView2Host.Handle.ToInt64(); _serializedWebView2Valid = true; _webView2Host = null; // 释放托管引用;native BrowserInstance 继续存活 } else { _serializedWebView2Handle = 0; _serializedWebView2Valid = false; _webView2Host?.SetVisible(false); } #endif #if UNITY_EDITOR_OSX // Serialize WebView handle for restoration after domain reload if (_webViewBridge != null && _webViewBridge.IsInitialized) { try { IntPtr handle = _webViewBridge.Handle; if (handle != IntPtr.Zero) { _serializedWebViewHandle = handle.ToInt64(); _serializedWebViewInitialized = true; } } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: Failed to serialize WebView handle: {ex.Message}"); _serializedWebViewHandle = 0; _serializedWebViewInitialized = false; } } #endif } private void OnDestroy() { TryNotifyPanelClosed(); bool preserveSharedResources = WindowResourcePreservationPolicy.ShouldPreserveSharedResources( _isDetachMode, GetLiveCodelyWindowCount()); if (!preserveSharedResources) { ReleaseIpcClient(); } #if UNITY_EDITOR_WIN // 若 OnDisable 序列化了 handle 但 OnEnable 未运行(窗口被真正关闭而非 Domain Reload), // 需要手动销毁 native BrowserInstance,防止内存泄漏 if (!preserveSharedResources && _serializedWebView2Valid && _serializedWebView2Handle != 0) { NativeBrowserAPI.NB_Destroy(new IntPtr(_serializedWebView2Handle)); _serializedWebView2Handle = 0; _serializedWebView2Valid = false; } if (!preserveSharedResources) { DisposeWebView2Host(); } #endif // 清除序列化的 WebView 指针,避免下次启动时尝试恢复已销毁的 WebView #if UNITY_EDITOR_OSX _serializedWebViewHandle = 0; _serializedWebViewInitialized = false; #endif // 如果处于 detach 模式,关闭窗口时保留 Tauri 进程 if (preserveSharedResources) { _preserveTauriProcessOnDestroy = true; _isDetachMode = false; } // 异步清理资源,避免阻塞窗口关闭 CleanupResourcesAsync(); } /// /// 异步清理资源,不阻塞窗口关闭 /// private async void CleanupResourcesAsync() { // The Tauri process is never killed on close — it is shared across every // Unity Editor / Cowork window in this user session, and it owns its own // lifetime (it exits by itself once its last workspace closes). // `_preserveTauriProcessOnDestroy` is set when the Editor window is // closing because of a workspace switch initiated from the Tauri // side: in that case we additionally keep the WebView alive (it is // reused by the next session). bool preserveTauri = _preserveTauriProcessOnDestroy; _preserveTauriProcessOnDestroy = false; _tauriProcess = null; _tauriServerPort = -1; SessionState.EraseInt(SESSION_STATE_TAURI_PID_KEY); SessionState.EraseInt(SESSION_STATE_TAURI_PORT_KEY); #if UNITY_EDITOR_OSX var webViewToDestroy = _webViewBridge; _webViewBridge = null; _serializedWebViewHandle = 0; _serializedWebViewInitialized = false; #endif // Background-thread cleanup: stop IPC client. The shared Tauri // process is owned app-wide and survives this window's teardown; // when its last workspace closes, Tauri exits on its own via its // window-close-requested handlers. await System.Threading.Tasks.Task.Run(() => { try { StopIpcClientStatic(); } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: Error stopping IPC client: {ex.Message}"); } if (preserveTauri) { CodelyLogger.Log("Codely: Workspace switch close — Tauri process left running, local state cleared"); } }); #if UNITY_EDITOR_OSX // WKWebView 必须在主线程销毁 if (!preserveTauri && webViewToDestroy != null && webViewToDestroy.IsInitialized) { EditorApplication.delayCall += () => { try { webViewToDestroy.Destroy(); CodelyLogger.Log("Codely: WebView destroyed asynchronously"); } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: Error destroying WebView: {ex.Message}"); } }; } #endif } private void OnGUI() { if (_showSetupUI) { DrawSetupUI(); return; } #if UNITY_EDITOR_OSX // macOS: Use WKWebView var webViewRect = GUILayoutUtility.GetRect( GUIContent.none, GUIStyle.none, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); HandleDragAndDrop(webViewRect); if (_isDragOverlay) { var overlayRect = new Rect(0, 0, position.width, position.height); DrawDropOverlay(overlayRect); } else if (_webViewBridge == null || !_webViewBridge.IsInitialized) { GUI.Box(webViewRect, "Loading Tuanjie AI..."); } #else // Windows: 使用 WebView2 内嵌浏览器 // embedRect 由 Unity 布局系统分配,y > 0 部分即为标题/Tab 栏高度 var embedRect = GUILayoutUtility.GetRect( GUIContent.none, GUIStyle.none, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); // 处理 Unity 拖拽事件 HandleDragAndDrop(embedRect); if (_isDragOverlay && !_showUpdateLoadingUI) { // WebView2 已通过 SetVisible(false) 隐藏,在 GUIView 上绘制拖拽覆盖层 var overlayRect = new Rect(0, 0, position.width, position.height); DrawDropOverlay(overlayRect); } else if (!_webView2Initialized) { GUI.Box(embedRect, "Loading Tuanjie AI..."); // Focus-independent backstop: 失焦启动时 EditorApplication.update / delayCall // 都被节流,server_ready 事件链可能整段卡住。OnGUI 在窗口收到 WM_PAINT 时仍会跑 // (CodelyIpcServer.WakeUnityMainThread 强制 InvalidateRect 触发了 WM_PAINT), // 因此在此处兜底拾起 IPC 后台线程已写入的 URL,直接推进 WebView2 初始化。 // 只在 Layout 阶段触发一次;Repaint 等阶段仅作绘制。 // TryInitializeWebView2 内部有 _webView2Initializing/_webView2Initialized 重入保护, // 后续 delayCall 真正跑起来 OnTauriServerReady 再调一次也是无副作用 no-op。 if (Event.current.type == EventType.Layout && !_webView2Initializing && !HasAnotherLiveCodelyWindow() && _isWindowVisible) { if (_tauriServerPort <= 0) { string pendingUrl = CodelyIpcManager.LastServerReadyUrl; if (!string.IsNullOrEmpty(pendingUrl) && Uri.TryCreate(pendingUrl, UriKind.Absolute, out var pendingUri) && pendingUri.Port > 0) { _tauriServerPort = pendingUri.Port; SessionState.SetInt(SESSION_STATE_TAURI_PORT_KEY, _tauriServerPort); CodelyLogger.Log($"[Codely] OnGUI backstop: picked up port={_tauriServerPort} from background-thread server_ready cache"); } } if (_tauriServerPort > 0) { TryInitializeWebView2(); } } } if (_webView2Host != null && _webView2Host.IsReady) { Vector2 contentOrigin = GUIUtility.GUIToScreenPoint(Vector2.zero); int insetPx = UnityEngine.Mathf.Max(0, UnityEngine.Mathf.RoundToInt((contentOrigin.y - position.y) * EditorGUIUtility.pixelsPerPoint)); _cachedTopInsetPx = insetPx; if (insetPx != _lastTopInsetPx) { _lastTopInsetPx = insetPx; _webView2Host.SetTopInset(insetPx); } } #endif // Update loading overlay on top of GUIView (native WebView hidden — same idea as drag overlay on Windows). if (_showUpdateLoadingUI) { DrawUpdateLoadingUI(); } } private void BuildSetupStyles() { if (_setupStylesBuilt) return; _setupGreenTex = MakeSetupTex(SetupPrimaryGreen); _setupPrimaryBtnNormalTex = MakeSetupTex(SetupPrimaryBtnNormal); _setupPrimaryBtnHoverTex = MakeSetupTex(SetupPrimaryBtnHover); _setupPrimaryBtnPressTex = MakeSetupTex(SetupPrimaryBtnPress); _setupLogoTex = AssetDatabase.LoadAssetAtPath(SetupIconsPath + "codely_logo.png"); _setupIllustrationTex = AssetDatabase.LoadAssetAtPath(SetupIconsPath + "setup_main.png"); _setupDownloadIconTex = AssetDatabase.LoadAssetAtPath(SetupIconsPath + "setup_download.png"); var logoFont = AssetDatabase.LoadAssetAtPath(SetupFontsPath + "NotoSans-Regular.ttf") ?? AssetDatabase.LoadAssetAtPath(SetupFontsPath + "Inter.ttc"); _setupLogoNameStyle = new GUIStyle(EditorStyles.label) { font = logoFont ?? EditorStyles.label.font, fontSize = 18, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft, normal = { textColor = SetupTextPrimary }, hover = { textColor = SetupTextPrimary }, }; _setupMainTitleStyle = new GUIStyle(EditorStyles.label) { fontSize = 28, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, wordWrap = true, normal = { textColor = SetupMainTitleColor }, hover = { textColor = SetupMainTitleColor }, }; _setupSubtitleStyle = new GUIStyle(EditorStyles.label) { fontSize = 13, alignment = TextAnchor.MiddleCenter, wordWrap = true, normal = { textColor = SetupSubtitleColor }, hover = { textColor = SetupSubtitleColor }, }; _setupPrimaryBtnStyle = new GUIStyle(EditorStyles.label) { fontSize = 14, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, fixedHeight = 44, normal = { background = null, textColor = Color.white }, hover = { background = null, textColor = Color.white }, active = { background = null, textColor = Color.white }, focused = { background = null, textColor = Color.white }, }; _setupLinkStyle = new GUIStyle(EditorStyles.label) { fontSize = 12, alignment = TextAnchor.MiddleCenter, wordWrap = true, normal = { textColor = SetupLinkNormal }, hover = { textColor = SetupLinkHover }, active = { textColor = SetupLinkPress }, }; _setupLinkStyle.normal.textColor = SetupLinkNormal; _setupLinkStyle.hover.textColor = SetupLinkHover; _setupLinkStyle.active.textColor = SetupLinkPress; _setupLinkStyle.fontStyle = FontStyle.Normal; _setupStylesBuilt = true; } private static Texture2D MakeSetupTex(Color c) { var t = new Texture2D(1, 1, TextureFormat.RGBA32, false); t.SetPixel(0, 0, c); t.Apply(); return t; } private void DrawUpdateLoadingUI() { _loadingAnimationTime += Time.deltaTime; float w = position.width; float h = position.height; // Full-screen overlay matching design: solid #2d2d2d const float bgR = 0x2d / 255f; Color bgColor = new Color(bgR, bgR, bgR, 1f); GUI.DrawTexture(new Rect(0, 0, w, h), EditorGUIUtility.whiteTexture, ScaleMode.StretchToFill, true, 0f, bgColor, 0f, 0f); const float contentWidth = 480f; float innerW = Mathf.Min(contentWidth, w - 48f); float centerX = w * 0.5f; GUIStyle titleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 22, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, normal = { textColor = Color.white } }; GUIStyle versionStyle = new GUIStyle(EditorStyles.label) { fontSize = 14, alignment = TextAnchor.MiddleCenter, normal = { textColor = new Color(0.62f, 0.62f, 0.62f, 1f) } }; GUIStyle statusStyle = new GUIStyle(EditorStyles.label) { fontSize = 13, alignment = TextAnchor.MiddleCenter, normal = { textColor = new Color(0.78f, 0.78f, 0.78f, 1f) } }; GUIStyle footerStyle = new GUIStyle(EditorStyles.label) { fontSize = 11, alignment = TextAnchor.MiddleCenter, wordWrap = true, normal = { textColor = new Color(0.42f, 0.42f, 0.42f, 1f) } }; const string statusText = "Tuanjie AI is updating. Please wait..."; const string footerText = "The window will automatically restore when the update completes."; string versionLine = $"Updating to version {_updateVersion}"; float titleH = titleStyle.CalcHeight(new GUIContent("Updating Tuanjie AI"), innerW); float versionH = versionStyle.CalcHeight(new GUIContent(versionLine), innerW); float statusH = statusStyle.CalcHeight(new GUIContent(statusText), innerW); float footerH = footerStyle.CalcHeight(new GUIContent(footerText), innerW); const float gapTitleToVersion = 8f; const float gapVersionToSpinner = 28f; const float spinnerBlockH = 56f; const float gapSpinnerToStatus = 22f; const float gapStatusToDivider = 20f; const float dividerH = 1f; const float gapDividerToFooter = 14f; float dividerW = innerW * 0.55f; float totalH = titleH + gapTitleToVersion + versionH + gapVersionToSpinner + spinnerBlockH + gapSpinnerToStatus + statusH + gapStatusToDivider + dividerH + gapDividerToFooter + footerH; float x = centerX - innerW * 0.5f; float y = (h - totalH) * 0.5f; GUI.Label(new Rect(x, y, innerW, titleH), "Updating Tuanjie AI", titleStyle); y += titleH + gapTitleToVersion; GUI.Label(new Rect(x, y, innerW, versionH), versionLine, versionStyle); y += versionH + gapVersionToSpinner; // Original 8-dot spinner logic; discs instead of textured quads (circles) float spinnerCenterY = y + spinnerBlockH * 0.5f; float rotation = _loadingAnimationTime * 2f; float radius = 20f; Color loadingColor = new Color(0.1f, 0.7f, 0.5f, 1f); Handles.BeginGUI(); try { for (int i = 0; i < 8; i++) { float angle = (i * 45f + rotation) * Mathf.Deg2Rad; float dotX = centerX + Mathf.Cos(angle) * radius; float dotY = spinnerCenterY + Mathf.Sin(angle) * radius; float dotSize = 6f - i * 0.5f; float alpha = 1f - (i * 0.12f); Handles.color = new Color(loadingColor.r, loadingColor.g, loadingColor.b, alpha); Handles.DrawSolidDisc(new Vector3(dotX, dotY, 0f), Vector3.forward, dotSize * 0.5f); } } finally { Handles.EndGUI(); } y += spinnerBlockH + gapSpinnerToStatus; GUI.Label(new Rect(x, y, innerW, statusH), statusText, statusStyle); y += statusH + gapStatusToDivider; Color dividerColor = new Color(0.38f, 0.38f, 0.38f, 1f); float divX = centerX - dividerW * 0.5f; EditorGUI.DrawRect(new Rect(divX, y, dividerW, dividerH), dividerColor); y += dividerH + gapDividerToFooter; GUI.Label(new Rect(x, y, innerW, footerH), footerText, footerStyle); Repaint(); } /// /// Same pattern as drag overlay: hide native WebView so IMGUI can draw on top; restore when loading ends. /// private void ApplyWebViewHiddenForUpdateLoading(bool hideNativeWebView) { bool applied = false; #if UNITY_EDITOR_OSX if (_webViewBridge != null && _webViewBridge.IsInitialized) { _webViewBridge.SetHidden(hideNativeWebView); if (!hideNativeWebView) { UpdateWebViewFrame(); } applied = true; } #else if (_webView2Host != null && _webView2Host.IsReady) { _webView2Host.SetVisible(!hideNativeWebView); applied = true; } #endif if (applied) { Repaint(); } } private void DrawSetupUI() { BuildSetupStyles(); float w = position.width; float h = position.height; const float padH = 48f; const float logoSize = 40f; const float illustrationHeight = 120f; const float spacing = 24f; const float btnHeight = 44f; // 顶部:Logo (codely_logo.png) + "Codely" 固定在窗口最顶端 float logoGroupW = logoSize + 10f + 80f; float logoX = (w - logoGroupW) * 0.5f; float logoY = SetupLogoTopMargin; var logoRect = new Rect(logoX, logoY, logoSize, logoSize); if (_setupLogoTex != null) GUI.DrawTexture(logoRect, _setupLogoTex, ScaleMode.ScaleToFit); else { GUI.Box(logoRect, ""); var logoIcon = EditorGUIUtility.IconContent("d_console.warnicon.sml"); if (logoIcon != null && logoIcon.image != null) GUI.Label(logoRect, logoIcon); } GUI.Label(new Rect(logoX + logoSize + 10f, logoY + (logoSize - 20f) * 0.5f, 120f, 22f), "Tuanjie AI", _setupLogoNameStyle); // 其余内容(插图、主标题、副标题、主按钮、次要链接)作为一个整体,在窗口垂直居中 const float illusW = 230f; float illusH = _setupIllustrationTex != null ? illusW * ((float)_setupIllustrationTex.height / _setupIllustrationTex.width) : illustrationHeight; float blockHeight = illusH + spacing + 36f + 8f + 24f + 56f + btnHeight + 16f + 24f; float y = (h - blockHeight) * 0.5f; // 插图:setup_main.png(保持原图宽高比,显示区域宽度 230 像素) if (_setupIllustrationTex != null) { float illusX = (w - illusW) * 0.5f; _setupIllustrationRect = new Rect(illusX, y, illusW, illusH); GUI.DrawTexture(_setupIllustrationRect, _setupIllustrationTex, ScaleMode.ScaleToFit); } else { _setupIllustrationRect = new Rect(padH, y, w - padH * 2f, illustrationHeight); GUI.Box(_setupIllustrationRect, ""); } y += illusH + spacing; // 主标题(距副标题 8px) float titleW = w - padH * 2f; GUI.Label(new Rect(padH, y, titleW, 36f), "Tuanjie AI App", _setupMainTitleStyle); y += 44f; // 副标题(距主按钮 56px) GUI.Label(new Rect(padH, y, titleW, 24f), "Download Tuanjie AI App to get started", _setupSubtitleStyle); y += 56f; // 主按钮:Download for Desktop(距次要链接 16px) const float btnW = 360f; const float btnH = 44f; const float btnRadius = 8f; const float btnIconSize = 20f; // 按钮前图标尺寸(可调) const int btnIconTextGap = 2; // 图标与文字间隔(空格数,可调) float btnX = (w - btnW) * 0.5f; var btnRect = new Rect(btnX, y, btnW, btnH); EditorGUIUtility.AddCursorRect(btnRect, MouseCursor.Link); bool btnHover = Event.current != null && btnRect.Contains(Event.current.mousePosition); bool btnPress = Event.current != null && btnHover && Event.current.type == EventType.MouseDown && Event.current.button == 0; Texture2D btnTex = btnPress ? _setupPrimaryBtnPressTex : (btnHover ? _setupPrimaryBtnHoverTex : _setupPrimaryBtnNormalTex); GUI.DrawTexture(btnRect, btnTex, ScaleMode.StretchToFill, true, 0f, Color.white, 0f, btnRadius); var downloadContent = _setupDownloadIconTex != null ? new GUIContent(new string(' ', btnIconTextGap) + "Download for Desktop", _setupDownloadIconTex) : new GUIContent("Download for Desktop"); using (new EditorGUIUtility.IconSizeScope(new Vector2(btnIconSize, btnIconSize))) { if (GUI.Button(btnRect, downloadContent, _setupPrimaryBtnStyle)) Application.OpenURL(SetupDownloadUrl); } y += btnH + 12f; // 次要链接:I have installed the desktop client.(点击即重新检测) var linkContent = new GUIContent("I have installed the desktop client."); float linkW = _setupLinkStyle.CalcSize(linkContent).x; float linkX = (w - linkW) * 0.5f; var linkRect = new Rect(linkX, y, linkW + 8f, 24f); EditorGUIUtility.AddCursorRect(linkRect, MouseCursor.Link); if (Event.current != null) { bool over = linkRect.Contains(Event.current.mousePosition); if (Event.current.type == EventType.MouseDown && Event.current.button == 0) _setupLinkPressed = over; else if (Event.current.type == EventType.MouseUp || Event.current.type == EventType.MouseLeaveWindow) _setupLinkPressed = false; } if (GUI.Button(linkRect, linkContent, _setupLinkStyle)) { string runnable = GetCodelyRunnablePublic(); if (!string.IsNullOrEmpty(runnable)) { CodelyEventTracker.SuppressNextPanelClose(); Close(); ToggleCodely(); } else EditorUtility.DisplayDialog("Not Found", "Tuanjie AI App is still not detected. Please ensure it is installed.", "OK"); } bool linkHover = Event.current != null && linkRect.Contains(Event.current.mousePosition); Color linkColor = _setupLinkPressed ? SetupLinkPress : (linkHover ? SetupLinkHover : SetupLinkNormal); EditorGUI.DrawRect(new Rect(linkX + 4f, linkRect.yMax - 2f, linkW, 1f), linkColor); } private void OnEditorUpdate() { #if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN // Flush pending metrics as soon as the HTTP port becomes available. // In some flows (async launch / throttled update loop), `_tauriServerPort` // is still -1 when `codely_panel_opened` is first queued; polling // SessionState here prevents it from being delayed until close. if (_tauriServerPort <= 0) { int portFromSession = SessionState.GetInt(SESSION_STATE_TAURI_PORT_KEY, -1); if (portFromSession > 0) { _tauriServerPort = portFromSession; } } if (_tauriServerPort > 0) { CodelyEventTracker.FlushPending(_tauriServerPort); if (_panelOpenedMetricQueued && !CodelyEventTracker.HasPending) { MarkPanelOpenedMetricDelivered(); } } #endif #if UNITY_EDITOR_OSX // macOS: keep syncing theme mode (dark/light) to Tauri once IPC is connected CheckThemeChange(); if (_webViewBridge == null) { _webViewBridge = new MacOSWebViewBridge(); _webViewInitialized = false; } if (!_webViewInitialized) { InitializeWebView(); } else if (_webViewBridge.IsInitialized) { // A WebView restored from a previous (now dead/replaced) server must // be reloaded so the SPA re-bootstraps instead of sitting on a white // screen — but only once the relaunched server actually answers, or // we just swap one connection-refused page for another. if (_pendingWebViewReloadAfterRelaunch && _tauriServerPort > 0) { if (_serverHttpReady) { _pendingWebViewReloadAfterRelaunch = false; CodelyLogger.Log("Codely: Tauri server ready after relaunch — reloading embedded WebView to re-bootstrap the UI"); _webViewBridge.LoadURL(GetCurrentWebViewUrl()); } else { EnsureServerReadyProbe(_tauriServerPort); } } IntPtr currentHandle = GetEditorWindowHandle(); if (currentHandle != _lastWindowParentHandle && currentHandle != IntPtr.Zero) { QueueWebViewFrameSync(); } Vector2 currentSize = new Vector2(position.width, position.height); if (currentSize != _lastWindowSize) { _lastWindowSize = currentSize; QueueWebViewFrameSync(); } TryApplyQueuedWebViewFrameSync(); UpdateDragPassthrough(); } #else // Windows: 主题检测 CheckThemeChange(); // 拖拽穿透检测:当 Unity 拖拽发生时隐藏 WebView2,让 GUIView 接收拖拽事件 UpdateDragPassthrough(); if (!_isWindowVisible) return; if (HasAnotherLiveCodelyWindow()) return; // 检测 GUIView HWND 是否变化(停靠/浮动切换时会变化) // 注意:GetHWND 内含 EnumWindows 枚举,仅在 WebView2 未初始化时调用,避免每帧开销和潜在崩溃 if (!_webView2Initialized) { try { IntPtr currentHWND = NativeWindowHelper.GetHWND(this); if (currentHWND != IntPtr.Zero && currentHWND != _currentGUIViewHWND) { CodelyLogger.Log($"[Codely] OnEditorUpdate: GUIView HWND changed → 0x{currentHWND.ToString("X")}"); // Reset _lastTopInsetPx alongside disposal: the new BrowserInstance // starts with native m_topInset=0, so the OnGUI guard // `insetPx != _lastTopInsetPx` must not short-circuit the first // SetTopInset call, otherwise the new WebView2 renders from y=0 // and overlaps the dock's tab bar. DisposeWebView2Host(); _currentGUIViewHWND = currentHWND; if (_serverReady || _tauriServerPort > 0) { TryInitializeWebView2(); } } } catch (Exception ex) { CodelyLogger.LogWarning($"[Codely] OnEditorUpdate GetHWND failed: {ex.Message}"); } } #endif } private void QueueWebViewFrameSync() { #if UNITY_EDITOR_OSX if (_webViewFrameSyncPending) { return; } _webViewFrameSyncPending = true; _pendingStableGUIViewHandle = IntPtr.Zero; #endif } private void TryApplyQueuedWebViewFrameSync() { #if UNITY_EDITOR_OSX if (!_webViewFrameSyncPending || _webViewBridge == null || !_webViewBridge.IsInitialized) { return; } IntPtr currentHandle = GetEditorWindowHandle(); var decision = WindowHostStabilityPolicy.Evaluate( currentHandle, _lastWindowParentHandle, _pendingStableGUIViewHandle); _lastWindowParentHandle = decision.NextStableHandle; _pendingStableGUIViewHandle = decision.NextPendingHandle; if (!decision.ShouldApply) { return; } _webViewFrameSyncPending = false; UpdateWebViewFrameWithHandle(_lastWindowParentHandle); #endif } protected virtual void OnBeforeRemovedAsTab() { #if UNITY_EDITOR_WIN DisposeWebView2Host(); #endif } protected virtual void OnBecameInvisible() { _isWindowVisible = false; #if UNITY_EDITOR_OSX _isDragOverlay = false; _webViewFrameSyncPending = false; _pendingStableGUIViewHandle = IntPtr.Zero; if (_webViewBridge != null && _webViewBridge.IsInitialized) { _webViewBridge.SetHidden(true); } #else _isDragOverlay = false; DisposeDragSnapshot(); _webView2Host?.SetVisible(false); #endif } protected virtual void OnBecameVisible() { _isWindowVisible = true; #if UNITY_EDITOR_OSX if (_webViewBridge != null && _webViewBridge.IsInitialized) { _webViewBridge.SetHidden(false); QueueWebViewFrameSync(); FocusEmbeddedInputOnBecameVisible(); } SendThemeColorsToTauri(); #else if (_webView2Host != null) { _webView2Host.SetVisible(true); FocusEmbeddedInputOnBecameVisible(); } else if (!_webView2Initialized && !HasAnotherLiveCodelyWindow() && (_serverReady || _tauriServerPort > 0)) { // Freshly created WebView2: initial focus is pushed into the // content by OnLoadCompleted (see _webView2DidInitialFocus). TryInitializeWebView2(); } SendThemeColorsToTauri(); #endif } /// /// When the tab becomes visible again (already-initialized WebView), push /// native focus into the embedded content so the page's input field gets a /// caret without the user having to click first. Deferred one tick so the /// view's SetVisible / frame-sync completes before focus is moved. /// private void FocusEmbeddedInputOnBecameVisible() { EditorApplication.delayCall += () => { if (this == null || !_isWindowVisible) { return; } Focus(); #if UNITY_EDITOR_OSX _webViewBridge?.Focus(); #else _webView2Host?.MoveFocus(); #endif }; } private async void LaunchTauriApplication() { try { // Logged-in state affects logging only; launch uses the current session token (no RefreshAccessToken). string unityAccessToken = UnityConnectSession.GetAccessToken(); string unityUserId = UnityConnectSession.GetUserId(); if (UnityConnectSession.IsLoggedIn()) { CodelyLogger.Log($"Codely: User is logged in, launching with current Unity access token (UserId: {unityUserId})"); } else { CodelyLogger.Log("Codely: User is not logged in, launching without Unity credentials"); } await LaunchTauriApplicationInternal(unityAccessToken, unityUserId); } catch (Exception ex) { CodelyLogger.LogError($"Codely: Failed to launch Tauri application: {ex.Message}"); } } private async System.Threading.Tasks.Task LaunchTauriApplicationInternal(string unityAccessToken, string unityUserId) { try { _isDetachMode = false; if (TryAttachToExistingProjectTauriFromLock()) { CodelyLogger.Log("Codely: Existing Tauri process for this workspace detected via lock file, skip launch."); return; } // Before spawning a new APP, check whether a single-instance // APP is already running on this machine (possibly for another // workspace). If so, ask it to attach our workspace — this // avoids starting a duplicate process and lets multiple Unity // Editors share one APP instance. if (await TryAttachViaRunningSingleInstanceAppAsync()) { CodelyLogger.Log("Codely: Attached to existing single-instance APP; skip launch."); return; } // Find the Tauri executable string tauriExePath = GetCodelyRunnable(); if (string.IsNullOrEmpty(tauriExePath)) { CodelyLogger.LogError($"Codely: Tauri executable not found at: {tauriExePath}"); Repaint(); return; } // Second attach attempt; a process can appear during earlier launch steps. if (TryAttachToExistingProjectTauriFromLock()) { CodelyLogger.Log("Codely: Existing Tauri process for this workspace detected, skip launch."); return; } if (await TryAttachViaRunningSingleInstanceAppAsync()) { CodelyLogger.Log("Codely: Attached to existing single-instance APP on second attempt; skip launch."); return; } // Find an available port for the Tauri server int port = FindAvailablePort(); if (port < 0) { CodelyLogger.LogError("Codely: Could not find an available port for Tauri server"); return; } // Store the port for later use _tauriServerPort = port; SessionState.SetInt(SESSION_STATE_TAURI_PORT_KEY, port); CodelyEventTracker.FlushPending(_tauriServerPort); #if UNITY_EDITOR_OSX // Freshly spawned process: its HTTP server is not bound yet. Clear // the readiness gate so InitializeWebView waits for /api/tauri/status // before navigating (the port is set here, well before bind). ResetServerReadyGate(); #endif // Get the Unity project root directory string workspaceDir = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); // Get the editor type (tuanjie or unity) string editorType = GetEditorType(); #if UNITY_EDITOR_OSX // On macOS, use server-only mode (no window creation to avoid crashes) // Build arguments with optional Unity tokens string arguments = $"\"{workspaceDir}\" --embed --editor-type \"{editorType}\" --port {port}"; if (!string.IsNullOrEmpty(unityAccessToken) && !string.IsNullOrEmpty(unityUserId)) { arguments += $" --unity-access-token \"{unityAccessToken}\" --unity-user-id \"{unityUserId}\""; CodelyLogger.Log($"Codely: Launching Tauri in server-only mode with Unity credentials (UserId: {unityUserId})"); } else { CodelyLogger.Log($"Codely: Launching Tauri in server-only mode without Unity credentials"); } ProcessStartInfo startInfo = new ProcessStartInfo { FileName = tauriExePath, Arguments = arguments, UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden }; TauriUtils.SanitizePkgEnvironment(startInfo); CodelyLogger.Log($"Codely: Launching Tauri in server-only mode with workspace-dir: {workspaceDir}, editor-type: {editorType}, port: {port}"); #else // Windows: 与 macOS 相同,使用 server-only 模式,由 WebView2 渲染 UI string arguments = $"\"{workspaceDir}\" --embed --editor-type \"{editorType}\" --port {port}"; if (!string.IsNullOrEmpty(unityAccessToken) && !string.IsNullOrEmpty(unityUserId)) { arguments += $" --unity-access-token \"{unityAccessToken}\" --unity-user-id \"{unityUserId}\""; CodelyLogger.Log($"Codely: Launching Tauri in server-only mode with Unity credentials (UserId: {unityUserId})"); } else { CodelyLogger.Log("Codely: Launching Tauri in server-only mode without Unity credentials"); } ProcessStartInfo startInfo = new ProcessStartInfo { FileName = tauriExePath, Arguments = arguments, UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden }; TauriUtils.SanitizePkgEnvironment(startInfo); CodelyLogger.Log($"Codely: Launching Tauri in server-only mode with workspace-dir: {workspaceDir}, editor-type: {editorType}, port: {port}"); #endif _tauriProcess = Process.Start(startInfo); _searchStartTime = (float)EditorApplication.timeSinceStartup; // Save process ID for restoration after recompilation SessionState.SetInt(SESSION_STATE_TAURI_PID_KEY, _tauriProcess.Id); CodelyLogger.Log($"Codely: Launched Tauri application (PID: {_tauriProcess.Id}, Port: {port})"); } catch (Exception ex) { CodelyLogger.LogError($"Codely: Failed to launch Tauri application: {ex.Message}"); } } private bool TryAttachToExistingProjectTauriFromLock() { try { if (!TryResolveVerifiedTauriProcessFromProjectLock( ProjectLockTauriResolveMode.AttachExternalInstance, out Process existingProcess, out int lockPort, out bool? hasStandaloneWindow)) { return false; } _tauriProcess = existingProcess; SessionState.SetInt(SESSION_STATE_TAURI_PID_KEY, existingProcess.Id); // Detach when a standalone window exists (or when the field is absent → conservative default). _isDetachMode = hasStandaloneWindow ?? true; #if UNITY_EDITOR_OSX if (_webViewBridge != null && _webViewBridge.IsInitialized) { _webViewBridge.SetHidden(true); } #else DisposeWebView2Host(); #endif Repaint(); _tauriServerPort = lockPort; SessionState.SetInt(SESSION_STATE_TAURI_PORT_KEY, lockPort); CodelyEventTracker.FlushPending(_tauriServerPort); CodelyLogger.Log($"Codely[LockAttach]: Restored server port from lock. port={lockPort}"); if (hasStandaloneWindow.HasValue) { CodelyLogger.Log($"Codely[LockAttach]: Synced detach mode from status has_standalone_window={hasStandaloneWindow.Value}"); } // Refresh the single-instance capability cache so later // cleanup paths (OnEditorQuitting, CleanupResourcesAsync) don't // mistake an attached single-instance APP for a legacy // per-workspace build and kill the shared process when this // Editor quits. Without this, a freshly-started Editor that // attaches via the lock file would default to // singleInstance=false and take down every other workspace's // UI on quit. RefreshTauriSingleInstanceCapability(_tauriServerPort); CodelyLogger.Log($"Codely[LockAttach]: Attached to existing Tauri process from lock. pid={existingProcess.Id}, port={lockPort}"); return true; } catch (Exception ex) { CodelyLogger.LogWarning($"Codely[LockAttach]: Failed to attach existing process from lock file: {ex.Message}"); return false; } } /// /// Discover a running single-instance APP via /// ~/.codely/app.lock.meta.json (written by the APP on startup). /// If found, POST the current workspace to /// /api/tauri/attach-embed-workspace so the APP registers this /// workspace and writes the per-workspace lock; then attach via the /// normal lock-file flow. /// /// Returns false when no running single-instance APP is /// discovered or any step fails — caller falls back to launching a new /// APP process. /// /// /// Async version of the single-instance APP attach flow. /// /// Fast checks (file reads, PID lookup) run on the calling thread; /// HTTP requests (1s + 3s timeouts) and the polling loop (2s) are /// dispatched to a background thread so the Editor main thread is /// never blocked for the ~6s worst case. /// /// When the background work succeeds, the final Unity-specific attach /// () runs on the /// calling thread to safely touch WebView, SessionState, and Repaint. /// private async System.Threading.Tasks.Task TryAttachViaRunningSingleInstanceAppAsync() { try { string appMetaPath = TauriUtils.GetGlobalAppMetaPath(); if (string.IsNullOrEmpty(appMetaPath) || !File.Exists(appMetaPath)) { return false; } string json = TryReadTextFileWithRetry(appMetaPath, retryCount: 2, retryDelayMs: 30); if (string.IsNullOrEmpty(json)) { return false; } int pid = TryExtractIntFieldFromJson(json, "pid"); int port = TryExtractIntFieldFromJson(json, "http_port"); if (pid <= 0 || port <= 0 || port > 65535) { return false; } // Process liveness check before any HTTP request — avoids // blocking on a dead process's port. try { var proc = Process.GetProcessById(pid); if (proc.HasExited) return false; } catch (ArgumentException) { // PID not found → stale meta file. return false; } // Prepare values needed by the background work. string workspaceDir = GetWorkspaceDirectory(); string editorType = GetEditorType(); string lockDir = GetLockDirForWorkspace(workspaceDir); string lockFilePath = lockDir != null ? Path.Combine(lockDir, ".codely.lock") : Path.Combine(workspaceDir, ".codely.lock"); string payload = BuildAttachEmbedWorkspacePayload(workspaceDir, editorType); // Run HTTP requests and polling on a background thread so the // Editor main thread is not blocked for up to ~6 seconds // (1s GET timeout + 3s POST timeout + 2s polling loop). bool backgroundSuccess = await System.Threading.Tasks.Task.Run(() => { // Verify the process is actually a single-instance APP (and // responsive on the advertised port). bool singleInstance = false; try { using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1))) { var resp = _httpClient.GetAsync($"http://127.0.0.1:{port}/api/tauri/status", cts.Token).GetAwaiter().GetResult(); if (!resp.IsSuccessStatusCode) return false; string statusBody = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); TryParseJsonSingleInstanceField(statusBody, out singleInstance); } } catch { return false; } if (!singleInstance) { // Stale meta file from a legacy APP that didn't advertise the flag. return false; } CodelyLogger.Log($"Codely[SingleInstance]: Discovered running APP pid={pid}, port={port}. Requesting attach-embed-workspace for {workspaceDir}"); try { using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3))) using (var content = new StringContent(payload, Encoding.UTF8, "application/json")) { var resp = _httpClient.PostAsync($"http://127.0.0.1:{port}/api/tauri/attach-embed-workspace", content, cts.Token).GetAwaiter().GetResult(); if (!resp.IsSuccessStatusCode) { CodelyLogger.LogWarning($"Codely[SingleInstance]: attach-embed-workspace returned {(int)resp.StatusCode}"); return false; } } } catch (Exception ex) { CodelyLogger.LogWarning($"Codely[SingleInstance]: attach-embed-workspace request failed: {ex.Message}"); return false; } // The APP writes the per-workspace lock file as part of // create_window_session; wait briefly for it to appear. // We only check File.Exists here (no Unity APIs) — the // full attach runs back on the main thread afterwards. const int maxAttempts = 20; const int attemptDelayMs = 100; for (int i = 0; i < maxAttempts; i++) { if (File.Exists(lockFilePath)) { return true; } System.Threading.Thread.Sleep(attemptDelayMs); } CodelyLogger.LogWarning("Codely[SingleInstance]: attach succeeded via HTTP but per-workspace lock did not appear in time."); return false; }); if (!backgroundSuccess) { return false; } // Back on main thread: do the Unity-specific attach via the // normal lock-file path so all bookkeeping (port, detach mode, // pid, WebView state) is populated consistently. // Prime the capability cache early. The follow-up // TryAttachToExistingProjectTauriFromLock() will also refresh // this, but setting it up-front prevents a race where an // Editor-quit fires before the per-workspace lock attach // completes and the legacy kill path tears down the shared APP. SessionState.SetString(SESSION_STATE_TAURI_SINGLE_INSTANCE_KEY, "1"); if (TryAttachToExistingProjectTauriFromLock()) { return true; } CodelyLogger.LogWarning("Codely[SingleInstance]: Lock file appeared but attach failed."); return false; } catch (Exception ex) { CodelyLogger.LogWarning($"Codely[SingleInstance]: TryAttachViaRunningSingleInstanceApp failed: {ex.Message}"); return false; } } private static string BuildAttachEmbedWorkspacePayload(string workspaceDir, string editorType) { // Manual JSON encoding to avoid pulling in Newtonsoft / System.Text.Json in the Editor assembly. string pathEscaped = EscapeJsonString(workspaceDir ?? string.Empty); string editorEscaped = EscapeJsonString(editorType ?? "unity"); return "{\"path\":\"" + pathEscaped + "\",\"editorType\":\"" + editorEscaped + "\"}"; } private static string EscapeJsonString(string s) { if (string.IsNullOrEmpty(s)) return string.Empty; var sb = new StringBuilder(s.Length + 8); foreach (char c in s) { switch (c) { case '\\': sb.Append("\\\\"); break; case '"': sb.Append("\\\""); break; case '\b': sb.Append("\\b"); break; case '\f': sb.Append("\\f"); break; case '\n': sb.Append("\\n"); break; case '\r': sb.Append("\\r"); break; case '\t': sb.Append("\\t"); break; default: if (c < 0x20) sb.Append("\\u").Append(((int)c).ToString("x4")); else sb.Append(c); break; } } return sb.ToString(); } private static string TryReadTextFileWithRetry(string filePath, int retryCount, int retryDelayMs) => TauriUtils.TryReadFileWithRetry(filePath, retryCount, retryDelayMs); private static int TryExtractIntFieldFromJson(string json, string fieldName) => TauriUtils.ExtractIntFromJson(json, fieldName); // Match "workspace_dir": "" — null values won't match (non-quoted), which is intentional private const string WorkspaceDirJsonFieldPattern = "\"workspace_dir\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\""; // `has_standalone_window` tells the bridge whether the workspace has a // native Tauri window open in standalone (non-embed) mode. `null` when // no native window is registered for this workspace. This is a // distinct field from `embed_mode` (which is forced to `true` on the // Editor WebView path for the React frontend's layout), so both // consumers of the same endpoint get the semantics they need. private const string HasStandaloneWindowJsonFieldPattern = "\"has_standalone_window\"\\s*:\\s*(true|false|null)"; // Legacy field returned by older APP builds that don't yet know about // `has_standalone_window`. On the legacy APP `embed_mode=true` means // "the main window is embedded in the Editor WebView" → no standalone // window exists → bridge should attach. `embed_mode=false` means the // main window is a standalone Tauri APP window → bridge must stay // detached. We map it to `has_standalone_window = !embed_mode` as a // fallback when the new field is absent. New bridges may still pair // with old APPs, so this fallback must remain. private const string EmbedModeJsonFieldPattern = "\"embed_mode\"\\s*:\\s*(true|false)"; private const string SingleInstanceJsonFieldPattern = "\"single_instance\"\\s*:\\s*(true|false)"; private static bool TryParseJsonWorkspaceDirField(string json, out string workspaceDir) { workspaceDir = null; if (string.IsNullOrEmpty(json)) return false; Match m = Regex.Match(json, WorkspaceDirJsonFieldPattern); if (!m.Success) return false; workspaceDir = m.Groups[1].Value; return true; } /// /// Parses the has_standalone_window field. Sets /// to the parsed bool, or to /// null when the JSON has "has_standalone_window": null /// (workspace registered but no native window). Returns false /// when the field is absent so callers can decide how to treat that /// (currently: conservatively stay detached). /// private static bool TryParseJsonHasStandaloneWindowField(string json, out bool? hasStandaloneWindow) { hasStandaloneWindow = null; if (string.IsNullOrEmpty(json)) return false; Match m = Regex.Match(json, HasStandaloneWindowJsonFieldPattern, RegexOptions.IgnoreCase); if (!m.Success) return false; string raw = m.Groups[1].Value; if (string.Equals(raw, "null", StringComparison.OrdinalIgnoreCase)) { hasStandaloneWindow = null; return true; } if (bool.TryParse(raw, out bool parsed)) { hasStandaloneWindow = parsed; return true; } return false; } /// /// Parses the legacy embed_mode field from the Tauri status /// response. Used as a fallback for old APP builds that don't yet /// emit has_standalone_window. Returns false when the /// field is missing. /// private static bool TryParseJsonEmbedModeField(string json, out bool embedMode) { embedMode = false; if (string.IsNullOrEmpty(json)) return false; Match m = Regex.Match(json, EmbedModeJsonFieldPattern, RegexOptions.IgnoreCase); if (!m.Success) return false; return bool.TryParse(m.Groups[1].Value, out embedMode); } /// /// Returns true when the Tauri `/api/tauri/status` response includes /// `"single_instance": true`. An absent field is treated as false so /// legacy Tauri builds (one process per workspace) keep the classic /// kill-on-close behaviour. /// private static bool TryParseJsonSingleInstanceField(string json, out bool singleInstance) { singleInstance = false; if (string.IsNullOrEmpty(json)) return false; Match m = Regex.Match(json, SingleInstanceJsonFieldPattern, RegexOptions.IgnoreCase); if (!m.Success) return false; return bool.TryParse(m.Groups[1].Value, out singleInstance); } /// /// Probes the running Tauri server at the given port for its /// `single_instance` capability flag and caches the answer in /// SessionState. Blocking HTTP call, 1 s timeout — safe to call from /// OnTauriServerReady (already on a background-capable path) or any /// other place that has a live port. Never throws; on error (e.g. /// legacy Tauri without the field) the cache is set to "0" so the /// legacy kill-on-close code path runs. /// private static void RefreshTauriSingleInstanceCapability(int port) { if (port <= 0) return; bool singleInstance = false; try { string url = $"http://127.0.0.1:{port}/api/tauri/status"; using (var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(1))) { var response = _httpClient.GetAsync(url, cts.Token).GetAwaiter().GetResult(); if (response.IsSuccessStatusCode) { string json = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); TryParseJsonSingleInstanceField(json, out singleInstance); } } } catch { // Network/parse error → treat as legacy (singleInstance=false). } SessionState.SetString(SESSION_STATE_TAURI_SINGLE_INSTANCE_KEY, singleInstance ? "1" : "0"); } /// /// Calls GET http://127.0.0.1:{port}/api/tauri/status?workspaceDir=... on the running Tauri process /// only to query standalone-window state. Workspace ownership is verified /// from .codely.lock.meta.json, not from the status response. /// Returns false on any HTTP/network error so that a failed check causes attach to be skipped. /// When is less than or equal to 0, no HTTP request is made and this returns true /// (legacy behaviour). /// /// reports whether the workspace has a native Tauri window open in /// standalone (non-embed) mode (from the has_standalone_window field). This lets the bridge /// distinguish between a pre-existing standalone Tauri APP window (true → Unity stays detached) /// and an embedded window it previously owned (false → Unity attaches). null means no /// native window is registered for this workspace. /// private static bool TryQueryTauriStandaloneWindowState(int port, string expectedWorkspaceDir, out bool? hasStandaloneWindow) { hasStandaloneWindow = null; if (port <= 0) return true; try { string workspaceDirParam = Uri.EscapeDataString(expectedWorkspaceDir ?? string.Empty); string url = $"http://127.0.0.1:{port}/api/tauri/status?workspaceDir={workspaceDirParam}"; using (var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(2))) { var response = _httpClient.GetAsync(url, cts.Token).GetAwaiter().GetResult(); if (!response.IsSuccessStatusCode) { CodelyLogger.LogWarning($"Codely[LockAttach]: status request returned {(int)response.StatusCode}, skip attach."); return false; } string json = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); if (TryParseJsonHasStandaloneWindowField(json, out bool? parsed)) { hasStandaloneWindow = parsed; } else if (TryParseJsonEmbedModeField(json, out bool legacyEmbedMode)) { // Legacy APP (pre-has_standalone_window): derive the new // semantics from the old field. embed_mode=true → embedded // main window → no standalone window → attach. hasStandaloneWindow = !legacyEmbedMode; } return true; } } catch (Exception ex) { CodelyLogger.LogWarning($"Codely[LockAttach]: standalone state query failed: {ex.Message}, skip attach."); return false; } } /// /// True when lock metadata JSON contains a workspace_dir that matches the expected project path (normalized). /// private static bool LockMetaWorkspaceMatchesJson(string lockMetaJson, string expectedWorkspaceDir) { if (string.IsNullOrEmpty(lockMetaJson) || string.IsNullOrEmpty(expectedWorkspaceDir)) return false; if (!TryParseJsonWorkspaceDirField(lockMetaJson, out string remoteWorkspace)) return false; return string.Equals( NormalizePathForComparison(expectedWorkspaceDir), NormalizePathForComparison(remoteWorkspace), StringComparison.OrdinalIgnoreCase); } private enum ProjectLockTauriResolveMode { /// Attach to an already-running desktop Tauri: require valid http_port in meta; query standalone state via HTTP. AttachExternalInstance, /// Refresh PID after in-place update: verify workspace_dir from lock metadata. RefreshProcessIdentity, } /// /// Reads .codely.lock.meta.json, verifies workspace, returns a live for the lock PID. /// /// http_port from JSON, or -1 if absent/invalid. private bool TryResolveVerifiedTauriProcessFromProjectLock( ProjectLockTauriResolveMode mode, out Process process, out int lockHttpPortFromMeta, out bool? hasStandaloneWindow) { process = null; lockHttpPortFromMeta = -1; hasStandaloneWindow = null; string workspaceDir = GetWorkspaceDirectory(); string lockDir = GetLockDirForWorkspace(workspaceDir); string lockFilePath = lockDir != null ? Path.Combine(lockDir, ".codely.lock") : Path.Combine(workspaceDir, ".codely.lock"); string lockMetaPath = lockDir != null ? Path.Combine(lockDir, ".codely.lock.meta.json") : Path.Combine(workspaceDir, ".codely.lock.meta.json"); if (mode == ProjectLockTauriResolveMode.AttachExternalInstance) { CodelyLogger.Log($"Codely[LockAttach]: Checking lock metadata. workspace={workspaceDir}, meta={lockMetaPath}"); } string lockContent = TryReadTextFileWithRetry(lockMetaPath, retryCount: 3, retryDelayMs: 50); if (string.IsNullOrEmpty(lockContent)) { if (mode == ProjectLockTauriResolveMode.AttachExternalInstance) { CodelyLogger.Log($"Codely[LockAttach]: Lock metadata not found or empty, skip attach. meta={lockMetaPath}, lock={lockFilePath}"); } return false; } int lockPid = TryExtractIntFieldFromJson(lockContent, "pid"); int lockPort = TryExtractIntFieldFromJson(lockContent, "http_port"); lockHttpPortFromMeta = lockPort; if (mode == ProjectLockTauriResolveMode.AttachExternalInstance) { CodelyLogger.Log($"Codely[LockAttach]: Parsed lock metadata. pid={lockPid}, http_port={lockPort}"); } if (lockPid <= 0) { if (mode == ProjectLockTauriResolveMode.AttachExternalInstance) { CodelyLogger.LogWarning($"Codely[LockAttach]: Lock file found but pid is invalid. lock={lockFilePath}"); } return false; } if (mode == ProjectLockTauriResolveMode.AttachExternalInstance) { if (lockPort <= 0 || lockPort > 65535) { CodelyLogger.LogWarning($"Codely[LockAttach]: Lock file found but http_port is missing or invalid. lock={lockFilePath}, port={lockPort}"); return false; } } // Check process is alive BEFORE making any HTTP request, to avoid blocking on a dead process's port. Process existingProcess; try { existingProcess = Process.GetProcessById(lockPid); } catch (ArgumentException) { if (mode == ProjectLockTauriResolveMode.AttachExternalInstance) { CodelyLogger.LogWarning($"Codely[LockAttach]: Lock pid not found in process table. pid={lockPid}, meta={lockMetaPath}"); } return false; } if (existingProcess.HasExited) { if (mode == ProjectLockTauriResolveMode.AttachExternalInstance) { CodelyLogger.LogWarning($"Codely[LockAttach]: Lock pid exists but already exited. pid={lockPid}"); } return false; } if (mode == ProjectLockTauriResolveMode.AttachExternalInstance) { CodelyLogger.Log($"Codely[LockAttach]: Found alive process. pid={existingProcess.Id}, name={existingProcess.ProcessName}"); } bool workspaceOk = LockMetaWorkspaceMatchesJson(lockContent, workspaceDir); if (!workspaceOk) { if (mode == ProjectLockTauriResolveMode.AttachExternalInstance) { CodelyLogger.LogWarning($"Codely[LockAttach]: Lock metadata workspace mismatch, skip attach. pid={lockPid}, meta={lockMetaPath}"); } return false; } if (mode == ProjectLockTauriResolveMode.AttachExternalInstance) { // Status is now only used to distinguish standalone vs embedded state. if (!TryQueryTauriStandaloneWindowState(lockPort, workspaceDir, out hasStandaloneWindow)) { CodelyLogger.LogWarning($"Codely[LockAttach]: Standalone state unavailable, skip attach. pid={lockPid}, port={lockPort}"); return false; } } process = existingProcess; return true; } /// /// After an in-place Tauri update the app restarts with a new PID; Unity may still hold the old . /// Refresh from .codely.lock.meta.json when IPC/server is back so close/cleanup kills the live process. /// private void TryRefreshTauriProcessFromProjectLock() { try { if (!TryResolveVerifiedTauriProcessFromProjectLock( ProjectLockTauriResolveMode.RefreshProcessIdentity, out Process fresh, out _, out _)) { return; } if (_tauriProcess != null && !_tauriProcess.HasExited && _tauriProcess.Id == fresh.Id) { fresh.Dispose(); return; } Process previous = _tauriProcess; _tauriProcess = fresh; SessionState.SetInt(SESSION_STATE_TAURI_PID_KEY, fresh.Id); // Do not overwrite _tauriServerPort / SESSION_STATE_TAURI_PORT_KEY here: URL/on-ready may already have the live port. try { previous?.Dispose(); } catch (ObjectDisposedException) { // already disposed } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: Failed to dispose previous Tauri process handle: {ex.Message}"); } CodelyLogger.Log($"Codely: Refreshed Tauri process tracking from lock meta (PID: {fresh.Id})"); } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: TryRefreshTauriProcessFromProjectLock failed: {ex.Message}"); } } private static string NormalizePathForComparison(string path) { if (string.IsNullOrEmpty(path)) return string.Empty; // Replace backslashes, collapse multiple slashes into one, trim trailing slash string normalized = path.Replace('\\', '/'); normalized = Regex.Replace(normalized, "/+", "/"); return normalized.TrimEnd('/'); } private static string GetWorkspaceDirectory() => TauriUtils.GetWorkspaceDirectory(); /// /// Returns the lock directory for the given workspace under ~/.codely/projects/<hash>/. /// Hash is the first 16 hex chars of SHA256(normalized workspace path), matching single_instance.rs. /// private static string GetLockDirForWorkspace(string workspaceDir) => TauriUtils.GetLockDirForWorkspace(workspaceDir); /// /// Resolves the full path to the cowork runnable without requiring a Unity restart. /// Reads CODELY_APP_HOME from the process environment or OS-level store (registry on Windows, /// login / interactive-login shell on macOS), appends the platform-specific executable name, and verifies the /// file exists. Returns null (with an error logged) if CODELY_APP_HOME is unset or the /// executable is missing. /// internal static string GetCodelyRunnablePublic() => TauriUtils.GetCodelyRunnable(); private static string GetCodelyRunnable() => TauriUtils.GetCodelyRunnable(); /// /// 尝试从 SessionState 恢复 Tauri 进程(域重载后调用)。 /// private bool TryRestoreTauriProcess() { int savedPid = SessionState.GetInt(SESSION_STATE_TAURI_PID_KEY, -1); if (savedPid > 0) { try { var process = Process.GetProcessById(savedPid); if (!process.HasExited) { _tauriProcess = process; _tauriServerPort = SessionState.GetInt(SESSION_STATE_TAURI_PORT_KEY, -1); if (_tauriServerPort > 0) { CodelyLogger.Log($"Codely: Restored Tauri server port from SessionState (Port: {_tauriServerPort})"); } #if UNITY_EDITOR_OSX // macOS: 从 SessionState 恢复窗口句柄(用于 macOS native 资源追踪) string savedHandleStr = SessionState.GetString(SESSION_STATE_TAURI_WINDOW_HANDLE_KEY, ""); if (!string.IsNullOrEmpty(savedHandleStr) && long.TryParse(savedHandleStr, out long handleValue)) { try { IntPtr savedHandle = new IntPtr(handleValue); CodelyLogger.Log($"Codely: Restored Tauri handle from SessionState (Handle: 0x{savedHandle.ToString("X")})"); } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: Failed to restore handle: {ex.Message}"); } } #endif CodelyLogger.Log($"Codely: Restored Tauri process from SessionState (PID: {savedPid})"); // Refresh single-instance capability so later attach // attempts know this process is shared across workspaces. if (_tauriServerPort > 0) { RefreshTauriSingleInstanceCapability(_tauriServerPort); } return true; } } catch (ArgumentException) { // Process doesn't exist } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: Failed to restore process: {ex.Message}"); } } // Clear stale state SessionState.EraseInt(SESSION_STATE_TAURI_PID_KEY); SessionState.EraseString(SESSION_STATE_TAURI_WINDOW_HANDLE_KEY); SessionState.EraseInt(SESSION_STATE_TAURI_PORT_KEY); return false; } /// /// Drops this window's references to the Tauri process and its IPC client. /// The process itself is left running and is re-attached to on the next open. /// private void CleanupTauriProcess() { #if UNITY_EDITOR_WIN DisposeWebView2Host(); #endif _tauriProcess = null; SessionState.EraseInt(SESSION_STATE_TAURI_PID_KEY); SessionState.EraseString(SESSION_STATE_TAURI_WINDOW_HANDLE_KEY); SessionState.EraseInt(SESSION_STATE_TAURI_PORT_KEY); _tauriServerPort = -1; // Stop IPC client when window is destroyed StopIpcClient(); } private void AcquireIpcClient() { if (!_ipcAcquired) { if (_ipcOwnerId == null) _ipcOwnerId = $"CodelyWindow:{this.GetStableInstanceId()}"; CodelyIpcManager.Acquire(_ipcOwnerId); _ipcAcquired = true; } if (_ipcEventsSubscribed) { return; } CodelyIpcManager.OnServerReady += OnTauriServerReady; CodelyIpcManager.OnSetEmbedMode += OnIpcSetEmbedMode; CodelyIpcManager.OnCloseCodelyEditorOnWorkspaceSwitch += OnIpcCloseCodelyEditorOnWorkspaceSwitch; CodelyIpcManager.OnConnectionChanged += OnIpcConnectionChanged; CodelyIpcManager.OnUpdateRestart += OnIpcUpdateRestart; _ipcEventsSubscribed = true; } private void ReleaseIpcClient() { if (_ipcEventsSubscribed) { CodelyIpcManager.OnServerReady -= OnTauriServerReady; CodelyIpcManager.OnSetEmbedMode -= OnIpcSetEmbedMode; CodelyIpcManager.OnCloseCodelyEditorOnWorkspaceSwitch -= OnIpcCloseCodelyEditorOnWorkspaceSwitch; CodelyIpcManager.OnConnectionChanged -= OnIpcConnectionChanged; CodelyIpcManager.OnUpdateRestart -= OnIpcUpdateRestart; _ipcEventsSubscribed = false; } if (_ipcAcquired && !string.IsNullOrEmpty(_ipcOwnerId)) { CodelyIpcManager.Release(_ipcOwnerId); _ipcAcquired = false; } } #region Panel lifecycle metrics private void TrackPanelOpenedIfNeeded() { if (_suppressPanelLifecycleMetrics) { return; } if (_eventTrackedOnEnable || _panelOpenedMetricQueued) { return; } if (CodelyEventTracker.Track( CodelyEventTracker.CodelyPanelOpened, tauriServerPort: _tauriServerPort)) { _eventTrackedOnEnable = true; return; } _panelOpenedMetricQueued = true; } private void MarkPanelOpenedMetricDelivered() { if (!_eventTrackedOnEnable && _panelOpenedMetricQueued) { _eventTrackedOnEnable = true; _panelOpenedMetricQueued = false; } } private void TryNotifyPanelClosed() { if (!_eventTrackedOnEnable) { return; } if (_suppressPanelLifecycleMetrics) { return; } if (CodelyEventTracker.ConsumeSuppressNextPanelClose()) { return; } CodelyEventTracker.TryTrackClose( CodelyEventTracker.CodelyPanelClosed, _tauriServerPort, ref _panelClosedEventSent); } #endregion private void AttachToEditorFromDetachMode() { if (_isAttachingToEditor || !_isDetachMode) { return; } _isAttachingToEditor = true; Repaint(); try { _isDetachMode = false; Repaint(); #if UNITY_EDITOR_OSX if (!_webViewInitialized) { InitializeWebView(); } else if (_webViewBridge != null && _webViewBridge.IsInitialized) { _webViewBridge.LoadURL(GetCurrentWebViewUrl()); _webViewBridge.SetHidden(false); UpdateWebViewFrame(); } #else if (_webView2Host != null && _webView2Host.IsReady) { _webView2Host.Navigate(GetCurrentWebViewUrl()); } else { TryInitializeWebView2(); } #endif } catch (Exception ex) { CodelyLogger.LogError($"Codely: Attach to editor failed: {ex.Message}"); } finally { _isAttachingToEditor = false; Repaint(); } } /// /// Called when Tauri sends a setEmbedMode IPC message. /// When embedMode is true, exit detach mode and load the main webview. /// private void OnIpcSetEmbedMode(bool embedMode) { CodelyLogger.Log($"Codely: Received setEmbedMode={embedMode} via IPC"); if (embedMode) { AttachToEditorFromDetachMode(); } else { // User triggered detach from the TitleBar button — switch Unity panel to detach mode OnSetEmbedModeFromTauri(false); } } /// /// Tauri switched to another project and will change the IPC pipe; close this EditorWindow if still in detach mode. /// Only clears Unity-side state; the Tauri process keeps running. /// private void OnIpcCloseCodelyEditorOnWorkspaceSwitch() { CodelyLogger.Log("Codely: Workspace switch from Tauri — closing Codely window (detach mode), preserving Tauri process"); _preserveTauriProcessOnDestroy = true; _isDetachMode = false; Close(); } /// /// Called when the IPC connection to the Tauri server changes. /// If the connection drops and the Tauri process has exited, close this window. /// private void OnIpcConnectionChanged(bool connected) { if (connected) { // Tauri 重新连接 _suppressCloseUntilUtc = DateTime.UtcNow.AddSeconds(UPDATE_RESTART_DISCONNECT_GRACE_SECONDS); TryRefreshTauriProcessFromProjectLock(); if (_isExpectingTauriRestart) { _isExpectingTauriRestart = false; _showUpdateLoadingUI = false; ApplyWebViewHiddenForUpdateLoading(false); CodelyLogger.Log("Codely: Tauri reconnected after update, hiding loading UI"); // Repaint 必须在主线程调用 EditorApplication.delayCall += Repaint; } } else { // Tauri 断开连接 EditorApplication.delayCall += () => { // 预期更新重启场景,或处于重连后的保护窗口内,不要关闭窗口 if (_isExpectingTauriRestart || DateTime.UtcNow < _suppressCloseUntilUtc) { CodelyLogger.Log("Codely: Tauri disconnected during update/reconnect grace period, waiting..."); return; } if (_tauriProcess != null && _tauriProcess.HasExited) { CodelyLogger.Log("Codely: Tauri process exited, closing Codely window"); Close(); } }; } } /// /// Called when Tauri sends an update restart notification. /// private void OnIpcUpdateRestart(string payload) { CodelyLogger.Log($"Codely: Tauri update restart detected, payload: {payload}"); // 解析版本信息 _updateVersion = "latest"; if (!string.IsNullOrEmpty(payload)) { try { var versionData = JsonUtility.FromJson(payload); if (!string.IsNullOrEmpty(versionData?.version)) { _updateVersion = versionData.version; } } catch { _updateVersion = "latest"; } } // 保存状态 SessionState.SetBool(SESSION_STATE_WINDOW_WAS_OPEN_KEY, true); SessionState.SetString(SESSION_STATE_UPDATE_VERSION_KEY, _updateVersion); // 显示 loading UI _showUpdateLoadingUI = true; _isExpectingTauriRestart = true; _suppressCloseUntilUtc = DateTime.UtcNow.AddSeconds(UPDATE_RESTART_DISCONNECT_GRACE_SECONDS); #if UNITY_EDITOR_WIN if (_isDragOverlay) { _isDragOverlay = false; DisposeDragSnapshot(); } #endif ApplyWebViewHiddenForUpdateLoading(true); // 开始动画 _loadingAnimationTime = 0f; // Repaint 必须在主线程调用 EditorApplication.delayCall += Repaint; // 设置超时保护(30秒) EditorApplication.delayCall += () => { System.Threading.Tasks.Task.Delay(30000).ContinueWith(_ => { EditorApplication.delayCall += () => { if (_isExpectingTauriRestart) { _isExpectingTauriRestart = false; _showUpdateLoadingUI = false; ApplyWebViewHiddenForUpdateLoading(false); CodelyLogger.LogWarning("Codely: Tauri restart timeout"); EditorUtility.DisplayDialog( "Update Timeout", "Tauri update took too long. Please check if the update completed manually.", "OK" ); Close(); } }; }); }; } [Serializable] private class UpdateVersionData { public string version; } /// /// Stop the IPC client. /// private void StopIpcClient() { StopIpcClientStatic(); CodelyLogger.Log("Codely: IPC client stopped"); } private IntPtr GetEditorWindowHandle() { return EditorWindowNativeHandleHelper.GetGUIViewHandle(this); } private static int GetLiveCodelyWindowCount() { return Resources.FindObjectsOfTypeAll().Count(window => window != null); } private bool HasAnotherLiveCodelyWindow() { return Resources.FindObjectsOfTypeAll().Any(window => window != null && window != this); } /// /// Check for theme changes and send to Tauri if changed /// private void CheckThemeChange() { try { bool currentIsProSkin = EditorGUIUtility.isProSkin; // Mark dirty when theme changes if (currentIsProSkin != _lastObservedIsProSkin) { _lastObservedIsProSkin = currentIsProSkin; _themeModeDirty = true; CodelyLogger.Log($"Codely: Theme changed to {(currentIsProSkin ? "dark" : "light")}"); } // If we haven't successfully sent the current theme yet, keep trying until it succeeds if (_themeModeDirty) { if (TrySendThemeModeToTauri()) { _themeModeDirty = false; } } } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: Error checking theme change: {ex.Message}"); } } /// /// Send debugUnityConsole message to Tauri /// private static void SendDebugUnityConsoleMessageStatic() { try { bool sent = CodelyIpcManager.TrySend(IpcMessageType.DebugUnityConsole, ""); if (sent) { FocusEmbeddedTauriWindow(); CodelyLogger.Log("Codely: Sent debugUnityConsole message to Tauri"); } else { CodelyLogger.LogWarning("Codely: Failed to send debugUnityConsole message (IPC not connected)"); } } catch (Exception ex) { CodelyLogger.LogError($"Codely: Error sending debugUnityConsole message: {ex.Message}"); } } /// /// Send focusInputWithNewSession message to Tauri (Ctrl+N shortcut). /// 因 Tauri 窗口已通过 SetParent 嵌入 Unity,子窗口无法通过 Tauri 的 set_focus 抢焦点, /// 必须由父进程 (Unity) 调用 SetFocus(子窗口句柄) 将焦点交给嵌入的 Tauri 窗口。 /// private static void SendFocusInputWithNewSessionMessageStatic() { try { bool sent = CodelyIpcManager.TrySend(IpcMessageType.FocusInputWithNewSession, ""); if (sent) { // 将焦点从 Unity 转移到嵌入的 Tauri 子窗口,否则焦点会一直留在 Unity FocusEmbeddedTauriWindow(); CodelyLogger.Log("Codely: Sent focusInputWithNewSession message to Tauri"); } else { CodelyLogger.LogWarning("Codely: Failed to send focusInputWithNewSession message (IPC not connected)"); } } catch (Exception ex) { CodelyLogger.LogError($"Codely: Error sending focusInputWithNewSession message: {ex.Message}"); } } /// /// 将键盘焦点设到已嵌入的 Tauri 子窗口上(仅当通过 SetParent 嵌入时有效)。 /// private static void FocusEmbeddedTauriWindow() { #if UNITY_EDITOR_WIN // WebView2 内嵌为 Unity GUIView 的子窗口:仅 EditorWindow.Focus() 只会把 Win32 // 焦点给到 GUIView 父窗口,WebView2 内容不会自动接管,HTML 的 autofocus / // element.focus() 因此不显示光标、收不到键盘输入。必须再调用 MoveFocus 把焦点 // 显式交给 WebView2 内容(等价于浏览器中窗口被激活时内容自动获得焦点)。 var windows = Resources.FindObjectsOfTypeAll(); if (windows.Length > 0) { var win = windows[0]; win.Focus(); win._webView2Host?.MoveFocus(); } #elif UNITY_EDITOR_OSX // On macOS, make both the Unity window and the embedded WKWebView focused. var windows = Resources.FindObjectsOfTypeAll(); if (windows.Length == 0) return; var window = windows[0]; window.Focus(); window._webViewBridge?.Focus(); #endif } /// /// Send theme colors to Tauri /// private bool TrySendThemeModeToTauri() { try { // 只发送主题类型(dark 或 light),Rust 端会根据类型获取颜色 string themeMode = EditorGUIUtility.isProSkin ? "dark" : "light"; // No need to send duplicates if (_lastSentThemeMode == themeMode) { return true; } if (!CodelyIpcManager.IsConnected) { return false; } bool sent = CodelyIpcManager.TrySend(IpcMessageType.SetColors, themeMode); if (sent) { _lastSentThemeMode = themeMode; } return sent; } catch (Exception ex) { CodelyLogger.LogError($"Codely: Error sending theme colors: {ex.Message}"); return false; } } // Backwards-compatible wrapper private void SendThemeColorsToTauri() { // Mark dirty and attempt once _themeModeDirty = true; TrySendThemeModeToTauri(); } /// /// Returns "tuanjie" if the project uses the Tuanjie engine, otherwise "unity". /// Public wrapper kept for external callers; implementation lives in . /// public static string GetEditorType() => TauriUtils.GetEditorType(); /// /// Handle drag and drop events from Unity editor (Project/Hierarchy) into the Codely window. /// The web view is temporarily hidden by UpdateDragPassthrough() so that Unity's GUIView /// receives mouse messages and dispatches DragUpdated/DragPerform here. /// private void HandleDragAndDrop(Rect dropArea) { Event evt = Event.current; _isDragExitInThisFrame = false; switch (evt.type) { case EventType.DragUpdated: if (dropArea.Contains(evt.mousePosition)) { bool hasObjects = DragAndDrop.objectReferences != null && DragAndDrop.objectReferences.Length > 0; bool hasPaths = DragAndDrop.paths != null && DragAndDrop.paths.Length > 0; if (hasObjects || hasPaths) { DragAndDrop.visualMode = DragAndDropVisualMode.Link; evt.Use(); } } break; case EventType.DragPerform: if (dropArea.Contains(evt.mousePosition)) { DragAndDrop.AcceptDrag(); evt.Use(); AddContexts(); RestoreTauriFromDragOverlay(); } break; case EventType.DragExited: RestoreTauriFromDragOverlay(); _isDragExitInThisFrame = true; break; } } /// /// Polls each frame: when a Unity drag is detected and the cursor is over this window, /// hides the web view so that Unity's GUIView receives drag events. /// private void UpdateDragPassthrough() { #if UNITY_EDITOR_WIN if (_webView2Host == null || !_webView2Host.IsReady) return; #elif UNITY_EDITOR_OSX if (_webViewBridge == null || !_webViewBridge.IsInitialized) return; #endif bool hasDragData = DragAndDrop.objectReferences != null && DragAndDrop.objectReferences.Length > 0; // Only handle Unity object references. External file path should be handled by Tauri. #if UNITY_EDITOR_WIN bool mouseDown = (GetAsyncKeyState(VK_LBUTTON) & 0x8000) != 0; #elif UNITY_EDITOR_OSX bool mouseDown = IsPrimaryMouseButtonDownOsx(); #endif if (!hasDragData || !mouseDown) { // No active drag: clear the per-frame "drag exit" guard. It's normally reset at // the top of HandleDragAndDrop, but that only runs from OnGUI. If the user drags // out of this window (DragExited sets the flag), releases the mouse outside, and // starts a fresh drag before this window next repaints, the stale flag would // suppress the next overlay activation in UpdateDragPassthrough. _isDragExitInThisFrame = false; return; } #if UNITY_EDITOR_WIN // Use cursor-position check as fallback: when WebView2 is on top it intercepts OS mouse // messages, preventing Unity from updating mouseOverWindow for this EditorWindow. if (!_isDragOverlay && (mouseOverWindow == this || IsCursorOverThisWindow()) && !_isDragExitInThisFrame) { // Freeze the current WebView2 frame as a Texture2D BEFORE hiding it, so the // overlay can render it as background and the user doesn't see a blank rect. CaptureDragSnapshot(); _webView2Host.SetVisible(false); #elif UNITY_EDITOR_OSX string titleTrim = (titleContent?.text ?? string.Empty).Trim(); if (!_isDragOverlay && (mouseOverWindow == this) && !_isDragExitInThisFrame) { _webViewBridge.SetHidden(true); #endif _isDragOverlay = true; Repaint(); } } #if UNITY_EDITOR_WIN /// /// Returns true when the OS cursor lies within this EditorWindow's screen rect. /// Used as a fallback for mouseOverWindow when WebView2 blocks Unity's hover detection. /// /// Prefers comparing the cursor against the backing GUIView HWND's screen rect /// (both in physical pixels — no DPI conversion involved). This avoids two /// failure modes of the legacy position+pixelsPerPoint approach: /// 1. EditorGUIUtility.pixelsPerPoint is documented for the current GUI /// rendering operation; outside OnGUI (we're called from /// EditorApplication.update) it may reflect the wrong monitor on /// multi-monitor mixed-DPI setups. /// 2. EditorWindow.position semantics differ between floating and docked /// tabs (docked excludes the tab strip), so logical-point containment /// can miss the dock host's slack. /// private bool IsCursorOverThisWindow() { if (!GetCursorPos(out POINT cp)) return false; IntPtr hwnd = _currentGUIViewHWND; if (hwnd != IntPtr.Zero && IsWindow(hwnd) && GetWindowRect(hwnd, out RECT wr)) { return cp.X >= wr.Left && cp.X < wr.Right && cp.Y >= wr.Top && cp.Y < wr.Bottom; } // Fallback for the brief window before _currentGUIViewHWND is populated // (first frames after OnEnable, before WebView2 initializes). float ppp = EditorGUIUtility.pixelsPerPoint; if (ppp <= 0f) ppp = 1f; return position.Contains(new Vector2(cp.X / ppp, cp.Y / ppp)); } #endif /// /// 拖拽结束后恢复 WebView2 可见性(仅当本窗口标签仍可见;否则保持隐藏以免盖住其他 tab)。 /// private void RestoreTauriFromDragOverlay() { if (!_isDragOverlay) return; _isDragOverlay = false; #if UNITY_EDITOR_WIN DisposeDragSnapshot(); _webView2Host?.SetVisible(true); #elif UNITY_EDITOR_OSX _webViewBridge?.SetHidden(false); #endif Repaint(); } // Brand green used by the React GlobalDragOverlay (#35C9A9). private static readonly Color s_dragBrandGreen = new Color(0x35 / 255f, 0xC9 / 255f, 0xA9 / 255f, 1f); /// /// Layered drag overlay modeled on continue/gui's GlobalDragOverlay.tsx: /// 1. Frozen WebView2 snapshot as background (Windows only — falls back /// to a flat dim panel on macOS where capture isn't wired up). /// 2. Translucent backdrop (the "badge background @ 50% alpha" layer). /// 3. Rounded frame with #35C9A9 2px border and #35C9A9 @ ~10% fill (8 px corner). /// 4. Centered rounded card (12 px corner) with upload icon, title, body, and chips. /// Corner rounding is delivered via small procedurally-built 9-slice textures /// (BuildRoundedRectTex) cached in EnsureOverlayResources and reused across frames. /// private void DrawDropOverlay(Rect rect) { var prevColor = GUI.color; EnsureOverlayResources(); // ── 1. Background: snapshot if we have one, otherwise a flat dim panel. #if UNITY_EDITOR_WIN if (_dragSnapshotTex != null) { float texH = _dragSnapshotTex.height; float insetPx = Mathf.Clamp(_dragSnapshotTopInsetPx, 0, texH - 1); float vHeight = (texH - insetPx) / texH; // Outset by 1 px on each side (total +2 px) so physical→logical-point // rounding doesn't leave a sliver of original UI peeking at the edges. var bgRect = new Rect(rect.x - 1f, rect.y - 1f, rect.width + 2f, rect.height + 2f); GUI.color = Color.white; GUI.DrawTextureWithTexCoords(bgRect, _dragSnapshotTex, new Rect(0f, 0f, 1f, vHeight)); } else #endif { GUI.color = _lastObservedIsProSkin ? new Color(0.13f, 0.15f, 0.16f, 1.00f) : new Color(0.92f, 0.92f, 0.92f, 1.00f); GUI.DrawTexture(rect, EditorGUIUtility.whiteTexture); } // ── 2. Backdrop tint. The React component uses `${vscBadgeBackground}80` // (~50% alpha), which over a dark Codely page barely changes the colour. // Match that subtlety — too much tint here makes "outside the frame" look // visibly darker than "inside the frame", which doesn't match the target. GUI.color = _lastObservedIsProSkin ? new Color(0f, 0f, 0f, 0.18f) : new Color(0f, 0f, 0f, 0.10f); GUI.DrawTexture(rect, EditorGUIUtility.whiteTexture); GUI.color = Color.white; // ── 3. Rounded green frame (12 px outer padding, 8 px corner radius). const float OuterPad = 12f; var inner = new Rect(rect.x + OuterPad, rect.y + OuterPad, Mathf.Max(0f, rect.width - OuterPad * 2f), Mathf.Max(0f, rect.height - OuterPad * 2f)); DrawNineSlice(inner, _overlayFrameStyle); // ── 4. Centered card with icon + title + body + chips. float cardW = Mathf.Min(380f, inner.width - 32f); float cardH = Mathf.Min(220f, inner.height - 32f); if (cardW < 200f || cardH < 140f) { GUI.color = prevColor; return; // panel too small for the card — just leave the green frame. } var card = new Rect(inner.center.x - cardW * 0.5f, inner.center.y - cardH * 0.5f, cardW, cardH); DrawNineSlice(card, _overlayCardStyle); Color textDefault = _lastObservedIsProSkin ? new Color(0.94f, 0.94f, 0.96f) : new Color(0.10f, 0.10f, 0.12f); Color textSecondary = _lastObservedIsProSkin ? new Color(0.65f, 0.66f, 0.70f) : new Color(0.40f, 0.40f, 0.45f); // Icon — bilinear-filtered AA upload glyph (built once in EnsureOverlayResources). const float IconSize = 32f; var iconRect = new Rect(card.center.x - IconSize * 0.5f, card.y + 24f, IconSize, IconSize); if (_overlayUploadIcon != null) { GUI.color = Color.white; GUI.DrawTexture(iconRect, _overlayUploadIcon, ScaleMode.ScaleToFit); } // Title — "Release to upload" (bold, slightly larger than the body). var titleStyle = new GUIStyle(EditorStyles.boldLabel) { alignment = TextAnchor.MiddleCenter, fontSize = 15, normal = { textColor = textDefault }, wordWrap = false, }; var titleRect = new Rect(card.x, iconRect.yMax + 12f, card.width, 22f); GUI.color = Color.white; GUI.Label(titleRect, "Release to upload", titleStyle); // Body — "Drop your scripts, logs, or assets to start a new session instantly." var descStyle = new GUIStyle(EditorStyles.label) { alignment = TextAnchor.UpperCenter, fontSize = 11, wordWrap = true, normal = { textColor = textSecondary }, }; const float DescW = 240f; var descRect = new Rect(card.center.x - DescW * 0.5f, titleRect.yMax + 4f, DescW, 36f); GUI.Label(descRect, "Drop your scripts, logs, or assets to start a new session instantly.", descStyle); // Chips — .cs / .meta / .json (rounded pills via 9-slice texture). string[] chips = { ".cs", ".meta", ".json" }; var chipStyle = new GUIStyle(EditorStyles.miniLabel) { alignment = TextAnchor.MiddleCenter, fontSize = 10, normal = { textColor = textSecondary }, }; const float ChipH = 18f; const float ChipHPad = 8f; const float ChipGap = 6f; var chipWidths = new float[chips.Length]; float totalChipsW = 0f; for (int i = 0; i < chips.Length; i++) { float w = chipStyle.CalcSize(new GUIContent(chips[i])).x + ChipHPad * 2f; chipWidths[i] = w; totalChipsW += w; } totalChipsW += ChipGap * (chips.Length - 1); float chipsY = Mathf.Min(descRect.yMax + 14f, card.yMax - ChipH - 18f); float cursorX = card.center.x - totalChipsW * 0.5f; for (int i = 0; i < chips.Length; i++) { var cr = new Rect(cursorX, chipsY, chipWidths[i], ChipH); DrawNineSlice(cr, _overlayChipStyle); GUI.color = Color.white; GUI.Label(cr, chips[i], chipStyle); cursorX += chipWidths[i] + ChipGap; } GUI.color = prevColor; } // ── Rounded-corner overlay resources ──────────────────────────────────── /// /// Build (or rebuild on skin change) the small textures + 9-slice GUIStyles /// used by DrawDropOverlay. Cheap: ~30 KB of textures generated once, reused /// across every drag. /// private void EnsureOverlayResources() { if (_overlayResourcesBuilt && _overlayResourcesIsProSkin == _lastObservedIsProSkin) return; DisposeOverlayResources(); bool isPro = _lastObservedIsProSkin; Color green = s_dragBrandGreen; Color greenFill = new Color(green.r, green.g, green.b, 0.10f); Color cardBg = isPro ? new Color(0.16f, 0.17f, 0.18f, 0.98f) // bg-codely-color-surface-card (dark) : new Color(1.00f, 1.00f, 1.00f, 0.98f); // surface-card (light) Color chipBg = isPro ? new Color(0.21f, 0.22f, 0.23f, 1.00f) : new Color(0.95f, 0.95f, 0.96f, 1.00f); Color chipBorder = isPro ? new Color(0.34f, 0.34f, 0.37f, 1.00f) : new Color(0.80f, 0.80f, 0.83f, 1.00f); Color iconColor = isPro ? new Color(0.62f, 0.63f, 0.68f, 1.00f) : new Color(0.48f, 0.50f, 0.55f, 1.00f); _overlayFrameTex = BuildRoundedRectTex(32, 8, 2, greenFill, green); _overlayCardTex = BuildRoundedRectTex(40, 12, 0, cardBg, Color.clear); _overlayChipTex = BuildRoundedRectTex(16, 4, 1, chipBg, chipBorder); _overlayUploadIcon = BuildUploadIconTex(iconColor, 64); _overlayFrameStyle = new GUIStyle { normal = { background = _overlayFrameTex }, border = new RectOffset(8, 8, 8, 8) }; _overlayCardStyle = new GUIStyle { normal = { background = _overlayCardTex }, border = new RectOffset(12, 12, 12, 12) }; _overlayChipStyle = new GUIStyle { normal = { background = _overlayChipTex }, border = new RectOffset(4, 4, 4, 4) }; _overlayResourcesIsProSkin = isPro; _overlayResourcesBuilt = true; } private void DisposeOverlayResources() { if (_overlayFrameTex != null) { DestroyImmediate(_overlayFrameTex); _overlayFrameTex = null; } if (_overlayCardTex != null) { DestroyImmediate(_overlayCardTex); _overlayCardTex = null; } if (_overlayChipTex != null) { DestroyImmediate(_overlayChipTex); _overlayChipTex = null; } if (_overlayUploadIcon != null) { DestroyImmediate(_overlayUploadIcon); _overlayUploadIcon = null; } _overlayFrameStyle = null; _overlayCardStyle = null; _overlayChipStyle = null; _overlayResourcesBuilt = false; } /// /// 9-slice helper. GUIStyle.Draw is a Repaint-only operation, so the call is /// gated and silently no-ops in Layout/MouseMove/etc. /// private static void DrawNineSlice(Rect rect, GUIStyle style) { if (style == null || style.normal.background == null) return; if (Event.current == null || Event.current.type != EventType.Repaint) return; style.Draw(rect, GUIContent.none, false, false, false, false); } /// /// Rasterizes an anti-aliased rounded rectangle into a Texture2D using a /// signed-distance field. Designed for GUIStyle 9-slice: pass border /// equal to so the rounded corners stay 1:1 and the /// straight edges + interior stretch to fill any size. /// /// Texture side length in pixels. Must be at least 2 × (radius + max(borderPx, 1)). /// Corner radius in pixels. /// Border thickness in pixels (0 for solid fill). /// Interior colour (alpha respected). /// Border colour (alpha respected); ignored when <= 0. private static Texture2D BuildRoundedRectTex(int size, int radius, int borderPx, Color fill, Color border) { var tex = new Texture2D(size, size, TextureFormat.RGBA32, false) { hideFlags = HideFlags.HideAndDontSave, filterMode = FilterMode.Bilinear, wrapMode = TextureWrapMode.Clamp, }; var pixels = new Color32[size * size]; var transparent = new Color(0f, 0f, 0f, 0f); float hs = size * 0.5f; for (int y = 0; y < size; y++) { for (int x = 0; x < size; x++) { // SDF of a rounded rect inscribed in [0, size]² with corner radius `radius`. float fx = x + 0.5f - hs; float fy = y + 0.5f - hs; float qx = Mathf.Abs(fx) - (hs - radius); float qy = Mathf.Abs(fy) - (hs - radius); float mxq = Mathf.Max(qx, 0f); float myq = Mathf.Max(qy, 0f); float outerSDF = Mathf.Sqrt(mxq * mxq + myq * myq) + Mathf.Min(Mathf.Max(qx, qy), 0f) - radius; float inside = -outerSDF; // > 0 inside the rounded rect // Anti-alias the outer edge over a 1 px band centred on the boundary. float outsideAlpha = Mathf.Clamp01(inside + 0.5f); if (outsideAlpha <= 0f) { pixels[y * size + x] = transparent; continue; } Color baseColor; if (borderPx <= 0) { baseColor = fill; } else { // Soft transition between border and fill at depth == borderPx. float fillT = Mathf.Clamp01(inside - borderPx + 0.5f); baseColor = Color.Lerp(border, fill, fillT); } pixels[y * size + x] = new Color(baseColor.r, baseColor.g, baseColor.b, baseColor.a * outsideAlpha); } } tex.SetPixels32(pixels); tex.Apply(false, true); return tex; } /// /// Rasterizes a small "document + up-arrow" upload glyph (mirroring the SVG /// from continue/gui's UploadIcon) into a Texture2D using 3×3 super-sampling /// for anti-aliasing. The result is drawn with bilinear filtering, so the /// 64×64 source looks clean at the 32×32 display size used by the card. /// private static Texture2D BuildUploadIconTex(Color color, int size) { var tex = new Texture2D(size, size, TextureFormat.RGBA32, false) { hideFlags = HideFlags.HideAndDontSave, filterMode = FilterMode.Bilinear, wrapMode = TextureWrapMode.Clamp, }; var pixels = new Color32[size * size]; const int SS = 3; // 3×3 = 9 sub-samples per pixel. float invSS = 1f / SS; float invSize = 1f / size; for (int y = 0; y < size; y++) { for (int x = 0; x < size; x++) { int hits = 0; for (int sy = 0; sy < SS; sy++) { for (int sx = 0; sx < SS; sx++) { float fx = (x + (sx + 0.5f) * invSS) * invSize; float fy = (y + (sy + 0.5f) * invSS) * invSize; if (IsInsideUploadGlyph(fx, fy)) hits++; } } float alpha = hits / (float)(SS * SS); int outY = size - 1 - y; // flip to bottom-up for Color32[] / SetPixels32 pixels[outY * size + x] = new Color(color.r, color.g, color.b, color.a * alpha); } } tex.SetPixels32(pixels); tex.Apply(); return tex; } // Vector definition of the upload glyph in normalised top-down 0..1 coords. // Approximates continue/gui's UploadIcon.tsx: a rounded document with a 2 px // stroke and a solid up-arrow on top. private static bool IsInsideUploadGlyph(float fx, float fy) { // Document outline as a stroke (inside outer rect AND outside inner rect). bool inOuter = IsInsideRoundedRect(fx, fy, 0.20f, 0.08f, 0.80f, 0.92f, 0.06f); bool inInner = IsInsideRoundedRect(fx, fy, 0.25f, 0.13f, 0.75f, 0.87f, 0.04f); if (inOuter && !inInner) return true; // Up-arrow on top of the document. // Shaft: vertical bar [0.44, 0.56] × [0.42, 0.72] // Head: triangle peak (0.50, 0.25), base (0.33, 0.45) → (0.67, 0.45) if (fx >= 0.44f && fx <= 0.56f && fy >= 0.42f && fy <= 0.72f) return true; if (PointInTriangle(fx, fy, 0.50f, 0.25f, 0.33f, 0.45f, 0.67f, 0.45f)) return true; return false; } private static bool IsInsideRoundedRect(float px, float py, float left, float top, float right, float bottom, float r) { if (px < left || px > right || py < top || py > bottom) return false; // Determine which (if any) corner zone the point falls into. float cx, cy; if (px < left + r && py < top + r) { cx = left + r; cy = top + r; } else if (px > right - r && py < top + r) { cx = right - r; cy = top + r; } else if (px < left + r && py > bottom - r) { cx = left + r; cy = bottom - r; } else if (px > right - r && py > bottom - r) { cx = right - r; cy = bottom - r; } else return true; // straight edge zone — inside. float dx = px - cx; float dy = py - cy; return dx * dx + dy * dy <= r * r; } private static bool PointInTriangle(float px, float py, float ax, float ay, float bx, float by, float cx, float cy) { float d1 = (px - bx) * (ay - by) - (ax - bx) * (py - by); float d2 = (px - cx) * (by - cy) - (bx - cx) * (py - cy); float d3 = (px - ax) * (cy - ay) - (cx - ax) * (py - ay); bool hasNeg = d1 < 0 || d2 < 0 || d3 < 0; bool hasPos = d1 > 0 || d2 > 0 || d3 > 0; return !(hasNeg && hasPos); } #if UNITY_EDITOR_WIN /// /// Captures the GUIView's pixels (which include the visible WebView2 child) /// into using PrintWindow + PW_RENDERFULLCONTENT. /// Must run BEFORE _webView2Host.SetVisible(false) — otherwise we'd capture /// the GUIView with the WebView2 already hidden. /// private void CaptureDragSnapshot() { DisposeDragSnapshot(); IntPtr hwnd = _currentGUIViewHWND; if (hwnd == IntPtr.Zero || !IsWindow(hwnd)) return; if (!GetWindowRect(hwnd, out RECT wr)) return; int width = wr.Right - wr.Left; int height = wr.Bottom - wr.Top; if (width <= 0 || height <= 0) return; // Hard upper bound to avoid pathological allocations if some Win32 oddity // returns a huge rect. if (width > 8192 || height > 8192) return; IntPtr hdcScreen = IntPtr.Zero; IntPtr hdcMem = IntPtr.Zero; IntPtr hBitmap = IntPtr.Zero; IntPtr hOld = IntPtr.Zero; try { hdcScreen = GetDC(IntPtr.Zero); if (hdcScreen == IntPtr.Zero) return; hdcMem = CreateCompatibleDC(hdcScreen); if (hdcMem == IntPtr.Zero) return; var bmi = new BITMAPINFO { bmiHeader = new BITMAPINFOHEADER { biSize = (uint)Marshal.SizeOf(typeof(BITMAPINFOHEADER)), biWidth = width, // Negative height → top-down DIB. Row 0 is the top of the image. biHeight = -height, biPlanes = 1, biBitCount = 32, biCompression = BI_RGB, }, bmiColors0 = 0, }; IntPtr bits; hBitmap = CreateDIBSection(hdcMem, ref bmi, DIB_RGB_COLORS, out bits, IntPtr.Zero, 0); if (hBitmap == IntPtr.Zero || bits == IntPtr.Zero) return; hOld = SelectObject(hdcMem, hBitmap); if (!PrintWindow(hwnd, hdcMem, PW_RENDERFULLCONTENT)) return; // Copy the DIB bits into a managed array, swapping BGRA→RGBA, forcing // alpha=255 (PrintWindow leaves alpha undefined), and flipping row // order so Texture2D's bottom-up convention is satisfied. int stride = width * 4; int byteCount = stride * height; var raw = new byte[byteCount]; Marshal.Copy(bits, raw, 0, byteCount); var pixels = new Color32[width * height]; for (int ty = 0; ty < height; ty++) { int srcRow = (height - 1 - ty) * stride; int dstRow = ty * width; for (int x = 0; x < width; x++) { int s = srcRow + x * 4; pixels[dstRow + x] = new Color32(raw[s + 2], raw[s + 1], raw[s + 0], 255); } } _dragSnapshotTex = new Texture2D(width, height, TextureFormat.RGBA32, false) { hideFlags = HideFlags.HideAndDontSave, wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear, }; _dragSnapshotTex.SetPixels32(pixels); _dragSnapshotTex.Apply(false, true); // Remember the inset so DrawDropOverlay can crop out the tab strip. _dragSnapshotTopInsetPx = Mathf.Max(0, _cachedTopInsetPx); } catch (Exception ex) { CodelyLogger.LogWarning($"[Codely] CaptureDragSnapshot failed: {ex.Message}"); DisposeDragSnapshot(); } finally { if (hOld != IntPtr.Zero && hdcMem != IntPtr.Zero) SelectObject(hdcMem, hOld); if (hBitmap != IntPtr.Zero) DeleteObject(hBitmap); if (hdcMem != IntPtr.Zero) DeleteDC(hdcMem); if (hdcScreen != IntPtr.Zero) ReleaseDC(IntPtr.Zero, hdcScreen); } } private void DisposeDragSnapshot() { if (_dragSnapshotTex != null) { DestroyImmediate(_dragSnapshotTex); _dragSnapshotTex = null; } _dragSnapshotTopInsetPx = 0; } #endif /// /// Collect items from the current DragAndDrop operation and send them to Tauri via IPC. /// Handles both Unity asset/GameObject references and external file paths (e.g. from Windows Explorer). /// private void AddContexts() { try { var dropItems = new List(); // Unity object references (dragged from Project/Hierarchy windows) if (DragAndDrop.objectReferences != null) { foreach (var obj in DragAndDrop.objectReferences) { if (obj == null) continue; var itemData = new DropItemData { name = obj.name, type = obj.GetType().Name }; string assetPath = AssetDatabase.GetAssetPath(obj); if (!string.IsNullOrEmpty(assetPath)) { itemData.path = Path.GetFullPath(assetPath); } else if (obj is UnityEngine.GameObject go) { itemData.path = GetGameObjectPath(go); } else { itemData.path = obj.name; } dropItems.Add(itemData); } } // External file paths (dragged from Windows Explorer or other apps) if (dropItems.Count == 0 && DragAndDrop.paths != null) { CodelyLogger.LogWarning("Externel file path should be handled by Tauri"); } if (dropItems.Count == 0) { return; } var ipcPayload = new DropItemsPayload { items = dropItems.ToArray() }; string ipcJson = JsonUtility.ToJson(ipcPayload); bool ipcSent = CodelyIpcManager.TrySend(IpcMessageType.AddContexts, ipcJson); CodelyLogger.Log(ipcSent ? "Codely: Sent drop items via IPC" : "Codely: Failed to send drop items via IPC (not connected)"); } catch (Exception ex) { CodelyLogger.LogError($"Codely: Failed to send dropped items: {ex.Message}"); } } /// /// Get currently selected objects and send them to Tauri via IPC /// public void SendSelectedItemsToTauri() { try { var dropItems = BuildDropItemsFromSelection(); if (dropItems == null) { CodelyLogger.LogWarning("Codely: No objects selected"); return; } SendContextItemsViaIpc(dropItems); } catch (Exception ex) { CodelyLogger.LogError($"Codely: Failed to send selected items: {ex.Message}"); } } /// /// Get the hierarchical path of a GameObject in the scene /// private static string GetGameObjectPath(UnityEngine.GameObject obj) { if (obj == null) return ""; // For GameObjects in a scene, return the scene asset path (relative to project root) var scene = obj.scene; if (scene.IsValid() && !string.IsNullOrEmpty(scene.path)) { return scene.path; } // Fallback to hierarchy path if scene is not valid var path = obj.name; var parent = obj.transform.parent; while (parent != null) { path = $"{parent.name}/{path}"; parent = parent.parent; } return path; } /// /// Tauri server 就绪时回调(macOS 和 Windows 均适用) /// private void OnTauriServerReady(string url) { CodelyLogger.Log($"Codely: Tauri server ready at {url}"); // 从 URL 中提取端口(Windows / macOS 逻辑一致) if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Port > 0) { int receivedPort = uri.Port; // 验证端口是否匹配预期端口 if (_tauriServerPort > 0 && receivedPort != _tauriServerPort) { CodelyLogger.LogWarning($"Codely: Received server_ready from unexpected port {receivedPort}, expected {_tauriServerPort}. Ignoring."); return; } _tauriServerPort = receivedPort; SessionState.SetInt(SESSION_STATE_TAURI_PORT_KEY, _tauriServerPort); } TrackPanelOpenedIfNeeded(); CodelyEventTracker.FlushPending(_tauriServerPort); if (_panelOpenedMetricQueued && !CodelyEventTracker.HasPending) { MarkPanelOpenedMetricDelivered(); } // Probe the capability flag as soon as the server is up so that // later cleanup paths (OnEditorQuitting, CleanupResourcesAsync) // know whether they are allowed to leave the process running. // Legacy Tauri builds → singleInstance=false → legacy kill-on-close. RefreshTauriSingleInstanceCapability(_tauriServerPort); _serverReady = true; #if UNITY_EDITOR_WIN // 直接调用——已在主线程,再 delayCall 一次只是在失焦场景下浪费一次 wake。 if (!_webView2Initialized) { TryInitializeWebView2(); } #elif UNITY_EDITOR_OSX if (!_webViewInitialized) { InitializeWebView(); } else if (_webViewBridge != null && _webViewBridge.IsInitialized) { // Check if WebView has valid content, reload if necessary if (!_webViewBridge.HasValidContent()) { CodelyLogger.LogWarning($"Codely: WebView has no valid content, reloading page..."); _webViewBridge.LoadURL(GetCurrentWebViewUrl()); } else { CodelyLogger.Log($"Codely: IPC reconnected, WebView content preserved (no reload)"); } } #endif TryRefreshTauriProcessFromProjectLock(); } private void OnSetEmbedModeFromTauri(bool embedMode) { CodelyLogger.Log($"Codely: Received set_embed={embedMode} from Tauri"); _isDetachMode = !embedMode; Repaint(); #if UNITY_EDITOR_OSX if (_webViewBridge != null && _webViewBridge.IsInitialized) { _webViewBridge.LoadURL(GetCurrentWebViewUrl()); _webViewBridge.SetHidden(!_isWindowVisible); UpdateWebViewFrame(); } else if (_serverReady || _tauriServerPort > 0) { InitializeWebView(); } #else if (_webView2Host != null && _webView2Host.IsReady) { _webView2Host.Navigate(GetCurrentWebViewUrl()); _webView2Host.SetVisible(_isWindowVisible); } else if ( !_webView2Initialized && !HasAnotherLiveCodelyWindow() && (_serverReady || _tauriServerPort > 0)) { TryInitializeWebView2(); } #endif } #if UNITY_EDITOR_WIN /// /// 销毁当前 WebView2Host 并重置所有相关状态。 /// private void DisposeWebView2Host() { if (_webView2Host != null) { _webView2Host.Dispose(); _webView2Host = null; } _webView2Initialized = false; _currentGUIViewHWND = IntPtr.Zero; _lastTopInsetPx = -1; } /// /// 异步初始化 WebView2:查找 GUIView HWND → 创建 WebView2Host → 导航到 Tauri 服务器。 /// 最多等待 500ms(10 次 × 50ms)让 HWND 就绪。 /// private void TryInitializeWebView2() { if (_webView2Initializing || _webView2Initialized) return; if (HasAnotherLiveCodelyWindow()) return; _webView2Initializing = true; CodelyLogger.Log("[Codely] TryInitializeWebView2: started"); try { if (_tauriServerPort <= 0) { CodelyLogger.LogWarning("[Codely] TryInitializeWebView2: skipped — Tauri server port not available"); return; } CodelyLogger.Log($"[Codely] TryInitializeWebView2: port={_tauriServerPort}, searching HWND..."); // 单次尝试:阻塞 Sleep 在主线程上对失焦场景毫无帮助(既不会让 GUIView 提早出现, // 也阻碍其他消息泵处理)。HWND 暂不可用时直接退出,OnGUI 的 backstop 会在下一次 // paint 自动重试,且那时 GUIView 必已就绪(用户能看到 "Loading ..." 占位证明)。 IntPtr hwnd = NativeWindowHelper.GetHWND(this); if (hwnd == IntPtr.Zero) { CodelyLogger.LogWarning("[Codely] TryInitializeWebView2: GUIView HWND not yet available, will retry from OnGUI/OnEditorUpdate"); return; } CodelyLogger.Log($"[Codely] TryInitializeWebView2: HWND=0x{hwnd:X}, creating WebView2Host..."); _currentGUIViewHWND = hwnd; var host = new WebView2Host(); _webView2DidInitialFocus = false; host.OnLoadCompleted += (success) => { // 首次导航完成后把焦点送入内容一次,让页面的 autofocus 输入框拿到光标 // (对齐浏览器中打开 URL 的体验)。仅当本窗口可见时执行,且只做一次, // 避免 SPA 后续刷新/路由切换时从用户当前窗口抢走焦点。 if (!success || _webView2DidInitialFocus || !_isWindowVisible) return; _webView2DidInitialFocus = true; // OnLoadCompleted 由 native STA 线程回调投递;MoveFocus 内部会再转发回 // STA 线程,这里无需切线程,但用 delayCall 确保在 Unity 主线程节奏上执行。 EditorApplication.delayCall += () => _webView2Host?.MoveFocus(); }; host.OnParentDestroyed += () => { CodelyLogger.Log("[Codely] WebView2Host: parent HWND destroyed, will recreate"); var destroyedHost = _webView2Host; _webView2Host = null; _webView2Initialized = false; _currentGUIViewHWND = IntPtr.Zero; _lastTopInsetPx = -1; EditorApplication.delayCall += () => { destroyedHost?.Dispose(); TryInitializeWebView2(); }; }; string url = GetCurrentWebViewUrl(); CodelyLogger.Log($"[Codely] TryInitializeWebView2: calling Init url={url}"); // Init 同步返回,WebView2 在 native STA 线程异步初始化 host.Init(hwnd, url); _webView2Host = host; _webView2Initialized = true; // Seed native m_topInset BEFORE SetVisible / first SyncBounds. // Otherwise the controller's first SyncBounds (called on the STA // thread immediately after CreateCoreWebView2Controller completes) // uses m_topInset=0 and the WebView2 paints over the tab bar // until OnGUI later pushes the real inset. if (_cachedTopInsetPx >= 0) { _webView2Host.SetTopInset(_cachedTopInsetPx); _lastTopInsetPx = _cachedTopInsetPx; } _webView2Host.SetVisible(_isWindowVisible); CodelyLogger.Log($"[Codely] WebView2 host created at {url} (HWND: 0x{hwnd:X})"); if (_showUpdateLoadingUI) { ApplyWebViewHiddenForUpdateLoading(true); } Repaint(); } catch (Exception ex) { CodelyLogger.LogError($"[Codely] TryInitializeWebView2: exception — {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}"); } finally { _webView2Initializing = false; CodelyLogger.Log($"[Codely] TryInitializeWebView2: finished (initialized={_webView2Initialized})"); } } #endif #if UNITY_EDITOR_OSX /// /// Clears the HTTP-readiness gate so the next InitializeWebView / reload /// waits for a fresh /api/tauri/status probe. Called whenever the backend is /// (re)launched or re-attached — the new server may reuse the old port, so a /// stale "ready" must be invalidated explicitly. /// private void ResetServerReadyGate() { _serverHttpReady = false; _serverReadyProbePort = -1; } /// /// Starts (at most one) background poll of /api/tauri/status on the given /// port and flips _serverHttpReady once the server answers. Cheap to call /// every tick: it no-ops while a probe is already in flight or already /// satisfied for this port. Main-thread only. /// private void EnsureServerReadyProbe(int port) { if (port <= 0) return; if (_serverHttpReady && _serverReadyProbePort == port) return; if (_serverReadyProbeRunning) return; _serverReadyProbePort = port; _serverHttpReady = false; _serverReadyProbeRunning = true; int probePort = port; System.Threading.Tasks.Task.Run(async () => { try { DateTime deadline = DateTime.UtcNow.AddSeconds(30); while (DateTime.UtcNow < deadline) { if (await ProbeServerReadyAsync(probePort)) { // Guard against a port change (another relaunch) mid-poll. if (_serverReadyProbePort == probePort) { _serverHttpReady = true; } return; } await System.Threading.Tasks.Task.Delay(200); } CodelyLogger.LogWarning($"Codely: Tauri HTTP server on port {probePort} did not become ready within timeout"); } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: server readiness probe failed: {ex.Message}"); } finally { _serverReadyProbeRunning = false; } }); } /// /// Single non-throwing GET to /api/tauri/status; true on a 2xx response. /// A refused connection (server not bound yet) fails fast and returns false. /// private static async System.Threading.Tasks.Task ProbeServerReadyAsync(int port) { try { using (var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(1))) { var response = await _httpClient.GetAsync($"http://127.0.0.1:{port}/api/tauri/status", cts.Token); return response.IsSuccessStatusCode; } } catch { return false; } } /// /// Initialize WKWebView for macOS /// private void InitializeWebView() { if (_webViewBridge == null) return; if (_webViewInitialized) return; IntPtr guiViewHandle = GetEditorWindowHandle(); if (guiViewHandle == IntPtr.Zero) { CodelyLogger.LogError("Codely: Failed to get GUIView handle for WebView"); return; } if (_tauriServerPort <= 0) { CodelyLogger.LogWarning("Codely: Tauri server port not available yet, waiting..."); return; } // Do not navigate until the Tauri HTTP server actually answers on the // port. Creating the WKWebView against a not-yet-bound port produces a // permanent connection-refused white screen (no auto-retry). The probe // runs off-thread; OnEditorUpdate calls back here every tick until ready. if (!_serverHttpReady) { EnsureServerReadyProbe(_tauriServerPort); return; } float x = 0; float y = 0; float w = position.width; float h = position.height; string url = GetCurrentWebViewUrl(); if (_webViewBridge.Create(guiViewHandle, url, x, y, w, h)) { _lastWindowSize = new Vector2(position.width, position.height); _webViewInitialized = true; // Created fresh against the current (ready) URL — no stale page left // to reload, so drop any relaunch-reload request to avoid a redundant // second navigation. _pendingWebViewReloadAfterRelaunch = false; _serializedWebViewHandle = _webViewBridge.Handle.ToInt64(); _serializedWebViewInitialized = true; CodelyLogger.Log($"Codely: WKWebView initialized at {url}"); // Install drag gateway to enable Unity GameObject drag and drop if (_webViewBridge.InstallDragGateway(guiViewHandle)) { CodelyLogger.Log("Codely: Drag gateway installed, Unity drag and drop enabled"); } else { CodelyLogger.LogWarning("Codely: Failed to install drag gateway"); } _webViewBridge.SetHidden(!_isWindowVisible); if (_showUpdateLoadingUI) { ApplyWebViewHiddenForUpdateLoading(true); } Repaint(); } else { CodelyLogger.LogError("Codely: Failed to initialize WKWebView"); } } #endif private string GetCurrentWebViewUrl() { string route = _isDetachMode ? "/detach" : "/"; // Include `workspaceDir` so the Tauri backend can route this // Editor-embedded WebView's requests to the correct workspace // without relying on a Tauri window label (this WebView has none). // `mode=embedded` declares the initial shell layout. Detach route // intentionally omits mode so the shell renders standalone UI. string workspaceDir = GetWorkspaceDirectory(); string encodedDir = Uri.EscapeDataString(workspaceDir); string editorType = Uri.EscapeDataString(GetEditorType()); string themeMode = EditorGUIUtility.isProSkin ? "dark" : "light"; string query = $"workspaceDir={encodedDir}&editorType={editorType}&theme={themeMode}"; if (!_isDetachMode) { query += "&mode=embedded"; } return $"http://127.0.0.1:{_tauriServerPort}{route}?{query}"; } private static bool IsPortAvailable(int port) => TauriUtils.IsPortAvailable(port); private static int FindAvailablePort() => TauriUtils.FindAvailablePort(); #if UNITY_EDITOR_OSX /// /// Validate that the restored WebView has valid content, recreate if not /// private void ValidateRestoredWebView() { if (_webViewBridge == null || !_webViewBridge.IsInitialized) { return; } // Check if WebView has valid content if (!_webViewBridge.HasValidContent()) { CodelyLogger.LogWarning("Codely: Restored WebView has no valid content (white screen detected), recreating..."); // Destroy the invalid WebView try { _webViewBridge.Destroy(); } catch (Exception ex) { CodelyLogger.LogWarning($"Codely: Error destroying invalid WebView: {ex.Message}"); } // Reset state to trigger recreation _webViewBridge = new MacOSWebViewBridge(); _webViewInitialized = false; _serializedWebViewHandle = 0; _serializedWebViewInitialized = false; // Force repaint to trigger WebView recreation in OnEditorUpdate Repaint(); } else { CodelyLogger.Log("Codely: Restored WebView content validation passed"); } } /// /// Update WebView frame when window is resized /// private void UpdateWebViewFrame() { if (_webViewBridge == null || !_webViewBridge.IsInitialized) return; // Get GUIView handle (may have changed if tab was moved) IntPtr guiViewHandle = GetEditorWindowHandle(); if (guiViewHandle == IntPtr.Zero) { CodelyLogger.LogWarning("Codely: Failed to get GUIView handle in UpdateWebViewFrame"); return; } UpdateWebViewFrameWithHandle(guiViewHandle); } private void UpdateWebViewFrameWithHandle(IntPtr guiViewHandle) { if (_webViewBridge == null || !_webViewBridge.IsInitialized || guiViewHandle == IntPtr.Zero) return; // Update to full window size (in points, not pixels) float x = 0; float y = 0; float w = position.width; // Use points directly float h = position.height; // Use points directly // Use UpdateParentAndFrame to re-attach to correct parent if needed _webViewBridge.UpdateParentAndFrame(guiViewHandle, x, y, w, h); // Also update drag gateway frame _webViewBridge.UpdateDragGatewayFrame(x, y, w, h); } #endif } } #endif