feat(gameplay): 更新单位属性和材质配置 - 调整多个单位的基础生命值和攻击力数值平衡 - 修改CoreBattleGround材质的纹理缩放比例 - 更新地砖材质颜色为白色 - 优化纹理资源设置,调整最大纹理尺寸并添加PlayableAds平台支持 - 修复弓箭手技能目标查找逻辑,改用统一的敌人检测方法 - 为水晶矿添加中立阵营设置和光球视觉效果同步 - 调整预制体中的全局缩放参数 - 为狼人和石巨人添加NavMeshAgent组件支持 - 优化水晶矿血条显示位置计算方式 ```
1165 lines
44 KiB
C#
1165 lines
44 KiB
C#
#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
|
||
{
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public class DrillWindow : EditorWindow
|
||
{
|
||
#region Constants
|
||
|
||
private const string DRILL_ROUTE = "/?route=/drill";
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private const string SESSION_STATE_DRILL_AUTO_OPENED_KEY = "Codely_DrillAutoOpened";
|
||
|
||
#endregion
|
||
|
||
#region Auto-Open On Load
|
||
|
||
/// <summary>
|
||
/// On editor load (startup / domain reload), open the walkthrough once per editor
|
||
/// session if <c>~/.codely/data.json</c> reports <c>drillCompleted: false</c>.
|
||
/// 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.
|
||
/// </summary>
|
||
[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<DrillWindow>())
|
||
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<DrillWindow>("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<Texture2D>(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 (!_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}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ensure a Tauri server is reachable for this workspace before pointing the
|
||
/// WebView at it. A single-instance <c>cowork</c> APP may already be running
|
||
/// (the Hub, another Editor, or a CodelyWindow). Spawning a fresh
|
||
/// <c>--embed --port X</c> 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.
|
||
/// </summary>
|
||
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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Discover a running single-instance APP via <c>~/.codely/app.lock.meta.json</c>
|
||
/// (written by the APP on startup). If found, POST this workspace to
|
||
/// <c>/api/tauri/attach-embed-workspace</c> 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 <c>false</c> when no running single-instance APP is discovered or any
|
||
/// step fails — the caller then launches a fresh APP process.
|
||
/// </summary>
|
||
private async Task<bool> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Editor 退出时由 CodelyWindow.OnEditorQuitting 调用:清空 native handle 引用,
|
||
/// 不再调用 NB_Destroy(NB_ShutdownAll 已同步关闭 native 端)。
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Thin wrapper kept for binary compatibility: <see cref="CodelyWindow"/> references this as
|
||
/// <c>DrillWindow.GetCodelyCliRunnable()</c>. The implementation lives in <see cref="TauriUtils"/>.
|
||
/// </summary>
|
||
internal static string GetCodelyCliRunnable() => TauriUtils.GetCodelyCliRunnable();
|
||
|
||
#endregion
|
||
}
|
||
}
|
||
#endif
|