#if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN using System; using System.Diagnostics; using System.IO; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; using Unity.InternalBridge; using UnityEditor; using UnityEngine; using UnityTcp.Editor.Helpers; namespace Cn.Tuanjie.Codely.Editor { /// /// Walkthrough window that opens the Tauri server at the / route. /// Manages the Tauri process independently via SessionState; when CodelyWindow is /// already running it will naturally reuse the same process through the shared /// SESSION_STATE_TAURI_PORT_KEY / SESSION_STATE_TAURI_PID_KEY values. /// public class DrillWindow : EditorWindow { #region Constants private const string DRILL_ROUTE = "/?route=/drill"; /// /// SessionState key tracking whether the auto-open-on-load check has already run /// this editor session. Without this guard every domain reload (script recompile) /// would re-pop the walkthrough window for users who haven't completed it yet. /// private const string SESSION_STATE_DRILL_AUTO_OPENED_KEY = "Codely_DrillAutoOpened"; #endregion #region Auto-Open On Load /// /// On editor load (startup / domain reload), open the walkthrough once per editor /// session if ~/.codely/data.json reports drillCompleted: false. /// Skipped when the cowork runnable is missing, when a CodelyWindow or DrillWindow /// is already open, or when this session has already attempted the auto-open. /// [InitializeOnLoadMethod] private static void AutoOpenOnLoadIfDrillIncomplete() { // Never auto-pop the walkthrough during unattended editor runs (batch mode / -nographics // CI / QA automation). Opening the window there triggers a native 'SUCCEEDED(hr)' // assertion that flips otherwise-passing test runs to FAIL. if (TauriUtils.IsAutomatedEditorRun()) return; if (SessionState.GetBool(SESSION_STATE_DRILL_AUTO_OPENED_KEY, false)) return; // Defer until the editor's first idle tick so EditorWindow operations are safe. EditorApplication.delayCall += () => { if (SessionState.GetBool(SESSION_STATE_DRILL_AUTO_OPENED_KEY, false)) return; SessionState.SetBool(SESSION_STATE_DRILL_AUTO_OPENED_KEY, true); if (string.IsNullOrEmpty(TauriUtils.GetCodelyRunnable())) return; if (HasOpenInstances()) return; if (TauriUtils.IsDrillCompleted()) return; OpenWalkThrough(); }; } #endregion #region Fields // Short-timeout client for single-instance APP discovery probes // (GET /api/tauri/status, POST /api/tauri/attach-embed-workspace). private static readonly HttpClient _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(3), }; private Process _tauriProcess; private int _tauriServerPort = -1; private bool _serverReady = false; private bool _isWindowVisible = true; private IntPtr _lastWindowParentHandle = IntPtr.Zero; private string _ipcOwnerId; private bool _ipcAcquired; private bool _ipcEventsSubscribed; [SerializeField] private bool _eventTrackedOnEnable = false; private bool _windowClosedEventSent; private bool _drillCompletionHandled; #if UNITY_EDITOR_WIN private WebView2Host _webView2Host; private IntPtr _currentGUIViewHWND = IntPtr.Zero; private bool _webView2Initializing = false; private bool _webView2Initialized = false; private int _lastTopInsetPx = -1; private int _cachedTopInsetPx = -1; #endif #if UNITY_EDITOR_OSX private MacOSWebViewBridge _webViewBridge; private Vector2 _lastWindowSize; private bool _webViewInitialized = false; [SerializeField] private long _serializedWebViewHandle = 0; [SerializeField] private bool _serializedWebViewInitialized = false; #endif #endregion #region Menu Command [MenuItem("AI/Open Walkthrough", priority = 997)] public static void OpenWalkThrough() { // Guard the actual window-creation call site: under batch mode / -nographics automation, // GetWindow forces native window + graphics-surface creation that fails with a // 'SUCCEEDED(hr)' native assertion. Stay closed regardless of which caller reached here. if (TauriUtils.IsAutomatedEditorRun()) return; // Without the cowork runnable the DrillWindow WebView has nothing to // load and would sit forever on "Loading WalkThrough...". Fall back to // CodelyWindow, which surfaces the install/setup UI instead. if (string.IsNullOrEmpty(TauriUtils.GetCodelyRunnable())) { CodelyWindow.ShowWindow(); return; } var window = GetWindow("Tuanjie AI WalkThrough", true); window.minSize = new Vector2(1200, 800); window.ApplyTitleIcon(); window.Show(); } #endregion #region EditorWindow Lifecycle private static Texture2D LoadIcon(string fileName) => AssetDatabase.LoadAssetAtPath(TauriUtils.TAURI_ICONS_BASE_PATH + fileName); private void ApplyTitleIcon() { // Unity title bar uses fixed icon size; use theme-specific asset for visibility. var icon = EditorGUIUtility.isProSkin ? LoadIcon("title_icon.png") : LoadIcon("title_icon_light_theme.png"); if (icon == null) return; titleContent = new GUIContent(" Tuanjie AI WalkThrough", icon); } private void OnEnable() { if (TauriUtils.IsAutomatedEditorRun()) return; if (!_eventTrackedOnEnable) { _eventTrackedOnEnable = true; CodelyEventTracker.Track(CodelyEventTracker.WelcomeWindowShown); } ApplyTitleIcon(); _lastWindowParentHandle = GetEditorWindowHandle(); // Try to reuse a Tauri process that CodelyWindow (or a previous DrillWindow) already started bool restored = TryRestoreTauriProcess(); if (!restored) { restored = TryAttachFromLockFile(); } AcquireIpcClient(); if (!restored) { EnsureTauriProcess(); } #if UNITY_EDITOR_OSX if (_webViewBridge == null) { _webViewBridge = new MacOSWebViewBridge(); _webViewInitialized = false; } if (_serializedWebViewInitialized && _serializedWebViewHandle != 0) { var savedHandle = new IntPtr(_serializedWebViewHandle); try { if (_webViewBridge.RestoreFromHandle(savedHandle)) { _webViewInitialized = true; _serializedWebViewHandle = 0; _serializedWebViewInitialized = false; _webViewBridge.SetHidden(false); UpdateWebViewFrame(); } else { _serializedWebViewHandle = 0; _serializedWebViewInitialized = false; } } catch (Exception ex) { CodelyLogger.LogError($"DrillWindow: Exception restoring WebView: {ex.Message}"); _serializedWebViewHandle = 0; _serializedWebViewInitialized = false; } } else if (_webViewBridge.IsInitialized) { _webViewInitialized = true; _webViewBridge.SetHidden(false); UpdateWebViewFrame(); } if (_serverReady && !_webViewInitialized) { InitializeWebView(); } #else _webView2Initialized = false; _currentGUIViewHWND = IntPtr.Zero; if (_serverReady) { TryInitializeWebView2(); } #endif EditorApplication.update += OnEditorUpdate; } private void OnDisable() { EditorApplication.update -= OnEditorUpdate; // Must fire before ReleaseIpcClient: OnDisable runs before OnDestroy and // tears down the IPC client when DrillWindow is the last owner. TryNotifyWindowClosed(); ReleaseIpcClient(); #if UNITY_EDITOR_WIN _webView2Host?.SetVisible(false); #endif #if UNITY_EDITOR_OSX if (_webViewBridge != null && _webViewBridge.IsInitialized) { try { IntPtr handle = _webViewBridge.Handle; if (handle != IntPtr.Zero) { _serializedWebViewHandle = handle.ToInt64(); _serializedWebViewInitialized = true; } } catch (Exception ex) { CodelyLogger.LogWarning($"DrillWindow: Failed to serialize WebView handle: {ex.Message}"); } } #endif } private void OnDestroy() { TryNotifyWindowClosed(); ReleaseIpcClient(); #if UNITY_EDITOR_WIN DisposeWebView2Host(); #endif #if UNITY_EDITOR_OSX _serializedWebViewHandle = 0; _serializedWebViewInitialized = false; var webViewToDestroy = _webViewBridge; _webViewBridge = null; if (webViewToDestroy != null && webViewToDestroy.IsInitialized) { EditorApplication.delayCall += () => { try { webViewToDestroy.Destroy(); } catch (Exception ex) { CodelyLogger.LogWarning($"DrillWindow: Error destroying WebView: {ex.Message}"); } }; } #endif // DrillWindow does not kill the Tauri process on close; CodelyWindow owns process lifecycle. _tauriProcess = null; _tauriServerPort = -1; CodelyLogger.Log("DrillWindow: OnDestroy called"); } internal void PrepareForPackageRemovalOrUpgrade() { ReleaseIpcClient(); #if UNITY_EDITOR_WIN DisposeWebView2Host(); #endif #if UNITY_EDITOR_OSX if (_webViewBridge != null && _webViewBridge.IsInitialized) { _webViewBridge.Destroy(); } _webViewBridge = null; _webViewInitialized = false; _serializedWebViewHandle = 0; _serializedWebViewInitialized = false; #endif } private void OnGUI() { #if UNITY_EDITOR_OSX var webViewRect = GUILayoutUtility.GetRect( GUIContent.none, GUIStyle.none, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); if (_webViewBridge == null || !_webViewBridge.IsInitialized) { GUI.Box(webViewRect, "Loading WalkThrough..."); } #else var embedRect = GUILayoutUtility.GetRect( GUIContent.none, GUIStyle.none, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); Vector2 contentOrigin = GUIUtility.GUIToScreenPoint(Vector2.zero); int insetPx = Mathf.Max(0, Mathf.RoundToInt( (contentOrigin.y - position.y) * EditorGUIUtility.pixelsPerPoint)); _cachedTopInsetPx = insetPx; if (CodelyIpcClient.ConsumePendingDrillCompleted()) { CompleteDrillFromOnGUI(); return; } if (!_webView2Initialized) { GUI.Box(embedRect, "Loading WalkThrough..."); // Focus-independent backstop: 失焦启动时 delayCall 被节流,server_ready 事件链 // 会卡住。OnGUI 仍会在 WM_PAINT 时跑(CodelyIpcServer.WakeUnityMainThread 强制 // 触发了 InvalidateRect),所以这里直接读 IPC 后台线程缓存的 URL,解出端口后 // 立刻调 TryInitializeWebView2,不依赖事件链。 if (Event.current.type == EventType.Layout && !_webView2Initializing) { // The IPC server_ready URL is authoritative for the actually-bound // port. The value optimistically stored at launch can be stale — the // bound port may have been bumped, or a duplicate process killed by // single-instance never bound at all. Prefer the IPC port whenever it // is available so we never point WebView2 at a dead port // (the ERR_CONNECTION_REFUSED bug). string pendingUrl = CodelyIpcManager.LastServerReadyUrl; if (!string.IsNullOrEmpty(pendingUrl) && Uri.TryCreate(pendingUrl, UriKind.Absolute, out var pendingUri) && pendingUri.Port > 0) { if (_tauriServerPort != pendingUri.Port) { CodelyLogger.Log($"[DrillWindow] OnGUI backstop: using IPC server_ready port={pendingUri.Port} (was {_tauriServerPort})"); _tauriServerPort = pendingUri.Port; } _serverReady = true; } if (_tauriServerPort > 0) { TryInitializeWebView2(); } } } if (_webView2Host != null && _webView2Host.IsReady) { if (insetPx != _lastTopInsetPx) { _lastTopInsetPx = insetPx; _webView2Host.SetTopInset(insetPx); } } #endif } private void OnEditorUpdate() { #if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN // Flush pending metrics as soon as the HTTP port becomes available. if (_tauriServerPort <= 0) { int portFromSession = SessionState.GetInt(TauriUtils.SESSION_STATE_TAURI_PORT_KEY, -1); if (portFromSession > 0) { _tauriServerPort = portFromSession; } } if (_tauriServerPort > 0) { CodelyEventTracker.FlushPending(_tauriServerPort); } #endif #if UNITY_EDITOR_OSX if (_webViewBridge == null) { _webViewBridge = new MacOSWebViewBridge(); _webViewInitialized = false; } // Skip update when window is not visible on any platform. if (!_isWindowVisible) return; if (!_webViewInitialized) { InitializeWebView(); } else if (_webViewBridge.IsInitialized) { IntPtr currentHandle = GetEditorWindowHandle(); if (currentHandle != _lastWindowParentHandle && currentHandle != IntPtr.Zero) { _lastWindowParentHandle = currentHandle; UpdateWebViewFrame(); } Vector2 currentSize = new Vector2(position.width, position.height); if (currentSize != _lastWindowSize) { _lastWindowSize = currentSize; UpdateWebViewFrame(); } } #else if (!_isWindowVisible) return; if (!_webView2Initialized) { try { IntPtr currentHWND = NativeWindowHelper.GetHWND(this); if (currentHWND != IntPtr.Zero && currentHWND != _currentGUIViewHWND) { _currentGUIViewHWND = currentHWND; if (_webView2Host != null) { _webView2Host.Dispose(); _webView2Host = null; _webView2Initialized = false; } if (_serverReady) { TryInitializeWebView2(); } } } catch (Exception ex) { CodelyLogger.LogWarning($"DrillWindow: OnEditorUpdate GetHWND failed: {ex.Message}"); } } #endif } protected virtual void OnBecameInvisible() { _isWindowVisible = false; #if UNITY_EDITOR_OSX _webViewBridge?.SetHidden(true); #else _webView2Host?.SetVisible(false); #endif } protected virtual void OnBecameVisible() { _isWindowVisible = true; #if UNITY_EDITOR_OSX if (_webViewBridge != null && _webViewBridge.IsInitialized) { _webViewBridge.SetHidden(false); UpdateWebViewFrame(); } #else if (_webView2Host != null && _webView2Host.IsReady) { _webView2Host.SetVisible(true); } else if (!_webView2Initialized && _serverReady) { TryInitializeWebView2(); } #endif } protected virtual void OnBeforeRemovedAsTab() { #if UNITY_EDITOR_WIN DisposeWebView2Host(); #endif } #endregion #region IPC private void AcquireIpcClient() { if (!_ipcAcquired) { if (_ipcOwnerId == null) _ipcOwnerId = $"DrillWindow:{this.GetStableInstanceId()}"; CodelyIpcManager.Acquire(_ipcOwnerId); _ipcAcquired = true; } if (_ipcEventsSubscribed) { return; } CodelyIpcManager.OnServerReady += OnTauriServerReady; CodelyIpcManager.OnDrillCompleted += OnDrillCompleted; _ipcEventsSubscribed = true; } private void ReleaseIpcClient() { if (_ipcEventsSubscribed) { CodelyIpcManager.OnServerReady -= OnTauriServerReady; CodelyIpcManager.OnDrillCompleted -= OnDrillCompleted; _ipcEventsSubscribed = false; } if (_ipcAcquired && !string.IsNullOrEmpty(_ipcOwnerId)) { CodelyIpcManager.Release(_ipcOwnerId); _ipcAcquired = false; } } private void OnTauriServerReady(string url) { CodelyLogger.Log($"DrillWindow: Tauri server ready at {url}"); _serverReady = true; if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Port > 0) { _tauriServerPort = uri.Port; } CodelyEventTracker.FlushPending(_tauriServerPort); #if UNITY_EDITOR_WIN // 直接调用:已在主线程,多一次 delayCall 在失焦场景下只是浪费一次 wake。 if (!_webView2Initialized) { TryInitializeWebView2(); } #elif UNITY_EDITOR_OSX if (!_webViewInitialized) { InitializeWebView(); } else if (_webViewBridge != null && _webViewBridge.IsInitialized && !_webViewBridge.HasValidContent()) { _webViewBridge.LoadURL(GetWebViewUrl()); } #endif } private void OnDrillCompleted() { CodelyIpcClient.ConsumePendingDrillCompleted(); if (!BeginDrillCompletion()) return; EditorApplication.delayCall += CompleteDrillTransition; EditorApplication.QueuePlayerLoopUpdate(); } private bool BeginDrillCompletion() { if (_drillCompletionHandled) return false; _drillCompletionHandled = true; CodelyEventTracker.Track(CodelyEventTracker.WelcomeButtonClick, "drillCompleted", _tauriServerPort); CodelyLogger.Log("DrillWindow: Drill completed, switching to Codely layout and opening CodelyWindow"); return true; } private void CompleteDrillFromOnGUI() { if (!BeginDrillCompletion()) return; PrepareAndCloseDrillWindow(); EditorApplication.delayCall += OpenCodelyAfterDrill; EditorApplication.QueuePlayerLoopUpdate(); } private void CompleteDrillTransition() { PrepareAndCloseDrillWindow(); OpenCodelyAfterDrill(); } private void PrepareAndCloseDrillWindow() { SessionState.EraseInt(TauriUtils.SESSION_STATE_TAURI_PID_KEY); SessionState.EraseInt(TauriUtils.SESSION_STATE_TAURI_PORT_KEY); Close(); } private static void OpenCodelyAfterDrill() { CodelyLayoutInstaller.ApplyCodelyLayout(); CodelyWindow.ShowWindow(); } #endregion #region Walkthrough lifecycle metrics private void TryNotifyWindowClosed() { CodelyEventTracker.TryTrackClose( CodelyEventTracker.WelcomeWindowClosed, _tauriServerPort, ref _windowClosedEventSent); } #endregion #region Tauri Process Management private bool TryRestoreTauriProcess() { int savedPid = SessionState.GetInt(TauriUtils.SESSION_STATE_TAURI_PID_KEY, -1); if (savedPid <= 0) return false; try { var process = Process.GetProcessById(savedPid); if (!process.HasExited) { _tauriProcess = process; _tauriServerPort = SessionState.GetInt(TauriUtils.SESSION_STATE_TAURI_PORT_KEY, -1); _serverReady = _tauriServerPort > 0; CodelyLogger.Log($"DrillWindow: Restored Tauri process from SessionState (PID: {savedPid}, port: {_tauriServerPort})"); return true; } } catch (ArgumentException) { } catch (Exception ex) { CodelyLogger.LogWarning($"DrillWindow: Failed to restore Tauri process: {ex.Message}"); } SessionState.EraseInt(TauriUtils.SESSION_STATE_TAURI_PID_KEY); SessionState.EraseInt(TauriUtils.SESSION_STATE_TAURI_PORT_KEY); return false; } private bool TryAttachFromLockFile() { try { string workspaceDir = TauriUtils.GetWorkspaceDirectory(); // Primary: use the same per-project hash directory as CodelyWindow / Tauri string lockDir = TauriUtils.GetLockDirForWorkspace(workspaceDir); string lockMetaPath = lockDir != null ? Path.Combine(lockDir, ".codely.lock.meta.json") : Path.Combine(workspaceDir, ".codely.lock.meta.json"); string lockContent = TauriUtils.TryReadFileWithRetry(lockMetaPath, retryCount: 3, retryDelayMs: 50); // Fallback: legacy project-root path (older Tauri versions) if (string.IsNullOrEmpty(lockContent)) { string legacyPath = Path.Combine(workspaceDir, ".codely.lock.meta.json"); if (legacyPath != lockMetaPath) lockContent = TauriUtils.TryReadFileWithRetry(legacyPath, retryCount: 1, retryDelayMs: 0); } if (string.IsNullOrEmpty(lockContent)) return false; int lockPid = TauriUtils.ExtractIntFromJson(lockContent, "pid"); int lockPort = TauriUtils.ExtractIntFromJson(lockContent, "http_port"); if (lockPid <= 0) return false; if (lockPort <= 0 || lockPort > 65535) return false; var process = Process.GetProcessById(lockPid); if (process.HasExited) return false; _tauriProcess = process; _tauriServerPort = lockPort; _serverReady = true; SessionState.SetInt(TauriUtils.SESSION_STATE_TAURI_PID_KEY, lockPid); SessionState.SetInt(TauriUtils.SESSION_STATE_TAURI_PORT_KEY, lockPort); CodelyLogger.Log($"DrillWindow: Attached to Tauri process from lock file (PID: {lockPid}, port: {lockPort}, meta: {lockMetaPath})"); return true; } catch (ArgumentException) { return false; } catch (Exception ex) { CodelyLogger.LogWarning($"DrillWindow: TryAttachFromLockFile failed: {ex.Message}"); return false; } } private void LaunchTauriApplication() { string exePath = TauriUtils.GetCodelyRunnable(); if (string.IsNullOrEmpty(exePath)) { CodelyLogger.LogError("DrillWindow: cowork executable not found"); return; } int port = TauriUtils.FindAvailablePort(); if (port < 0) { CodelyLogger.LogError("DrillWindow: No available port found for Tauri server"); return; } string workspaceDir = TauriUtils.GetWorkspaceDirectory(); string editorType = TauriUtils.GetEditorType(); string unityAccessToken = UnityConnectSession.GetAccessToken(); string unityUserId = UnityConnectSession.GetUserId(); 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}\""; } try { var startInfo = new ProcessStartInfo { FileName = exePath, Arguments = arguments, UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden, }; TauriUtils.SanitizePkgEnvironment(startInfo); _tauriProcess = Process.Start(startInfo); _tauriServerPort = port; SessionState.SetInt(TauriUtils.SESSION_STATE_TAURI_PID_KEY, _tauriProcess.Id); SessionState.SetInt(TauriUtils.SESSION_STATE_TAURI_PORT_KEY, port); CodelyEventTracker.FlushPending(_tauriServerPort); CodelyLogger.Log($"DrillWindow: Launched Tauri (PID: {_tauriProcess.Id}, port: {port})"); } catch (Exception ex) { CodelyLogger.LogError($"DrillWindow: Failed to launch Tauri: {ex.Message}"); } } /// /// Ensure a Tauri server is reachable for this workspace before pointing the /// WebView at it. A single-instance cowork APP may already be running /// (the Hub, another Editor, or a CodelyWindow). Spawning a fresh /// --embed --port X process in that case is futile: tauri_plugin_single_instance /// forwards the args to the existing instance and the new process exits without /// ever binding port X, leaving the WebView pointed at a dead port /// (ERR_CONNECTION_REFUSED). So attach to a running APP first, and only launch a /// brand-new process when none exists. Mirrors CodelyWindow's launch flow. /// private async void EnsureTauriProcess() { try { if (await TryAttachViaRunningSingleInstanceAppAsync()) { CodelyLogger.Log("DrillWindow: Attached to existing single-instance APP; skip launch."); return; } } catch (Exception ex) { CodelyLogger.LogWarning($"DrillWindow: EnsureTauriProcess attach step failed: {ex.Message}"); } LaunchTauriApplication(); } /// /// Discover a running single-instance APP via ~/.codely/app.lock.meta.json /// (written by the APP on startup). If found, POST this workspace to /// /api/tauri/attach-embed-workspace so the APP registers it and writes the /// per-workspace lock; then attach through the normal lock-file flow. /// /// Fast checks (file reads, PID lookup) run on the calling thread; HTTP requests /// (1s + 3s timeouts) and the lock-poll loop (2s) run on a background thread so the /// Editor main thread is never blocked for the ~6s worst case. The final Unity-side /// attach runs back on the calling thread. /// /// Returns false when no running single-instance APP is discovered or any /// step fails — the caller then launches a fresh APP process. /// private async Task TryAttachViaRunningSingleInstanceAppAsync() { try { string appMetaPath = TauriUtils.GetGlobalAppMetaPath(); if (string.IsNullOrEmpty(appMetaPath) || !File.Exists(appMetaPath)) return false; string json = TauriUtils.TryReadFileWithRetry(appMetaPath, retryCount: 2, retryDelayMs: 30); if (string.IsNullOrEmpty(json)) return false; int pid = TauriUtils.ExtractIntFromJson(json, "pid"); int port = TauriUtils.ExtractIntFromJson(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) { return false; // PID not found → stale meta file } string workspaceDir = TauriUtils.GetWorkspaceDirectory(); string editorType = TauriUtils.GetEditorType(); string lockDir = TauriUtils.GetLockDirForWorkspace(workspaceDir); string lockMetaPath = lockDir != null ? Path.Combine(lockDir, ".codely.lock.meta.json") : Path.Combine(workspaceDir, ".codely.lock.meta.json"); string payload = BuildAttachEmbedWorkspacePayload(workspaceDir, editorType); bool backgroundSuccess = await Task.Run(() => { // Verify the discovered process is a single-instance APP responding on // the advertised port. 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(); if (!StatusReportsSingleInstance(statusBody)) return false; } } catch { return false; } CodelyLogger.Log($"DrillWindow[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($"DrillWindow[SingleInstance]: attach-embed-workspace returned {(int)resp.StatusCode}"); return false; } } } catch (Exception ex) { CodelyLogger.LogWarning($"DrillWindow[SingleInstance]: attach-embed-workspace request failed: {ex.Message}"); return false; } // The APP writes the per-workspace lock meta during registration; wait // briefly for it to appear (File.Exists only — no Unity APIs here). for (int i = 0; i < 20; i++) { if (File.Exists(lockMetaPath)) return true; Thread.Sleep(100); } CodelyLogger.LogWarning("DrillWindow[SingleInstance]: attach succeeded via HTTP but per-workspace lock meta did not appear in time."); return false; }); if (!backgroundSuccess) return false; // Back on the main thread: attach via the normal lock-file flow so port, // PID, server-ready state and SessionState are populated consistently. if (TryAttachFromLockFile()) { CodelyLogger.Log("DrillWindow[SingleInstance]: Attached to running single-instance APP."); return true; } CodelyLogger.LogWarning("DrillWindow[SingleInstance]: Lock meta appeared but attach failed."); return false; } catch (Exception ex) { CodelyLogger.LogWarning($"DrillWindow[SingleInstance]: TryAttachViaRunningSingleInstanceApp failed: {ex.Message}"); return false; } } private static string BuildAttachEmbedWorkspacePayload(string workspaceDir, string editorType) { // Manual JSON encoding to avoid pulling Newtonsoft / System.Text.Json into the Editor assembly. string pathEscaped = EscapeJsonString(workspaceDir ?? string.Empty); string editorEscaped = EscapeJsonString(editorType ?? "unity"); return $"{{\"path\":\"{pathEscaped}\",\"editorType\":\"{editorEscaped}\"}}"; } private static string EscapeJsonString(string value) { if (string.IsNullOrEmpty(value)) return string.Empty; var sb = new StringBuilder(value.Length + 8); foreach (char c in value) { 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 bool StatusReportsSingleInstance(string statusJson) { if (string.IsNullOrEmpty(statusJson)) return false; // Whitespace-insensitive check for "single_instance": true string compact = statusJson.Replace(" ", string.Empty).Replace("\t", string.Empty); return compact.Contains("\"single_instance\":true"); } #endregion #region WebView URL private string GetWebViewUrl() { // Include workspaceDir + mode=embedded so the Tauri server injects // window.__CODELY_WORKSPACE_DIR__ and the shell can route this // Editor-embedded WebView's invokes (drill/getProgress, // drill/updateProgress, drill/drillCompleted, …) to the correct // workspace — this WebView has no Tauri window label. Without // workspaceDir the backend rejects every invoke with "Missing // workspace routing info", so drill/drillCompleted never reaches its // handler and Unity never receives the drillCompleted IPC. // Walkthrough open/close metrics are sent via CodelyEventTracker in // DrillWindow.cs, not the WebView. // DRILL_ROUTE already carries a leading "?", so append with "&". string workspaceDir = TauriUtils.GetWorkspaceDirectory(); string encodedDir = Uri.EscapeDataString(workspaceDir ?? string.Empty); string themeMode = EditorGUIUtility.isProSkin ? "dark" : "light"; return $"http://127.0.0.1:{_tauriServerPort}{DRILL_ROUTE}&workspaceDir={encodedDir}&theme={themeMode}&mode=embedded"; } #endregion #region WebView2 (Windows) #if UNITY_EDITOR_WIN private void TryInitializeWebView2() { if (_webView2Initializing || _webView2Initialized) return; if (_cachedTopInsetPx < 0) { Repaint(); return; } _webView2Initializing = true; CodelyLogger.Log("[DrillWindow] TryInitializeWebView2: started"); try { if (_tauriServerPort <= 0) { CodelyLogger.LogWarning("[DrillWindow] TryInitializeWebView2: skipped — port not available"); return; } // 单次尝试:Sleep 阻塞主线程在失焦场景下毫无收益。GUIView HWND 不可用时退出, // OnGUI backstop / OnEditorUpdate 会在下一次 paint / tick 自动重试。 IntPtr hwnd = NativeWindowHelper.GetHWND(this); if (hwnd == IntPtr.Zero) { CodelyLogger.LogWarning("[DrillWindow] TryInitializeWebView2: GUIView HWND not yet available, will retry from OnGUI/OnEditorUpdate"); return; } _currentGUIViewHWND = hwnd; var host = new WebView2Host(); host.OnParentDestroyed += () => { var destroyedHost = _webView2Host; _webView2Host = null; _webView2Initialized = false; _currentGUIViewHWND = IntPtr.Zero; _lastTopInsetPx = -1; EditorApplication.delayCall += () => { destroyedHost?.Dispose(); TryInitializeWebView2(); }; }; string url = GetWebViewUrl(); host.Init(hwnd, url); _webView2Host = host; _webView2Initialized = true; if (_cachedTopInsetPx >= 0) { _webView2Host.SetTopInset(_cachedTopInsetPx); _lastTopInsetPx = _cachedTopInsetPx; } _webView2Host.SetVisible(_isWindowVisible); CodelyLogger.Log($"[DrillWindow] WebView2 initialized at {url} (HWND: 0x{hwnd:X})"); Repaint(); } catch (Exception ex) { CodelyLogger.LogError($"[DrillWindow] TryInitializeWebView2 exception: {ex.GetType().Name}: {ex.Message}"); } finally { _webView2Initializing = false; } } private void DisposeWebView2Host() { if (_webView2Host != null) { try { _webView2Host.Dispose(); } catch (Exception ex) { CodelyLogger.LogWarning($"DrillWindow: Error disposing WebView2Host: {ex.Message}"); } _webView2Host = null; } _webView2Initialized = false; _currentGUIViewHWND = IntPtr.Zero; _lastTopInsetPx = -1; } /// /// Editor 退出时由 CodelyWindow.OnEditorQuitting 调用:清空 native handle 引用, /// 不再调用 NB_Destroy(NB_ShutdownAll 已同步关闭 native 端)。 /// internal void ClearWebView2OnQuit() { _webView2Host = null; _webView2Initialized = false; _currentGUIViewHWND = IntPtr.Zero; _lastTopInsetPx = -1; } #endif #endregion #region WKWebView (macOS) #if UNITY_EDITOR_OSX private void InitializeWebView() { if (_webViewBridge == null || _webViewInitialized) return; IntPtr guiViewHandle = GetEditorWindowHandle(); if (guiViewHandle == IntPtr.Zero || _tauriServerPort <= 0) return; string url = GetWebViewUrl(); if (_webViewBridge.Create(guiViewHandle, url, 0, 0, position.width, position.height)) { _lastWindowSize = new Vector2(position.width, position.height); _webViewInitialized = true; _serializedWebViewHandle = _webViewBridge.Handle.ToInt64(); _serializedWebViewInitialized = true; CodelyLogger.Log($"DrillWindow: WKWebView initialized at {url}"); Repaint(); } else { CodelyLogger.LogError("DrillWindow: Failed to initialize WKWebView"); } } private void UpdateWebViewFrame() { if (_webViewBridge == null || !_webViewBridge.IsInitialized) return; IntPtr guiViewHandle = GetEditorWindowHandle(); if (guiViewHandle == IntPtr.Zero) return; _webViewBridge.UpdateParentAndFrame(guiViewHandle, 0, 0, position.width, position.height); } #endif #endregion #region Utilities private IntPtr GetEditorWindowHandle() { return EditorWindowNativeHandleHelper.GetGUIViewHandle(this); } /// /// Thin wrapper kept for binary compatibility: references this as /// DrillWindow.GetCodelyCliRunnable(). The implementation lives in . /// internal static string GetCodelyCliRunnable() => TauriUtils.GetCodelyCliRunnable(); #endregion } } #endif